Skip to content

Commit 7ede53e

Browse files
committed
Add camera_sim + monitor_demo launch (one-command anomaly-monitor demo)
camera_sim publishes a synthetic camera stream that is nominal most of the time and periodically occludes the lens (an anomaly event); monitor_demo.launch.py wires it to the monitor so `adapter:=ijepa` shows live anomaly flags on a GPU (dummy runs GPU-free for wiring). README documents the one-command demo.
1 parent 2cf3f80 commit 7ede53e

4 files changed

Lines changed: 126 additions & 0 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,10 @@ out-of-distribution scene — **without needing any failure data**.
322322
```bash
323323
ros2 run world_model_py monitor_node --ros-args -p adapter:=ijepa
324324
# ~/surprise (Float32) · ~/anomaly_threshold (Float32) · ~/anomaly (Bool)
325+
326+
# one-command demo: a synthetic camera with periodic occlusion events + monitor
327+
ros2 launch world_model_bringup monitor_demo.launch.py adapter:=ijepa
328+
ros2 topic echo /world_model_monitor/anomaly
325329
```
326330

327331
The detector core (`world_model_py.anomaly.AnomalyDetector`) is ROS-free and
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Runtime anomaly-monitor demo: a synthetic camera (with periodic occlusion
2+
events) feeding the World Model monitor.
3+
4+
ros2 launch world_model_bringup monitor_demo.launch.py # dummy (GPU-free)
5+
ros2 launch world_model_bringup monitor_demo.launch.py adapter:=ijepa # real detection
6+
7+
Watch it flag the occlusion events:
8+
ros2 topic echo /world_model_monitor/anomaly
9+
"""
10+
from launch import LaunchDescription
11+
from launch.actions import DeclareLaunchArgument
12+
from launch.substitutions import LaunchConfiguration
13+
from launch_ros.actions import Node
14+
15+
16+
def generate_launch_description() -> LaunchDescription:
17+
adapter = LaunchConfiguration("adapter")
18+
19+
camera = Node(
20+
package="world_model_py",
21+
executable="camera_sim",
22+
name="camera_sim",
23+
output="screen",
24+
)
25+
monitor = Node(
26+
package="world_model_py",
27+
executable="monitor_node",
28+
name="world_model_monitor",
29+
parameters=[{"adapter": adapter}],
30+
remappings=[("image", "/camera_sim/image")],
31+
output="screen",
32+
)
33+
return LaunchDescription(
34+
[
35+
DeclareLaunchArgument("adapter", default_value="dummy"),
36+
camera,
37+
monitor,
38+
]
39+
)

world_model_py/setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"runtime_node = world_model_py.runtime_node:main",
2424
"monitor_node = world_model_py.monitor_node:main",
2525
"sample_publisher = world_model_py.sample_publisher:main",
26+
"camera_sim = world_model_py.camera_sim:main",
2627
# standalone CLI + reference remote server (also usable without ROS)
2728
"world-model = world_model_py.cli:main",
2829
"world-model-server = world_model_py.server:main",
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Synthetic camera for the anomaly-monitor demo (GPU-free).
2+
3+
Publishes a sensor_msgs/Image stream that is normally "nominal" (a smoothly
4+
moving block on a gradient) and periodically injects an *anomaly* event: the
5+
lens is briefly occluded by a dark blob. Point the monitor at it and, with a
6+
real adapter (``ijepa``), watch ``~/anomaly`` fire during the occlusions.
7+
8+
ros2 run world_model_py camera_sim
9+
"""
10+
from __future__ import annotations
11+
12+
import numpy as np
13+
import rclpy
14+
from rclpy.node import Node
15+
16+
from sensor_msgs.msg import Image
17+
from . import conversions as conv
18+
19+
20+
class CameraSim(Node):
21+
def __init__(self):
22+
super().__init__("camera_sim")
23+
self.declare_parameter("rate_hz", 4.0)
24+
self.declare_parameter("size", 256)
25+
self.declare_parameter("period", 40) # frames between anomaly events
26+
self.declare_parameter("event_len", 8) # occlusion length in frames
27+
28+
rate = self.get_parameter("rate_hz").get_parameter_value().double_value or 4.0
29+
self._n = int(self.get_parameter("size").get_parameter_value().integer_value) or 256
30+
self._period = int(self.get_parameter("period").get_parameter_value().integer_value)
31+
self._event = int(self.get_parameter("event_len").get_parameter_value().integer_value)
32+
self._k = 0
33+
34+
s = self._n
35+
yy, xx = np.mgrid[0:s, 0:s]
36+
self._bg = np.stack([xx / s * 170 + 40, yy / s * 110 + 30, np.full((s, s), 90)], 2).astype(np.uint8)
37+
self._yy, self._xx = yy, xx
38+
39+
self._pub = self.create_publisher(Image, "image", 10)
40+
self.create_timer(1.0 / rate, self._tick)
41+
self.get_logger().info(
42+
f"camera_sim: {rate} Hz, anomaly every {self._period} frames for {self._event} frames")
43+
44+
def _frame(self) -> np.ndarray:
45+
s = self._n
46+
img = self._bg.copy()
47+
# nominal: a block gliding back and forth
48+
cx = int((0.5 + 0.4 * np.sin(0.15 * self._k)) * s)
49+
img[s // 2 - 24:s // 2 + 24, max(0, cx - 24):cx + 24] = [210, 60, 60]
50+
# periodic anomaly: a dark occluder sweeps across the lens
51+
phase = self._k % self._period
52+
if phase < self._event:
53+
prog = phase / max(1, self._event - 1)
54+
ox = int(40 + prog * (s - 80))
55+
a = np.clip(1.25 - (((self._xx - ox) / 120.0) ** 2 + ((self._yy - s / 2) / 165.0) ** 2), 0, 1) * 0.93
56+
img = (img * (1 - a[..., None]) + np.array([16, 16, 20]) * a[..., None]).astype(np.uint8)
57+
return img
58+
59+
def _tick(self) -> None:
60+
self._k += 1
61+
msg_header_img = Image()
62+
msg_header_img.header.stamp = self.get_clock().now().to_msg()
63+
msg_header_img.header.frame_id = "camera"
64+
out = conv.np_to_image_msg(self._frame(), msg_header_img.header)
65+
self._pub.publish(out)
66+
67+
68+
def main(args=None) -> None:
69+
rclpy.init(args=args)
70+
node = CameraSim()
71+
try:
72+
rclpy.spin(node)
73+
except KeyboardInterrupt:
74+
pass
75+
finally:
76+
node.destroy_node()
77+
if rclpy.ok():
78+
rclpy.shutdown()
79+
80+
81+
if __name__ == "__main__":
82+
main()

0 commit comments

Comments
 (0)