Skip to content

Commit 190daa0

Browse files
committed
examples: runnable external adapter (entry-point hub demo)
A standalone pip package (wm-example-adapter) that adds a GPU-free histogram "surprise" backend purely via the world_model_ros2.adapters entry point — no edits to the main repo. Copy-paste template for shipping your own World Model. - wm_example_adapter: numpy-only HistogramAdapter + make_example_adapter. - pyproject.toml wires the entry point; README shows pip install -e -> world-model list. - test_example.py (4 logic tests). examples/COLCON_IGNORE keeps colcon out. Verified end-to-end: installed to a temp target, `world-model list` and `world-model info --adapter example` discover it via the entry point.
1 parent 608d075 commit 190daa0

6 files changed

Lines changed: 170 additions & 0 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,11 @@ Contributions are welcome — especially new **adapters** (model backends) and
345345
for the build/test flow and the design rules that keep adapters ROS-free and
346346
GPU-optional.
347347

348+
Adapters can even live in a **separate pip package** and register via an entry
349+
point — see the runnable
350+
[example adapter](examples/world_model_adapter_example/) (`pip install -e .`
351+
`world-model list` shows it, no edits to this repo).
352+
348353
## License
349354

350355
Apache-2.0. See [LICENSE](LICENSE).

examples/COLCON_IGNORE

Whitespace-only changes.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Example external World Model adapter
2+
3+
A tiny, **separate** pip package that adds a World Model backend to
4+
`world_model_ros2` **without editing the main repo** — the entry-point hub in
5+
action. The adapter (`example`) encodes each frame as an RGB colour histogram
6+
and reports surprise as the histogram change (a GPU-free appearance-novelty
7+
signal). Copy this layout to ship your own model.
8+
9+
## Try it
10+
11+
```bash
12+
source /opt/ros/jazzy/setup.bash
13+
source ../../install/setup.bash # provides world_model_py
14+
pip install -e . # registers the entry point
15+
16+
world-model list # -> ... example ...
17+
world-model info --adapter example
18+
```
19+
20+
In ROS 2 it then works like any backend:
21+
22+
```bash
23+
ros2 run world_model_py runtime_node --ros-args -p adapter:=example
24+
```
25+
26+
## The wiring
27+
28+
```toml
29+
# pyproject.toml
30+
[project.entry-points."world_model_ros2.adapters"]
31+
example = "wm_example_adapter:make_example_adapter"
32+
```
33+
34+
`make_example_adapter(**kwargs)` returns a `WorldModelAdapter`. That's the whole
35+
contract — see `wm_example_adapter/__init__.py`.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[build-system]
2+
requires = ["setuptools>=61"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "wm-example-adapter"
7+
version = "0.1.0"
8+
description = "Example external World Model adapter for world_model_ros2 (entry-point demo)"
9+
requires-python = ">=3.10"
10+
dependencies = ["numpy"]
11+
# world_model_py is provided by the ROS 2 workspace, not PyPI, so it is not
12+
# listed here; source the workspace before using this adapter.
13+
14+
[project.entry-points."world_model_ros2.adapters"]
15+
example = "wm_example_adapter:make_example_adapter"
16+
17+
[tool.setuptools]
18+
packages = ["wm_example_adapter"]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Logic test for the example adapter (no ROS, no GPU)."""
2+
import numpy as np
3+
4+
from wm_example_adapter import HistogramAdapter, color_histogram
5+
from world_model_py.adapters.base import Observation
6+
7+
8+
def _img(color):
9+
return np.tile(np.array(color, np.uint8), (16, 16, 1))
10+
11+
12+
def test_histogram_normalized():
13+
h = color_histogram(_img([255, 0, 0]))
14+
assert abs(h.sum() - 1.0) < 1e-5
15+
assert h.shape == (64,)
16+
17+
18+
def test_first_frame_zero_surprise():
19+
wm = HistogramAdapter()
20+
pred = wm.predict_future(Observation(image=_img([10, 10, 10])), horizon=3)
21+
assert pred.risk == 0.0
22+
assert pred.horizon == 3
23+
assert pred.risk_label == "example-hist"
24+
25+
26+
def test_same_image_low_surprise():
27+
wm = HistogramAdapter()
28+
wm.predict_future(Observation(image=_img([10, 200, 10])))
29+
pred = wm.predict_future(Observation(image=_img([10, 200, 10])))
30+
assert pred.risk < 1e-6
31+
32+
33+
def test_color_change_high_surprise():
34+
wm = HistogramAdapter()
35+
wm.predict_future(Observation(image=_img([255, 0, 0])))
36+
pred = wm.predict_future(Observation(image=_img([0, 0, 255])))
37+
assert pred.risk > 0.9 # entirely different colour bin
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Example external World Model adapter for world_model_ros2.
2+
3+
A GPU-free, numpy-only backend that demonstrates how an *outside* package adds a
4+
World Model: it encodes each camera frame as a normalized RGB colour histogram
5+
(the "latent") and reports surprise as the L1 distance between successive
6+
histograms — a cheap appearance-change / novelty signal.
7+
8+
It is wired in via a package entry point (see pyproject.toml), so installing
9+
this package makes ``load_model("example")`` and ``world-model list`` work with
10+
no changes to world_model_ros2 itself.
11+
"""
12+
from __future__ import annotations
13+
14+
from typing import Optional
15+
16+
import numpy as np
17+
18+
from world_model_py.adapters.base import (
19+
ActionCondition,
20+
FuturePrediction,
21+
Observation,
22+
WorldModelAdapter,
23+
)
24+
25+
_BINS = 4 # per channel -> 64-d histogram
26+
27+
28+
def color_histogram(image_hwc_uint8: np.ndarray) -> np.ndarray:
29+
px = np.asarray(image_hwc_uint8).reshape(-1, 3).astype(np.int64)
30+
q = np.clip(px // (256 // _BINS), 0, _BINS - 1)
31+
idx = q[:, 0] * _BINS * _BINS + q[:, 1] * _BINS + q[:, 2]
32+
h = np.bincount(idx, minlength=_BINS ** 3).astype(np.float32)
33+
return h / (h.sum() + 1e-8)
34+
35+
36+
class HistogramAdapter(WorldModelAdapter):
37+
name = "example"
38+
39+
def __init__(self, dt: float = 0.1):
40+
self.dt = float(dt)
41+
self._prev: Optional[np.ndarray] = None
42+
43+
def predict_future(
44+
self,
45+
obs: Observation,
46+
action: Optional[ActionCondition] = None,
47+
horizon: int = 8,
48+
) -> FuturePrediction:
49+
if obs.image is None or getattr(obs.image, "size", 0) == 0:
50+
raise ValueError("example adapter needs obs.image")
51+
if action is not None and action.horizon > 0:
52+
horizon = action.horizon
53+
horizon = max(1, int(horizon))
54+
55+
hist = color_histogram(obs.image)
56+
if self._prev is None:
57+
risk, conf = 0.0, 0.0
58+
else:
59+
risk = float(np.clip(0.5 * np.abs(hist - self._prev).sum(), 0.0, 1.0))
60+
conf = 0.8
61+
self._prev = hist
62+
return FuturePrediction(
63+
dt=self.dt,
64+
latents=[hist.copy() for _ in range(horizon)],
65+
risk=risk,
66+
risk_confidence=conf,
67+
risk_label="example-hist",
68+
)
69+
70+
def reset(self) -> None:
71+
self._prev = None
72+
73+
74+
def make_example_adapter(**kwargs) -> HistogramAdapter:
75+
return HistogramAdapter(**kwargs)

0 commit comments

Comments
 (0)