From f574a2de29cd60927f8cf16ca5bc8a920c608758 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 20 Oct 2026 21:31:34 -0700 Subject: [PATCH 1/8] feat(viewers): render generated CDR messages in browser and Rerun --- dimos/memory/cli/render.py | 109 +++++-- dimos/memory/cli/test_render.py | 60 ++++ dimos/navigation/global_planner/test_viz.py | 47 ++- dimos/navigation/global_planner/viz.py | 15 +- dimos/navigation/local_planner/profile.py | 14 +- dimos/navigation/local_planner/viz.py | 25 +- dimos/visualization/rerun/bridge.py | 119 ++++++- dimos/visualization/rerun/cdr.py | 277 ++++++++++++++++ dimos/visualization/rerun/init.py | 2 +- dimos/visualization/rerun/test_cdr.py | 298 ++++++++++++++++++ dimos/visualization/rerun/test_cdr_e2e.py | 60 ++++ .../visualization/rerun/test_cdr_entities.py | 48 +++ .../rerun/test_cdr_image_values.py | 57 ++++ .../rerun/test_cdr_sensor_docs.py | 37 +++ dimos/web/cdr_codec.py | 63 ++++ dimos/web/cockpit.py | 10 +- dimos/web/codecs.py | 27 +- dimos/web/relay_bridge/gen_cdr_fixtures.py | 139 ++++++++ dimos/web/relay_bridge/module_test_support.py | 6 + .../test_relay_bridge_authoring.py | 38 +++ dimos/web/test_cdr_codec.py | 100 ++++++ docs/usage/visualization.md | 96 ++++++ docs/web/bridge.md | 54 ++++ examples/message-viewer/demo_cdr_rerun.py | 143 +++++++++ web/cockpit/src/subscriptions.test.ts | 17 + web/cockpit/src/subscriptions.ts | 11 +- web/deno.lock | 31 ++ web/sdk/fixture/demo_cdr.html | 77 +++++ web/sdk/fixture/demo_cdr.ts | 59 ++++ web/sdk/package.json | 4 + web/sdk/src/decoders/cdr.test.ts | 67 ++++ web/sdk/src/decoders/cdr.ts | 106 +++++++ web/sdk/src/decoders/index.ts | 15 +- web/sdk/src/index.ts | 2 + web/shared/fixtures/cdr_frames.json | 236 ++++++++++++++ 35 files changed, 2388 insertions(+), 81 deletions(-) create mode 100644 dimos/visualization/rerun/cdr.py create mode 100644 dimos/visualization/rerun/test_cdr.py create mode 100644 dimos/visualization/rerun/test_cdr_e2e.py create mode 100644 dimos/visualization/rerun/test_cdr_entities.py create mode 100644 dimos/visualization/rerun/test_cdr_image_values.py create mode 100644 dimos/visualization/rerun/test_cdr_sensor_docs.py create mode 100644 dimos/web/cdr_codec.py create mode 100644 dimos/web/relay_bridge/gen_cdr_fixtures.py create mode 100644 dimos/web/test_cdr_codec.py create mode 100644 examples/message-viewer/demo_cdr_rerun.py create mode 100644 web/sdk/fixture/demo_cdr.html create mode 100644 web/sdk/fixture/demo_cdr.ts create mode 100644 web/sdk/src/decoders/cdr.test.ts create mode 100644 web/sdk/src/decoders/cdr.ts create mode 100644 web/shared/fixtures/cdr_frames.json diff --git a/dimos/memory/cli/render.py b/dimos/memory/cli/render.py index 9df7af193ad..c560b43c754 100644 --- a/dimos/memory/cli/render.py +++ b/dimos/memory/cli/render.py @@ -14,9 +14,9 @@ """Render any memory store into rerun. -Generic: walks the store's streams and logs every observation whose payload -implements ``to_rerun()`` (the :class:`RerunConvertible` convention). Streams -whose payload has no ``to_rerun`` are skipped. Each stream becomes an entity +Walks the store's streams and renders generated values through external adapters. +Existing local helper values with ``to_rerun()`` remain supported. Unsupported +payloads are reported and skipped. Each stream becomes an entity path; observations share one ``time`` timeline (relative to the store's earliest observation, so streams stay aligned). CameraInfo streams are the exception: logged once as a Pinhole on their matching image entity (see @@ -30,43 +30,82 @@ import subprocess from typing import TYPE_CHECKING, Any +import numpy as np +import rerun as rr + +from dimos.core.introspection.utils import GROUP_COLORS +from dimos.memory.utils.progress import progress +from dimos.messages.sensor_msgs.msg import ( + CameraInfo as CdrCameraInfo, + CompressedImage, + Image as CdrImage, +) +from dimos.messages.tf2_msgs.msg import TFMessage as CdrTFMessage +from dimos.msgs.nav_msgs.Path import Path as NavPath +from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo +from dimos.msgs.sensor_msgs.Image import Image +from dimos.msgs.shape_msgs.TriangleMesh import TriangleMesh +from dimos.msgs.tf2_msgs.TFMessage import TfFrameTree, TFMessage +from dimos.msgs.time import message_header, to_nanoseconds +from dimos.visualization.rerun.cdr import camera_pinhole, generated_archetypes, message_frame_id +from dimos.visualization.rerun.init import rerun_init + if TYPE_CHECKING: from dimos.memory.store.base import Store from dimos.memory.stream import Stream from dimos.memory.type.observation import Observation - from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo + + +def _render_entries(data: Any, entity: str) -> list[tuple[str, Any]] | None: + generated = generated_archetypes(data, entity) + if generated is not None: + return [(entry[0], entry[1]) for entry in generated] + if not hasattr(data, "to_rerun"): + return None + local = data.to_rerun() + if isinstance(local, list): + return [(f"{entity}/{sub}", archetype) for sub, archetype in local] + return [(entity, local)] def _pair_camera_infos( renderable: list[tuple[str, Stream[Any], Observation[Any]]], -) -> tuple[dict[str, tuple[CameraInfo, str]], set[str]]: +) -> tuple[dict[str, tuple[CameraInfo | CdrCameraInfo, str]], set[str]]: """Match each CameraInfo stream to the Image stream(s) it calibrates. Pairs by ``frame_id``, falling back to the only image stream when the store has one of each; the image's frame_id wins as the pinhole's parent frame. Returns (image stream -> (CameraInfo, parent frame), paired stream names). """ - from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo - from dimos.msgs.sensor_msgs.Image import Image - infos = [(n, f.data) for n, _, f in renderable if isinstance(f.data, CameraInfo)] - images = [(n, f.data) for n, _, f in renderable if isinstance(f.data, Image)] + infos = [ + (n, f.data) for n, _, f in renderable if isinstance(f.data, (CameraInfo, CdrCameraInfo)) + ] + images = [ + (n, f.data) + for n, _, f in renderable + if isinstance(f.data, (Image, CdrImage, CompressedImage)) + ] - pinholes: dict[str, tuple[CameraInfo, str]] = {} + pinholes: dict[str, tuple[CameraInfo | CdrCameraInfo, str]] = {} paired: set[str] = set() for info_name, info in infos: - targets = [(n, img) for n, img in images if img.frame_id and img.frame_id == info.frame_id] + targets = [ + (n, img) + for n, img in images + if message_frame_id(img) and message_frame_id(img) == message_frame_id(info) + ] if not targets and len(infos) == 1 and len(images) == 1: targets = [images[0]] print( - f" {info_name}: frame_id {info.frame_id!r} matches no image stream, " + f" {info_name}: frame_id {message_frame_id(info)!r} matches no image stream, " f"pairing with {targets[0][0]!r} as the only image stream" ) if not targets: - print(f" {info_name}: no image stream matches frame_id {info.frame_id!r}") + print(f" {info_name}: no image stream matches frame_id {message_frame_id(info)!r}") continue for target, img in targets: - pinholes[target] = (info, img.frame_id or info.frame_id) + pinholes[target] = (info, message_frame_id(img) or message_frame_id(info) or "") paired.add(info_name) return pinholes, paired @@ -81,11 +120,10 @@ def _frame_first_seen( first tf message overall (which may be an unrelated or static edge). Scans each tf stream only until every frame is found. """ - from dimos.msgs.tf2_msgs.TFMessage import TFMessage seen: dict[str, float] = {} for _, stream, first in renderable: - if not isinstance(first.data, TFMessage): + if not isinstance(first.data, (TFMessage, CdrTFMessage)): continue missing = set(frames) for obs in stream: @@ -124,15 +162,6 @@ def render_store( (``/``) — except a stream whose name matches ``root``'s last segment, which stays at ```` itself. Returns the ``.rrd`` path. """ - import rerun as rr - - from dimos.core.introspection.utils import GROUP_COLORS - from dimos.memory.utils.progress import progress - from dimos.msgs.nav_msgs.Path import Path as NavPath - from dimos.msgs.shape_msgs.TriangleMesh import TriangleMesh - from dimos.msgs.tf2_msgs.TFMessage import TfFrameTree, TFMessage - from dimos.visualization.rerun.init import rerun_init - if out is None: src = getattr(store.config, "path", None) or "store" out = str(Path(src).with_suffix(".rrd")) @@ -154,13 +183,12 @@ def entity(name: str) -> str: except LookupError: continue data = first.data - if not hasattr(data, "to_rerun"): - print(f" skip {name}: {type(data).__name__} has no to_rerun()") - continue try: - data.to_rerun() + if _render_entries(data, entity(name)) is None: + print(f" skip {name}: no renderer for {type(data).__name__}") + continue except Exception as e: - print(f" skip {name}: to_rerun() failed ({e})") + print(f" skip {name}: renderer failed ({e})") continue renderable.append((name, stream, first)) @@ -176,14 +204,18 @@ def entity(name: str) -> str: # Log each pinhole when its own parent frame first appears in tf: before # that the frame is undefined and the pinhole would sit at the origin. # Static only when the store has no tf at all. - has_tf = any(isinstance(f.data, TFMessage) for _, _, f in renderable) + has_tf = any(isinstance(f.data, (TFMessage, CdrTFMessage)) for _, _, f in renderable) frame_at = _frame_first_seen(renderable, {frame for _, frame in pinholes.values()}) rerun_init("dimos mem rerun") rr.save(out) for image_name, (info, frame) in pinholes.items(): - pinhole = info.to_rerun_pinhole(optical_frame=frame) + pinhole = ( + camera_pinhole(info, optical_frame=frame) + if isinstance(info, CdrCameraInfo) + else info.to_rerun_pinhole(optical_frame=frame) + ) at = frame_at.get(frame) if not has_tf: rr.log(entity(image_name), pinhole, static=True) @@ -213,6 +245,19 @@ def entity(name: str) -> str: report(obs) continue rr.set_time("time", duration=obs.ts - t0) + header = message_header(obs.data) + if header is not None: + rr.set_time( + "source", timestamp=np.datetime64(to_nanoseconds(header.stamp), "ns") + ) + else: + rr.disable_timeline("source") + generated = generated_archetypes(obs.data, path) + if generated is not None: + for entry_path, archetype in _render_entries(obs.data, path) or []: + rr.log(entry_path, archetype) + report(obs) + continue if isinstance(obs.data, TFMessage): # absolute tf paths, like the bridge for tf_path, arch in obs.data.to_rerun(tf_tree): rr.log(tf_path, arch) diff --git a/dimos/memory/cli/test_render.py b/dimos/memory/cli/test_render.py index 9ba344e9a08..cb8fde22c0e 100644 --- a/dimos/memory/cli/test_render.py +++ b/dimos/memory/cli/test_render.py @@ -16,12 +16,25 @@ from types import SimpleNamespace import numpy as np +import rerun as rr from dimos.memory.cli.render import _frame_first_seen, _pair_camera_infos, render_store from dimos.memory.store.memory import MemoryStore +from dimos.messages.builtin_interfaces.msg import Time +from dimos.messages.geometry_msgs.msg import ( + Quaternion as CdrQuaternion, + Transform as CdrTransform, + TransformStamped, + Vector3 as CdrVector3, +) +from dimos.messages.sensor_msgs.msg import CameraInfo as CdrCameraInfo, CompressedImage +from dimos.messages.std_msgs.msg import Header +from dimos.messages.tf2_msgs.msg import TFMessage as CdrTFMessage +from dimos.msgs.camera_info import camera_info_from_intrinsics from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Transform import Transform from dimos.msgs.geometry_msgs.Vector3 import Vector3 +from dimos.msgs.image import compressed_image_from_image, image_from_array from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo from dimos.msgs.sensor_msgs.Image import Image from dimos.msgs.tf2_msgs.TFMessage import TFMessage @@ -233,3 +246,50 @@ def test_render_store_themes_meshes(tmp_path, monkeypatch) -> None: # type: ign paths = [p for p, _ in logged] assert "mesh" not in paths assert sum(p.startswith("world/mesh/") for p in paths) == 2 # geometry + turbo colours + + +def test_generated_compressed_image_pinhole_waits_for_its_frame(tmp_path, capsys, mocker): + log = mocker.spy(rr, "log") + set_time = mocker.spy(rr, "set_time") + info = camera_info_from_intrinsics( + fx=5.0, fy=5.0, cx=2.0, cy=2.0, width=4, height=4, header=Header(Time(0, 0), "camera") + ) + image = compressed_image_from_image( + image_from_array( + np.full((4, 4, 3), [255, 0, 0], dtype=np.uint8), + encoding="rgb8", + header=Header(Time(0, 0), "camera"), + ) + ) + image.header.stamp = Time(1_789_654_321, 1) + tf = CdrTFMessage( + [ + TransformStamped( + Header(Time(1_789_654_321, 2), "world"), + "camera", + CdrTransform(CdrVector3(1.0, 0.0, 0.0), CdrQuaternion(0.0, 0.0, 0.0, 1.0)), + ) + ] + ) + with MemoryStore() as store: + store.stream("info", CdrCameraInfo).append(info, ts=90.0) + store.stream("image", CompressedImage).append(image, ts=100.0) + store.stream("tf", CdrTFMessage).append(tf, ts=101.0) + output = render_store(store, out=str(tmp_path / "generated.rrd"), no_gui=True, root="run") + assert _written(output) + assert "frame 'camera' at +1.00s" in capsys.readouterr().out + entries = [(call.args[0], call.args[1]) for call in log.call_args_list] + assert any(path == "run/image" and isinstance(value, rr.Pinhole) for path, value in entries) + assert any( + path == "run/image" and isinstance(value, rr.EncodedImage) for path, value in entries + ) + transform = next(value for path, value in entries if path == "tf#/camera") + assert transform.translation.as_arrow_array().to_pylist() == [[1.0, 0.0, 0.0]] + assert transform.parent_frame.as_arrow_array().to_pylist() == ["tf#/world"] + assert any( + call.args == ("source",) + and call.kwargs.get("timestamp") == np.datetime64(1_789_654_321_000_000_001, "ns") + for call in set_time.call_args_list + ) + assert image.header.stamp == Time(1_789_654_321, 1) + assert not hasattr(image, "to_rerun") diff --git a/dimos/navigation/global_planner/test_viz.py b/dimos/navigation/global_planner/test_viz.py index 1deb9ba4dcb..12fd7e4c263 100644 --- a/dimos/navigation/global_planner/test_viz.py +++ b/dimos/navigation/global_planner/test_viz.py @@ -19,25 +19,58 @@ import rerun as rr from dimos.mapping.ray_tracing.viz import render_map_region -from dimos.msgs.geometry_msgs.PointStamped import PointStamped -from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped -from dimos.msgs.nav_msgs.Path import Path +from dimos.messages.geometry_msgs.msg import Point, PointStamped, Pose, PoseStamped +from dimos.messages.nav_msgs.msg import Path +from dimos.messages.std_msgs.msg import Header +from dimos.msgs.geometry import quaternion_from_euler from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 +from dimos.msgs.time import time_from_nanoseconds from dimos.navigation.global_planner import viz from dimos.navigation.global_planner.mls_planner.viz import render_surface_region from dimos.visualization.rerun.bridge import is_keyed_by_seq def test_goal_placeholder_is_not_drawn() -> None: - assert viz.render_goal(PointStamped(x=float("nan"), y=0.0, z=0.0)) is None - goal = viz.render_goal(PointStamped(x=1.0, y=2.0, z=0.0)) + assert ( + viz.render_goal( + PointStamped( + header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), + point=Point(x=float("nan"), y=0.0, z=0.0), + ) + ) + is None + ) + goal = viz.render_goal( + PointStamped( + header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), + point=Point(x=1.0, y=2.0, z=0.0), + ) + ) assert goal is not None assert goal.positions.as_arrow_array().to_pylist() == [[1.0, 2.0, 0.0]] def test_empty_path_keeps_the_last_one_drawn() -> None: - assert viz.render_path(Path(poses=[])) is None - path = viz.render_path(Path(poses=[PoseStamped(1.0, 0.0, 0.0), PoseStamped(2.0, 0.0, 0.0)])) + assert ( + viz.render_path( + Path(header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), poses=[]) + ) + is None + ) + path = viz.render_path( + Path( + header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), + poses=[ + PoseStamped( + header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), + pose=Pose( + position=Point(x=x, y=0, z=0), orientation=quaternion_from_euler(0, 0, 0) + ), + ) + for x in [1.0, 2.0] + ], + ) + ) assert path is not None lift = viz.PATH_Z_LIFT np.testing.assert_allclose( diff --git a/dimos/navigation/global_planner/viz.py b/dimos/navigation/global_planner/viz.py index 21f720bbd31..db509d0bde0 100644 --- a/dimos/navigation/global_planner/viz.py +++ b/dimos/navigation/global_planner/viz.py @@ -32,8 +32,8 @@ from numpy.typing import NDArray from rerun._baseclasses import Archetype - from dimos.msgs.geometry_msgs.PointStamped import PointStamped - from dimos.msgs.nav_msgs.Path import Path + from dimos.messages.geometry_msgs.msg import PointStamped + from dimos.messages.nav_msgs.msg import Path from dimos.visualization.rerun.bridge import VisualOverride # rerun is imported inside the renderers: it is heavy and only viewers load it. @@ -66,7 +66,12 @@ def render_path(msg: Path) -> Archetype | None: """The planned route. An empty path means no route, so the last one stays drawn.""" if len(msg.poses) == 0: return None - return path_strip(np.array([[p.x, p.y, p.z] for p in msg.poses], dtype=np.float32)) + return path_strip( + np.array( + [[p.pose.position.x, p.pose.position.y, p.pose.position.z] for p in msg.poses], + dtype=np.float32, + ) + ) def goal_point(xyz: tuple[float, float, float]) -> Archetype: @@ -77,9 +82,9 @@ def goal_point(xyz: tuple[float, float, float]) -> Archetype: def render_goal(msg: PointStamped) -> Archetype | None: """The active goal. NaN is the movement manager's placeholder for none.""" - if any(math.isnan(v) for v in (msg.x, msg.y, msg.z)): + if any(math.isnan(v) for v in (msg.point.x, msg.point.y, msg.point.z)): return None - return goal_point((msg.x, msg.y, msg.z)) + return goal_point((msg.point.x, msg.point.y, msg.point.z)) def robot_body_box(length: float, width: float, height: float) -> Archetype: diff --git a/dimos/navigation/local_planner/profile.py b/dimos/navigation/local_planner/profile.py index e2a2a0a442c..eb0fdfa9b1d 100644 --- a/dimos/navigation/local_planner/profile.py +++ b/dimos/navigation/local_planner/profile.py @@ -25,13 +25,15 @@ import numpy as np from numpy.typing import NDArray +from dimos.messages.nav_msgs.msg import Path as CdrPath from dimos.msgs.nav_msgs.Path import Path +from dimos.msgs.time import NANOSECONDS_PER_SECOND, to_nanoseconds from dimos.navigation.embodiment.base import Embodiment _FAN_EPS = 1e-6 # segment shorter than this is a rotation, not a move -def decode_ceilings(path: Path, lo: float, hi: float) -> NDArray[np.float64] | None: +def decode_ceilings(path: Path | CdrPath, lo: float, hi: float) -> NDArray[np.float64] | None: """Per-waypoint speed ceiling (m/s) from the stamps; None if unstamped. Unstamped = flat or non-monotone ts. Fan segments inherit the previous @@ -40,11 +42,15 @@ def decode_ceilings(path: Path, lo: float, hi: float) -> NDArray[np.float64] | N n = len(path.poses) if n < 2: return None - ts = np.array([p.ts for p in path.poses]) - dt = np.diff(ts) + if isinstance(path, CdrPath): + stamps = np.asarray([to_nanoseconds(p.header.stamp) for p in path.poses], dtype=np.int64) + dt = np.diff(stamps).astype(np.float64) / NANOSECONDS_PER_SECOND + xy = np.asarray([[p.pose.position.x, p.pose.position.y] for p in path.poses]) + else: + dt = np.diff(np.asarray([p.ts for p in path.poses])) + xy = np.asarray([[p.position.x, p.position.y] for p in path.poses]) if np.any(dt < 0) or not np.any(dt > 0): return None - xy = np.array([[p.position.x, p.position.y] for p in path.poses]).reshape(-1, 2) ds = np.linalg.norm(np.diff(xy, axis=0), axis=1) # a config is free to set min above max; ordering here rather than # trusting the caller keeps the clip a clip diff --git a/dimos/navigation/local_planner/viz.py b/dimos/navigation/local_planner/viz.py index b6fea546b86..8297b49571a 100644 --- a/dimos/navigation/local_planner/viz.py +++ b/dimos/navigation/local_planner/viz.py @@ -26,8 +26,9 @@ import numpy as np from numpy.typing import NDArray -from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped -from dimos.msgs.nav_msgs.Path import Path +from dimos.messages.geometry_msgs.msg import PoseStamped +from dimos.messages.nav_msgs.msg import Path +from dimos.msgs.geometry import yaw from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 from dimos.navigation.embodiment.base import Embodiment from dimos.navigation.local_planner.profile import ( @@ -48,10 +49,10 @@ def _body_centre(pose: PoseStamped, center_off: float) -> tuple[float, float]: """The body centre for a pose point, offset along the pose's own heading.""" - yaw = pose.yaw + heading = yaw(pose.pose.orientation) return ( - pose.position.x + center_off * np.cos(yaw), - pose.position.y + center_off * np.sin(yaw), + pose.pose.position.x + center_off * np.cos(heading), + pose.pose.position.y + center_off * np.sin(heading), ) @@ -84,16 +85,18 @@ def render_body( p = msg.poses[0] cx, cy = _body_centre(p, center_off) return rr.Boxes3D( - centers=[[cx, cy, p.position.z + height / 2.0 + _Z_LIFT]], + centers=[[cx, cy, p.pose.position.z + height / 2.0 + _Z_LIFT]], half_sizes=[[length / 2.0, width / 2.0, height / 2.0]], - rotation_axis_angles=[rr.RotationAxisAngle([0.0, 0.0, 1.0], rr.Angle(rad=p.yaw))], + rotation_axis_angles=[ + rr.RotationAxisAngle([0.0, 0.0, 1.0], rr.Angle(rad=yaw(p.pose.orientation))) + ], colors=[[255, 60, 60, 200]], radii=[line_radius], fill_mode="majorwireframe", labels=["VETO: no safe route"], ) - xy = np.array([[p.position.x, p.position.y] for p in msg.poses]).reshape(-1, 2) + xy = np.array([[p.pose.position.x, p.pose.position.y] for p in msg.poses]).reshape(-1, 2) seg = np.linalg.norm(np.diff(xy, axis=0), axis=1) arcs = np.concatenate([[0.0], np.cumsum(seg)]) # one box per stride_m of arc, plus the last pose so the goal end is drawn @@ -105,8 +108,8 @@ def render_body( for i in picks: p = msg.poses[int(i)] cx, cy = _body_centre(p, center_off) - centers.append([cx, cy, p.position.z + height / 2.0 + _Z_LIFT]) - angles.append(rr.RotationAxisAngle([0.0, 0.0, 1.0], rr.Angle(rad=p.yaw))) + centers.append([cx, cy, p.pose.position.z + height / 2.0 + _Z_LIFT]) + angles.append(rr.RotationAxisAngle([0.0, 0.0, 1.0], rr.Angle(rad=yaw(p.pose.orientation)))) if clear is None: colors.append([100, 160, 255, 160]) # unstamped: the plan line's own blue else: @@ -157,7 +160,7 @@ def render_plan( return None out: RerunMulti = [ # the bridge only pins single-archetype entities to their tf frame; the boxes are the plan - (entity, rr.Transform3D(parent_frame=f"tf#/{msg.frame_id}")), + (entity, rr.Transform3D(parent_frame=f"tf#/{msg.header.frame_id}")), ] boxes = render_body(msg, emb, stride_m, line_radius) if boxes is not None: diff --git a/dimos/visualization/rerun/bridge.py b/dimos/visualization/rerun/bridge.py index 543e3488b2f..389ff0e5d2b 100644 --- a/dimos/visualization/rerun/bridge.py +++ b/dimos/visualization/rerun/bridge.py @@ -17,6 +17,7 @@ from __future__ import annotations from collections.abc import Callable +from contextlib import ExitStack from dataclasses import field import functools from operator import attrgetter @@ -48,13 +49,24 @@ from toolz import pipe # type: ignore[import-untyped] from dimos.constants import RECORDINGS_DIR +from dimos.core.cdr_transport import CdrTransport from dimos.core.coordination.process_lifecycle import DIMOS_RUN_ID_ENV from dimos.core.core import rpc from dimos.core.global_config import global_config from dimos.core.module import Module, ModuleConfig +from dimos.messages.sensor_msgs.msg import ( + CameraInfo as CdrCameraInfo, + CompressedImage as CdrCompressedImage, + Image as CdrImage, +) +from dimos.messages.tf2_msgs.msg import TFMessage as CdrTFMessage +from dimos.msgs.geometry_msgs.Quaternion import Quaternion +from dimos.msgs.geometry_msgs.Transform import Transform +from dimos.msgs.geometry_msgs.Vector3 import Vector3 from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo from dimos.msgs.sensor_msgs.Image import Image from dimos.msgs.tf2_msgs.TFMessage import TfFrameTree, TFMessage +from dimos.msgs.time import to_seconds from dimos.protocol.pubsub.impl.lcmpubsub import LCM from dimos.protocol.pubsub.impl.zenohpubsub import Topic as ZenohTopic, Zenoh from dimos.protocol.pubsub.patterns import Glob, pattern_matches @@ -62,6 +74,7 @@ from dimos.protocol.service.lcmservice import autoconf from dimos.utils.generic import get_local_ips from dimos.utils.logging_config import setup_logger +from dimos.visualization.rerun.cdr import camera_pinhole, generated_archetypes, message_frame_id from dimos.visualization.rerun.constants import ( RERUN_ENABLE_WEB, RERUN_GRPC_PORT, @@ -278,7 +291,12 @@ class Config(ModuleConfig): honored. """ - pubsubs: list[SubscribeAllCapable[Any, Any]] = field(default_factory=lambda: [LCM()]) + # Construct the selected backend only when the bridge starts. Creating a + # blueprint or importing a camera module must not open multicast sockets. + pubsubs: list[SubscribeAllCapable[Any, Any]] | None = None + # Explicit subscriptions for the proposal's generated CDR channels. Each + # transport belongs to this bridge; legacy discovery/defaults stay intact. + cdr_transports: dict[str, CdrTransport[Any]] = field(default_factory=dict) visual_override: dict[Glob | str, VisualOverride] = field(default_factory=dict) static: dict[str, Callable[[Any], Any]] = field(default_factory=dict) @@ -342,7 +360,7 @@ def __init__(self, **kwargs: Any) -> None: self._last_log = {} self._override_cache: dict[str, Callable[[Any], RerunData | None]] = {} self._frame_attached: dict[str, str] = {} - self._camera_infos: dict[str, CameraInfo] = {} + self._camera_infos: dict[str, CameraInfo | CdrCameraInfo] = {} self._image_entities: set[str] = set() self._min_intervals: dict[str, float] = {} self._tf_lock = threading.Lock() @@ -409,7 +427,7 @@ def final_convert(msg: Any) -> RerunData | None: return msg if isinstance(msg, RerunConvertible): return msg.to_rerun() - return None + return generated_archetypes(msg, entity_path) # compose all converters def composed(msg: Any) -> RerunData | None: @@ -456,13 +474,43 @@ def _on_message(self, msg: Any, topic: Any) -> None: return self._last_log[entity_path] = now + if self._tf_tree is not None and isinstance(msg, CdrTFMessage): + # The existing frame-tree layout helper consumes local numerical values. + transforms = [ + Transform( + translation=Vector3( + t.transform.translation.x, + t.transform.translation.y, + t.transform.translation.z, + ), + rotation=Quaternion( + t.transform.rotation.x, + t.transform.rotation.y, + t.transform.rotation.z, + t.transform.rotation.w, + ), + frame_id=t.header.frame_id, + child_frame_id=t.child_frame_id, + ts=to_seconds(t.header.stamp), + ) + for t in msg.transforms + ] + with self._tf_lock: + for entry in generated_archetypes(msg, entity_path) or []: + rr.log(entry[0], entry[1]) + self._tf_tree.update(transforms) + return + if self._tf_tree is not None and isinstance(msg, TFMessage): with self._tf_lock: for path, archetype in msg.to_rerun(self._tf_tree): rr.log(path, archetype) return - if isinstance(msg, CameraInfo) and entity_path not in self.config.visual_override: + if ( + isinstance(msg, (CameraInfo, CdrCameraInfo)) + and entity_path not in self.config.visual_override + ): self._log_camera_info(entity_path, msg) return @@ -471,16 +519,26 @@ def _on_message(self, msg: Any, topic: Any) -> None: if not rerun_data: return + is_image = isinstance(msg, (Image, CdrImage, CdrCompressedImage)) + if is_image: + self._image_entities.add(entity_path) + # TFMessage for example returns list of (entity_path, archetype) tuples if is_rerun_multi(rerun_data): for entry in rerun_data: rr.log(entry[0], entry[1], static=isinstance(entry, RerunEntry) and entry.static) # Pin the base entity to the message frame once, so child entities # follow it the way a single archetype does below. - frame_id = getattr(msg, "frame_id", None) + frame_id = message_frame_id(msg) if frame_id and self._frame_attached.get(entity_path) != frame_id: - rr.log(entity_path, rr.Transform3D(parent_frame=f"tf#/{frame_id}")) + if not any( + entry[0] == entity_path and isinstance(entry[1], rr.Transform3D) + for entry in rerun_data + ): + rr.log(entity_path, rr.Transform3D(parent_frame=f"tf#/{frame_id}")) self._frame_attached[entity_path] = frame_id + if is_image and frame_id in self._camera_infos: + rr.log(entity_path, self._camera_pinhole(self._camera_infos[frame_id])) else: rr.log(entity_path, cast("Archetype", rerun_data)) if isinstance(msg, Image): @@ -488,14 +546,18 @@ def _on_message(self, msg: Any, topic: Any) -> None: # if source msg carries a frame_id, attach the entity to that TF frame # should skip if archetype is a Transform3D if not isinstance(rerun_data, rr.Transform3D): - frame_id = getattr(msg, "frame_id", None) + frame_id = message_frame_id(msg) if frame_id and self._frame_attached.get(entity_path) != frame_id: rr.log(entity_path, rr.Transform3D(parent_frame=f"tf#/{frame_id}")) self._frame_attached[entity_path] = frame_id - if isinstance(msg, Image) and frame_id in self._camera_infos: - rr.log(entity_path, self._camera_infos[frame_id].to_rerun_pinhole()) + if is_image and frame_id in self._camera_infos: + rr.log(entity_path, self._camera_pinhole(self._camera_infos[frame_id])) + + @staticmethod + def _camera_pinhole(info: CameraInfo | CdrCameraInfo) -> Archetype: + return camera_pinhole(info) if isinstance(info, CdrCameraInfo) else info.to_rerun_pinhole() - def _log_camera_info(self, entity_path: str, info: CameraInfo) -> None: + def _log_camera_info(self, entity_path: str, info: CameraInfo | CdrCameraInfo) -> None: """A CameraInfo is the pinhole of every image in its optical frame. Rerun draws the frustum only when the Pinhole sits on the image entity, @@ -504,13 +566,14 @@ def _log_camera_info(self, entity_path: str, info: CameraInfo) -> None: """ import rerun as rr - if not info.frame_id: - rr.log(entity_path, info.to_rerun_pinhole()) + frame = message_frame_id(info) + if not frame: + rr.log(entity_path, self._camera_pinhole(info)) return - self._camera_infos[info.frame_id] = info + self._camera_infos[frame] = info for image_path, frame_id in self._frame_attached.items(): - if frame_id == info.frame_id and image_path in self._image_entities: - rr.log(image_path, info.to_rerun_pinhole()) + if frame_id == frame and image_path in self._image_entities: + rr.log(image_path, self._camera_pinhole(info)) @rpc def start(self) -> None: @@ -596,6 +659,32 @@ def start(self) -> None: self.register_disposable(Disposable(pubsub.stop)) # type: ignore[union-attr] self._log_static() + for topic, transport in self.config.cdr_transports.items(): + self._subscribe_cdr(topic, transport) + + def _subscribe_cdr(self, topic: str, transport: CdrTransport[Any]) -> None: + cleanup = ExitStack() + cleanup.callback(transport.stop) + try: + unsubscribe = transport.subscribe(functools.partial(self._on_cdr_message, topic=topic)) + cleanup.callback(unsubscribe) + except BaseException: + cleanup.close() + raise + self.register_disposable(Disposable(cleanup.close)) + + def _on_cdr_message(self, msg: Any, *, topic: str) -> None: + import rerun as rr + + stamp = getattr(getattr(msg, "header", None), "stamp", None) + if stamp is not None: + nanos = int(stamp.sec) * 1_000_000_000 + int(stamp.nanosec) + rr.set_time("source", timestamp=np.datetime64(nanos, "ns")) + try: + self._on_message(msg, "/" + topic.strip("/")) + finally: + if stamp is not None: + rr.disable_timeline("source") def _subscribe(self, pubsub: SubscribeAllCapable[Any, Any]) -> Callable[[], None]: """Subscribe to the named topics, or to everything when none are named. diff --git a/dimos/visualization/rerun/cdr.py b/dimos/visualization/rerun/cdr.py new file mode 100644 index 00000000000..4c3f48c99dd --- /dev/null +++ b/dimos/visualization/rerun/cdr.py @@ -0,0 +1,277 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Viewer adapters live outside the generated plain value classes.""" + +from __future__ import annotations + +from dataclasses import asdict +from functools import lru_cache +import json +from typing import TYPE_CHECKING, Any + +import numpy as np + +from dimos.messages.dimos_msgs.msg import EntityMarkers +from dimos.messages.geometry_msgs.msg import PointStamped, PoseStamped +from dimos.messages.nav_msgs.msg import Odometry, Path +from dimos.messages.sensor_msgs.msg import CameraInfo, CompressedImage, Image, PointCloud2 +from dimos.messages.tf2_msgs.msg import TFMessage +from dimos.msgs.image import image_from_compressed, image_to_rgb, image_view +from dimos.msgs.pointcloud import pointcloud_rgb, pointcloud_xyz + +if TYPE_CHECKING: + import rerun as rr + + from dimos.visualization.rerun.bridge import RerunMulti + + +def message_frame_id(message: Any) -> str | None: + header = getattr(message, "header", None) + frame = ( + getattr(header, "frame_id", None) + if header is not None + else getattr(message, "frame_id", None) + ) + return frame if isinstance(frame, str) else None + + +def camera_pinhole(info: CameraInfo, optical_frame: str | None = None) -> rr.Pinhole: + import rerun as rr + + frame = info.header.frame_id if optical_frame is None else optical_frame + return rr.Pinhole( + focal_length=[info.k[0], info.k[4]], + principal_point=[info.k[2], info.k[5]], + width=info.width, + height=info.height, + image_plane_distance=1.0, + parent_frame=f"tf#/{frame}" if frame else None, + ) + + +def image_archetype(message: Image | CompressedImage) -> rr.Image | rr.DepthImage | rr.EncodedImage: + import rerun as rr + + if isinstance(message, CompressedImage): + name = message.format.lower() + if "jxl" in name or ("png" in name and name.startswith("16uc1;")): + return image_archetype(image_from_compressed(message)) + if "jpeg" in name or "jpg" in name: + return rr.EncodedImage(contents=bytes(message.data), media_type="image/jpeg") + if "png" in name: + return rr.EncodedImage(contents=bytes(message.data), media_type="image/png") + raise ValueError(f"unsupported compressed image format {message.format!r}") + if message.encoding in ("16UC1", "32FC1", "mono16"): + pixels = image_view(message) + pixels = np.ascontiguousarray(pixels, dtype=pixels.dtype.newbyteorder("=")) + if message.encoding != "mono16": + return rr.DepthImage(pixels, meter=1000.0 if message.encoding == "16UC1" else 1.0) + return rr.Image( + bytes=pixels.tobytes(), + width=message.width, + height=message.height, + datatype=np.uint16, + color_model="L", + ) + return rr.Image( + bytes=image_to_rgb(message).tobytes(), + width=message.width, + height=message.height, + datatype=np.uint8, + color_model="RGB", + ) + + +def cloud_archetype( + message: PointCloud2, + *, + voxel_size: float = 0.05, + mode: str = "points", + bottom_cutoff: float | None = None, + height_colors: bool = False, + color: tuple[int, int, int] | None = None, +) -> rr.Points3D | rr.Boxes3D: + """Render plain cloud values, keeping color/geometry filters aligned. + + Existing bridge defaults use screen-space dots. Offline map tools explicitly + select world-space spheres/boxes and the existing height-based turbo colors. + """ + from matplotlib import colormaps + import rerun as rr + + points, colors = pointcloud_xyz(message), pointcloud_rgb(message) + keep = np.isfinite(points).all(axis=1) + if bottom_cutoff is not None: + keep &= points[:, 2] >= bottom_cutoff + points = points[keep] + if colors is not None: + colors = colors[keep] + elif height_colors and len(points): + z = points[:, 2] + levels = ((z - z.min()) / (z.max() - z.min() + 1e-8) * 255).astype(np.uint8) + colors = (colormaps["turbo"](levels)[:, :3] * 255).astype(np.uint8) + if color is not None: + colors = np.asarray(color, dtype=np.uint8) + if mode == "boxes": + return rr.Boxes3D(centers=points, half_sizes=[voxel_size / 2] * 3, colors=colors) + return rr.Points3D( + points, + colors=colors, + radii=rr.components.Radius.ui_points(2.0) if mode == "points" else voxel_size / 2, + ) + + +@lru_cache(maxsize=16) +def _colormap_lut(name: str) -> np.ndarray[Any, np.dtype[np.uint8]]: + from matplotlib import colormaps + + return (colormaps[name](np.linspace(0, 1, 256))[:, :3] * 255).astype(np.uint8) + + +def register_colormap_annotation(name: str = "turbo") -> None: + """Register the height palette outside the generated point-cloud value type.""" + import rerun as rr + + lut = _colormap_lut(name) + rr.log( + "/", + rr.AnnotationContext( + [ + rr.datatypes.ClassDescription( + info=rr.datatypes.AnnotationInfo(id=i, color=lut[i].tolist()) + ) + for i in range(256) + ] + ), + static=True, + ) + + +def entity_points(message: EntityMarkers) -> rr.Points3D: + """Render generated world entities with the existing labels, colors and radii.""" + import rerun as rr + + colors = { + "person": (255, 100, 100, 255), + "object": (100, 255, 100, 255), + "location": (100, 100, 255, 255), + } + return rr.Points3D( + positions=[[m.position.x, m.position.y, m.position.z] for m in message.markers], + labels=[f"{m.entity_id}: {m.label[:40]}" for m in message.markers], + colors=[colors.get(m.entity_type, (200, 200, 200, 255)) for m in message.markers], + radii=[0.15] * len(message.markers), + ) + + +def path_archetype( + message: Path, + *, + color: tuple[int, int, int] = (0, 255, 128), + z_offset: float = 0.5, + radii: float = 0.05, +) -> rr.LineStrips3D: + import rerun as rr + + if not message.poses: + return rr.LineStrips3D([]) + points = [ + [pose.pose.position.x, pose.pose.position.y, pose.pose.position.z + z_offset] + for pose in message.poses + ] + return rr.LineStrips3D([points], colors=[color], radii=radii) + + +def generated_archetypes(message: Any, entity: str) -> RerunMulti | None: + """Render stamped geometry and its exact fields; other domains remain explicit.""" + import rerun as rr + + if type(message) in (Image, CompressedImage, PointCloud2): + archetype = ( + cloud_archetype(message) if type(message) is PointCloud2 else image_archetype(message) + ) + entries: RerunMulti = [(entity, archetype)] + frame = message_frame_id(message) + if frame: + entries.append((entity, rr.Transform3D(parent_frame=f"tf#/{frame}"))) + return entries + if type(message) is CameraInfo: + entries = [(entity, camera_pinhole(message))] + elif type(message) is TFMessage: + entries = [] + for value in message.transforms: + t, q = value.transform.translation, value.transform.rotation + entries.append( + ( + "tf#/" + rr.escape_entity_path_part(value.child_frame_id), + rr.Transform3D( + translation=[t.x, t.y, t.z], + quaternion=rr.Quaternion(xyzw=[q.x, q.y, q.z, q.w]), + parent_frame="tf#/" + value.header.frame_id, + child_frame="tf#/" + value.child_frame_id, + ), + ) + ) + elif type(message) is Path: + entries = [(entity, path_archetype(message))] + if message.header.frame_id: + entries.append((entity, rr.Transform3D(parent_frame=f"tf#/{message.header.frame_id}"))) + elif type(message) is EntityMarkers: + entries = [(entity, entity_points(message))] + if message.header.frame_id: + entries.append((entity, rr.Transform3D(parent_frame=f"tf#/{message.header.frame_id}"))) + elif type(message) in (PoseStamped, Odometry): + pose = message.pose.pose if type(message) is Odometry else message.pose + point, rotation = pose.position, pose.orientation + entries = [ + ( + entity, + rr.Transform3D( + translation=[point.x, point.y, point.z], + quaternion=rr.Quaternion(xyzw=[rotation.x, rotation.y, rotation.z, rotation.w]), + parent_frame=f"tf#/{message.header.frame_id}" + if message.header.frame_id + else None, + child_frame=f"tf#/{message.child_frame_id}" + if type(message) is Odometry and message.child_frame_id + else None, + ), + ), + (entity, rr.TransformAxes3D(0.35)), + ] + elif type(message) is PointStamped: + point = message.point + entries = [(entity, rr.Points3D([[point.x, point.y, point.z]]))] + if message.header.frame_id: + entries.append((entity, rr.Transform3D(parent_frame=f"tf#/{message.header.frame_id}"))) + else: + return None + fields = asdict( + message, + dict_factory=lambda items: {key: value for key, value in items if key != "__msgtype__"}, + ) + entries.append( + (entity + "/message", rr.TextDocument(json.dumps(fields, indent=2, default=_json_value))) + ) + return entries + + +def _json_value(value: Any) -> Any: + """Represent generated numerical arrays without changing their source values.""" + if isinstance(value, np.ndarray): + return value.tolist() + if isinstance(value, np.generic): + return value.item() + raise TypeError(f"Cannot render {type(value).__name__} as JSON") diff --git a/dimos/visualization/rerun/init.py b/dimos/visualization/rerun/init.py index da5a6afc25c..d225edd3b14 100644 --- a/dimos/visualization/rerun/init.py +++ b/dimos/visualization/rerun/init.py @@ -23,8 +23,8 @@ from typing import Any from urllib.parse import urlparse -from dimos.msgs.sensor_msgs.PointCloud2 import register_colormap_annotation from dimos.utils.logging_config import setup_logger +from dimos.visualization.rerun.cdr import register_colormap_annotation from dimos.visualization.rerun.constants import RERUN_GRPC_PORT, RERUN_WEBSOCKET_URL logger = setup_logger() diff --git a/dimos/visualization/rerun/test_cdr.py b/dimos/visualization/rerun/test_cdr.py new file mode 100644 index 00000000000..416737abea4 --- /dev/null +++ b/dimos/visualization/rerun/test_cdr.py @@ -0,0 +1,298 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generated values stay plain while the viewer owns rendering and subscriptions.""" + +import json +from unittest.mock import Mock + +from dimos_cdrgen.registry import decode, encode +import numpy as np +import pytest +import rerun as rr + +from dimos.core.cdr_transport import CdrTransport +from dimos.core.global_config import GlobalConfig +from dimos.messages.builtin_interfaces.msg import Time +from dimos.messages.dimos_msgs.msg import EntityMarker, EntityMarkers +from dimos.messages.geometry_msgs.msg import ( + Point, + PointStamped, + PoseWithCovariance, + Quaternion, + Transform, + TransformStamped, + Twist, + TwistWithCovariance, + Vector3, +) +from dimos.messages.nav_msgs.msg import Odometry +from dimos.messages.sensor_msgs.msg import Image +from dimos.messages.std_msgs.msg import Header +from dimos.messages.tf2_msgs.msg import TFMessage +from dimos.msgs.camera_info import camera_info_from_intrinsics +from dimos.msgs.image import image_from_array +from dimos.msgs.pointcloud import pointcloud_from_xyz_rgb +from dimos.protocol.rpc.zenohrpc import ZenohRPC +from dimos.visualization.rerun.bridge import Config, RerunBridgeModule, _resolve_pubsubs +from dimos.visualization.rerun.cdr import ( + cloud_archetype, + generated_archetypes, + image_archetype, + register_colormap_annotation, +) +from dimos.visualization.vis_module import vis_module +from dimos.web.relay_bridge.gen_cdr_fixtures import build_messages + + +@pytest.fixture +def bridge(mocker): + mocker.patch.object(ZenohRPC, "start") + mocker.patch.object(ZenohRPC, "serve_module_rpc") + instance = RerunBridgeModule(pubsubs=[]) + yield instance + instance.stop() + + +def test_pose_axes_fields_and_frame_are_kept_separate_from_value_class() -> None: + message = build_messages()[0][1] + entries = generated_archetypes(message, "world/pose") + assert entries is not None + assert [path for path, _ in entries] == ["world/pose", "world/pose", "world/pose/message"] + transform, axes, text = [archetype for _, archetype in entries] + np.testing.assert_allclose( + transform.translation.as_arrow_array().to_pylist(), [[1.5, -2.5, 0.25]] + ) + np.testing.assert_allclose( + transform.quaternion.as_arrow_array().to_pylist(), [[0, 0, 0.6, 0.8]] + ) + assert transform.parent_frame.as_arrow_array().to_pylist() == ["tf#/map"] + assert isinstance(axes, rr.TransformAxes3D) + fields = json.loads(text.text.as_arrow_array().to_pylist()[0]) + assert fields["header"] == { + "stamp": {"sec": 1757400000, "nanosec": 500000123}, + "frame_id": "map", + } + assert "__msgtype__" not in fields + assert not hasattr(message, "to_rerun") + + +def test_point_frame_and_unknown_values() -> None: + pose = build_messages()[0][1] + msg = PointStamped(header=pose.header, point=pose.pose.position) + entries = generated_archetypes(msg, "world/point") + assert entries is not None + assert isinstance(entries[0][1], rr.Points3D) + assert entries[1][1].parent_frame.as_arrow_array().to_pylist() == ["tf#/map"] + assert generated_archetypes({"x": 1}, "world/no") is None + + +@pytest.mark.parametrize("nanoseconds", [0, 1757400000500000123]) +def test_source_time_is_scoped_and_reset_after_render_failure(bridge, mocker, nanoseconds) -> None: + msg = build_messages()[0][1] + msg.header.stamp = Time(sec=nanoseconds // 1_000_000_000, nanosec=nanoseconds % 1_000_000_000) + render = mocker.patch.object(bridge, "_on_message", side_effect=ValueError("bad renderer")) + set_time = mocker.patch.object(rr, "set_time") + clear_time = mocker.patch.object(rr, "disable_timeline") + with pytest.raises(ValueError, match="bad renderer"): + bridge._on_cdr_message(msg, topic="/pose") + render.assert_called_once_with(msg, "/pose") + set_time.assert_called_once_with("source", timestamp=np.datetime64(nanoseconds, "ns")) + clear_time.assert_called_once_with("source") + + +def test_subscription_cleanup_order_and_failed_start(bridge) -> None: + operations: list[str] = [] + transport = Mock(spec=CdrTransport) + transport.subscribe.return_value = lambda: operations.append("unsubscribe") + transport.stop.side_effect = lambda: operations.append("stop") + bridge._subscribe_cdr("pose", transport) + bridge.stop() + assert operations == ["unsubscribe", "stop"] + failed = Mock(spec=CdrTransport) + failed.subscribe.side_effect = RuntimeError("native start failed") + with pytest.raises(RuntimeError, match="native start failed"): + bridge._subscribe_cdr("pose", failed) + failed.stop.assert_called_once_with() + + +def test_bridge_default_renderer_handles_generated_pose(bridge, mocker) -> None: + log = mocker.patch.object(rr, "log") + bridge._on_message(build_messages()[0][1], "/pose") + assert [call.args[0] for call in log.call_args_list] == [ + "world/pose", + "world/pose", + "world/pose/message", + ] + assert isinstance(log.call_args_list[0].args[1], rr.Transform3D) + + +@pytest.mark.parametrize("shape", [(1, 2), (2, 1)]) +def test_single_row_or_column_rgb_keeps_dimensions_and_color_bytes(shape) -> None: + pixels = np.array([[[255, 0, 0], [0, 255, 0]]], dtype=np.uint8).reshape(*shape, 3) + image = decode(encode(image_from_array(pixels, encoding="rgb8")), Image) + archetype = image_archetype(image) + assert archetype.format.as_arrow_array().to_pylist()[0]["height"] == shape[0] + assert archetype.format.as_arrow_array().to_pylist()[0]["width"] == shape[1] + assert archetype.buffer.as_arrow_array().to_pylist() == [[255, 0, 0, 0, 255, 0]] + + +def test_big_endian_depth_renderer_preserves_values_and_units() -> None: + pixels = np.array([[1, 256, 65535]], dtype=">u2") + archetype = image_archetype(image_from_array(pixels, encoding="16UC1")) + assert isinstance(archetype, rr.DepthImage) + assert archetype.meter.as_arrow_array().to_pylist() == [1000.0] + raw = bytes(archetype.buffer.as_arrow_array().to_pylist()[0]) + np.testing.assert_array_equal(np.frombuffer(raw, dtype=np.uint16), pixels.ravel()) + + +@pytest.mark.parametrize("camera_first", [False, True]) +def test_calibration_attaches_in_both_arrival_orders(bridge, mocker, camera_first) -> None: + header = build_messages()[0][1].header + image = image_from_array(np.zeros((1, 2, 3), dtype=np.uint8), encoding="rgb8", header=header) + calibration = camera_info_from_intrinsics(10, 20, 1, 0.5, 2, 1, header=header) + log = mocker.patch.object(rr, "log") + samples = [(calibration, "/calibration"), (image, "/image")] + for message, topic in samples if camera_first else reversed(samples): + bridge._on_message(message, topic) + pinholes = [call for call in log.call_args_list if isinstance(call.args[1], rr.Pinhole)] + assert len(pinholes) == 1 + assert pinholes[0].args[0] == "world/image" + assert pinholes[0].args[1].parent_frame.as_arrow_array().to_pylist() == ["tf#/map"] + + +def test_cloud_renderer_filters_nonfinite_positions_with_matching_colors() -> None: + header = build_messages()[0][1].header + cloud = pointcloud_from_xyz_rgb( + np.array([[1, 2, 3], [float("nan"), 0, 0], [4, 5, 6]]), + np.array([[255, 0, 0], [0, 255, 0], [0, 0, 255]], dtype=np.uint8), + header=header, + ) + archetype = cloud_archetype(cloud) + assert archetype.positions.as_arrow_array().to_pylist() == [[1, 2, 3], [4, 5, 6]] + assert archetype.colors.as_arrow_array().to_pylist() == [0xFF0000FF, 0x0000FFFF] + + +@pytest.mark.parametrize("backend", ["zenoh", "lcm"]) +def test_blueprint_does_not_construct_pubsubs_and_resolution_keeps_backend(mocker, backend) -> None: + lcm = mocker.patch("dimos.visualization.rerun.bridge.LCM") + zenoh = mocker.patch("dimos.visualization.rerun.bridge.Zenoh") + config = Config(g=GlobalConfig(transport=backend)) + assert config.pubsubs is None + vis_module("rerun") + lcm.assert_not_called() + zenoh.assert_not_called() + resolved = _resolve_pubsubs(config) + expected = zenoh if backend == "zenoh" else lcm + expected.assert_called_once_with() + assert resolved == [expected.return_value] + + +def test_generated_entity_marker_adapter_keeps_labels_colors_frame_and_exact_fields(): + header = Header(frame_id="world", stamp=Time(sec=1700000000, nanosec=123456789)) + message = EntityMarkers( + header=header, + markers=[ + EntityMarker( + entity_id="E1", + label="person", + entity_type="person", + position=Point(x=1, y=2, z=0.3), + ) + ], + ) + message = decode(encode(message), EntityMarkers) + entries = generated_archetypes(message, "memory/entities") + assert entries is not None + points, frame, fields = [value for _, value in entries] + np.testing.assert_allclose(points.positions.as_arrow_array().to_pylist(), [[1, 2, 0.3]]) + assert points.labels.as_arrow_array().to_pylist() == ["E1: person"] + assert points.colors.as_arrow_array().to_pylist() == [0xFF6464FF] + assert frame.parent_frame.as_arrow_array().to_pylist() == ["tf#/world"] + assert ( + json.loads(fields.text.as_arrow_array().to_pylist()[0])["header"]["stamp"]["nanosec"] + == 123456789 + ) + + +@pytest.mark.parametrize("mode", ["points", "boxes", "spheres"]) +def test_fixed_display_color_overrides_sensor_rgb_after_invalid_points_filter(mode): + cloud = pointcloud_from_xyz_rgb( + np.array([[1, 2, 3], [np.nan, 0, 0]], dtype=np.float32), + np.array([[255, 0, 0], [0, 255, 0]], dtype=np.uint8), + header=Header(stamp=Time(sec=0, nanosec=0), frame_id="world"), + ) + before = encode(cloud) + value = cloud_archetype(cloud, mode=mode, color=(76, 220, 41)) + positions = value.centers if mode == "boxes" else value.positions + assert positions.as_arrow_array().to_pylist() == [[1, 2, 3]] + assert value.colors.as_arrow_array().to_pylist() == [0x4CDC29FF] + assert encode(cloud) == before + + +def test_height_palette_uses_all_class_ids_at_the_recording_root(mocker): + log = mocker.patch.object(rr, "log") + register_colormap_annotation("turbo") + log.assert_called_once() + path, annotation = log.call_args.args + assert path == "/" and log.call_args.kwargs == {"static": True} + [classes] = annotation.context.as_arrow_array().to_pylist() + assert [item["class_id"] for item in classes] == list(range(256)) + assert classes[0]["class_description"]["info"]["color"] == 0x30123BFF + + +def test_generated_odometry_exposes_pose_axes_and_full_covariance_fields(): + pose = build_messages()[0][1] + value = Odometry( + pose.header, + "base", + PoseWithCovariance(pose.pose, np.zeros(36)), + TwistWithCovariance(Twist(Vector3(0.0, 0.0, 0.0), Vector3(0.0, 0.0, 0.0)), np.zeros(36)), + ) + value.pose.covariance[0] = 0.25 + entries = generated_archetypes(value, "world/odometry") + assert entries is not None + assert isinstance(entries[0][1], rr.Transform3D) + assert isinstance(entries[1][1], rr.TransformAxes3D) + assert entries[0][1].translation.as_arrow_array().to_pylist() == [[1.5, -2.5, 0.25]] + fields = json.loads(entries[-1][1].text.as_arrow_array().to_pylist()[0]) + assert fields["pose"]["covariance"][0] == 0.25 + assert fields["child_frame_id"] == "base" + assert entries[0][1].child_frame.as_arrow_array().to_pylist() == ["tf#/base"] + assert fields["header"]["stamp"]["nanosec"] == 500000123 + assert value.pose.covariance[0] == 0.25 + + +def test_generated_tf_preserves_frame_tree_layout_and_transform_values(bridge, mocker): + bridge.config.tf_axes = 0.35 + bridge._tf_tree = bridge._new_tf_tree() + log = mocker.patch.object(rr, "log") + value = TFMessage( + [ + TransformStamped( + Header(Time(0, 1), "world"), + "camera", + Transform(Vector3(1.0, 2.0, 3.0), Quaternion(0.0, 0.0, 0.0, 1.0)), + ) + ] + ) + bridge._on_message(value, "tf") + assert bridge._tf_tree.placements() == { + "world": "world/tf/world", + "camera": "world/tf/world/camera", + } + transform = next(call.args[1] for call in log.call_args_list if call.args[0] == "tf#/camera") + assert transform.translation.as_arrow_array().to_pylist() == [[1.0, 2.0, 3.0]] + assert transform.child_frame.as_arrow_array().to_pylist() == ["tf#/camera"] + assert value.transforms[0].header.stamp == Time(0, 1) diff --git a/dimos/visualization/rerun/test_cdr_e2e.py b/dimos/visualization/rerun/test_cdr_e2e.py new file mode 100644 index 00000000000..b05a1a6d120 --- /dev/null +++ b/dimos/visualization/rerun/test_cdr_e2e.py @@ -0,0 +1,60 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run the exact offline example and inspect its real RRD with the SDK's CLI.""" + +from pathlib import Path +import subprocess +import sys + +import pytest + + +def test_actual_loopback_example_preserves_pose_axes_values_and_nanoseconds( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # The pinned Rerun CLI disables analytics when CI is present. This changes + # only the child environment, not the user's persistent viewer settings. + monkeypatch.setenv("CI", "1") + monkeypatch.delenv("FORCE_RERUN_ANALYTICS", raising=False) + example = Path(__file__).resolve().parents[3] / "examples/message-viewer/demo_cdr_rerun.py" + output = tmp_path / "pose.rrd" + command = [sys.executable, "-I", str(example), str(output)] + subprocess.run(command, check=True, capture_output=True, text=True, timeout=30) + contents = output.read_bytes() + assert contents.startswith(b"RRF") + rerun = Path(sys.executable).parent / "rerun" + result = subprocess.run( + [str(rerun), "rrd", "print", "-vvv", str(output)], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + assert "TransformAxes3D:axis_length" in result.stdout + assert "[[1.5, -2.5, 0.25]]" in result.stdout + assert "[[0.0, 0.0, 0.6, 0.8]]" in result.stdout + assert "tf#/map" in result.stdout + assert "2023-11-14T22:13:20.123456789" in result.stdout + assert '"nanosec": 123456789' in result.stdout + assert "Exact message values" in result.stdout + assert "Points3D:positions" in result.stdout + assert "Points3D:colors" in result.stdout + assert "Image:buffer" in result.stdout + assert "Pinhole:image_from_camera" in result.stdout + assert "Generated RGB pixels" in result.stdout + repeated = subprocess.run(command, capture_output=True, text=True, timeout=30) + assert repeated.returncode != 0 + assert "FileExistsError" in repeated.stderr + assert output.read_bytes() == contents diff --git a/dimos/visualization/rerun/test_cdr_entities.py b/dimos/visualization/rerun/test_cdr_entities.py new file mode 100644 index 00000000000..9cfd9c3676d --- /dev/null +++ b/dimos/visualization/rerun/test_cdr_entities.py @@ -0,0 +1,48 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for refactored temporal memory components.""" + +from dimos_cdrgen.registry import decode as cdr_decode, encode as cdr_encode +import rerun as rr + +from dimos.messages.builtin_interfaces.msg import Time +from dimos.messages.dimos_msgs.msg import EntityMarker, EntityMarkers +from dimos.messages.geometry_msgs.msg import Point +from dimos.messages.std_msgs.msg import Header +from dimos.visualization.rerun.cdr import entity_points + + +def test_markers_to_rerun() -> None: + markers = EntityMarkers( + markers=[ + EntityMarker( + entity_id="E1", + label="person walking", + entity_type="person", + position=Point(x=1.0, y=2.0, z=0.3), + ), + EntityMarker( + entity_id="E2", + label="wooden table", + entity_type="object", + position=Point(x=3.0, y=4.0, z=0.3), + ), + ], + header=Header(stamp=Time(sec=0, nanosec=0), frame_id=""), + ) + archetype = entity_points(cdr_decode(cdr_encode(markers), EntityMarkers)) + # Should return rr.Points3D + + assert isinstance(archetype, rr.Points3D) diff --git a/dimos/visualization/rerun/test_cdr_image_values.py b/dimos/visualization/rerun/test_cdr_image_values.py new file mode 100644 index 00000000000..ef19268abc9 --- /dev/null +++ b/dimos/visualization/rerun/test_cdr_image_values.py @@ -0,0 +1,57 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import numpy as np +import pytest +import rerun as rr + +from dimos.messages.builtin_interfaces.msg import Time +from dimos.messages.std_msgs.msg import Header +from dimos.msgs.image import ( + compressed_image_from_image, + image_from_array, + image_from_compressed, + image_view, +) +from dimos.msgs.time import time_from_seconds +from dimos.visualization.rerun.cdr import image_archetype + + +@pytest.fixture() +def rgb_image(): + rng = np.random.RandomState(42) + gradient = np.linspace(0, 255, 640, dtype=np.uint8) + data = np.broadcast_to(gradient, (480, 640)).copy() + data = np.stack([data, data // 2, rng.randint(0, 50, (480, 640), dtype=np.uint8)], axis=-1) + return image_from_array( + data, encoding="rgb8", header=Header(frame_id="cam", stamp=time_from_seconds(1234.5678)) + ) + + +def test_to_rerun_is_encoded_image(rgb_image) -> None: + assert isinstance(image_archetype(compressed_image_from_image(rgb_image)), rr.EncodedImage) + + +@pytest.mark.parametrize("codec", ["png", "jxl"]) +def test_compressed_uint16_depth_keeps_units_and_rerun_depth(codec: str) -> None: + pixels = np.arange(80, dtype=np.uint16).reshape(10, 8) + source = image_from_array( + pixels, encoding="16UC1", header=Header(frame_id="depth", stamp=Time(sec=0, nanosec=0)) + ) + compressed = compressed_image_from_image(source, format=codec) + decoded = image_from_compressed(compressed) + assert decoded.encoding == "16UC1" + assert decoded.header == source.header + np.testing.assert_array_equal(image_view(decoded), pixels) + assert isinstance(image_archetype(compressed), rr.DepthImage) diff --git a/dimos/visualization/rerun/test_cdr_sensor_docs.py b/dimos/visualization/rerun/test_cdr_sensor_docs.py new file mode 100644 index 00000000000..29aa0452bd7 --- /dev/null +++ b/dimos/visualization/rerun/test_cdr_sensor_docs.py @@ -0,0 +1,37 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from pathlib import Path +import re + +import numpy as np + +from dimos.msgs.image import image_to_rgb + + +def test_documented_sensor_blocks_run_as_written() -> None: + guide = Path(__file__).resolve().parents[3] / "docs/usage/visualization.md" + section = guide.read_text().split("## Arrays and compression for generated sensors\n", 1)[1] + blocks = re.findall(r"```python\n(.*?)```", section, re.DOTALL) + assert len(blocks) == 3 + namespace = {} + for block in blocks: + exec(compile(block, str(guide), "exec"), namespace) + np.testing.assert_allclose( + namespace["xyz"], [[-0.25, -0.25, 1], [0.25, -0.25, 1], [-0.25, 0.25, 1], [0.25, 0.25, 1]] + ) + np.testing.assert_array_equal(image_to_rgb(namespace["restored"]), namespace["rgb"]) + assert namespace["cloud"].header == namespace["image"].header + assert not namespace["pixels"].flags.writeable diff --git a/dimos/web/cdr_codec.py b/dimos/web/cdr_codec.py new file mode 100644 index 00000000000..459565fe887 --- /dev/null +++ b/dimos/web/cdr_codec.py @@ -0,0 +1,63 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generated CDR payloads and self-contained ROS2 schemas for web channels.""" + +from collections.abc import Mapping +import re +from typing import Any + +from dimos_cdrgen.registry import encode, message_types, schema + +CDR_V1_SUFFIX = ".cdr.v1" +_TYPE_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_]*/msg/[A-Za-z][A-Za-z0-9_]*$") + + +def cdr_type_name(message_type: type[Any]) -> str | None: + name = getattr(message_type, "__msgtype__", None) + if ( + isinstance(name, str) + and _TYPE_NAME.fullmatch(name) + and message_types().get(name) is message_type + ): + return name + return None + + +def export_schema(message_type: type[Any]) -> dict[str, str]: + name = cdr_type_name(message_type) + if name is None: + raise ValueError(f"{message_type.__qualname__} has no generated CDR schema") + return {"type": name, "definition": schema(name)} + + +def check_cdr_params(params: Mapping[str, Any]) -> None: + schema = params.get("cdr") + if not ( + isinstance(schema, Mapping) + and isinstance(schema.get("type"), str) + and _TYPE_NAME.fullmatch(schema["type"]) + and isinstance(schema.get("definition"), str) + ): + raise ValueError( + "*.cdr.v1 channels need params['cdr'] = {type, definition}; cockpit() fills it in" + ) + + +def encode_cdr_v1(msg: Any, params: Mapping[str, Any]) -> bytes: + if cdr_type_name(type(msg)) != params["cdr"]["type"]: + raise ValueError( + f"message type does not match declared channel type {params['cdr']['type']}" + ) + return encode(msg) diff --git a/dimos/web/cockpit.py b/dimos/web/cockpit.py index 22dbe908c44..9049f922b80 100644 --- a/dimos/web/cockpit.py +++ b/dimos/web/cockpit.py @@ -51,7 +51,8 @@ from dimos.msgs.geometry_msgs.PointStamped import PointStamped from dimos.msgs.nav_msgs.Path import Path from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 -from dimos.web.codecs import is_generic_lcm_encoding +from dimos.web.cdr_codec import export_schema as export_cdr_schema +from dimos.web.codecs import is_generic_cdr_encoding, is_generic_lcm_encoding from dimos.web.lcm_codec import default_encoding, export_schema, schema_class_for from dimos.web.relay_bridge.manifest import ( MANIFEST_VERSION, @@ -168,7 +169,8 @@ class Channel: JSON-shaped types and dataclasses (rx) / JSON scalars, lists and dicts (tx decode), or `.lcm.v1` for rx DimOS messages with a dimos_lcm schema (the frame is `lcm_encode()`, the schema rides - params["lcm"]). None picks the default: `.lcm.v1` for rx + params["lcm"]), or explicit `/msg/.cdr.v1` for installed + generated values (complete ROS2 schemas ride params["cdr"]). None picks the default: `.lcm.v1` for rx DimOS messages, "json.v1" otherwise. A dir="tx" channel is a generic browser publish input: it must declare @@ -278,6 +280,10 @@ def _request_of(channel: Channel) -> ChannelRequest: # Deep plain copy: the manifest and specs must not alias the (frozen) # authoring record's nested values. params = _thaw_params(channel.params or {}) + if is_generic_cdr_encoding(channel.encoding): + if "cdr" in params: + raise ValueError("params key 'cdr' is reserved for the CDR schema") + params["cdr"] = export_cdr_schema(channel.message_type) if is_generic_lcm_encoding(channel.encoding): if "lcm" in params: raise ValueError("params key 'lcm' is reserved for the LCM schema") diff --git a/dimos/web/codecs.py b/dimos/web/codecs.py index e0129908846..fee05c93c96 100644 --- a/dimos/web/codecs.py +++ b/dimos/web/codecs.py @@ -35,10 +35,11 @@ def decode_goal(value: dict[str, Any], context: PublishContext) -> PoseStamped: Encoders take the message (plus, optionally, the channel's params mapping) and return `bytes`, an `EncodedPayload` when the frame needs header meta, or None -to skip the sample. Two codecs apply without registration: json.v1 for +to skip the sample. Codecs apply without registration: json.v1 for JSON-shaped message types, and `.lcm.v1` (dimos/web/lcm_codec.py) for DimOS messages with a dimos_lcm schema (the frame is `msg.lcm_encode()`, -the schema rides the channel's params["lcm"]). Anything else potentially +the schema rides the channel's params["lcm"]), and explicit `/msg/.cdr.v1` +for installed generated CDR values with complete params["cdr"] schemas. Anything else potentially large (images, arrays, bytes) needs an explicit @web_encoder. """ @@ -52,6 +53,13 @@ def decode_goal(value: dict[str, Any], context: PublishContext) -> PoseStamped: import types from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints +from dimos.web.cdr_codec import ( + CDR_V1_SUFFIX, + cdr_type_name, + check_cdr_params, + encode_cdr_v1, + export_schema as export_cdr_schema, +) from dimos.web.lcm_codec import ( LCM_V1_SUFFIX, check_lcm_params, @@ -421,6 +429,11 @@ def resolve_decoder(encoding: str, message_type: type[Any]) -> DecoderDef: ) +def is_generic_cdr_encoding(encoding: str) -> bool: + """True for a generic CDR encoder unless a registered encoder owns the id.""" + return encoding.endswith(CDR_V1_SUFFIX) and encoding not in _encoders + + def is_generic_lcm_encoding(encoding: str) -> bool: """True for a *.lcm.v1 id the generic LCM codec serves (a registered @web_encoder with that id keeps its own params and encoder).""" @@ -446,6 +459,16 @@ def resolve_encoder(encoding: str, message_type: type[Any]) -> EncoderDef: "and qualified name" ) return definition + if encoding.endswith(CDR_V1_SUFFIX): + if encoding != f"{cdr_type_name(message_type)}{CDR_V1_SUFFIX}": + raise ValueError( + f"encoding {encoding!r} does not match the installed CDR type " + f"{message_type.__qualname__}" + ) + export_cdr_schema(message_type) + return EncoderDef( + encoding, message_type, encode_cdr_v1, takes_params=True, check_params=check_cdr_params + ) if encoding.endswith(LCM_V1_SUFFIX): if encoding != f"{lcm_type_name(message_type)}{LCM_V1_SUFFIX}": raise ValueError( diff --git a/dimos/web/relay_bridge/gen_cdr_fixtures.py b/dimos/web/relay_bridge/gen_cdr_fixtures.py new file mode 100644 index 00000000000..c4e2124f968 --- /dev/null +++ b/dimos/web/relay_bridge/gen_cdr_fixtures.py @@ -0,0 +1,139 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Regenerate browser fixtures with: python -m dimos.web.relay_bridge.gen_cdr_fixtures.""" + +import base64 +import json +from typing import Any + +from dimos_cdrgen.definitions import Definitions +from dimos_cdrgen.registry import encode as cdr_encode +import numpy as np + +from dimos.messages.builtin_interfaces.msg import Time +from dimos.messages.dimos_msgs.msg import LineSegment3D, LineSegments3D +from dimos.messages.geometry_msgs.msg import Point, Pose, PoseStamped, Quaternion, Vector3 +from dimos.messages.sensor_msgs.msg import Image, Imu, JointState +from dimos.messages.std_msgs.msg import Empty, Header, Int64, String +from dimos.web.cdr_codec import CDR_V1_SUFFIX, export_schema +from dimos.web.relay_bridge.locate import find_web_dir + + +def build_messages() -> list[tuple[str, Any]]: + header = Header(stamp=Time(sec=1757400000, nanosec=500000123), frame_id="map") + return [ + ( + "pose_stamped", + PoseStamped( + header=header, + pose=Pose( + position=Point(x=1.5, y=-2.5, z=0.25), + orientation=Quaternion(z=0.6, w=0.8, x=0.0, y=0.0), + ), + ), + ), + ( + "custom_segments", + LineSegments3D( + header=header, + segments=[ + LineSegment3D( + start=Point(x=1, y=0.0, z=0.0), end=Point(y=2, x=0.0, z=0.0), weight=4 + ) + ], + ), + ), + ( + "image", + Image( + header=header, + width=2, + height=1, + encoding="rgb8", + step=6, + data=np.array([0, 1, 2, 253, 254, 255], dtype=np.uint8), + is_bigendian=0, + ), + ), + ( + "imu", + Imu( + header=header, + orientation_covariance=np.asarray(range(9), dtype=np.float64), + orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0), + angular_velocity=Vector3(x=0.0, y=0.0, z=0.0), + angular_velocity_covariance=np.zeros(9, dtype=np.float64), + linear_acceleration=Vector3(x=0.0, y=0.0, z=0.0), + linear_acceleration_covariance=np.zeros(9, dtype=np.float64), + ), + ), + ( + "joints", + JointState( + header=header, + name=["joint_a", "joint_b"], + position=np.array([1.5, -2.5], dtype=np.float64), + velocity=np.array([], dtype=np.float64), + effort=np.array([], dtype=np.float64), + ), + ), + ("int64", Int64(data=-9007199254740993)), + ("empty", Empty()), + ("string", String(data="robot 🦾 café")), + ] + + +def build_vectors() -> list[dict[str, Any]]: + definitions = {message.name: message for message in Definitions([], installed=True).resolve()} + + def walk(msg: Any) -> dict[str, Any]: + result: dict[str, Any] = {} + for field in definitions[msg.__msgtype__].fields: + value = getattr(msg, field.name) + convert = ( + walk + if field.type.nested + else ( + str + if field.type.name in ("int64", "uint64") + else lambda x: x.item() if isinstance(x, np.generic) else x + ) + ) + result[field.name] = ( + [convert(item) for item in value] if field.type.is_array else convert(value) + ) + return result + + return [ + { + "name": name, + "encoding": msg.__msgtype__ + CDR_V1_SUFFIX, + "schema": export_schema(type(msg)), + "value": walk(msg), + "payload_b64": base64.b64encode(cdr_encode(msg)).decode(), + "big_endian_b64": base64.b64encode(cdr_encode(msg, little_endian=False)).decode(), + } + for name, msg in build_messages() + ] + + +def main() -> None: + path = find_web_dir() / "shared" / "fixtures" / "cdr_frames.json" + path.write_text(json.dumps({"vectors": build_vectors()}, indent=2, ensure_ascii=False) + "\n") + print(path) + + +if __name__ == "__main__": + main() diff --git a/dimos/web/relay_bridge/module_test_support.py b/dimos/web/relay_bridge/module_test_support.py index 7bb849106c3..51fdf38cbfc 100644 --- a/dimos/web/relay_bridge/module_test_support.py +++ b/dimos/web/relay_bridge/module_test_support.py @@ -27,9 +27,11 @@ import threading import time from typing import Any +from unittest.mock import Mock import pytest +from dimos.protocol.rpc.zenohrpc import ZenohRPC from dimos.web.relay_bridge import relay_bridge_module from dimos.web.relay_bridge.protocol import PROTOCOL_VERSION, DataFrame, IceServer, Msg, RtcTrack from dimos.web.relay_bridge.relay_bridge_module import RelayBridgeModule @@ -261,6 +263,10 @@ async def fake_fetch(base_url: str, **kwargs: Any) -> RelayInfo: monkeypatch.setattr(relay_bridge_module, "fetch_relay_info", fake_fetch) monkeypatch.setattr(RelayClient, "connect", fake_connect) + # Module construction starts RPC before the relay itself. Keep this + # no-network harness off native sockets as well as off WebTransport. + monkeypatch.setattr(ZenohRPC, "start", Mock()) + monkeypatch.setattr(ZenohRPC, "serve_module_rpc", Mock()) def make_bridge( diff --git a/dimos/web/relay_bridge/test_relay_bridge_authoring.py b/dimos/web/relay_bridge/test_relay_bridge_authoring.py index 8b59103dc61..b07bd3ca5de 100644 --- a/dimos/web/relay_bridge/test_relay_bridge_authoring.py +++ b/dimos/web/relay_bridge/test_relay_bridge_authoring.py @@ -27,6 +27,7 @@ import struct from typing import Any +from dimos_cdrgen.registry import encode as encode_cdr from langchain_core.messages import AIMessage import numpy as np import pytest @@ -36,6 +37,7 @@ from dimos.core.module import Module, ModuleConfig from dimos.core.resource_monitor.stats import ProcessStats, WorkerStats from dimos.core.stream import In, Out +from dimos.messages.geometry_msgs.msg import PoseStamped as GeneratedPoseStamped from dimos.msgs.geometry_msgs.PointStamped import PointStamped from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -50,6 +52,7 @@ from dimos.web.relay_bridge.channels import RuntimeChannelSpec from dimos.web.relay_bridge.config import RelayBridgeConfig from dimos.web.relay_bridge.e2e_support import stop_module +from dimos.web.relay_bridge.gen_cdr_fixtures import build_messages from dimos.web.relay_bridge.module_test_support import ( FakeClient, FakeTransport, @@ -212,6 +215,41 @@ def test_authored_specs_drive_generated_port(monkeypatch) -> None: stop_module(module) +def test_generated_cdr_is_encoded_only_for_subscribed_viewers(monkeypatch) -> None: + blueprint = cockpit( + channels=[ + Channel( + "generated_pose", + GeneratedPoseStamped, + encoding="geometry_msgs/msg/PoseStamped.cdr.v1", + max_hz=1000.0, + ) + ] + ) + module, clients = start_authored(monkeypatch, blueprint, wire=("generated_pose",)) + try: + transport = transport_of(module, "generated_pose") + msg = build_messages()[0][1] + transport.publish(msg) + flush_loop(module) + assert clients[0].frames == [] + push(module, clients[0], Subs(chs=["generated_pose"], n=1)) + assert wait_until(lambda: transport.subscribers) + transport.publish(msg) + assert wait_until(lambda: clients[0].frames) + channel, payload, delivery, meta = clients[0].frames[0] + assert (channel, payload, delivery, meta) == ( + "generated_pose", + encode_cdr(msg), + "reliable", + None, + ) + push(module, clients[0], Subs(chs=[], n=2)) + assert wait_until(lambda: not transport.subscribers) + finally: + stop_module(module) + + def test_two_jpeg_channels_use_independent_quality(monkeypatch) -> None: # The second camera the old single-int _jpeg_quality could not express. blueprint = cockpit( diff --git a/dimos/web/test_cdr_codec.py b/dimos/web/test_cdr_codec.py new file mode 100644 index 00000000000..bfb8c090885 --- /dev/null +++ b/dimos/web/test_cdr_codec.py @@ -0,0 +1,100 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Web CDR schemas and bytes come from the installed owner, not another generator.""" + +import base64 +import json +import pickle +import subprocess +import sys +from typing import Any + +from dimos_cdrgen.registry import decode, encode +import pytest + +from dimos.messages.geometry_msgs.msg import Point, PoseStamped +from dimos.web.cdr_codec import check_cdr_params, encode_cdr_v1, export_schema +from dimos.web.cockpit import Channel, cockpit +from dimos.web.codecs import resolve_encoder +from dimos.web.relay_bridge.gen_cdr_fixtures import build_messages, build_vectors +from dimos.web.relay_bridge.locate import find_web_dir + + +def test_cdr_channel_embeds_schema_and_preserves_owned_encoder() -> None: + channel = Channel( + "pose", PoseStamped, encoding="geometry_msgs/msg/PoseStamped.cdr.v1", params={"note": "x"} + ) + blueprint = cockpit(channels=[channel]) + atom = blueprint.blueprints[0] + wire = atom.kwargs["manifest"]["channels"][0] + assert wire["params"] == {"note": "x", "cdr": export_schema(PoseStamped)} + assert "MSG: std_msgs/Header" in wire["params"]["cdr"]["definition"] + assert "MSG: builtin_interfaces/Time" in wire["params"]["cdr"]["definition"] + assert dict(channel.params) == {"note": "x"} + spec = pickle.loads(pickle.dumps(blueprint)).blueprints[0].kwargs["channels"][0] + msg = build_messages()[0][1] + assert spec.encoder is encode_cdr_v1 + assert spec.encoder(msg, spec.params) == encode(msg) + assert next(stream for stream in atom.streams if stream.name == "pose").type is PoseStamped + + +def test_cdr_channel_rejects_foreign_type_and_reserved_schema() -> None: + with pytest.raises(ValueError, match="does not match"): + resolve_encoder("geometry_msgs/msg/Point.cdr.v1", PoseStamped) + with pytest.raises(ValueError, match="reserved"): + cockpit( + channels=[ + Channel( + "pose", + PoseStamped, + encoding="geometry_msgs/msg/PoseStamped.cdr.v1", + params={"cdr": {}}, + ) + ] + ) + with pytest.raises(ValueError, match="declared channel type"): + encode_cdr_v1(Point(x=0, y=0, z=0), {"cdr": export_schema(PoseStamped)}) + with pytest.raises(ValueError, match="no generated CDR schema"): + export_schema(dict) + + +@pytest.mark.parametrize("params", [{}, {"cdr": {}}, {"cdr": {"type": "p.T", "definition": ""}}]) +def test_missing_schema_is_rejected(params: dict[str, Any]) -> None: + with pytest.raises(ValueError, match="params"): + check_cdr_params(params) + + +def test_browser_vectors_are_actual_generated_messages_in_both_byte_orders() -> None: + vectors = json.loads((find_web_dir() / "shared/fixtures/cdr_frames.json").read_text())[ + "vectors" + ] + assert vectors == build_vectors() + for (_, msg), vector in zip(build_messages(), vectors, strict=True): + for key, little in [("payload_b64", True), ("big_endian_b64", False)]: + payload = base64.b64decode(vector[key]) + restored = decode(payload, type(msg)) + assert type(restored) is type(msg) + assert encode(restored, little_endian=little) == payload + + +def test_codec_import_has_no_generated_or_numpy_side_effects() -> None: + subprocess.run( + [ + sys.executable, + "-c", + "import sys; import dimos.web.cdr_codec; assert 'numpy' not in sys.modules; assert 'dimos.messages' not in sys.modules", + ], + check=True, + ) diff --git a/docs/usage/visualization.md b/docs/usage/visualization.md index 4b3fe350685..edeabaf6b8d 100644 --- a/docs/usage/visualization.md +++ b/docs/usage/visualization.md @@ -147,3 +147,99 @@ When a `RerunBridgeModule` is already part of your blueprint, you typically don' Rerun on `dev` is **module-driven**: modules decide what to log, and `Blueprint.build()` sets up the shared viewer + default layout. To use a custom websocket port, set `--rerunwebsocketserver.port`; the bridge in the same blueprint takes the viewer URL from that server. `--rerunbridgemodule.websocket-url` points a bridge at a websocket server elsewhere. Set `host` on each module to override its bind address; both default to the shared `listen_host`. + +## Generated CDR geometry + +Generated message classes are plain values. The Rerun bridge supplies the +PoseStamped/PointStamped adapters, including pose axes and a text view of every +field, plus raw/compressed image, depth, CameraInfo and colored point-cloud +adapters. An explicit CDR subscription is independent of legacy pubsub discovery: + +```python +from dimos.core.cdr_transport import CdrTransport +from dimos.messages.geometry_msgs.msg import PoseStamped +from dimos.visualization.rerun.bridge import RerunBridgeModule + +viewer = RerunBridgeModule(pubsubs=[], cdr_transports={ + "measured_pose": CdrTransport("measured_pose", PoseStamped, backend="zenoh"), +}) +viewer.start() +# Stop the bridge when the application shuts down: +viewer.stop() +``` + +The producer must use the matching explicit CDR channel and schema. Each +transport instance in `cdr_transports` belongs to the bridge; use a separate +instance for the producer. Header stamps use a source timeline with exact +nanoseconds, including zero. Header frames remain named `tf#/` frames; +they are not silently reinterpreted as world coordinates. The existing TF tree +must locate that frame for a shared spatial view. CameraInfo attaches to images +in the same Header frame in either arrival order. Mapping/control and remaining +generated domains still need their own adapters; transport defaults are unchanged. + +To see an actual offline exchange, from this checkout with its normal installed +DimOS environment: + +```sh +python examples/message-viewer/demo_cdr_rerun.py pose.rrd +rerun pose.rrd +``` + +The example sends generated pose, RGB image, CameraInfo and RGBD-projected cloud bytes over +discovery-disabled loopback Zenoh/CDR, then writes a real Rerun recording. Its +layout shows pose axes, colored points, exact message values and RGB pixels. +For this demonstration only, it explicitly +places the message's map frame at the viewer origin. It refuses an existing +output, needs no robot, and does not start the module coordinator or change host +configuration. CI runs this exact example and reads the RRD back with the +pinned Rerun CLI to check geometry, axes, image/point buffers and nanosecond timestamps. + +## Arrays and compression for generated sensors + +Image values hold ROS fields; array operations are separate helpers: + +```python +import numpy as np +from dimos.msgs.image import image_from_array, image_view +from dimos.msgs.time import header_now + +rgb = np.array([[[255, 0, 0], [0, 255, 0]], [[0, 0, 255], [255, 255, 255]]], dtype=np.uint8) +image = image_from_array(rgb, encoding="rgb8", header=header_now("camera_optical")) +pixels = image_view(image) +``` + +`image_from_array` copies its input. `image_view` borrows a read-only view that +keeps storage alive and respects row padding and byte order. Use `pixels.copy()` +when you need independent writable data. These helpers require generated +`dimos.messages` values; legacy convenience classes retain their old path. + +Project a matching color/depth pair using explicit camera calibration and units: + +```python +from dimos.msgs.camera_info import camera_info_from_intrinsics +from dimos.msgs.pointcloud import pointcloud_from_rgbd, pointcloud_xyz + +depth = image_from_array(np.full((2, 2), 1000, dtype=np.uint16), encoding="16UC1", header=image.header) +camera = camera_info_from_intrinsics(2, 2, 0.5, 0.5, 2, 2, header=image.header) +cloud = pointcloud_from_rgbd(image, depth, camera, depth_scale=0.001) +xyz = pointcloud_xyz(cloud) +``` + +Integer depth is scaled to meters; `32FC1` depth is already meters. Invalid, +nonpositive or truncated depths are excluded. The cloud keeps the depth Header +and RGB colors. Structured cloud views retain field counts, endian and row +padding; selection and transforms preserve extra fields rather than rebuilding +only XYZ. Calibration dimensions and focal lengths are validated. + +Compression is explicit and keeps source metadata: + +```python +from dimos.msgs.image import compressed_image_from_image, image_from_compressed + +encoded = compressed_image_from_image(image, format="png") +restored = image_from_compressed(encoded) +``` + +JPEG is lossy color/gray; PNG preserves uint16 depth. JXL is an explicit consumer +extension and preserves uint16/float32 depth losslessly. The Rerun adapter keeps +depth units and normalizes byte order without changing source CDR values. diff --git a/docs/web/bridge.md b/docs/web/bridge.md index 651b513e5bf..e8eb6f4177d 100644 --- a/docs/web/bridge.md +++ b/docs/web/bridge.md @@ -40,6 +40,60 @@ The relay flags are `dimos run` options. Most of the rest are fields of the brid ## Channels +### Generated CDR messages + +An installed generated type can be sent as CDR with its complete ROS2 definition +in the manifest. Declare the encoding explicitly in this proposal: + +```python +from dimos.messages.geometry_msgs.msg import PoseStamped +from dimos.web.cockpit import Channel, cockpit + +viewer = cockpit(channels=[Channel( + "measured_pose", PoseStamped, + encoding="geometry_msgs/msg/PoseStamped.cdr.v1", +)]) +``` + +Compose `viewer` with the module producing `measured_pose: Out[PoseStamped]`. +Bind both ports to the same explicit `CdrTransport` when using the opt-in native +CDR transport. The manifest embeds PoseStamped, Header and Time definitions from +the installed message owner; the browser needs neither ROS nor another generated +message package. Registered custom web encoders continue to take precedence. +Existing LCM/JSON channels and default transport selection are unchanged here. + +The SDK registry resolves the channel schema once and decodes its CDR frames: + +```ts +import { createDecoderRegistry } from "@dimos/sdk"; + +const decode = createDecoderRegistry().resolve(channel); +const value = decode?.(payload, frameHeader).value; +``` + +`channel` is the adopted manifest record, `payload` the frame's `Uint8Array` and +`frameHeader` its SDK header. Invalid schemas are unsupported; malformed CDR +frames fail decoding. The raw cockpit table previews small messages. Messages +with variable-length arrays require a visible panel subscription so a preview +does not pull a large image/cloud buffer. A decoder alone does not add a typed +camera, map or control panel. + +To inspect actual generated Python bytes in a browser from this checkout: + +```sh +cd web/sdk +deno task fixture --host 127.0.0.1 +``` + +Open `http://127.0.0.1:5174/demo_cdr.html`. Select a message and byte order to see +its decoded fields and full schema; the image case also displays its RGB pixels. +This fixture uses checked-in packets generated by the installed CDR codecs, not +a live robot stream. Regenerate them with +`python -m dimos.web.relay_bridge.gen_cdr_fixtures`; Python tests check that the +packets still match those codecs, and SDK tests decode both byte orders. + +### Existing channels + A channel is one stream crossing the wire in one direction with one encoding. `rx` channels flow from the robot to the browser, `tx` channels from the browser to the robot. The bridge has four built-in ports: | Channel | Type | Encoding | Delivery | diff --git a/examples/message-viewer/demo_cdr_rerun.py b/examples/message-viewer/demo_cdr_rerun.py new file mode 100644 index 00000000000..4b2a3b1b5b1 --- /dev/null +++ b/examples/message-viewer/demo_cdr_rerun.py @@ -0,0 +1,143 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Record generated pose, image and RGBD cloud over loopback CDR into Rerun.""" + +from __future__ import annotations + +import argparse +from contextlib import ExitStack +from functools import partial +from pathlib import Path +import threading +from typing import Any + +import numpy as np +import rerun as rr +import rerun.blueprint as rrb + +from dimos.core.cdr_transport import CdrTransport +from dimos.messages.builtin_interfaces.msg import Time +from dimos.messages.geometry_msgs.msg import Point, Pose, PoseStamped, Quaternion +from dimos.messages.sensor_msgs.msg import CameraInfo +from dimos.messages.std_msgs.msg import Header +from dimos.msgs.camera_info import camera_info_from_intrinsics +from dimos.msgs.image import image_from_array +from dimos.msgs.pointcloud import pointcloud_from_rgbd +from dimos.visualization.rerun.cdr import camera_pinhole, generated_archetypes + + +def run(output: Path) -> None: + # Reserve a new destination exclusively; never replace a user's recording. + with output.open("xb"): + pass + message = PoseStamped( + header=Header(stamp=Time(sec=1700000000, nanosec=123456789), frame_id="map"), + pose=Pose( + position=Point(x=1.5, y=-2.5, z=0.25), orientation=Quaternion(x=0, y=0, z=0.6, w=0.8) + ), + ) + image = image_from_array( + np.array([[[255, 0, 0], [0, 255, 0]], [[0, 0, 255], [255, 255, 255]]], dtype=np.uint8), + encoding="rgb8", + header=message.header, + ) + depth = image_from_array( + np.full((2, 2), 1000, dtype=np.uint16), encoding="16UC1", header=message.header + ) + calibration = camera_info_from_intrinsics(2, 2, 0.5, 0.5, 2, 2, header=message.header) + cloud = pointcloud_from_rgbd(image, depth, calibration, depth_scale=0.001) + messages: dict[str, Any] = { + "pose": message, + "image": image, + "cloud": cloud, + "camera_info": calibration, + } + config: dict[str, Any] = dict( + mode="peer", listen=["tcp/127.0.0.1:0"], scouting=False, multicast=False, gossip=False + ) + recording = rr.RecordingStream("dimos-cdr-pose") + received = threading.Event() + received_topics: set[str] = set() + lock = threading.Lock() + errors: list[Exception] = [] + + def receive(value: Any, *, topic: str) -> None: + try: + stamp = value.header.stamp + recording.set_time( + "source", timestamp=np.datetime64(stamp.sec * 1_000_000_000 + stamp.nanosec, "ns") + ) + if isinstance(value, CameraInfo): + # A spatial image needs its calibration, not just pixel bytes. + recording.log("world/image", camera_pinhole(value), strict=True) + else: + entries = generated_archetypes(value, "world/" + topic) + assert entries is not None + for entry in entries: + recording.log(entry[0], entry[1], strict=True) + except Exception as error: + errors.append(error) + finally: + with lock: + received_topics.add(topic) + if errors or received_topics == set(messages): + received.set() + + with ExitStack() as cleanup: + cleanup.callback(recording.disconnect) + recording.save( + output, + default_blueprint=rrb.Blueprint( + rrb.Horizontal( + rrb.Spatial3DView(origin="world", name="Pose axes and RGBD cloud"), + rrb.Vertical( + rrb.TextDocumentView( + origin="world/pose/message", name="Exact message values" + ), + rrb.Spatial2DView(origin="world/image", name="Generated RGB pixels"), + ), + ) + ), + ) + # This demo explicitly identifies map with its viewer origin. Runtime + # bridges use the producer's named TF frame; no coordinates are rewritten. + recording.log("world", rr.Transform3D(child_frame="tf#/map"), static=True) + publishers: dict[str, CdrTransport[Any]] = {} + for topic, value in messages.items(): + subscriber = CdrTransport(topic, type(value), backend="zenoh", **config) + cleanup.callback(subscriber.stop) + cleanup.callback(subscriber.subscribe(partial(receive, topic=topic))) + publisher = CdrTransport(topic, type(value), backend="zenoh", **config) + cleanup.callback(publisher.stop) + publishers[topic] = publisher + for topic, value in messages.items(): + publishers[topic].broadcast(None, value) + if not received.wait(timeout=10): + raise TimeoutError(f"Missing loopback CDR topics: {set(messages) - received_topics}") + if errors: + raise errors[0] + recording.flush(timeout_sec=10) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path, help="new .rrd file (must not exist)") + args = parser.parse_args() + run(args.output) + print(f"Recorded actual loopback CDR; open with: rerun {args.output}") + + +if __name__ == "__main__": + main() diff --git a/web/cockpit/src/subscriptions.test.ts b/web/cockpit/src/subscriptions.test.ts index 420179092e5..fec2c09a5eb 100644 --- a/web/cockpit/src/subscriptions.test.ts +++ b/web/cockpit/src/subscriptions.test.ts @@ -66,6 +66,23 @@ const voxels = spec({ ch: "global_map", encoding: "voxels.zlib.v1", delivery: "l const map3dPanel = panel({ id: "map3d", kind: "map3d", channels: ["global_map", "odom"] }); describe("subscribableChannels", () => { + it("previews small CDR values but only subscribes CDR sequences through a panel", () => { + const small = spec({ + ch: "scalar", + encoding: "demo_msgs/msg/Scalar.cdr.v1", + params: { cdr: { type: "demo_msgs/msg/Scalar", definition: "float64 value\n" } }, + }); + const bulk = spec({ + ch: "bulk", + encoding: "demo_msgs/msg/Bulk.cdr.v1", + params: { cdr: { type: "demo_msgs/msg/Bulk", definition: "uint8[] data\n" } }, + }); + const view = panel({ id: "view", kind: "video", channels: ["bulk"] }); + expect(channelSubscribable(small, [])).toBe(true); + expect(channelSubscribable(bulk, [])).toBe(false); + expect(channelSubscribable(bulk, [view])).toBe(true); + expect(channelSubscribable({ ...bulk, params: {} }, [view])).toBe(false); + }); it("keeps only channels with a decoder (undecodable ones waste bandwidth)", () => { expect(subscribableChannels([odom, jpeg, future], [videoPanel])).toEqual([odom, jpeg]); expect(subscribableChannels([future], [])).toEqual([]); diff --git a/web/cockpit/src/subscriptions.ts b/web/cockpit/src/subscriptions.ts index 5ff52e5b0ab..87dba75644b 100644 --- a/web/cockpit/src/subscriptions.ts +++ b/web/cockpit/src/subscriptions.ts @@ -5,7 +5,13 @@ // manifest and view change. import type { ChannelSpec, DecoderRegistry, Session } from "@dimos/sdk"; -import { createDecoderRegistry, isLcmSchema, TRACK_ENCODING } from "@dimos/sdk"; +import { + cdrHasSequences, + createDecoderRegistry, + isCdrSchema, + isLcmSchema, + TRACK_ENCODING, +} from "@dimos/sdk"; import type { PanelSpec } from "@dimos/shared"; import { getPanel } from "./panels/registry.tsx"; @@ -30,6 +36,9 @@ const PANEL_ONLY_ENCODINGS = new Set([ * subscribes like json.v1 so the table can show it. */ function panelOnly(spec: ChannelSpec): boolean { if (PANEL_ONLY_ENCODINGS.has(spec.encoding)) return true; + if (/\.cdr\.v1$/.test(spec.encoding) && isCdrSchema(spec.params.cdr)) { + return cdrHasSequences(spec.params.cdr); + } const lcm = spec.params.lcm; return isLcmSchema(lcm) && Object.values(lcm.structs).some((rows) => diff --git a/web/deno.lock b/web/deno.lock index 0fe06125699..f87e68507f3 100644 --- a/web/deno.lock +++ b/web/deno.lock @@ -8,6 +8,8 @@ "jsr:@std/internal@^1.0.12": "1.0.12", "npm:@fontsource-variable/inter@^5.3.0": "5.3.0", "npm:@fontsource-variable/jetbrains-mono@^5.3.0": "5.3.0", + "npm:@foxglove/rosmsg2-serialization@3.1.2": "3.1.2", + "npm:@foxglove/rosmsg@5.0.5": "5.0.5", "npm:@peculiar/x509@2": "2.0.0", "npm:@types/node@^24.10.1": "24.12.2", "npm:@types/react-dom@^19.2.0": "19.2.3_@types+react@19.2.17", @@ -327,6 +329,30 @@ "@fontsource-variable/jetbrains-mono@5.3.0": { "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==" }, + "@foxglove/cdr@3.5.1": { + "integrity": "sha512-Fubm04/xC1SkeNaviwUhbFk8nAiiNvIqlmP5MW7r6IVGFa35rsA0E3/Rg/pGdE77ZXf4XNwShZcQoEmje62ddQ==" + }, + "@foxglove/message-definition@0.5.0": { + "integrity": "sha512-B/2GvWn4wRSsPDywcmy6uNXgAfZRhiOAnmy3qAaS6MHVqiHQqs/RoDsoW6Gw9sXSweJpeH5qAqAihvm1pR9Thg==" + }, + "@foxglove/rosmsg2-serialization@3.1.2": { + "integrity": "sha512-KKtmHuuIQy6I0JLUi01LFhV7hrZb5xd+LegtuGz2+9L9pftBxtVVKhoxO6B/kQREGocIUOLP8494kxLF58LMWw==", + "dependencies": [ + "@foxglove/cdr", + "@foxglove/message-definition", + "@foxglove/rostime" + ] + }, + "@foxglove/rosmsg@5.0.5": { + "integrity": "sha512-+qY6xW+4HdbPI3ItAlFFMjXgAiplXyRsv+kqdBk8OcWGH38BmEd7s6v/VnCkbYovAoB5rfR5giuKfmPRNzwbrA==", + "dependencies": [ + "@foxglove/message-definition", + "md5-typescript" + ] + }, + "@foxglove/rostime@1.1.3": { + "integrity": "sha512-Gr3Xy4sNbo2NcXg0tBHb/jVUqIcDyZ1kgHj9dB7K+Y1A7AQ0oSCmSXIuf8oqeteSIs0pl8BJOWEwAtLNC99Izg==" + }, "@jridgewell/gen-mapping@0.3.13": { "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dependencies": [ @@ -939,6 +965,9 @@ "@jridgewell/sourcemap-codec" ] }, + "md5-typescript@1.0.5": { + "integrity": "sha512-ovAc4EtiNt2dY8JPhPr/wkC9h4U5k/nuClNVcG0Ga3V1rMlYpAY24ZaaymFXJlz+ccJ6UMPo3FSaVKe7czBsXw==" + }, "meshoptimizer@1.1.1": { "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==" }, @@ -1229,6 +1258,8 @@ "sdk": { "packageJson": { "dependencies": [ + "npm:@foxglove/rosmsg2-serialization@3.1.2", + "npm:@foxglove/rosmsg@5.0.5", "npm:@types/node@^24.10.1", "npm:@types/react-dom@^19.2.0", "npm:@types/react@^19.2.0", diff --git a/web/sdk/fixture/demo_cdr.html b/web/sdk/fixture/demo_cdr.html new file mode 100644 index 00000000000..9e891fec8c5 --- /dev/null +++ b/web/sdk/fixture/demo_cdr.html @@ -0,0 +1,77 @@ + + + + + + DimOS generated CDR demo + + + +

Generated messages in the browser

+

+ Python-generated CDR bytes, decoded by the DimOS browser SDK from their embedded ROS2 + definitions. No ROS installation or browser message package. +

+ + +
+
+

+ +

+      
+ Embedded ROS2 definition +

+      
+
+ + + diff --git a/web/sdk/fixture/demo_cdr.ts b/web/sdk/fixture/demo_cdr.ts new file mode 100644 index 00000000000..6e13812c927 --- /dev/null +++ b/web/sdk/fixture/demo_cdr.ts @@ -0,0 +1,59 @@ +import frames from "../../shared/fixtures/cdr_frames.json"; +import { createDecoderRegistry } from "@dimos/sdk"; +import type { ChannelSpec } from "@dimos/shared"; + +const registry = createDecoderRegistry(); +const messages = document.querySelector("#message")!; +const endian = document.querySelector("#endian")!; +for (const [index, vector] of frames.vectors.entries()) { + messages.add(new Option(vector.name, String(index))); +} +function render(): void { + const vector = frames.vectors[Number(messages.value)]; + const channel: ChannelSpec = { + ch: vector.name, + dir: "rx", + encoding: vector.encoding, + delivery: "reliable", + maxHz: 1, + params: { cdr: vector.schema }, + publish: "none", + requiredScope: null, + }; + const encoded = endian.value === "big" ? vector.big_endian_b64 : vector.payload_b64; + const payload = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0)); + const decoded = registry.resolve(channel)!(payload, { + ch: vector.name, + seq: 1, + ts: 0, + delivery: "reliable", + }); + document.querySelector("#status")!.textContent = + `Decoded ${payload.length} CDR bytes · ${endian.value} endian`; + document.querySelector("#type")!.textContent = vector.schema.type; + document.querySelector("#schema")!.textContent = vector.schema.definition; + document.querySelector("#value")!.textContent = JSON.stringify( + decoded.value, + (_key, value) => + typeof value === "bigint" + ? `${value}n` + : ArrayBuffer.isView(value) + ? Array.from(value as unknown as ArrayLike) + : value, + 2, + ); + const canvas = document.querySelector("#image")!; + canvas.hidden = vector.name !== "image"; + if (!canvas.hidden) { + const image = decoded.value as { data: Uint8Array }; + const rgba = new Uint8ClampedArray(8); + for (let i = 0; i < 2; i++) { + rgba.set(image.data.subarray(i * 3, i * 3 + 3), i * 4); + rgba[i * 4 + 3] = 255; + } + canvas.getContext("2d")!.putImageData(new ImageData(rgba, 2, 1), 0, 0); + } +} +messages.onchange = render; +endian.onchange = render; +render(); diff --git a/web/sdk/package.json b/web/sdk/package.json index d3521895cda..a67fa16b3e4 100644 --- a/web/sdk/package.json +++ b/web/sdk/package.json @@ -21,5 +21,9 @@ "typescript": "~5.9.2", "vite": "^7.1.0", "vitest": "^3.2.4" + }, + "dependencies": { + "@foxglove/rosmsg": "5.0.5", + "@foxglove/rosmsg2-serialization": "3.1.2" } } diff --git a/web/sdk/src/decoders/cdr.test.ts b/web/sdk/src/decoders/cdr.test.ts new file mode 100644 index 00000000000..98549748315 --- /dev/null +++ b/web/sdk/src/decoders/cdr.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import frames from "../../../shared/fixtures/cdr_frames.json"; +import { cdrDecoder, cdrDecoderFor, cdrPreview } from "./cdr.ts"; +import { createDecoderRegistry } from "./index.ts"; +import type { ChannelSpec } from "@dimos/shared"; +const HEADER = { ch: "message", seq: 1, ts: 0, delivery: "latest" as const }; +const bytes = (value: string) => Uint8Array.from(atob(value), (c) => c.charCodeAt(0)); +function plain(value: unknown): unknown { + if (typeof value === "bigint") return String(value); + if (ArrayBuffer.isView(value)) return Array.from(value as unknown as ArrayLike, plain); + if (Array.isArray(value)) return value.map(plain); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, plain(item)]), + ); + } + return value; +} +describe("generated CDR browser decoding", () => { + it("resolves a CDR manifest once and refuses conflicting type identities", () => { + const vector = frames.vectors[0]; + const spec: ChannelSpec = { + ch: "pose", + dir: "rx", + encoding: vector.encoding, + delivery: "reliable", + maxHz: 1, + params: { cdr: vector.schema }, + publish: "none", + requiredScope: null, + }; + const registry = createDecoderRegistry(); + const decode = registry.resolve(spec)!; + expect(registry.resolve(spec)).toBe(decode); + expect(plain(decode(bytes(vector.payload_b64), HEADER).value)).toEqual(vector.value); + expect(registry.resolve({ ...spec, encoding: "other_msgs/msg/Pose.cdr.v1" })).toBeUndefined(); + expect(registry.resolve({ ...spec, params: {} })).toBeUndefined(); + expect(registry.resolve({ ...spec })).not.toBe(decode); + }); + for (const vector of frames.vectors) { + it(`decodes ${vector.name} in both byte orders from its embedded schema`, () => { + const decode = cdrDecoder(vector.schema); + for (const encoded of [vector.payload_b64, vector.big_endian_b64]) { + expect(plain(decode(bytes(encoded), HEADER).value)).toEqual(vector.value); + } + }); + } + it("rejects truncation, invalid encapsulation and trailing bytes", () => { + const vector = frames.vectors[0]; + const decode = cdrDecoder(vector.schema); + const payload = bytes(vector.payload_b64); + expect(() => decode(payload.subarray(0, payload.length - 1), HEADER)).toThrow(); + expect(() => decode(new Uint8Array([...payload, 0]), HEADER)).toThrow(/trailing/); + payload[0] = 255; + expect(() => decode(payload, HEADER)).toThrow(/header/); + }); + it("declines missing and malformed schemas", () => { + expect(cdrDecoderFor(undefined)).toBeNull(); + expect(cdrDecoderFor({ type: "p/msg/T", definition: "not a definition !" })).toBeNull(); + expect(cdrDecoderFor({ type: "p.T", definition: "float64 x" })).toBeNull(); + }); + it("bounds previews of large images and arrays", () => { + const text = cdrPreview({ data: new Uint8Array(1000000), label: "x".repeat(10000) }); + expect(text.length).toBeLessThan(550); + expect(text).toContain("Uint8Array(1000000)"); + }); +}); diff --git a/web/sdk/src/decoders/cdr.ts b/web/sdk/src/decoders/cdr.ts new file mode 100644 index 00000000000..10c244c780d --- /dev/null +++ b/web/sdk/src/decoders/cdr.ts @@ -0,0 +1,106 @@ +// Decode generated messages from the manifest's complete ROS2 definition. +import { parse } from "@foxglove/rosmsg"; +import { MessageReader } from "@foxglove/rosmsg2-serialization"; +import type { Decoder } from "./index.ts"; + +export interface CdrSchema { + type: string; + definition: string; +} +export const CDR_ENCODING_RE = /\.cdr\.v1$/; +export const CDR_PREVIEW_MAX_CHARS = 512; +const PREVIEW_MAX_ITEMS = 8; +const PREVIEW_MAX_STRING = 64; + +export function isCdrSchema(value: unknown): value is CdrSchema { + if (value === null || typeof value !== "object") return false; + const schema = value as Record; + return typeof schema.type === "string" && + /^[A-Za-z][A-Za-z0-9_]*\/msg\/[A-Za-z][A-Za-z0-9_]*$/.test(schema.type) && + typeof schema.definition === "string"; +} + +export function cdrDecoder(schema: CdrSchema): Decoder { + const definitions = parse(schema.definition, { ros2: true }); + const reader = new MessageReader(definitions); + return (payload) => { + // Generated codecs use encapsulated XCDR1 with no trailing bytes. + if ( + payload.length < 4 || payload[0] !== 0 || payload[1] > 1 || + payload[2] !== 0 || payload[3] !== 0 + ) throw new Error("invalid XCDR1 header"); + const value = reader.readMessage(payload); + if (reader.lastReadByteLength() !== payload.byteLength) throw new Error("trailing CDR bytes"); + return { value, preview: cdrPreview(value) }; + }; +} + +export function cdrDecoderFor(value: unknown): Decoder | null { + if (!isCdrSchema(value)) return null; + try { + return cdrDecoder(value); + } catch { + return null; + } +} + +/** Variable-length arrays require an explicit panel subscription in the cockpit. */ +export function cdrHasSequences(schema: CdrSchema): boolean { + return parse(schema.definition, { ros2: true }).some((definition) => + definition.definitions.some((field) => field.isArray && field.arrayLength === undefined) + ); +} + +export function cdrPreview(value: unknown, budget = CDR_PREVIEW_MAX_CHARS): string { + const parts: string[] = []; + let len = 0; + let full = false; + const push = (s: string) => { + parts.push(s); + len += s.length; + if (len >= budget) full = true; + }; + const items = (n: number, at: (i: number) => unknown) => { + for (let i = 0; i < n && i < PREVIEW_MAX_ITEMS && !full; i++) { + if (i > 0) push(", "); + walk(at(i)); + } + if (n > PREVIEW_MAX_ITEMS && !full) push(", ..."); + push("]"); + }; + const walk = (v: unknown): void => { + if (full) return; + if (v === null || v === undefined) { + push("null"); + } else if (typeof v === "string") { + push( + JSON.stringify(v.length > PREVIEW_MAX_STRING ? `${v.slice(0, PREVIEW_MAX_STRING)}...` : v), + ); + } else if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") { + push(String(v)); + } else if (ArrayBuffer.isView(v)) { + const a = v as unknown as ArrayLike; + push(`${v.constructor.name}(${a.length})[`); + items(a.length, (i) => a[i]); + } else if (Array.isArray(v)) { + push("["); + items(v.length, (i) => v[i]); + } else if (typeof v === "object") { + push("{"); + let first = true; + for (const [key, item] of Object.entries(v)) { + if (full) break; + if (!first) push(", "); + first = false; + push(`${key}: `); + walk(item); + } + push("}"); + } else { + push(String(v)); + } + }; + walk(value); + const text = parts.join(""); + return full ? `${text.slice(0, budget)} ... (truncated)` : text; +} diff --git a/web/sdk/src/decoders/index.ts b/web/sdk/src/decoders/index.ts index dd8ce12e0d3..86e8a105d75 100644 --- a/web/sdk/src/decoders/index.ts +++ b/web/sdk/src/decoders/index.ts @@ -1,5 +1,5 @@ // Payload decoder registry. resolve(spec) finds a manifest channel's decoder: -// a registered encoding id, the *.json.vN convention, or a *.lcm.v1 decoder +// a registered encoding id, the *.json.vN convention, or a *.lcm.v1/*.cdr.v1 decoder // compiled from the schema the robot put in the channel's params. An encoding // without a decoder is not an error: the channel renders as "unsupported" // (forward compatibility with newer bridges). Binary decoders (h264.v1, ...) @@ -9,6 +9,7 @@ import type { ChannelSpec, FrameHeader } from "@dimos/shared"; import { TRACK_ENCODING } from "@dimos/shared/manifest"; +import { CDR_ENCODING_RE, cdrDecoderFor, isCdrSchema } from "./cdr.ts"; import { costmapDecoder } from "./costmap.ts"; import { jpegDecoder } from "./jpeg.ts"; import { jsonDecoder } from "./json.ts"; @@ -27,6 +28,7 @@ export class DecoderRegistry { #decoders = new Map(); // Compiled *.lcm.v1 decoders per adopted manifest record (null: params.lcm // unusable); entries die with the manifest object that carried them. + #cdr = new WeakMap(); #lcm = new WeakMap(); /** Duplicate registration is an error unless `replace` is set. */ @@ -52,6 +54,17 @@ export class DecoderRegistry { resolve(spec: ChannelSpec): Decoder | undefined { const known = this.get(spec.encoding); if (known !== undefined) return known; + if (CDR_ENCODING_RE.test(spec.encoding)) { + if (!isCdrSchema(spec.params.cdr) || spec.encoding !== `${spec.params.cdr.type}.cdr.v1`) { + return undefined; + } + let compiled = this.#cdr.get(spec); + if (compiled === undefined) { + compiled = cdrDecoderFor(spec.params.cdr); + this.#cdr.set(spec, compiled); + } + return compiled ?? undefined; + } if (!LCM_ENCODING_RE.test(spec.encoding)) return undefined; let compiled = this.#lcm.get(spec); if (compiled === undefined) { diff --git a/web/sdk/src/index.ts b/web/sdk/src/index.ts index f7efa3c7846..e4d6d348247 100644 --- a/web/sdk/src/index.ts +++ b/web/sdk/src/index.ts @@ -42,3 +42,5 @@ export type { } from "@dimos/shared"; export { TRACK_ENCODING } from "@dimos/shared/manifest"; export type { Manifest } from "@dimos/shared/manifest"; + +export { cdrDecoder, cdrHasSequences, isCdrSchema } from "./decoders/cdr.ts"; diff --git a/web/shared/fixtures/cdr_frames.json b/web/shared/fixtures/cdr_frames.json new file mode 100644 index 00000000000..9cb2c72f5f3 --- /dev/null +++ b/web/shared/fixtures/cdr_frames.json @@ -0,0 +1,236 @@ +{ + "vectors": [ + { + "name": "pose_stamped", + "encoding": "geometry_msgs/msg/PoseStamped.cdr.v1", + "schema": { + "type": "geometry_msgs/msg/PoseStamped", + "definition": "# A Pose with reference coordinate frame and timestamp\n\nstd_msgs/Header header\nPose pose\n================================================================================\nMSG: builtin_interfaces/Time\n# This message communicates ROS Time defined here:\n# https://design.ros2.org/articles/clock_and_time.html\n\n# The seconds component, valid over all int32 values.\nint32 sec\n\n# The nanoseconds component, valid in the range [0, 1e9), to be added to the seconds component. \n# e.g.\n# The time -1.7 seconds is represented as {sec: -2, nanosec: 3e8}\n# The time 1.7 seconds is represented as {sec: 1, nanosec: 7e8}\nuint32 nanosec\n================================================================================\nMSG: geometry_msgs/Point\n# This contains the position of a point in free space\nfloat64 x\nfloat64 y\nfloat64 z\n================================================================================\nMSG: geometry_msgs/Pose\n# A representation of pose in free space, composed of position and orientation.\n\nPoint position\nQuaternion orientation\n================================================================================\nMSG: geometry_msgs/Quaternion\n# This represents an orientation in free space in quaternion form.\n\nfloat64 x 0\nfloat64 y 0\nfloat64 z 0\nfloat64 w 1\n================================================================================\nMSG: std_msgs/Header\n# Standard metadata for higher-level stamped data types.\n# This is generally used to communicate timestamped data\n# in a particular coordinate frame.\n\n# Two-integer timestamp that is expressed as seconds and nanoseconds.\nbuiltin_interfaces/Time stamp\n\n# Transform frame with which this data is associated.\nstring frame_id\n" + }, + "value": { + "header": { + "stamp": { + "sec": 1757400000, + "nanosec": 500000123 + }, + "frame_id": "map" + }, + "pose": { + "position": { + "x": 1.5, + "y": -2.5, + "z": 0.25 + }, + "orientation": { + "x": 0.0, + "y": 0.0, + "z": 0.6, + "w": 0.8 + } + } + }, + "payload_b64": "AAEAAMDLv2h7Zc0dBAAAAG1hcAAAAAAAAAD4PwAAAAAAAATAAAAAAAAA0D8AAAAAAAAAAAAAAAAAAAAAMzMzMzMz4z+amZmZmZnpPw==", + "big_endian_b64": "AAAAAGi/y8AdzWV7AAAABG1hcAA/+AAAAAAAAMAEAAAAAAAAP9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP+MzMzMzMzM/6ZmZmZmZmg==" + }, + { + "name": "custom_segments", + "encoding": "dimos_msgs/msg/LineSegments3D.cdr.v1", + "schema": { + "type": "dimos_msgs/msg/LineSegments3D", + "definition": "# Copyright 2026 Dimensional Inc.\n# SPDX-License-Identifier: Apache-2.0\n\n# Weighted segments in a common coordinate frame.\nstd_msgs/Header header\nLineSegment3D[] segments\n================================================================================\nMSG: builtin_interfaces/Time\n# This message communicates ROS Time defined here:\n# https://design.ros2.org/articles/clock_and_time.html\n\n# The seconds component, valid over all int32 values.\nint32 sec\n\n# The nanoseconds component, valid in the range [0, 1e9), to be added to the seconds component. \n# e.g.\n# The time -1.7 seconds is represented as {sec: -2, nanosec: 3e8}\n# The time 1.7 seconds is represented as {sec: 1, nanosec: 7e8}\nuint32 nanosec\n================================================================================\nMSG: dimos_msgs/LineSegment3D\n# Copyright 2026 Dimensional Inc.\n# SPDX-License-Identifier: Apache-2.0\n\ngeometry_msgs/Point start\ngeometry_msgs/Point end\nfloat64 weight 1.0\n================================================================================\nMSG: geometry_msgs/Point\n# This contains the position of a point in free space\nfloat64 x\nfloat64 y\nfloat64 z\n================================================================================\nMSG: std_msgs/Header\n# Standard metadata for higher-level stamped data types.\n# This is generally used to communicate timestamped data\n# in a particular coordinate frame.\n\n# Two-integer timestamp that is expressed as seconds and nanoseconds.\nbuiltin_interfaces/Time stamp\n\n# Transform frame with which this data is associated.\nstring frame_id\n" + }, + "value": { + "header": { + "stamp": { + "sec": 1757400000, + "nanosec": 500000123 + }, + "frame_id": "map" + }, + "segments": [ + { + "start": { + "x": 1, + "y": 0.0, + "z": 0.0 + }, + "end": { + "x": 0.0, + "y": 2, + "z": 0.0 + }, + "weight": 4 + } + ] + }, + "payload_b64": "AAEAAMDLv2h7Zc0dBAAAAG1hcAABAAAAAAAAAAAAAAAAAPA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAABBA", + "big_endian_b64": "AAAAAGi/y8AdzWV7AAAABG1hcAAAAAABAAAAAD/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAQAAAAAAAA" + }, + { + "name": "image", + "encoding": "sensor_msgs/msg/Image.cdr.v1", + "schema": { + "type": "sensor_msgs/msg/Image", + "definition": "# This message contains an uncompressed image\n# (0, 0) is at top-left corner of image\n\nstd_msgs/Header header # Header timestamp should be acquisition time of image\n # Header frame_id should be optical frame of camera\n # origin of frame should be optical center of cameara\n # +x should point to the right in the image\n # +y should point down in the image\n # +z should point into to plane of the image\n # If the frame_id here and the frame_id of the CameraInfo\n # message associated with the image conflict\n # the behavior is undefined\n\nuint32 height # image height, that is, number of rows\nuint32 width # image width, that is, number of columns\n\n# The legal values for encoding are in file include/sensor_msgs/image_encodings.hpp\n# If you want to standardize a new string format, join\n# ros-users@lists.ros.org and send an email proposing a new encoding.\n\nstring encoding # Encoding of pixels -- channel meaning, ordering, size\n # taken from the list of strings in include/sensor_msgs/image_encodings.hpp\n\nuint8 is_bigendian # is this data bigendian?\nuint32 step # Full row length in bytes\nuint8[] data # actual matrix data, size is (step * rows)\n================================================================================\nMSG: builtin_interfaces/Time\n# This message communicates ROS Time defined here:\n# https://design.ros2.org/articles/clock_and_time.html\n\n# The seconds component, valid over all int32 values.\nint32 sec\n\n# The nanoseconds component, valid in the range [0, 1e9), to be added to the seconds component. \n# e.g.\n# The time -1.7 seconds is represented as {sec: -2, nanosec: 3e8}\n# The time 1.7 seconds is represented as {sec: 1, nanosec: 7e8}\nuint32 nanosec\n================================================================================\nMSG: std_msgs/Header\n# Standard metadata for higher-level stamped data types.\n# This is generally used to communicate timestamped data\n# in a particular coordinate frame.\n\n# Two-integer timestamp that is expressed as seconds and nanoseconds.\nbuiltin_interfaces/Time stamp\n\n# Transform frame with which this data is associated.\nstring frame_id\n" + }, + "value": { + "header": { + "stamp": { + "sec": 1757400000, + "nanosec": 500000123 + }, + "frame_id": "map" + }, + "height": 1, + "width": 2, + "encoding": "rgb8", + "is_bigendian": 0, + "step": 6, + "data": [ + 0, + 1, + 2, + 253, + 254, + 255 + ] + }, + "payload_b64": "AAEAAMDLv2h7Zc0dBAAAAG1hcAABAAAAAgAAAAUAAAByZ2I4AAAAAAYAAAAGAAAAAAEC/f7/", + "big_endian_b64": "AAAAAGi/y8AdzWV7AAAABG1hcAAAAAABAAAAAgAAAAVyZ2I4AAAAAAAAAAYAAAAGAAEC/f7/" + }, + { + "name": "imu", + "encoding": "sensor_msgs/msg/Imu.cdr.v1", + "schema": { + "type": "sensor_msgs/msg/Imu", + "definition": "# This is a message to hold data from an IMU (Inertial Measurement Unit)\n#\n# Accelerations should be in m/s^2 (not in g's), and rotational velocity should be in rad/sec\n#\n# If the covariance of the measurement is known, it should be filled in (if all you know is the\n# variance of each measurement, e.g. from the datasheet, just put those along the diagonal)\n# A covariance matrix of all zeros will be interpreted as \"covariance unknown\", and to use the\n# data a covariance will have to be assumed or gotten from some other source\n#\n# If you have no estimate for one of the data elements (e.g. your IMU doesn't produce an\n# orientation estimate), please set element 0 of the associated covariance matrix to -1\n# If you are interpreting this message, please check for a value of -1 in the first element of each\n# covariance matrix, and disregard the associated estimate.\n\nstd_msgs/Header header\n\ngeometry_msgs/Quaternion orientation\nfloat64[9] orientation_covariance # Row major about x, y, z axes\n\ngeometry_msgs/Vector3 angular_velocity\nfloat64[9] angular_velocity_covariance # Row major about x, y, z axes\n\ngeometry_msgs/Vector3 linear_acceleration\nfloat64[9] linear_acceleration_covariance # Row major x, y z\n================================================================================\nMSG: builtin_interfaces/Time\n# This message communicates ROS Time defined here:\n# https://design.ros2.org/articles/clock_and_time.html\n\n# The seconds component, valid over all int32 values.\nint32 sec\n\n# The nanoseconds component, valid in the range [0, 1e9), to be added to the seconds component. \n# e.g.\n# The time -1.7 seconds is represented as {sec: -2, nanosec: 3e8}\n# The time 1.7 seconds is represented as {sec: 1, nanosec: 7e8}\nuint32 nanosec\n================================================================================\nMSG: geometry_msgs/Quaternion\n# This represents an orientation in free space in quaternion form.\n\nfloat64 x 0\nfloat64 y 0\nfloat64 z 0\nfloat64 w 1\n================================================================================\nMSG: geometry_msgs/Vector3\n# This represents a vector in free space.\n\n# This is semantically different than a point.\n# A vector is always anchored at the origin.\n# When a transform is applied to a vector, only the rotational component is applied.\n\nfloat64 x\nfloat64 y\nfloat64 z\n================================================================================\nMSG: std_msgs/Header\n# Standard metadata for higher-level stamped data types.\n# This is generally used to communicate timestamped data\n# in a particular coordinate frame.\n\n# Two-integer timestamp that is expressed as seconds and nanoseconds.\nbuiltin_interfaces/Time stamp\n\n# Transform frame with which this data is associated.\nstring frame_id\n" + }, + "value": { + "header": { + "stamp": { + "sec": 1757400000, + "nanosec": 500000123 + }, + "frame_id": "map" + }, + "orientation": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 1.0 + }, + "orientation_covariance": [ + 0.0, + 1.0, + 2.0, + 3.0, + 4.0, + 5.0, + 6.0, + 7.0, + 8.0 + ], + "angular_velocity": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "angular_velocity_covariance": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "linear_acceleration": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "linear_acceleration_covariance": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + }, + "payload_b64": "AAEAAMDLv2h7Zc0dBAAAAG1hcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwPwAAAAAAAAAAAAAAAAAA8D8AAAAAAAAAQAAAAAAAAAhAAAAAAAAAEEAAAAAAAAAUQAAAAAAAABhAAAAAAAAAHEAAAAAAAAAgQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "big_endian_b64": "AAAAAGi/y8AdzWV7AAAABG1hcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/8AAAAAAAAAAAAAAAAAAAP/AAAAAAAABAAAAAAAAAAEAIAAAAAAAAQBAAAAAAAABAFAAAAAAAAEAYAAAAAAAAQBwAAAAAAABAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + }, + { + "name": "joints", + "encoding": "sensor_msgs/msg/JointState.cdr.v1", + "schema": { + "type": "sensor_msgs/msg/JointState", + "definition": "# This is a message that holds data to describe the state of a set of torque controlled joints.\n#\n# The state of each joint (revolute or prismatic) is defined by:\n# * the position of the joint (rad or m),\n# * the velocity of the joint (rad/s or m/s) and\n# * the effort that is applied in the joint (Nm or N).\n#\n# Each joint is uniquely identified by its name\n# The header specifies the time at which the joint states were recorded. All the joint states\n# in one message have to be recorded at the same time.\n#\n# This message consists of a multiple arrays, one for each part of the joint state.\n# The goal is to make each of the fields optional. When e.g. your joints have no\n# effort associated with them, you can leave the effort array empty.\n#\n# All arrays in this message should have the same size, or be empty.\n# This is the only way to uniquely associate the joint name with the correct\n# states.\n\nstd_msgs/Header header\n\nstring[] name\nfloat64[] position\nfloat64[] velocity\nfloat64[] effort\n================================================================================\nMSG: builtin_interfaces/Time\n# This message communicates ROS Time defined here:\n# https://design.ros2.org/articles/clock_and_time.html\n\n# The seconds component, valid over all int32 values.\nint32 sec\n\n# The nanoseconds component, valid in the range [0, 1e9), to be added to the seconds component. \n# e.g.\n# The time -1.7 seconds is represented as {sec: -2, nanosec: 3e8}\n# The time 1.7 seconds is represented as {sec: 1, nanosec: 7e8}\nuint32 nanosec\n================================================================================\nMSG: std_msgs/Header\n# Standard metadata for higher-level stamped data types.\n# This is generally used to communicate timestamped data\n# in a particular coordinate frame.\n\n# Two-integer timestamp that is expressed as seconds and nanoseconds.\nbuiltin_interfaces/Time stamp\n\n# Transform frame with which this data is associated.\nstring frame_id\n" + }, + "value": { + "header": { + "stamp": { + "sec": 1757400000, + "nanosec": 500000123 + }, + "frame_id": "map" + }, + "name": [ + "joint_a", + "joint_b" + ], + "position": [ + 1.5, + -2.5 + ], + "velocity": [], + "effort": [] + }, + "payload_b64": "AAEAAMDLv2h7Zc0dBAAAAG1hcAACAAAACAAAAGpvaW50X2EACAAAAGpvaW50X2IAAgAAAAAAAAAAAPg/AAAAAAAABMAAAAAAAAAAAA==", + "big_endian_b64": "AAAAAGi/y8AdzWV7AAAABG1hcAAAAAACAAAACGpvaW50X2EAAAAACGpvaW50X2IAAAAAAj/4AAAAAAAAwAQAAAAAAAAAAAAAAAAAAA==" + }, + { + "name": "int64", + "encoding": "std_msgs/msg/Int64.cdr.v1", + "schema": { + "type": "std_msgs/msg/Int64", + "definition": "# This was originally provided as an example message.\n# It is deprecated as of Foxy\n# It is recommended to create your own semantically meaningful message.\n# However if you would like to continue using this please use the equivalent in example_msgs.\n\nint64 data\n" + }, + "value": { + "data": "-9007199254740993" + }, + "payload_b64": "AAEAAP///////9//", + "big_endian_b64": "AAAAAP/f////////" + }, + { + "name": "empty", + "encoding": "std_msgs/msg/Empty.cdr.v1", + "schema": { + "type": "std_msgs/msg/Empty", + "definition": "\n" + }, + "value": {}, + "payload_b64": "AAEAAAA=", + "big_endian_b64": "AAAAAAA=" + }, + { + "name": "string", + "encoding": "std_msgs/msg/String.cdr.v1", + "schema": { + "type": "std_msgs/msg/String", + "definition": "# This was originally provided as an example message.\n# It is deprecated as of Foxy\n# It is recommended to create your own semantically meaningful message.\n# However if you would like to continue using this please use the equivalent in example_msgs.\n\nstring data\n" + }, + "value": { + "data": "robot 🦾 café" + }, + "payload_b64": "AAEAABEAAAByb2JvdCDwn6a+IGNhZsOpAA==", + "big_endian_b64": "AAAAAAAAABFyb2JvdCDwn6a+IGNhZsOpAA==" + } + ] +} From 52d9ceab5e1422ac8b94bb893330050c95adf458 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 20 Oct 2026 21:38:23 -0700 Subject: [PATCH 2/8] fix(viewers): use shared codecs and retain generated-message helpers --- dimos/msgs/camera_info.py | 178 ++++++++++ dimos/msgs/geometry.py | 376 +++++++++++++++++++++ dimos/msgs/image.py | 318 ++++++++++++++++++ dimos/msgs/pointcloud.py | 382 ++++++++++++++++++++++ dimos/msgs/time.py | 77 +++++ dimos/visualization/rerun/bridge.py | 6 +- dimos/visualization/rerun/test_cdr.py | 6 +- examples/message-viewer/demo_cdr_rerun.py | 9 +- 8 files changed, 1342 insertions(+), 10 deletions(-) create mode 100644 dimos/msgs/camera_info.py create mode 100644 dimos/msgs/geometry.py create mode 100644 dimos/msgs/image.py create mode 100644 dimos/msgs/pointcloud.py create mode 100644 dimos/msgs/time.py diff --git a/dimos/msgs/camera_info.py b/dimos/msgs/camera_info.py new file mode 100644 index 00000000000..c8040990805 --- /dev/null +++ b/dimos/msgs/camera_info.py @@ -0,0 +1,178 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Calibration construction and array access for generated ROS CameraInfo values.""" + +from copy import deepcopy +import math +from pathlib import Path +import re +from typing import Any, Literal + +import numpy as np +from numpy.typing import NDArray +import yaml + +from dimos.messages.builtin_interfaces.msg import Time +from dimos.messages.sensor_msgs.msg import CameraInfo, RegionOfInterest +from dimos.messages.std_msgs.msg import Header + + +def _dimensions(width: int, height: int) -> None: + if any( + isinstance(value, bool) or not isinstance(value, int) or value <= 0 + for value in (width, height) + ): + raise ValueError("Calibration width and height must be positive integers") + + +def camera_info_from_intrinsics( + fx: float, fy: float, cx: float, cy: float, width: int, height: int, *, header: Header +) -> CameraInfo: + """Construct an undistorted pinhole calibration with an explicit source header.""" + _dimensions(width, height) + if not all(math.isfinite(value) for value in (fx, fy, cx, cy)) or fx <= 0 or fy <= 0: + raise ValueError("Intrinsics must be finite with positive focal lengths") + return CameraInfo( + header=header, + width=width, + height=height, + distortion_model="plumb_bob", + d=np.asarray([0.0] * 5, dtype=np.float64), + k=np.array([fx, 0, cx, 0, fy, cy, 0, 0, 1], dtype=np.float64), + r=np.array([1, 0, 0, 0, 1, 0, 0, 0, 1], dtype=np.float64), + p=np.array([fx, 0, cx, 0, 0, fy, cy, 0, 0, 0, 1, 0], dtype=np.float64), + binning_x=0, + binning_y=0, + roi=RegionOfInterest(x_offset=0, y_offset=0, height=0, width=0, do_rectify=False), + ) + + +def camera_info_from_fov( + fov_degrees: float, + width: int, + height: int, + *, + header: Header, + axis: Literal["horizontal", "vertical"] = "vertical", +) -> CameraInfo: + """Construct a centered pinhole model with square pixels from an axis's FOV.""" + _dimensions(width, height) + if not math.isfinite(fov_degrees) or not 0 < fov_degrees < 180: + raise ValueError("Field of view must be finite and between 0 and 180 degrees") + if axis not in ("horizontal", "vertical"): + raise ValueError("Field-of-view axis must be horizontal or vertical") + extent = width if axis == "horizontal" else height + focal = extent / (2 * math.tan(math.radians(fov_degrees) / 2)) + return camera_info_from_intrinsics( + focal, focal, width / 2, height / 2, width, height, header=header + ) + + +def _matrix(data: dict[str, Any], name: str, rows: int, columns: int | None) -> list[float]: + matrix = data[name] + if not isinstance(matrix, dict) or matrix.get("rows") != rows: + raise ValueError(f"{name} must declare {rows} rows") + actual_columns = matrix.get("cols") + if ( + not isinstance(actual_columns, int) + or actual_columns < 0 + or (columns is not None and actual_columns != columns) + ): + raise ValueError(f"{name} has invalid columns") + values = matrix.get("data") + if not isinstance(values, list) or len(values) != rows * actual_columns: + raise ValueError(f"{name} data length does not match its dimensions") + converted = [float(value) for value in values] + if not all(math.isfinite(value) for value in converted): + raise ValueError(f"{name} must contain finite values") + return converted + + +def camera_info_from_yaml(path: str | Path, *, header: Header) -> CameraInfo: + """Read a ROS calibration YAML file, validating declared matrix dimensions.""" + with Path(path).open() as source: + data = yaml.safe_load(source) + if not isinstance(data, dict): + raise TypeError("Calibration YAML must contain a mapping") + width, height = data["image_width"], data["image_height"] + _dimensions(width, height) + return CameraInfo( + header=header, + width=width, + height=height, + distortion_model=data["distortion_model"], + k=np.asarray(_matrix(data, "camera_matrix", 3, 3), dtype=np.float64), + d=np.asarray(_matrix(data, "distortion_coefficients", 1, None), dtype=np.float64), + r=np.asarray(_matrix(data, "rectification_matrix", 3, 3), dtype=np.float64), + p=np.asarray(_matrix(data, "projection_matrix", 3, 4), dtype=np.float64), + binning_x=0, + binning_y=0, + roi=RegionOfInterest(x_offset=0, y_offset=0, height=0, width=0, do_rectify=False), + ) + + +def intrinsic_matrix(message: CameraInfo) -> NDArray[np.float64]: + """Copy the 3x3 intrinsic matrix into independently mutable NumPy storage.""" + return np.array(message.k, dtype=np.float64, copy=True).reshape(3, 3) + + +def camera_info_with_stamp(message: CameraInfo, stamp: Time) -> CameraInfo: + """Copy a calibration with an exact source stamp, without mutating prior publishes.""" + result = deepcopy(message) + result.header.stamp = deepcopy(stamp) + return result + + +class CalibrationProvider: + """Lazily load named YAML calibrations as plain generated CameraInfo values. + + Both SingleWebcam and single_webcam resolve single_webcam.yaml. This + helper lives outside generated messages; calibration files stay local. + """ + + def __init__(self, calibration_dir: str | Path, *, frame_id: str = "camera_optical") -> None: + self._calibration_dir = Path(calibration_dir) + self._frame_id = frame_id + self._cache: dict[str, CameraInfo] = {} + + @staticmethod + def _to_snake_case(name: str) -> str: + first = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) + return re.sub("([a-z0-9])([A-Z])", r"\1_\2", first).lower() + + def __getattr__(self, name: str) -> CameraInfo: + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", name): + raise AttributeError(f"No calibration file found for: {name}") + snake = self._to_snake_case(name) + for key in (name, snake): + if key in self._cache: + return self._cache[key] + for key in (name, snake): + path = self._calibration_dir / f"{key}.yaml" + if path.is_file(): + value = camera_info_from_yaml( + path, header=Header(frame_id=self._frame_id, stamp=Time(sec=0, nanosec=0)) + ) + self._cache[name] = value + self._cache[snake] = value + return value + raise AttributeError(f"No calibration file found for: {name}") + + def __dir__(self) -> list[str]: + names: set[str] = set() + for path in self._calibration_dir.glob("*.yaml"): + names.add(path.stem) + names.add("".join(word.capitalize() for word in path.stem.split("_"))) + return sorted(names) diff --git a/dimos/msgs/geometry.py b/dimos/msgs/geometry.py new file mode 100644 index 00000000000..2ab85421267 --- /dev/null +++ b/dimos/msgs/geometry.py @@ -0,0 +1,376 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Geometry operations on generated ROS value types.""" + +from collections.abc import Sequence +from copy import deepcopy +import math + +import numpy as np +from numpy.typing import NDArray +from scipy.spatial.transform import Rotation + +from dimos.messages.geometry_msgs.msg import ( + Point, + Pose, + PoseStamped, + Quaternion, + Transform, + TransformStamped, + Vector3, + Wrench, +) +from dimos.messages.nav_msgs.msg import Odometry +from dimos.messages.std_msgs.msg import Header + + +def yaw(rotation: Quaternion) -> float: + """Return rotation about Z in radians (ROS XYZW quaternion convention).""" + return math.atan2( + 2 * (rotation.w * rotation.z + rotation.x * rotation.y), + 1 - 2 * (rotation.y * rotation.y + rotation.z * rotation.z), + ) + + +def transform_from_odometry(message: Odometry) -> TransformStamped: + """Copy the pose and declared frames into TF, preserving the exact source stamp.""" + position = message.pose.pose.position + return TransformStamped( + header=deepcopy(message.header), + child_frame_id=message.child_frame_id, + transform=Transform( + translation=Vector3(x=position.x, y=position.y, z=position.z), + rotation=deepcopy(message.pose.pose.orientation), + ), + ) + + +def quaternion_from_euler(roll: float, pitch: float, yaw: float) -> Quaternion: + """Convert fixed-axis XYZ angles in radians to a normalized ROS quaternion.""" + if not all(math.isfinite(value) for value in (roll, pitch, yaw)): + raise ValueError("Euler angles must be finite") + x, y, z, w = Rotation.from_euler("xyz", [roll, pitch, yaw]).as_quat() + return Quaternion(x=x, y=y, z=z, w=w) + + +def _rotation(quaternion: Quaternion) -> Rotation: + values = [quaternion.x, quaternion.y, quaternion.z, quaternion.w] + if not all(math.isfinite(value) for value in values): + raise ValueError("Quaternion components must be finite") + # SciPy rejects zero norm and normalizes non-unit input. + return Rotation.from_quat(values) + + +def _translation(transform: Transform) -> NDArray[np.float64]: + translation = transform.translation + values = np.array([translation.x, translation.y, translation.z], dtype=np.float64) + if not np.isfinite(values).all(): + raise ValueError("Translation components must be finite") + return values + + +def _transform(translation: NDArray[np.float64], rotation: Rotation) -> Transform: + x, y, z, w = rotation.as_quat() + return Transform( + translation=Vector3(x=translation[0], y=translation[1], z=translation[2]), + rotation=Quaternion(x=x, y=y, z=z, w=w), + ) + + +def transform_matrix(transform: Transform) -> NDArray[np.float64]: + """Copy a generated transform into a homogeneous 4-by-4 matrix.""" + matrix = np.eye(4, dtype=np.float64) + matrix[:3, :3] = _rotation(transform.rotation).as_matrix() + matrix[:3, 3] = _translation(transform) + return matrix + + +def compose_transforms(first: TransformStamped, second: TransformStamped) -> TransformStamped: + """Compose A←B and B←C, preserving A's exact source stamp in the A←C result. + + Inputs are copied into a new value. The shared frame must match; callers + querying a time-varying chain are responsible for choosing each edge's time. + """ + if first.child_frame_id != second.header.frame_id: + raise ValueError( + f"Cannot compose frames {first.child_frame_id!r} and {second.header.frame_id!r}" + ) + rotation = _rotation(first.transform.rotation) + translation = _translation(first.transform) + rotation.apply(_translation(second.transform)) + return TransformStamped( + header=deepcopy(first.header), + child_frame_id=second.child_frame_id, + transform=_transform(translation, rotation * _rotation(second.transform.rotation)), + ) + + +def inverse_transform(message: TransformStamped) -> TransformStamped: + """Return B←A from A←B, swapping frame names and preserving the exact stamp.""" + rotation = _rotation(message.transform.rotation).inv() + return TransformStamped( + header=Header(stamp=deepcopy(message.header.stamp), frame_id=message.child_frame_id), + child_frame_id=message.header.frame_id, + transform=_transform(-rotation.apply(_translation(message.transform)), rotation), + ) + + +def pose_from_transform(message: TransformStamped) -> PoseStamped: + """Copy the child frame's pose in the parent frame into a generated PoseStamped.""" + translation = message.transform.translation + return PoseStamped( + header=deepcopy(message.header), + pose=Pose( + position=Point(x=translation.x, y=translation.y, z=translation.z), + orientation=deepcopy(message.transform.rotation), + ), + ) + + +def transform_from_pose(message: PoseStamped, *, child_frame_id: str) -> TransformStamped: + """Copy a stamped pose into TF with an explicit child and unchanged source header.""" + if not isinstance(message, PoseStamped): + raise TypeError("Expected a generated PoseStamped") + position = message.pose.position + return TransformStamped( + header=deepcopy(message.header), + child_frame_id=child_frame_id, + transform=Transform( + translation=Vector3(x=position.x, y=position.y, z=position.z), + rotation=deepcopy(message.pose.orientation), + ), + ) + + +def pose_matrix(pose: Pose) -> NDArray[np.float64]: + """Copy a generated pose into a homogeneous matrix, rejecting invalid geometry.""" + values = np.array([pose.position.x, pose.position.y, pose.position.z], dtype=np.float64) + if not np.isfinite(values).all(): + raise ValueError("Position components must be finite") + result = np.eye(4, dtype=np.float64) + result[:3, :3] = _rotation(pose.orientation).as_matrix() + result[:3, 3] = values + return result + + +def quaternion_from_matrix(matrix: NDArray[np.float64]) -> Quaternion: + """Convert a finite proper 3-by-3 rotation matrix into a generated quaternion.""" + values = np.asarray(matrix, dtype=np.float64) + if values.shape != (3, 3) or not np.isfinite(values).all(): + raise ValueError("Rotation matrix must be finite and 3-by-3") + if not np.allclose(values.T @ values, np.eye(3), atol=1e-6, rtol=0.0) or not np.isclose( + np.linalg.det(values), 1.0, atol=1e-6, rtol=0.0 + ): + raise ValueError("Rotation matrix must be orthonormal with determinant +1") + x, y, z, w = Rotation.from_matrix(values).as_quat() + return Quaternion(x=x, y=y, z=z, w=w) + + +def pose_from_matrix(matrix: NDArray[np.float64]) -> Pose: + """Copy a finite rigid 4-by-4 transform into a generated pose.""" + values = np.asarray(matrix, dtype=np.float64) + if values.shape != (4, 4) or not np.isfinite(values).all(): + raise ValueError("Pose matrix must be finite and 4-by-4") + if not np.allclose(values[3], [0.0, 0.0, 0.0, 1.0], atol=1e-9, rtol=0.0): + raise ValueError("Pose matrix must have homogeneous last row [0, 0, 0, 1]") + return Pose( + position=Point(x=values[0, 3], y=values[1, 3], z=values[2, 3]), + orientation=quaternion_from_matrix(values[:3, :3]), + ) + + +def transform_from_matrix(matrix: NDArray[np.float64]) -> Transform: + """Copy a finite rigid homogeneous matrix into a generated transform.""" + pose = pose_from_matrix(matrix) + return Transform( + translation=Vector3(x=pose.position.x, y=pose.position.y, z=pose.position.z), + rotation=deepcopy(pose.orientation), + ) + + +def quaternion_euler(rotation: Quaternion) -> tuple[float, float, float]: + """Return fixed-axis XYZ Euler angles in radians for a generated quaternion.""" + roll, pitch, yaw = _rotation(rotation).as_euler("xyz") + return float(roll), float(pitch), float(yaw) + + +def translate_pose_local(pose: Pose, offset: Vector3) -> Pose: + """Copy a pose displaced by an offset expressed in its local axes.""" + matrix = pose_matrix(pose) + values = np.array([offset.x, offset.y, offset.z], dtype=np.float64) + if not np.isfinite(values).all(): + raise ValueError("Offset components must be finite") + delta = matrix[:3, :3] @ values + return Pose( + position=Point( + x=pose.position.x + delta[0], y=pose.position.y + delta[1], z=pose.position.z + delta[2] + ), + orientation=deepcopy(pose.orientation), + ) + + +def point_distance(first: Point, second: Point) -> float: + """Euclidean distance between generated position values.""" + return math.dist((first.x, first.y, first.z), (second.x, second.y, second.z)) + + +def quaternion_angle(first: Quaternion, second: Quaternion) -> float: + """Return the shortest angular separation in radians, normalizing inputs.""" + return float((_rotation(first).inv() * _rotation(second)).magnitude()) + + +def wrench_from_array(values: Sequence[float] | NDArray[np.float64]) -> Wrench: + """Copy force in N and torque in Nm from [fx, fy, fz, tx, ty, tz].""" + array = np.asarray(values, dtype=np.float64) + if array.shape != (6,): + raise ValueError("Expected 6 elements [fx, fy, fz, tx, ty, tz]") + return Wrench( + force=Vector3(x=array[0], y=array[1], z=array[2]), + torque=Vector3(x=array[3], y=array[4], z=array[5]), + ) + + +def wrench_array(message: Wrench) -> NDArray[np.float64]: + """Return an independent numeric force/torque array for arithmetic.""" + return np.array( + [ + message.force.x, + message.force.y, + message.force.z, + message.torque.x, + message.torque.y, + message.torque.z, + ], + dtype=np.float64, + ) + + +def vector_array(value: Vector3 | Point) -> NDArray[np.float64]: + """Copy a generated spatial vector/point into an independent XYZ array.""" + return np.array([value.x, value.y, value.z], dtype=np.float64) + + +def vector_from_array(values: Sequence[float] | NDArray[np.float64]) -> Vector3: + """Copy exactly three coordinates into a generated vector.""" + array = np.asarray(values, dtype=np.float64) + if array.shape != (3,): + raise ValueError("Expected 3 vector components") + return Vector3(x=array[0], y=array[1], z=array[2]) + + +def normalized_vector(value: Vector3) -> Vector3: + """Return a unit vector; zero length remains zero.""" + array = vector_array(value) + length = float(np.linalg.norm(array)) + return vector_from_array(array / length) if length else Vector3(x=0.0, y=0.0, z=0.0) + + +def quaternion_array(value: Quaternion) -> NDArray[np.float64]: + """Copy XYZW components without normalizing their declared values.""" + return np.array([value.x, value.y, value.z, value.w], dtype=np.float64) + + +def quaternion_from_array(values: Sequence[float] | NDArray[np.float64]) -> Quaternion: + """Copy exactly four XYZW components into a generated quaternion.""" + array = np.asarray(values, dtype=np.float64) + if array.shape != (4,): + raise ValueError("Quaternion requires exactly 4 components") + return Quaternion(x=float(array[0]), y=float(array[1]), z=float(array[2]), w=float(array[3])) + + +def quaternion_product(first: Quaternion, second: Quaternion) -> Quaternion: + """Hamilton product, retaining non-unit magnitudes and multiplication order.""" + if not isinstance(first, Quaternion) or not isinstance(second, Quaternion): + raise TypeError("Cannot multiply Quaternion with a non-quaternion value") + a, b = quaternion_array(first), quaternion_array(second) + xyz = a[3] * b[:3] + b[3] * a[:3] + np.cross(a[:3], b[:3]) + return quaternion_from_array(np.append(xyz, a[3] * b[3] - np.dot(a[:3], b[:3]))) + + +def quaternion_conjugate(value: Quaternion) -> Quaternion: + """Negate the imaginary components, preserving the scalar component.""" + return Quaternion(x=-value.x, y=-value.y, z=-value.z, w=value.w) + + +def quaternion_inverse(value: Quaternion) -> Quaternion: + """Algebraic inverse, including non-unit quaternions.""" + array = quaternion_array(value) + squared = float(np.dot(array, array)) + if squared == 0: + raise ZeroDivisionError("Cannot invert zero quaternion") + return quaternion_from_array(quaternion_array(quaternion_conjugate(value)) / squared) + + +def normalized_quaternion(value: Quaternion) -> Quaternion: + """Return unit components without changing sign; reject zero norm.""" + array = quaternion_array(value) + length = float(np.linalg.norm(array)) + if length == 0: + raise ZeroDivisionError("Cannot normalize zero quaternion") + return quaternion_from_array(array / length) + + +def rotate_vector(rotation: Quaternion, value: Vector3) -> Vector3: + """Rotate an XYZ vector using a normalized spatial rotation.""" + return vector_from_array(_rotation(rotation).apply(vector_array(value))) + + +def point_from_array(values: Sequence[float] | NDArray[np.float64]) -> Point: + """Copy exactly three Cartesian coordinates into a generated point.""" + vector = vector_from_array(values) + return Point(x=vector.x, y=vector.y, z=vector.z) + + +def compose_poses(first: Pose, second: Pose) -> Pose: + """Compose parent/local poses without mutating inputs or normalizing stored quaternions.""" + offset = _rotation(first.orientation).apply(vector_array(second.position)) + return Pose( + position=point_from_array(vector_array(first.position) + offset), + orientation=quaternion_product(first.orientation, second.orientation), + ) + + +def compose_transform_values(first: Transform, second: Transform) -> Transform: + """Compose unframed spatial transforms in parent/local order.""" + if not isinstance(first, Transform) or not isinstance(second, Transform): + raise TypeError("Expected generated Transform values") + return Transform( + translation=vector_from_array( + vector_array(first.translation) + + _rotation(first.rotation).apply(vector_array(second.translation)) + ), + rotation=quaternion_product(first.rotation, second.rotation), + ) + + +def transform_pose_local(pose: Pose, transform: Transform) -> Pose: + """Apply an offset expressed in a pose's local axes.""" + return compose_poses( + pose, + Pose( + position=point_from_array(vector_array(transform.translation)), + orientation=transform.rotation, + ), + ) + + +def relative_pose_transform(first: Pose, second: Pose) -> Transform: + """Find the local transform taking the first pose to the second.""" + rotation = quaternion_inverse(first.orientation) + return Transform( + translation=vector_from_array( + _rotation(rotation).apply(vector_array(second.position) - vector_array(first.position)) + ), + rotation=quaternion_product(rotation, second.orientation), + ) diff --git a/dimos/msgs/image.py b/dimos/msgs/image.py new file mode 100644 index 00000000000..db80913a814 --- /dev/null +++ b/dimos/msgs/image.py @@ -0,0 +1,318 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Array views and compression for generated ROS image messages.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import imagecodecs +import numpy as np +from numpy.typing import NDArray +from PIL import Image as PILImage + +from dimos.messages.builtin_interfaces.msg import Time +from dimos.messages.sensor_msgs.msg import CompressedImage, Image +from dimos.messages.std_msgs.msg import Header + +_FORMATS = { + "rgb8": ("u1", 3), + "bgr8": ("u1", 3), + "rgba8": ("u1", 4), + "bgra8": ("u1", 4), + "mono8": ("u1", 1), + "mono16": ("u2", 1), + "16UC1": ("u2", 1), + "32FC1": ("f4", 1), + "64FC1": ("f8", 1), + "16SC1": ("i2", 1), + "32FC3": ("f4", 3), +} + + +def image_from_array(pixels: NDArray[Any], *, encoding: str, header: Header | None = None) -> Image: + """Copy an array into a generated image with an explicit pixel encoding.""" + if encoding not in _FORMATS: + raise ValueError(f"unsupported image encoding {encoding!r}") + kind, channels = _FORMATS[encoding] + expected = np.dtype(kind) + if pixels.dtype.kind != expected.kind or pixels.dtype.itemsize != expected.itemsize: + raise ValueError(f"array dtype does not match {encoding}") + if (channels == 1 and pixels.ndim != 2) or ( + channels != 1 and (pixels.ndim != 3 or pixels.shape[2] != channels) + ): + raise ValueError(f"array shape does not match {encoding}") + contiguous = np.array(pixels, copy=True, order="C") + return Image( + header=header if header is not None else Header(stamp=Time(sec=0, nanosec=0), frame_id=""), + width=pixels.shape[1], + height=pixels.shape[0], + encoding=encoding, + is_bigendian=int(not pixels.dtype.isnative if np.little_endian else pixels.dtype.isnative), + step=pixels.shape[1] * channels * pixels.dtype.itemsize, + data=np.asarray(contiguous.view(np.uint8).ravel(), dtype=np.uint8), + ) + + +def image_view(msg: Image) -> NDArray[Any]: + """Borrow a read-only array, retaining the message and respecting row padding/endian.""" + if msg.encoding not in _FORMATS: + raise ValueError(f"unsupported image encoding {msg.encoding!r}") + kind, channels = _FORMATS[msg.encoding] + dtype = np.dtype((">" if msg.is_bigendian else "<") + kind) + row_bytes = msg.width * channels * dtype.itemsize + if msg.step < row_bytes or len(msg.data) != msg.height * msg.step: + raise ValueError("image data length/step does not match its dimensions") + shape = (msg.height, msg.width) if channels == 1 else (msg.height, msg.width, channels) + strides = ( + (msg.step, dtype.itemsize) + if channels == 1 + else (msg.step, channels * dtype.itemsize, dtype.itemsize) + ) + view = np.ndarray(shape, dtype=dtype, buffer=msg.data, strides=strides) + view.setflags(write=False) + return view + + +def image_to_jpeg(msg: Image, quality: int = 75) -> bytes: + """Encode an 8-bit gray or color image using OpenCV's JPEG encoder.""" + import cv2 + + if isinstance(quality, bool) or not 0 <= quality <= 100: + raise ValueError("JPEG quality must be in 0..100") + pixels = image_view(msg) + conversions = { + "rgb8": cv2.COLOR_RGB2BGR, + "rgba8": cv2.COLOR_RGBA2BGR, + "bgra8": cv2.COLOR_BGRA2BGR, + } + if msg.encoding in conversions: + pixels = cv2.cvtColor(pixels, conversions[msg.encoding]) + elif msg.encoding not in ("bgr8", "mono8"): + raise ValueError(f"JPEG requires 8-bit color or gray, got {msg.encoding!r}") + ok, encoded = cv2.imencode(".jpg", pixels, [cv2.IMWRITE_JPEG_QUALITY, quality]) + if not ok: + raise ValueError("JPEG encoding failed") + return bytes(encoded) + + +def compressed_image_from_image( + message: Image, + quality: int = 75, + *, + format: str = "jpeg", + max_width: int | None = None, + effort: int | None = None, +) -> CompressedImage: + """Compress generated pixels, retaining the exact header. + + PNG preserves uint16 depth. JXL preserves uint16/float32 depth losslessly; + it requires imagecodecs and is an explicit codec extension for consumers. + """ + import cv2 + + if not isinstance(message, Image): + raise TypeError("compression expects a generated Image") + if max_width is not None: + message, _ = image_resize_to_fit(message, max_width, max_width) + if format == "jpeg": + output_encoding = "mono8" if message.encoding == "mono8" else "bgr8" + data = image_to_jpeg(message, quality=quality) + elif format == "png": + pixels = image_view(message) + if pixels.dtype.kind != "u" or pixels.dtype.itemsize not in (1, 2): + raise ValueError("PNG cannot encode floating-point depth") + if message.encoding in ("rgb8", "rgba8"): + code = cv2.COLOR_RGB2BGR if message.encoding == "rgb8" else cv2.COLOR_RGBA2BGRA + pixels = cv2.cvtColor(pixels, code) + pixels = np.ascontiguousarray(pixels, dtype=pixels.dtype.newbyteorder("=")) + ok, encoded = cv2.imencode(".png", pixels) + if not ok: + raise ValueError("PNG encoding failed") + data = bytes(encoded) + output_encoding = {"rgb8": "bgr8", "rgba8": "bgra8"}.get(message.encoding, message.encoding) + elif format == "jxl": + pixels = image_view(message) + if pixels.dtype.kind not in ("u", "f") or ( + pixels.dtype.kind, + pixels.dtype.itemsize, + ) not in (("u", 1), ("u", 2), ("f", 4)): + raise ValueError(f"JXL cannot encode dtype {pixels.dtype}") + if message.encoding == "bgr8": + pixels = pixels[..., ::-1] + elif message.encoding == "bgra8": + pixels = pixels[..., [2, 1, 0, 3]] + pixels = np.ascontiguousarray(pixels, dtype=pixels.dtype.newbyteorder("=")) + if pixels.dtype == np.uint8: + data = bytes(imagecodecs.jpegxl_encode(pixels, level=quality, effort=effort or 3)) + else: + data = bytes(imagecodecs.jpegxl_encode(pixels, lossless=True, effort=effort or 1)) + output_encoding = {"bgr8": "rgb8", "bgra8": "rgba8"}.get(message.encoding, message.encoding) + else: + raise ValueError(f"unsupported compression format {format!r}") + return CompressedImage( + header=message.header, + format=f"{message.encoding}; {format} compressed {output_encoding}", + data=np.asarray(np.frombuffer(data, dtype=np.uint8).copy(), dtype=np.uint8), + ) + + +def image_from_compressed(message: CompressedImage) -> Image: + """Decode JPEG/PNG or the JXL extension into explicitly encoded generated pixels.""" + import cv2 + + format_name = message.format.lower() + if "jxl" in format_name: + jxl_pixels = imagecodecs.jpegxl_decode(bytes(message.data)) + if jxl_pixels.ndim == 2: + encoding = { + np.dtype("float32"): "32FC1", + np.dtype("uint16"): "mono16", + np.dtype("uint8"): "mono8", + }.get(jxl_pixels.dtype) + elif jxl_pixels.ndim == 3 and jxl_pixels.shape[2] in (3, 4): + encoding = "rgb8" if jxl_pixels.shape[2] == 3 else "rgba8" + else: + encoding = None + if encoding is None: + raise ValueError("unsupported decoded JXL layout") + if encoding == "mono16" and format_name.startswith("16uc1;"): + encoding = "16UC1" + return image_from_array(jxl_pixels, encoding=encoding, header=message.header) + if not any(name in format_name for name in ("jpeg", "jpg", "png")): + raise ValueError(f"unsupported compressed image format {message.format!r}") + pixels = cv2.imdecode(np.frombuffer(bytes(message.data), dtype=np.uint8), cv2.IMREAD_UNCHANGED) + if pixels is None: + raise ValueError("invalid compressed image data") + if pixels.ndim == 2: + encoding = "mono16" if pixels.dtype.itemsize == 2 else "mono8" + elif pixels.ndim == 3 and pixels.shape[2] == 3: + encoding = "bgr8" + elif pixels.ndim == 3 and pixels.shape[2] == 4: + encoding = "bgra8" + else: + raise ValueError("unsupported decoded image layout") + if encoding == "mono16" and format_name.startswith("16uc1;"): + encoding = "16UC1" + return image_from_array(pixels, encoding=encoding, header=message.header) + + +def image_brightness(message: Image) -> float: + """Sample mean pixel intensity in [0, 1] for 8/16-bit image encodings. + + Respect row padding and endian. Floating-point depth has no normalized + intensity scale and is rejected rather than treated as a color image. + """ + pixels = image_view(message) + if pixels.dtype.kind != "u": + raise ValueError("brightness requires unsigned integer pixels") + stride = max(1, max(message.height, message.width) // 256) + return float(pixels[::stride, ::stride].mean() / np.iinfo(pixels.dtype).max) + + +def image_sharpness(message: Image) -> float: + """Laplacian variance of an 8-bit visual image, downsampled to 160 pixels wide.""" + import cv2 + + pixels = image_view(message) + codes = { + "rgb8": cv2.COLOR_RGB2GRAY, + "bgr8": cv2.COLOR_BGR2GRAY, + "rgba8": cv2.COLOR_RGBA2GRAY, + "bgra8": cv2.COLOR_BGRA2GRAY, + } + if message.encoding == "mono8": + gray = pixels + elif message.encoding in codes: + gray = cv2.cvtColor(pixels, codes[message.encoding]) + else: + raise ValueError("Sharpness requires an 8-bit visual image encoding") + if gray.size == 0: + raise ValueError("Sharpness requires a nonempty image") + if message.width > 160: + gray = cv2.resize( + gray, + (160, max(1, round(message.height * 160 / message.width))), + interpolation=cv2.INTER_AREA, + ) + return float(cv2.Laplacian(gray, cv2.CV_64F).var()) + + +def image_to_bgr(message: Image) -> NDArray[np.uint8]: + """Return an independent BGR8 array for drawing or OpenCV color operations.""" + import cv2 + + pixels = image_view(message) + if message.encoding == "bgr8": + return pixels.copy() + codes = { + "rgb8": cv2.COLOR_RGB2BGR, + "rgba8": cv2.COLOR_RGBA2BGR, + "bgra8": cv2.COLOR_BGRA2BGR, + "mono8": cv2.COLOR_GRAY2BGR, + } + if message.encoding not in codes: + raise ValueError(f"Cannot convert {message.encoding!r} to BGR8") + return np.asarray(cv2.cvtColor(pixels, codes[message.encoding]), dtype=np.uint8) + + +def image_to_rgb(message: Image) -> NDArray[np.uint8]: + """Copy a display image as RGB8; 16-bit grayscale uses its high byte. + + Alpha is discarded. Floating-point depth needs an explicit visualization + scale and is rejected rather than assigning pixel values to meters. + """ + import cv2 + + pixels = image_view(message) + if message.encoding == "rgb8": + return pixels.copy() + if message.encoding in ("mono16", "16UC1"): + return np.asarray( + cv2.cvtColor((pixels / 256).astype(np.uint8), cv2.COLOR_GRAY2RGB), dtype=np.uint8 + ) + codes = { + "bgr8": cv2.COLOR_BGR2RGB, + "rgba8": cv2.COLOR_RGBA2RGB, + "bgra8": cv2.COLOR_BGRA2RGB, + "mono8": cv2.COLOR_GRAY2RGB, + } + if message.encoding not in codes: + raise ValueError(f"Cannot convert {message.encoding!r} to RGB8") + return np.asarray(cv2.cvtColor(pixels, codes[message.encoding]), dtype=np.uint8) + + +def image_resize_to_fit(message: Image, max_width: int, max_height: int) -> tuple[Image, float]: + """Downscale a generated image while preserving encoding, aspect and exact header.""" + import cv2 + + if min(max_width, max_height, message.width, message.height) <= 0: + raise ValueError("Image and target dimensions must be positive") + if message.width <= max_width and message.height <= max_height: + return message, 1.0 + scale = min(max_width / message.width, max_height / message.height) + pixels = cv2.resize( + image_view(message), + (max(1, int(message.width * scale)), max(1, int(message.height * scale))), + interpolation=cv2.INTER_LINEAR, + ) + return image_from_array(pixels, encoding=message.encoding, header=message.header), scale + + +def image_from_file(path: str | Path, *, header: Header | None = None) -> Image: + """Decode an image file as generated RGB8 with an explicit optional source header.""" + with PILImage.open(path) as image: + return image_from_array(np.asarray(image.convert("RGB")), encoding="rgb8", header=header) diff --git a/dimos/msgs/pointcloud.py b/dimos/msgs/pointcloud.py new file mode 100644 index 00000000000..1b9c5e420ed --- /dev/null +++ b/dimos/msgs/pointcloud.py @@ -0,0 +1,382 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""NumPy conversions for generated PointCloud2 messages.""" + +from copy import deepcopy +import math +from typing import Any + +from dimos_cdrgen.registry import decode as cdr_decode, encode as cdr_encode +import numpy as np +from numpy.typing import NDArray + +from dimos.messages.geometry_msgs.msg import TransformStamped +from dimos.messages.sensor_msgs.msg import CameraInfo, Image, PointCloud2, PointField +from dimos.messages.std_msgs.msg import Header +from dimos.msgs.camera_info import intrinsic_matrix +from dimos.msgs.geometry import transform_matrix +from dimos.msgs.image import image_to_rgb, image_view +from dimos.msgs.time import to_nanoseconds + +_FIELD_KINDS = {1: "i1", 2: "u1", 3: "i2", 4: "u2", 5: "i4", 6: "u4", 7: "f4", 8: "f8"} + + +def pointcloud_view(message: PointCloud2) -> NDArray[Any]: + """Borrow read-only structured (height, width) points, including row/field padding. + + Fields retain their declared counts and endianness. The returned array keeps + the message's storage alive. Use ``view.copy()`` for independent mutable data. + """ + if message.width and not message.point_step: + raise ValueError("nonempty point cloud requires a positive point_step") + if message.row_step < message.width * message.point_step: + raise ValueError("point cloud row_step is smaller than its row of points") + if len(message.data) != message.height * message.row_step: + raise ValueError("point cloud data length does not match height * row_step") + names: list[str] = [] + formats: list[Any] = [] + offsets: list[int] = [] + for field in message.fields: + if not field.name or field.name in names: + raise ValueError("point cloud field names must be nonempty and unique") + if field.datatype not in _FIELD_KINDS or not field.count: + raise ValueError(f"invalid point cloud datatype/count for {field.name!r}") + dtype = np.dtype((">" if message.is_bigendian else "<") + _FIELD_KINDS[field.datatype]) + if field.offset + dtype.itemsize * field.count > message.point_step: + raise ValueError(f"point cloud field {field.name!r} exceeds point_step") + names.append(field.name) + formats.append(dtype if field.count == 1 else (dtype, (field.count,))) + offsets.append(field.offset) + dtype = np.dtype( + {"names": names, "formats": formats, "offsets": offsets, "itemsize": message.point_step} + ) + view = np.ndarray( + (message.height, message.width), + dtype=dtype, + buffer=message.data, + strides=(message.row_step, message.point_step), + ) + view.setflags(write=False) + return view + + +def pointcloud_xyz(message: PointCloud2) -> NDArray[np.float64]: + """Copy scalar x/y/z fields into an N-by-3 float64 array, preserving point order.""" + points = pointcloud_view(message) + for name in ("x", "y", "z"): + if name not in (points.dtype.names or ()) or points[name].ndim != 2: + raise ValueError(f"point cloud requires a scalar {name!r} field") + return np.stack([points[name].ravel() for name in ("x", "y", "z")], axis=-1).astype(np.float64) + + +def pointcloud_from_xyz( + points: NDArray[Any], *, header: Header, stamps: NDArray[Any] | None = None +) -> PointCloud2: + """Copy Nx3 or HxWx3 coordinates into a tightly packed little-endian XYZ cloud. + + XYZ fields are float32. NaNs/infinities remain missing-point markers and set + is_dense=False; finite coordinates outside float32 range are rejected. + """ + values = np.asarray(points) + if values.ndim not in (2, 3) or values.shape[-1] != 3: + raise ValueError("XYZ coordinates must have shape (N, 3) or (H, W, 3)") + if values.dtype.kind not in "fiu": + raise ValueError("XYZ coordinates must be numeric") + finite = np.isfinite(values) + if np.any(np.abs(values[finite].astype(np.float64)) > np.finfo(np.float32).max): + raise ValueError("XYZ coordinates exceed float32 range") + packed = np.ascontiguousarray(values, dtype=" NDArray[np.float64] | None: + """Read per-point capture times; absence falls back to the header at the caller.""" + fields = [field for field in message.fields if field.name == "stamps"] + if not fields: + return None + if len(fields) != 1 or fields[0].datatype != PointField.FLOAT64 or fields[0].count != 1: + raise ValueError("Per-point stamps must be a scalar FLOAT64 field") + return np.asarray(pointcloud_view(message)["stamps"].reshape(-1), dtype=np.float64) + + +def select_points(message: PointCloud2, keep: NDArray[np.bool_]) -> PointCloud2: + """Copy selected point records in row order, retaining every field and point byte. + + The result is unorganized with no row padding. Point padding, endianness, + field metadata, source header, and the conservative density flag survive. + """ + pointcloud_view(message) # Validate the full declared layout before selecting bytes. + if keep.dtype != np.bool_ or keep.shape != (message.height * message.width,): + raise ValueError("point selection must be a flat boolean mask matching the point count") + records: NDArray[np.uint8] = np.ndarray( + (message.height, message.width, message.point_step), + dtype=np.uint8, + buffer=message.data, + strides=(message.row_step, message.point_step, 1), + ) + selected = records.reshape(message.height * message.width, message.point_step)[keep] + width = len(selected) + return PointCloud2( + header=deepcopy(message.header), + height=1, + width=width, + fields=deepcopy(message.fields), + is_bigendian=message.is_bigendian, + point_step=message.point_step, + row_step=width * message.point_step, + data=np.asarray(np.frombuffer(selected.tobytes(), dtype=np.uint8).copy(), dtype=np.uint8), + is_dense=message.is_dense, + ) + + +def pointcloud_rgb(message: PointCloud2) -> NDArray[np.uint8] | None: + """Copy RGB bytes from the standard packed rgb/rgba field, if present. + + FLOAT32 fields carry color bits, not numeric floating-point color values. + Both representations preserve the declared byte order and row padding. + """ + points = pointcloud_view(message) + names = points.dtype.names or () + name = next((name for name in ("rgb", "rgba") if name in names), None) + if name is None: + return None + field = points[name] + if field.ndim != 2 or field.dtype.kind not in "fu" or field.dtype.itemsize != 4: + raise ValueError("packed point cloud color must be a scalar FLOAT32 or UINT32 field") + packed = field.view(np.dtype(">u4" if message.is_bigendian else "> shift) & 255 for shift in (16, 8, 0)], axis=-1).astype(np.uint8) + + +def concatenate_clouds(first: PointCloud2, second: PointCloud2) -> PointCloud2: + """Join compatible point records, preserving fields, endian, and point padding. + + Row padding is removed. The first frame and newest exact source stamp survive. + Empty inputs are identities; differing nonempty frames/layouts are rejected. + """ + pointcloud_view(first) + pointcloud_view(second) + if not first.width * first.height: + return cdr_decode(cdr_encode(second), PointCloud2) + if not second.width * second.height: + return cdr_decode(cdr_encode(first), PointCloud2) + if first.header.frame_id != second.header.frame_id: + raise ValueError("cannot concatenate point clouds in different frames") + if ( + first.fields != second.fields + or first.point_step != second.point_step + or first.is_bigendian != second.is_bigendian + ): + raise ValueError("cannot concatenate point clouds with different record layouts") + a = select_points(first, np.ones(first.width * first.height, dtype=np.bool_)) + b = select_points(second, np.ones(second.width * second.height, dtype=np.bool_)) + stamp = max((first.header.stamp, second.header.stamp), key=to_nanoseconds) + return PointCloud2( + header=Header(frame_id=first.header.frame_id, stamp=stamp), + height=1, + width=a.width + b.width, + fields=deepcopy(first.fields), + is_bigendian=first.is_bigendian, + point_step=first.point_step, + row_step=a.row_step + b.row_step, + data=np.asarray(np.concatenate((a.data, b.data)), dtype=np.uint8), + is_dense=first.is_dense and second.is_dense, + ) + + +def transform_cloud(message: PointCloud2, transform: TransformStamped) -> PointCloud2: + """Apply parent←child to XYZ while retaining the complete organized record layout. + + Copy storage before changing coordinates. Keep all other fields, point/row + padding, endian and the cloud's exact source stamp. Frames must match; + integer XYZ is rejected because transformed coordinates would be truncated. + """ + if message.header.frame_id != transform.child_frame_id: + raise ValueError("point cloud frame does not match transform child frame") + view = pointcloud_view(message) + for name in ("x", "y", "z"): + if name not in (view.dtype.names or ()) or view.dtype[name].kind != "f": + raise ValueError("transform requires scalar floating-point XYZ fields") + matrix = transform_matrix(transform.transform) + xyz = pointcloud_xyz(message) + transformed = xyz @ matrix[:3, :3].T + matrix[:3, 3] + for index, name in enumerate(("x", "y", "z")): + values = transformed[:, index] + if np.any(np.abs(values[np.isfinite(values)]) > np.finfo(view.dtype[name]).max): + raise ValueError(f"transformed {name} coordinates exceed field range") + payload = bytearray(bytes(message.data)) + writable = np.ndarray( + (message.height, message.width), + dtype=view.dtype, + buffer=payload, + strides=(message.row_step, message.point_step), + ) + for index, name in enumerate(("x", "y", "z")): + writable[name] = transformed[:, index].reshape(message.height, message.width) + output = cdr_decode(cdr_encode(message), PointCloud2) + output.data = np.frombuffer(payload, dtype=np.uint8).copy() + output.header = Header(stamp=deepcopy(message.header.stamp), frame_id=transform.header.frame_id) + return output + + +def pointcloud_from_xyz_rgb( + points: NDArray[Any], colors: NDArray[np.uint8], *, header: Header +) -> PointCloud2: + """Copy XYZ and RGB8 into a declared XYZ FLOAT32 / packed RGB UINT32 layout.""" + cloud = pointcloud_from_xyz(points, header=header) + rgb = np.asarray(colors) + if rgb.dtype != np.uint8 or rgb.shape != np.asarray(points).shape: + raise ValueError("RGB must be uint8 with the same (..., 3) shape as XYZ") + records = np.empty((cloud.height, cloud.width), dtype=[("xyz", " PointCloud2: + """Project a rectified RGB/depth pair into the depth optical frame. + + Integer depth is multiplied by depth_scale to obtain meters; 32FC1 already + represents meters. Invalid/nonpositive/too-distant samples are removed. + Scale K when calibration resolution differs, retaining the depth header. + """ + if ( + not math.isfinite(depth_scale) + or depth_scale <= 0 + or not math.isfinite(depth_trunc) + or depth_trunc <= 0 + ): + raise ValueError("depth scale and truncation must be finite and positive") + if (color.width, color.height) != (depth.width, depth.height): + raise ValueError("color and depth dimensions do not match") + if depth.encoding not in ("16UC1", "mono16", "32FC1"): + raise ValueError("RGBD requires uint16 or float32 depth") + if calibration.width <= 0 or calibration.height <= 0: + raise ValueError("calibration dimensions must be positive") + matrix = intrinsic_matrix(calibration).copy() + matrix[0, :] *= depth.width / calibration.width + matrix[1, :] *= depth.height / calibration.height + distances = image_view(depth).astype(np.float64) + if depth.encoding != "32FC1": + distances *= depth_scale + valid = np.isfinite(distances) & (distances > 0) & (distances < depth_trunc) + rows = np.broadcast_to(np.arange(depth.height, dtype=np.float64)[:, None], distances.shape) + columns = np.broadcast_to(np.arange(depth.width, dtype=np.float64)[None, :], distances.shape) + z = distances[valid] + xyz = np.column_stack( + ( + (columns[valid] - matrix[0, 2]) * z / matrix[0, 0], + (rows[valid] - matrix[1, 2]) * z / matrix[1, 1], + z, + ) + ) + return pointcloud_from_xyz_rgb(xyz, image_to_rgb(color)[valid], header=depth.header) + + +def voxel_downsample_cloud(message: PointCloud2, voxel_size: float = 0.025) -> PointCloud2: + """Downsample XYZ/RGB clouds with the existing Open3D tensor backend. + + Non-positive sizes and clouds below twenty points retain the existing fast + path. Unsupported extra fields are rejected rather than silently dropped. + Source headers are preserved; generated messages own no numerical methods. + """ + import open3d as o3d # type: ignore[import-untyped] + + if not math.isfinite(voxel_size): + raise ValueError("voxel_size must be finite") + if voxel_size <= 0 or message.width * message.height < 20: + return message + names = {field.name for field in message.fields} + if names not in ({"x", "y", "z"}, {"x", "y", "z", "rgb"}): + raise ValueError("voxel downsampling supports only XYZ and optional RGB fields") + points = pointcloud_xyz(message).astype(np.float32) + if not np.isfinite(points).all(): + raise ValueError("voxel downsampling requires finite XYZ positions") + colors = pointcloud_rgb(message) + + device = o3d.core.Device("CPU:0") + cloud = o3d.t.geometry.PointCloud(o3d.core.Tensor(points, o3d.core.Dtype.Float32, device)) + if colors is not None: + cloud.point["colors"] = o3d.core.Tensor( + colors.astype(np.float32) / 255.0, o3d.core.Dtype.Float32, device + ) + reduced = cloud.voxel_down_sample(voxel_size) + xyz = reduced.point["positions"].numpy() + if colors is None: + return pointcloud_from_xyz(xyz, header=message.header) + rgb = np.clip(reduced.point["colors"].numpy() * 255.0, 0, 255).astype(np.uint8) + return pointcloud_from_xyz_rgb(xyz, rgb, header=message.header) + + +def pointcloud_to_open3d(message: PointCloud2) -> Any: + """Copy generated XYZ/RGB values into a native geometry algorithm input.""" + import open3d as o3d # type: ignore[import-untyped] + + result = o3d.geometry.PointCloud() + result.points = o3d.utility.Vector3dVector(pointcloud_xyz(message)) + colors = pointcloud_rgb(message) + if colors is not None: + result.colors = o3d.utility.Vector3dVector(colors.astype(np.float64) / 255.0) + return result + + +def cloud_bounds_intersect(first: PointCloud2, second: PointCloud2) -> bool: + """Test closed axis-aligned bounds; empty or entirely nonfinite clouds do not intersect.""" + a, b = pointcloud_xyz(first), pointcloud_xyz(second) + a, b = a[np.isfinite(a).all(axis=1)], b[np.isfinite(b).all(axis=1)] + if len(a) == 0 or len(b) == 0: + return False + return bool(np.all(a.min(axis=0) <= b.max(axis=0)) and np.all(a.max(axis=0) >= b.min(axis=0))) diff --git a/dimos/msgs/time.py b/dimos/msgs/time.py new file mode 100644 index 00000000000..3d9197cb58a --- /dev/null +++ b/dimos/msgs/time.py @@ -0,0 +1,77 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Explicit conversions for generated ROS time, duration, and header fields.""" + +import math +import time +from typing import Any + +from dimos.messages.builtin_interfaces.msg import Duration, Time +from dimos.messages.std_msgs.msg import Header + +NANOSECONDS_PER_SECOND = 1_000_000_000 + + +def to_nanoseconds(stamp: Time | Duration) -> int: + """Convert a normalized ROS time or duration without losing integer precision.""" + if not 0 <= stamp.nanosec < NANOSECONDS_PER_SECOND: + raise ValueError("nanosec must be in [0, 1000000000)") + return stamp.sec * NANOSECONDS_PER_SECOND + stamp.nanosec + + +def to_seconds(stamp: Time | Duration) -> float: + """Convert to floating seconds for APIs that require them.""" + return to_nanoseconds(stamp) / NANOSECONDS_PER_SECOND + + +def time_from_nanoseconds(value: int) -> Time: + sec, nanosec = divmod(value, NANOSECONDS_PER_SECOND) + if not -(2**31) <= sec < 2**31: + raise ValueError("seconds must fit the ROS signed 32-bit field") + return Time(sec=sec, nanosec=nanosec) + + +def duration_from_nanoseconds(value: int) -> Duration: + sec, nanosec = divmod(value, NANOSECONDS_PER_SECOND) + if not -(2**31) <= sec < 2**31: + raise ValueError("seconds must fit the ROS signed 32-bit field") + return Duration(sec=sec, nanosec=nanosec) + + +def _seconds_to_nanoseconds(value: float) -> int: + if not math.isfinite(value): + raise ValueError("seconds must be finite") + sec = math.floor(value) + return sec * NANOSECONDS_PER_SECOND + round((value - sec) * NANOSECONDS_PER_SECOND) + + +def time_from_seconds(value: float) -> Time: + """Round to the nearest nanosecond, normalizing negative and carried fractions.""" + return time_from_nanoseconds(_seconds_to_nanoseconds(value)) + + +def duration_from_seconds(value: float) -> Duration: + return duration_from_nanoseconds(_seconds_to_nanoseconds(value)) + + +def header_now(frame_id: str = "") -> Header: + """Construct a wall-clock header; generated defaults themselves remain zero.""" + return Header(stamp=time_from_nanoseconds(time.time_ns()), frame_id=frame_id) + + +def message_header(message: Any) -> Header | None: + """Return source metadata from a generated stamped value.""" + header = getattr(message, "header", None) + return header if isinstance(header, Header) else None diff --git a/dimos/visualization/rerun/bridge.py b/dimos/visualization/rerun/bridge.py index 389ff0e5d2b..7a7308f3470 100644 --- a/dimos/visualization/rerun/bridge.py +++ b/dimos/visualization/rerun/bridge.py @@ -49,11 +49,11 @@ from toolz import pipe # type: ignore[import-untyped] from dimos.constants import RECORDINGS_DIR -from dimos.core.cdr_transport import CdrTransport from dimos.core.coordination.process_lifecycle import DIMOS_RUN_ID_ENV from dimos.core.core import rpc from dimos.core.global_config import global_config from dimos.core.module import Module, ModuleConfig +from dimos.core.transport import PubSubTransport from dimos.messages.sensor_msgs.msg import ( CameraInfo as CdrCameraInfo, CompressedImage as CdrCompressedImage, @@ -296,7 +296,7 @@ class Config(ModuleConfig): pubsubs: list[SubscribeAllCapable[Any, Any]] | None = None # Explicit subscriptions for the proposal's generated CDR channels. Each # transport belongs to this bridge; legacy discovery/defaults stay intact. - cdr_transports: dict[str, CdrTransport[Any]] = field(default_factory=dict) + cdr_transports: dict[str, PubSubTransport[Any]] = field(default_factory=dict) visual_override: dict[Glob | str, VisualOverride] = field(default_factory=dict) static: dict[str, Callable[[Any], Any]] = field(default_factory=dict) @@ -662,7 +662,7 @@ def start(self) -> None: for topic, transport in self.config.cdr_transports.items(): self._subscribe_cdr(topic, transport) - def _subscribe_cdr(self, topic: str, transport: CdrTransport[Any]) -> None: + def _subscribe_cdr(self, topic: str, transport: PubSubTransport[Any]) -> None: cleanup = ExitStack() cleanup.callback(transport.stop) try: diff --git a/dimos/visualization/rerun/test_cdr.py b/dimos/visualization/rerun/test_cdr.py index 416737abea4..7456d961ee1 100644 --- a/dimos/visualization/rerun/test_cdr.py +++ b/dimos/visualization/rerun/test_cdr.py @@ -22,8 +22,8 @@ import pytest import rerun as rr -from dimos.core.cdr_transport import CdrTransport from dimos.core.global_config import GlobalConfig +from dimos.core.transport import ZenohTransport from dimos.messages.builtin_interfaces.msg import Time from dimos.messages.dimos_msgs.msg import EntityMarker, EntityMarkers from dimos.messages.geometry_msgs.msg import ( @@ -114,13 +114,13 @@ def test_source_time_is_scoped_and_reset_after_render_failure(bridge, mocker, na def test_subscription_cleanup_order_and_failed_start(bridge) -> None: operations: list[str] = [] - transport = Mock(spec=CdrTransport) + transport = Mock(spec=ZenohTransport) transport.subscribe.return_value = lambda: operations.append("unsubscribe") transport.stop.side_effect = lambda: operations.append("stop") bridge._subscribe_cdr("pose", transport) bridge.stop() assert operations == ["unsubscribe", "stop"] - failed = Mock(spec=CdrTransport) + failed = Mock(spec=ZenohTransport) failed.subscribe.side_effect = RuntimeError("native start failed") with pytest.raises(RuntimeError, match="native start failed"): bridge._subscribe_cdr("pose", failed) diff --git a/examples/message-viewer/demo_cdr_rerun.py b/examples/message-viewer/demo_cdr_rerun.py index 4b2a3b1b5b1..d894462778d 100644 --- a/examples/message-viewer/demo_cdr_rerun.py +++ b/examples/message-viewer/demo_cdr_rerun.py @@ -27,7 +27,7 @@ import rerun as rr import rerun.blueprint as rrb -from dimos.core.cdr_transport import CdrTransport +from dimos.core.transport import ZenohTransport from dimos.messages.builtin_interfaces.msg import Time from dimos.messages.geometry_msgs.msg import Point, Pose, PoseStamped, Quaternion from dimos.messages.sensor_msgs.msg import CameraInfo @@ -35,6 +35,7 @@ from dimos.msgs.camera_info import camera_info_from_intrinsics from dimos.msgs.image import image_from_array from dimos.msgs.pointcloud import pointcloud_from_rgbd +from dimos.protocol.pubsub.impl.zenohpubsub import Topic as ZenohTopic from dimos.visualization.rerun.cdr import camera_pinhole, generated_archetypes @@ -114,12 +115,12 @@ def receive(value: Any, *, topic: str) -> None: # This demo explicitly identifies map with its viewer origin. Runtime # bridges use the producer's named TF frame; no coordinates are rewritten. recording.log("world", rr.Transform3D(child_frame="tf#/map"), static=True) - publishers: dict[str, CdrTransport[Any]] = {} + publishers: dict[str, ZenohTransport[Any]] = {} for topic, value in messages.items(): - subscriber = CdrTransport(topic, type(value), backend="zenoh", **config) + subscriber = ZenohTransport(ZenohTopic(topic, type(value)), **config) cleanup.callback(subscriber.stop) cleanup.callback(subscriber.subscribe(partial(receive, topic=topic))) - publisher = CdrTransport(topic, type(value), backend="zenoh", **config) + publisher = ZenohTransport(ZenohTopic(topic, type(value)), **config) cleanup.callback(publisher.stop) publishers[topic] = publisher for topic, value in messages.items(): From 1e18dabf8377390b0007c003fe0c6d026a2bdbd1 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 20 Oct 2026 21:49:03 -0700 Subject: [PATCH 3/8] refactor(viewers): keep sensor adapters local and defer domain migration --- dimos/memory/cli/render.py | 11 +- dimos/memory/cli/test_render.py | 34 +- dimos/msgs/camera_info.py | 178 -------- dimos/msgs/geometry.py | 376 ----------------- dimos/msgs/image.py | 318 --------------- dimos/msgs/pointcloud.py | 382 ------------------ dimos/msgs/time.py | 77 ---- dimos/navigation/global_planner/test_viz.py | 47 +-- dimos/navigation/global_planner/viz.py | 15 +- dimos/navigation/local_planner/profile.py | 14 +- dimos/navigation/local_planner/viz.py | 25 +- dimos/visualization/rerun/_cdr_sensors.py | 220 ++++++++++ dimos/visualization/rerun/bridge.py | 3 +- dimos/visualization/rerun/cdr.py | 9 +- dimos/visualization/rerun/test_cdr.py | 61 ++- .../rerun/test_cdr_image_values.py | 56 +-- .../rerun/test_cdr_sensor_docs.py | 37 -- docs/usage/visualization.md | 118 ++---- docs/web/bridge.md | 5 +- examples/message-viewer/demo_cdr_rerun.py | 66 ++- 20 files changed, 454 insertions(+), 1598 deletions(-) delete mode 100644 dimos/msgs/camera_info.py delete mode 100644 dimos/msgs/geometry.py delete mode 100644 dimos/msgs/image.py delete mode 100644 dimos/msgs/pointcloud.py delete mode 100644 dimos/msgs/time.py create mode 100644 dimos/visualization/rerun/_cdr_sensors.py delete mode 100644 dimos/visualization/rerun/test_cdr_sensor_docs.py diff --git a/dimos/memory/cli/render.py b/dimos/memory/cli/render.py index c560b43c754..d0f735441a8 100644 --- a/dimos/memory/cli/render.py +++ b/dimos/memory/cli/render.py @@ -40,13 +40,13 @@ CompressedImage, Image as CdrImage, ) +from dimos.messages.std_msgs.msg import Header as CdrHeader from dimos.messages.tf2_msgs.msg import TFMessage as CdrTFMessage from dimos.msgs.nav_msgs.Path import Path as NavPath from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo from dimos.msgs.sensor_msgs.Image import Image from dimos.msgs.shape_msgs.TriangleMesh import TriangleMesh from dimos.msgs.tf2_msgs.TFMessage import TfFrameTree, TFMessage -from dimos.msgs.time import message_header, to_nanoseconds from dimos.visualization.rerun.cdr import camera_pinhole, generated_archetypes, message_frame_id from dimos.visualization.rerun.init import rerun_init @@ -245,10 +245,13 @@ def entity(name: str) -> str: report(obs) continue rr.set_time("time", duration=obs.ts - t0) - header = message_header(obs.data) - if header is not None: + header = getattr(obs.data, "header", None) + if isinstance(header, CdrHeader): rr.set_time( - "source", timestamp=np.datetime64(to_nanoseconds(header.stamp), "ns") + "source", + timestamp=np.datetime64( + header.stamp.sec * 1_000_000_000 + header.stamp.nanosec, "ns" + ), ) else: rr.disable_timeline("source") diff --git a/dimos/memory/cli/test_render.py b/dimos/memory/cli/test_render.py index cb8fde22c0e..6747ed3bc57 100644 --- a/dimos/memory/cli/test_render.py +++ b/dimos/memory/cli/test_render.py @@ -15,6 +15,7 @@ from pathlib import Path from types import SimpleNamespace +import imagecodecs import numpy as np import rerun as rr @@ -27,14 +28,16 @@ TransformStamped, Vector3 as CdrVector3, ) -from dimos.messages.sensor_msgs.msg import CameraInfo as CdrCameraInfo, CompressedImage +from dimos.messages.sensor_msgs.msg import ( + CameraInfo as CdrCameraInfo, + CompressedImage, + RegionOfInterest, +) from dimos.messages.std_msgs.msg import Header from dimos.messages.tf2_msgs.msg import TFMessage as CdrTFMessage -from dimos.msgs.camera_info import camera_info_from_intrinsics from dimos.msgs.geometry_msgs.Quaternion import Quaternion from dimos.msgs.geometry_msgs.Transform import Transform from dimos.msgs.geometry_msgs.Vector3 import Vector3 -from dimos.msgs.image import compressed_image_from_image, image_from_array from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo from dimos.msgs.sensor_msgs.Image import Image from dimos.msgs.tf2_msgs.TFMessage import TFMessage @@ -251,15 +254,24 @@ def test_render_store_themes_meshes(tmp_path, monkeypatch) -> None: # type: ign def test_generated_compressed_image_pinhole_waits_for_its_frame(tmp_path, capsys, mocker): log = mocker.spy(rr, "log") set_time = mocker.spy(rr, "set_time") - info = camera_info_from_intrinsics( - fx=5.0, fy=5.0, cx=2.0, cy=2.0, width=4, height=4, header=Header(Time(0, 0), "camera") + info = CdrCameraInfo( + distortion_model="plumb_bob", + d=np.zeros(5, dtype=np.float64), + r=np.eye(3, dtype=np.float64).ravel(), + p=np.zeros(12, dtype=np.float64), + binning_x=0, + binning_y=0, + roi=RegionOfInterest(x_offset=0, y_offset=0, height=0, width=0, do_rectify=False), + header=Header(Time(0, 0), "camera"), + width=4, + height=4, + k=np.array([5, 0, 2, 0, 5, 2, 0, 0, 1], dtype=np.float64), ) - image = compressed_image_from_image( - image_from_array( - np.full((4, 4, 3), [255, 0, 0], dtype=np.uint8), - encoding="rgb8", - header=Header(Time(0, 0), "camera"), - ) + pixels = np.full((4, 4, 3), [255, 0, 0], dtype=np.uint8) + image = CompressedImage( + header=info.header, + format="png", + data=np.frombuffer(imagecodecs.png_encode(pixels), dtype=np.uint8), ) image.header.stamp = Time(1_789_654_321, 1) tf = CdrTFMessage( diff --git a/dimos/msgs/camera_info.py b/dimos/msgs/camera_info.py deleted file mode 100644 index c8040990805..00000000000 --- a/dimos/msgs/camera_info.py +++ /dev/null @@ -1,178 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Calibration construction and array access for generated ROS CameraInfo values.""" - -from copy import deepcopy -import math -from pathlib import Path -import re -from typing import Any, Literal - -import numpy as np -from numpy.typing import NDArray -import yaml - -from dimos.messages.builtin_interfaces.msg import Time -from dimos.messages.sensor_msgs.msg import CameraInfo, RegionOfInterest -from dimos.messages.std_msgs.msg import Header - - -def _dimensions(width: int, height: int) -> None: - if any( - isinstance(value, bool) or not isinstance(value, int) or value <= 0 - for value in (width, height) - ): - raise ValueError("Calibration width and height must be positive integers") - - -def camera_info_from_intrinsics( - fx: float, fy: float, cx: float, cy: float, width: int, height: int, *, header: Header -) -> CameraInfo: - """Construct an undistorted pinhole calibration with an explicit source header.""" - _dimensions(width, height) - if not all(math.isfinite(value) for value in (fx, fy, cx, cy)) or fx <= 0 or fy <= 0: - raise ValueError("Intrinsics must be finite with positive focal lengths") - return CameraInfo( - header=header, - width=width, - height=height, - distortion_model="plumb_bob", - d=np.asarray([0.0] * 5, dtype=np.float64), - k=np.array([fx, 0, cx, 0, fy, cy, 0, 0, 1], dtype=np.float64), - r=np.array([1, 0, 0, 0, 1, 0, 0, 0, 1], dtype=np.float64), - p=np.array([fx, 0, cx, 0, 0, fy, cy, 0, 0, 0, 1, 0], dtype=np.float64), - binning_x=0, - binning_y=0, - roi=RegionOfInterest(x_offset=0, y_offset=0, height=0, width=0, do_rectify=False), - ) - - -def camera_info_from_fov( - fov_degrees: float, - width: int, - height: int, - *, - header: Header, - axis: Literal["horizontal", "vertical"] = "vertical", -) -> CameraInfo: - """Construct a centered pinhole model with square pixels from an axis's FOV.""" - _dimensions(width, height) - if not math.isfinite(fov_degrees) or not 0 < fov_degrees < 180: - raise ValueError("Field of view must be finite and between 0 and 180 degrees") - if axis not in ("horizontal", "vertical"): - raise ValueError("Field-of-view axis must be horizontal or vertical") - extent = width if axis == "horizontal" else height - focal = extent / (2 * math.tan(math.radians(fov_degrees) / 2)) - return camera_info_from_intrinsics( - focal, focal, width / 2, height / 2, width, height, header=header - ) - - -def _matrix(data: dict[str, Any], name: str, rows: int, columns: int | None) -> list[float]: - matrix = data[name] - if not isinstance(matrix, dict) or matrix.get("rows") != rows: - raise ValueError(f"{name} must declare {rows} rows") - actual_columns = matrix.get("cols") - if ( - not isinstance(actual_columns, int) - or actual_columns < 0 - or (columns is not None and actual_columns != columns) - ): - raise ValueError(f"{name} has invalid columns") - values = matrix.get("data") - if not isinstance(values, list) or len(values) != rows * actual_columns: - raise ValueError(f"{name} data length does not match its dimensions") - converted = [float(value) for value in values] - if not all(math.isfinite(value) for value in converted): - raise ValueError(f"{name} must contain finite values") - return converted - - -def camera_info_from_yaml(path: str | Path, *, header: Header) -> CameraInfo: - """Read a ROS calibration YAML file, validating declared matrix dimensions.""" - with Path(path).open() as source: - data = yaml.safe_load(source) - if not isinstance(data, dict): - raise TypeError("Calibration YAML must contain a mapping") - width, height = data["image_width"], data["image_height"] - _dimensions(width, height) - return CameraInfo( - header=header, - width=width, - height=height, - distortion_model=data["distortion_model"], - k=np.asarray(_matrix(data, "camera_matrix", 3, 3), dtype=np.float64), - d=np.asarray(_matrix(data, "distortion_coefficients", 1, None), dtype=np.float64), - r=np.asarray(_matrix(data, "rectification_matrix", 3, 3), dtype=np.float64), - p=np.asarray(_matrix(data, "projection_matrix", 3, 4), dtype=np.float64), - binning_x=0, - binning_y=0, - roi=RegionOfInterest(x_offset=0, y_offset=0, height=0, width=0, do_rectify=False), - ) - - -def intrinsic_matrix(message: CameraInfo) -> NDArray[np.float64]: - """Copy the 3x3 intrinsic matrix into independently mutable NumPy storage.""" - return np.array(message.k, dtype=np.float64, copy=True).reshape(3, 3) - - -def camera_info_with_stamp(message: CameraInfo, stamp: Time) -> CameraInfo: - """Copy a calibration with an exact source stamp, without mutating prior publishes.""" - result = deepcopy(message) - result.header.stamp = deepcopy(stamp) - return result - - -class CalibrationProvider: - """Lazily load named YAML calibrations as plain generated CameraInfo values. - - Both SingleWebcam and single_webcam resolve single_webcam.yaml. This - helper lives outside generated messages; calibration files stay local. - """ - - def __init__(self, calibration_dir: str | Path, *, frame_id: str = "camera_optical") -> None: - self._calibration_dir = Path(calibration_dir) - self._frame_id = frame_id - self._cache: dict[str, CameraInfo] = {} - - @staticmethod - def _to_snake_case(name: str) -> str: - first = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) - return re.sub("([a-z0-9])([A-Z])", r"\1_\2", first).lower() - - def __getattr__(self, name: str) -> CameraInfo: - if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", name): - raise AttributeError(f"No calibration file found for: {name}") - snake = self._to_snake_case(name) - for key in (name, snake): - if key in self._cache: - return self._cache[key] - for key in (name, snake): - path = self._calibration_dir / f"{key}.yaml" - if path.is_file(): - value = camera_info_from_yaml( - path, header=Header(frame_id=self._frame_id, stamp=Time(sec=0, nanosec=0)) - ) - self._cache[name] = value - self._cache[snake] = value - return value - raise AttributeError(f"No calibration file found for: {name}") - - def __dir__(self) -> list[str]: - names: set[str] = set() - for path in self._calibration_dir.glob("*.yaml"): - names.add(path.stem) - names.add("".join(word.capitalize() for word in path.stem.split("_"))) - return sorted(names) diff --git a/dimos/msgs/geometry.py b/dimos/msgs/geometry.py deleted file mode 100644 index 2ab85421267..00000000000 --- a/dimos/msgs/geometry.py +++ /dev/null @@ -1,376 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Geometry operations on generated ROS value types.""" - -from collections.abc import Sequence -from copy import deepcopy -import math - -import numpy as np -from numpy.typing import NDArray -from scipy.spatial.transform import Rotation - -from dimos.messages.geometry_msgs.msg import ( - Point, - Pose, - PoseStamped, - Quaternion, - Transform, - TransformStamped, - Vector3, - Wrench, -) -from dimos.messages.nav_msgs.msg import Odometry -from dimos.messages.std_msgs.msg import Header - - -def yaw(rotation: Quaternion) -> float: - """Return rotation about Z in radians (ROS XYZW quaternion convention).""" - return math.atan2( - 2 * (rotation.w * rotation.z + rotation.x * rotation.y), - 1 - 2 * (rotation.y * rotation.y + rotation.z * rotation.z), - ) - - -def transform_from_odometry(message: Odometry) -> TransformStamped: - """Copy the pose and declared frames into TF, preserving the exact source stamp.""" - position = message.pose.pose.position - return TransformStamped( - header=deepcopy(message.header), - child_frame_id=message.child_frame_id, - transform=Transform( - translation=Vector3(x=position.x, y=position.y, z=position.z), - rotation=deepcopy(message.pose.pose.orientation), - ), - ) - - -def quaternion_from_euler(roll: float, pitch: float, yaw: float) -> Quaternion: - """Convert fixed-axis XYZ angles in radians to a normalized ROS quaternion.""" - if not all(math.isfinite(value) for value in (roll, pitch, yaw)): - raise ValueError("Euler angles must be finite") - x, y, z, w = Rotation.from_euler("xyz", [roll, pitch, yaw]).as_quat() - return Quaternion(x=x, y=y, z=z, w=w) - - -def _rotation(quaternion: Quaternion) -> Rotation: - values = [quaternion.x, quaternion.y, quaternion.z, quaternion.w] - if not all(math.isfinite(value) for value in values): - raise ValueError("Quaternion components must be finite") - # SciPy rejects zero norm and normalizes non-unit input. - return Rotation.from_quat(values) - - -def _translation(transform: Transform) -> NDArray[np.float64]: - translation = transform.translation - values = np.array([translation.x, translation.y, translation.z], dtype=np.float64) - if not np.isfinite(values).all(): - raise ValueError("Translation components must be finite") - return values - - -def _transform(translation: NDArray[np.float64], rotation: Rotation) -> Transform: - x, y, z, w = rotation.as_quat() - return Transform( - translation=Vector3(x=translation[0], y=translation[1], z=translation[2]), - rotation=Quaternion(x=x, y=y, z=z, w=w), - ) - - -def transform_matrix(transform: Transform) -> NDArray[np.float64]: - """Copy a generated transform into a homogeneous 4-by-4 matrix.""" - matrix = np.eye(4, dtype=np.float64) - matrix[:3, :3] = _rotation(transform.rotation).as_matrix() - matrix[:3, 3] = _translation(transform) - return matrix - - -def compose_transforms(first: TransformStamped, second: TransformStamped) -> TransformStamped: - """Compose A←B and B←C, preserving A's exact source stamp in the A←C result. - - Inputs are copied into a new value. The shared frame must match; callers - querying a time-varying chain are responsible for choosing each edge's time. - """ - if first.child_frame_id != second.header.frame_id: - raise ValueError( - f"Cannot compose frames {first.child_frame_id!r} and {second.header.frame_id!r}" - ) - rotation = _rotation(first.transform.rotation) - translation = _translation(first.transform) + rotation.apply(_translation(second.transform)) - return TransformStamped( - header=deepcopy(first.header), - child_frame_id=second.child_frame_id, - transform=_transform(translation, rotation * _rotation(second.transform.rotation)), - ) - - -def inverse_transform(message: TransformStamped) -> TransformStamped: - """Return B←A from A←B, swapping frame names and preserving the exact stamp.""" - rotation = _rotation(message.transform.rotation).inv() - return TransformStamped( - header=Header(stamp=deepcopy(message.header.stamp), frame_id=message.child_frame_id), - child_frame_id=message.header.frame_id, - transform=_transform(-rotation.apply(_translation(message.transform)), rotation), - ) - - -def pose_from_transform(message: TransformStamped) -> PoseStamped: - """Copy the child frame's pose in the parent frame into a generated PoseStamped.""" - translation = message.transform.translation - return PoseStamped( - header=deepcopy(message.header), - pose=Pose( - position=Point(x=translation.x, y=translation.y, z=translation.z), - orientation=deepcopy(message.transform.rotation), - ), - ) - - -def transform_from_pose(message: PoseStamped, *, child_frame_id: str) -> TransformStamped: - """Copy a stamped pose into TF with an explicit child and unchanged source header.""" - if not isinstance(message, PoseStamped): - raise TypeError("Expected a generated PoseStamped") - position = message.pose.position - return TransformStamped( - header=deepcopy(message.header), - child_frame_id=child_frame_id, - transform=Transform( - translation=Vector3(x=position.x, y=position.y, z=position.z), - rotation=deepcopy(message.pose.orientation), - ), - ) - - -def pose_matrix(pose: Pose) -> NDArray[np.float64]: - """Copy a generated pose into a homogeneous matrix, rejecting invalid geometry.""" - values = np.array([pose.position.x, pose.position.y, pose.position.z], dtype=np.float64) - if not np.isfinite(values).all(): - raise ValueError("Position components must be finite") - result = np.eye(4, dtype=np.float64) - result[:3, :3] = _rotation(pose.orientation).as_matrix() - result[:3, 3] = values - return result - - -def quaternion_from_matrix(matrix: NDArray[np.float64]) -> Quaternion: - """Convert a finite proper 3-by-3 rotation matrix into a generated quaternion.""" - values = np.asarray(matrix, dtype=np.float64) - if values.shape != (3, 3) or not np.isfinite(values).all(): - raise ValueError("Rotation matrix must be finite and 3-by-3") - if not np.allclose(values.T @ values, np.eye(3), atol=1e-6, rtol=0.0) or not np.isclose( - np.linalg.det(values), 1.0, atol=1e-6, rtol=0.0 - ): - raise ValueError("Rotation matrix must be orthonormal with determinant +1") - x, y, z, w = Rotation.from_matrix(values).as_quat() - return Quaternion(x=x, y=y, z=z, w=w) - - -def pose_from_matrix(matrix: NDArray[np.float64]) -> Pose: - """Copy a finite rigid 4-by-4 transform into a generated pose.""" - values = np.asarray(matrix, dtype=np.float64) - if values.shape != (4, 4) or not np.isfinite(values).all(): - raise ValueError("Pose matrix must be finite and 4-by-4") - if not np.allclose(values[3], [0.0, 0.0, 0.0, 1.0], atol=1e-9, rtol=0.0): - raise ValueError("Pose matrix must have homogeneous last row [0, 0, 0, 1]") - return Pose( - position=Point(x=values[0, 3], y=values[1, 3], z=values[2, 3]), - orientation=quaternion_from_matrix(values[:3, :3]), - ) - - -def transform_from_matrix(matrix: NDArray[np.float64]) -> Transform: - """Copy a finite rigid homogeneous matrix into a generated transform.""" - pose = pose_from_matrix(matrix) - return Transform( - translation=Vector3(x=pose.position.x, y=pose.position.y, z=pose.position.z), - rotation=deepcopy(pose.orientation), - ) - - -def quaternion_euler(rotation: Quaternion) -> tuple[float, float, float]: - """Return fixed-axis XYZ Euler angles in radians for a generated quaternion.""" - roll, pitch, yaw = _rotation(rotation).as_euler("xyz") - return float(roll), float(pitch), float(yaw) - - -def translate_pose_local(pose: Pose, offset: Vector3) -> Pose: - """Copy a pose displaced by an offset expressed in its local axes.""" - matrix = pose_matrix(pose) - values = np.array([offset.x, offset.y, offset.z], dtype=np.float64) - if not np.isfinite(values).all(): - raise ValueError("Offset components must be finite") - delta = matrix[:3, :3] @ values - return Pose( - position=Point( - x=pose.position.x + delta[0], y=pose.position.y + delta[1], z=pose.position.z + delta[2] - ), - orientation=deepcopy(pose.orientation), - ) - - -def point_distance(first: Point, second: Point) -> float: - """Euclidean distance between generated position values.""" - return math.dist((first.x, first.y, first.z), (second.x, second.y, second.z)) - - -def quaternion_angle(first: Quaternion, second: Quaternion) -> float: - """Return the shortest angular separation in radians, normalizing inputs.""" - return float((_rotation(first).inv() * _rotation(second)).magnitude()) - - -def wrench_from_array(values: Sequence[float] | NDArray[np.float64]) -> Wrench: - """Copy force in N and torque in Nm from [fx, fy, fz, tx, ty, tz].""" - array = np.asarray(values, dtype=np.float64) - if array.shape != (6,): - raise ValueError("Expected 6 elements [fx, fy, fz, tx, ty, tz]") - return Wrench( - force=Vector3(x=array[0], y=array[1], z=array[2]), - torque=Vector3(x=array[3], y=array[4], z=array[5]), - ) - - -def wrench_array(message: Wrench) -> NDArray[np.float64]: - """Return an independent numeric force/torque array for arithmetic.""" - return np.array( - [ - message.force.x, - message.force.y, - message.force.z, - message.torque.x, - message.torque.y, - message.torque.z, - ], - dtype=np.float64, - ) - - -def vector_array(value: Vector3 | Point) -> NDArray[np.float64]: - """Copy a generated spatial vector/point into an independent XYZ array.""" - return np.array([value.x, value.y, value.z], dtype=np.float64) - - -def vector_from_array(values: Sequence[float] | NDArray[np.float64]) -> Vector3: - """Copy exactly three coordinates into a generated vector.""" - array = np.asarray(values, dtype=np.float64) - if array.shape != (3,): - raise ValueError("Expected 3 vector components") - return Vector3(x=array[0], y=array[1], z=array[2]) - - -def normalized_vector(value: Vector3) -> Vector3: - """Return a unit vector; zero length remains zero.""" - array = vector_array(value) - length = float(np.linalg.norm(array)) - return vector_from_array(array / length) if length else Vector3(x=0.0, y=0.0, z=0.0) - - -def quaternion_array(value: Quaternion) -> NDArray[np.float64]: - """Copy XYZW components without normalizing their declared values.""" - return np.array([value.x, value.y, value.z, value.w], dtype=np.float64) - - -def quaternion_from_array(values: Sequence[float] | NDArray[np.float64]) -> Quaternion: - """Copy exactly four XYZW components into a generated quaternion.""" - array = np.asarray(values, dtype=np.float64) - if array.shape != (4,): - raise ValueError("Quaternion requires exactly 4 components") - return Quaternion(x=float(array[0]), y=float(array[1]), z=float(array[2]), w=float(array[3])) - - -def quaternion_product(first: Quaternion, second: Quaternion) -> Quaternion: - """Hamilton product, retaining non-unit magnitudes and multiplication order.""" - if not isinstance(first, Quaternion) or not isinstance(second, Quaternion): - raise TypeError("Cannot multiply Quaternion with a non-quaternion value") - a, b = quaternion_array(first), quaternion_array(second) - xyz = a[3] * b[:3] + b[3] * a[:3] + np.cross(a[:3], b[:3]) - return quaternion_from_array(np.append(xyz, a[3] * b[3] - np.dot(a[:3], b[:3]))) - - -def quaternion_conjugate(value: Quaternion) -> Quaternion: - """Negate the imaginary components, preserving the scalar component.""" - return Quaternion(x=-value.x, y=-value.y, z=-value.z, w=value.w) - - -def quaternion_inverse(value: Quaternion) -> Quaternion: - """Algebraic inverse, including non-unit quaternions.""" - array = quaternion_array(value) - squared = float(np.dot(array, array)) - if squared == 0: - raise ZeroDivisionError("Cannot invert zero quaternion") - return quaternion_from_array(quaternion_array(quaternion_conjugate(value)) / squared) - - -def normalized_quaternion(value: Quaternion) -> Quaternion: - """Return unit components without changing sign; reject zero norm.""" - array = quaternion_array(value) - length = float(np.linalg.norm(array)) - if length == 0: - raise ZeroDivisionError("Cannot normalize zero quaternion") - return quaternion_from_array(array / length) - - -def rotate_vector(rotation: Quaternion, value: Vector3) -> Vector3: - """Rotate an XYZ vector using a normalized spatial rotation.""" - return vector_from_array(_rotation(rotation).apply(vector_array(value))) - - -def point_from_array(values: Sequence[float] | NDArray[np.float64]) -> Point: - """Copy exactly three Cartesian coordinates into a generated point.""" - vector = vector_from_array(values) - return Point(x=vector.x, y=vector.y, z=vector.z) - - -def compose_poses(first: Pose, second: Pose) -> Pose: - """Compose parent/local poses without mutating inputs or normalizing stored quaternions.""" - offset = _rotation(first.orientation).apply(vector_array(second.position)) - return Pose( - position=point_from_array(vector_array(first.position) + offset), - orientation=quaternion_product(first.orientation, second.orientation), - ) - - -def compose_transform_values(first: Transform, second: Transform) -> Transform: - """Compose unframed spatial transforms in parent/local order.""" - if not isinstance(first, Transform) or not isinstance(second, Transform): - raise TypeError("Expected generated Transform values") - return Transform( - translation=vector_from_array( - vector_array(first.translation) - + _rotation(first.rotation).apply(vector_array(second.translation)) - ), - rotation=quaternion_product(first.rotation, second.rotation), - ) - - -def transform_pose_local(pose: Pose, transform: Transform) -> Pose: - """Apply an offset expressed in a pose's local axes.""" - return compose_poses( - pose, - Pose( - position=point_from_array(vector_array(transform.translation)), - orientation=transform.rotation, - ), - ) - - -def relative_pose_transform(first: Pose, second: Pose) -> Transform: - """Find the local transform taking the first pose to the second.""" - rotation = quaternion_inverse(first.orientation) - return Transform( - translation=vector_from_array( - _rotation(rotation).apply(vector_array(second.position) - vector_array(first.position)) - ), - rotation=quaternion_product(rotation, second.orientation), - ) diff --git a/dimos/msgs/image.py b/dimos/msgs/image.py deleted file mode 100644 index db80913a814..00000000000 --- a/dimos/msgs/image.py +++ /dev/null @@ -1,318 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Array views and compression for generated ROS image messages.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import imagecodecs -import numpy as np -from numpy.typing import NDArray -from PIL import Image as PILImage - -from dimos.messages.builtin_interfaces.msg import Time -from dimos.messages.sensor_msgs.msg import CompressedImage, Image -from dimos.messages.std_msgs.msg import Header - -_FORMATS = { - "rgb8": ("u1", 3), - "bgr8": ("u1", 3), - "rgba8": ("u1", 4), - "bgra8": ("u1", 4), - "mono8": ("u1", 1), - "mono16": ("u2", 1), - "16UC1": ("u2", 1), - "32FC1": ("f4", 1), - "64FC1": ("f8", 1), - "16SC1": ("i2", 1), - "32FC3": ("f4", 3), -} - - -def image_from_array(pixels: NDArray[Any], *, encoding: str, header: Header | None = None) -> Image: - """Copy an array into a generated image with an explicit pixel encoding.""" - if encoding not in _FORMATS: - raise ValueError(f"unsupported image encoding {encoding!r}") - kind, channels = _FORMATS[encoding] - expected = np.dtype(kind) - if pixels.dtype.kind != expected.kind or pixels.dtype.itemsize != expected.itemsize: - raise ValueError(f"array dtype does not match {encoding}") - if (channels == 1 and pixels.ndim != 2) or ( - channels != 1 and (pixels.ndim != 3 or pixels.shape[2] != channels) - ): - raise ValueError(f"array shape does not match {encoding}") - contiguous = np.array(pixels, copy=True, order="C") - return Image( - header=header if header is not None else Header(stamp=Time(sec=0, nanosec=0), frame_id=""), - width=pixels.shape[1], - height=pixels.shape[0], - encoding=encoding, - is_bigendian=int(not pixels.dtype.isnative if np.little_endian else pixels.dtype.isnative), - step=pixels.shape[1] * channels * pixels.dtype.itemsize, - data=np.asarray(contiguous.view(np.uint8).ravel(), dtype=np.uint8), - ) - - -def image_view(msg: Image) -> NDArray[Any]: - """Borrow a read-only array, retaining the message and respecting row padding/endian.""" - if msg.encoding not in _FORMATS: - raise ValueError(f"unsupported image encoding {msg.encoding!r}") - kind, channels = _FORMATS[msg.encoding] - dtype = np.dtype((">" if msg.is_bigendian else "<") + kind) - row_bytes = msg.width * channels * dtype.itemsize - if msg.step < row_bytes or len(msg.data) != msg.height * msg.step: - raise ValueError("image data length/step does not match its dimensions") - shape = (msg.height, msg.width) if channels == 1 else (msg.height, msg.width, channels) - strides = ( - (msg.step, dtype.itemsize) - if channels == 1 - else (msg.step, channels * dtype.itemsize, dtype.itemsize) - ) - view = np.ndarray(shape, dtype=dtype, buffer=msg.data, strides=strides) - view.setflags(write=False) - return view - - -def image_to_jpeg(msg: Image, quality: int = 75) -> bytes: - """Encode an 8-bit gray or color image using OpenCV's JPEG encoder.""" - import cv2 - - if isinstance(quality, bool) or not 0 <= quality <= 100: - raise ValueError("JPEG quality must be in 0..100") - pixels = image_view(msg) - conversions = { - "rgb8": cv2.COLOR_RGB2BGR, - "rgba8": cv2.COLOR_RGBA2BGR, - "bgra8": cv2.COLOR_BGRA2BGR, - } - if msg.encoding in conversions: - pixels = cv2.cvtColor(pixels, conversions[msg.encoding]) - elif msg.encoding not in ("bgr8", "mono8"): - raise ValueError(f"JPEG requires 8-bit color or gray, got {msg.encoding!r}") - ok, encoded = cv2.imencode(".jpg", pixels, [cv2.IMWRITE_JPEG_QUALITY, quality]) - if not ok: - raise ValueError("JPEG encoding failed") - return bytes(encoded) - - -def compressed_image_from_image( - message: Image, - quality: int = 75, - *, - format: str = "jpeg", - max_width: int | None = None, - effort: int | None = None, -) -> CompressedImage: - """Compress generated pixels, retaining the exact header. - - PNG preserves uint16 depth. JXL preserves uint16/float32 depth losslessly; - it requires imagecodecs and is an explicit codec extension for consumers. - """ - import cv2 - - if not isinstance(message, Image): - raise TypeError("compression expects a generated Image") - if max_width is not None: - message, _ = image_resize_to_fit(message, max_width, max_width) - if format == "jpeg": - output_encoding = "mono8" if message.encoding == "mono8" else "bgr8" - data = image_to_jpeg(message, quality=quality) - elif format == "png": - pixels = image_view(message) - if pixels.dtype.kind != "u" or pixels.dtype.itemsize not in (1, 2): - raise ValueError("PNG cannot encode floating-point depth") - if message.encoding in ("rgb8", "rgba8"): - code = cv2.COLOR_RGB2BGR if message.encoding == "rgb8" else cv2.COLOR_RGBA2BGRA - pixels = cv2.cvtColor(pixels, code) - pixels = np.ascontiguousarray(pixels, dtype=pixels.dtype.newbyteorder("=")) - ok, encoded = cv2.imencode(".png", pixels) - if not ok: - raise ValueError("PNG encoding failed") - data = bytes(encoded) - output_encoding = {"rgb8": "bgr8", "rgba8": "bgra8"}.get(message.encoding, message.encoding) - elif format == "jxl": - pixels = image_view(message) - if pixels.dtype.kind not in ("u", "f") or ( - pixels.dtype.kind, - pixels.dtype.itemsize, - ) not in (("u", 1), ("u", 2), ("f", 4)): - raise ValueError(f"JXL cannot encode dtype {pixels.dtype}") - if message.encoding == "bgr8": - pixels = pixels[..., ::-1] - elif message.encoding == "bgra8": - pixels = pixels[..., [2, 1, 0, 3]] - pixels = np.ascontiguousarray(pixels, dtype=pixels.dtype.newbyteorder("=")) - if pixels.dtype == np.uint8: - data = bytes(imagecodecs.jpegxl_encode(pixels, level=quality, effort=effort or 3)) - else: - data = bytes(imagecodecs.jpegxl_encode(pixels, lossless=True, effort=effort or 1)) - output_encoding = {"bgr8": "rgb8", "bgra8": "rgba8"}.get(message.encoding, message.encoding) - else: - raise ValueError(f"unsupported compression format {format!r}") - return CompressedImage( - header=message.header, - format=f"{message.encoding}; {format} compressed {output_encoding}", - data=np.asarray(np.frombuffer(data, dtype=np.uint8).copy(), dtype=np.uint8), - ) - - -def image_from_compressed(message: CompressedImage) -> Image: - """Decode JPEG/PNG or the JXL extension into explicitly encoded generated pixels.""" - import cv2 - - format_name = message.format.lower() - if "jxl" in format_name: - jxl_pixels = imagecodecs.jpegxl_decode(bytes(message.data)) - if jxl_pixels.ndim == 2: - encoding = { - np.dtype("float32"): "32FC1", - np.dtype("uint16"): "mono16", - np.dtype("uint8"): "mono8", - }.get(jxl_pixels.dtype) - elif jxl_pixels.ndim == 3 and jxl_pixels.shape[2] in (3, 4): - encoding = "rgb8" if jxl_pixels.shape[2] == 3 else "rgba8" - else: - encoding = None - if encoding is None: - raise ValueError("unsupported decoded JXL layout") - if encoding == "mono16" and format_name.startswith("16uc1;"): - encoding = "16UC1" - return image_from_array(jxl_pixels, encoding=encoding, header=message.header) - if not any(name in format_name for name in ("jpeg", "jpg", "png")): - raise ValueError(f"unsupported compressed image format {message.format!r}") - pixels = cv2.imdecode(np.frombuffer(bytes(message.data), dtype=np.uint8), cv2.IMREAD_UNCHANGED) - if pixels is None: - raise ValueError("invalid compressed image data") - if pixels.ndim == 2: - encoding = "mono16" if pixels.dtype.itemsize == 2 else "mono8" - elif pixels.ndim == 3 and pixels.shape[2] == 3: - encoding = "bgr8" - elif pixels.ndim == 3 and pixels.shape[2] == 4: - encoding = "bgra8" - else: - raise ValueError("unsupported decoded image layout") - if encoding == "mono16" and format_name.startswith("16uc1;"): - encoding = "16UC1" - return image_from_array(pixels, encoding=encoding, header=message.header) - - -def image_brightness(message: Image) -> float: - """Sample mean pixel intensity in [0, 1] for 8/16-bit image encodings. - - Respect row padding and endian. Floating-point depth has no normalized - intensity scale and is rejected rather than treated as a color image. - """ - pixels = image_view(message) - if pixels.dtype.kind != "u": - raise ValueError("brightness requires unsigned integer pixels") - stride = max(1, max(message.height, message.width) // 256) - return float(pixels[::stride, ::stride].mean() / np.iinfo(pixels.dtype).max) - - -def image_sharpness(message: Image) -> float: - """Laplacian variance of an 8-bit visual image, downsampled to 160 pixels wide.""" - import cv2 - - pixels = image_view(message) - codes = { - "rgb8": cv2.COLOR_RGB2GRAY, - "bgr8": cv2.COLOR_BGR2GRAY, - "rgba8": cv2.COLOR_RGBA2GRAY, - "bgra8": cv2.COLOR_BGRA2GRAY, - } - if message.encoding == "mono8": - gray = pixels - elif message.encoding in codes: - gray = cv2.cvtColor(pixels, codes[message.encoding]) - else: - raise ValueError("Sharpness requires an 8-bit visual image encoding") - if gray.size == 0: - raise ValueError("Sharpness requires a nonempty image") - if message.width > 160: - gray = cv2.resize( - gray, - (160, max(1, round(message.height * 160 / message.width))), - interpolation=cv2.INTER_AREA, - ) - return float(cv2.Laplacian(gray, cv2.CV_64F).var()) - - -def image_to_bgr(message: Image) -> NDArray[np.uint8]: - """Return an independent BGR8 array for drawing or OpenCV color operations.""" - import cv2 - - pixels = image_view(message) - if message.encoding == "bgr8": - return pixels.copy() - codes = { - "rgb8": cv2.COLOR_RGB2BGR, - "rgba8": cv2.COLOR_RGBA2BGR, - "bgra8": cv2.COLOR_BGRA2BGR, - "mono8": cv2.COLOR_GRAY2BGR, - } - if message.encoding not in codes: - raise ValueError(f"Cannot convert {message.encoding!r} to BGR8") - return np.asarray(cv2.cvtColor(pixels, codes[message.encoding]), dtype=np.uint8) - - -def image_to_rgb(message: Image) -> NDArray[np.uint8]: - """Copy a display image as RGB8; 16-bit grayscale uses its high byte. - - Alpha is discarded. Floating-point depth needs an explicit visualization - scale and is rejected rather than assigning pixel values to meters. - """ - import cv2 - - pixels = image_view(message) - if message.encoding == "rgb8": - return pixels.copy() - if message.encoding in ("mono16", "16UC1"): - return np.asarray( - cv2.cvtColor((pixels / 256).astype(np.uint8), cv2.COLOR_GRAY2RGB), dtype=np.uint8 - ) - codes = { - "bgr8": cv2.COLOR_BGR2RGB, - "rgba8": cv2.COLOR_RGBA2RGB, - "bgra8": cv2.COLOR_BGRA2RGB, - "mono8": cv2.COLOR_GRAY2RGB, - } - if message.encoding not in codes: - raise ValueError(f"Cannot convert {message.encoding!r} to RGB8") - return np.asarray(cv2.cvtColor(pixels, codes[message.encoding]), dtype=np.uint8) - - -def image_resize_to_fit(message: Image, max_width: int, max_height: int) -> tuple[Image, float]: - """Downscale a generated image while preserving encoding, aspect and exact header.""" - import cv2 - - if min(max_width, max_height, message.width, message.height) <= 0: - raise ValueError("Image and target dimensions must be positive") - if message.width <= max_width and message.height <= max_height: - return message, 1.0 - scale = min(max_width / message.width, max_height / message.height) - pixels = cv2.resize( - image_view(message), - (max(1, int(message.width * scale)), max(1, int(message.height * scale))), - interpolation=cv2.INTER_LINEAR, - ) - return image_from_array(pixels, encoding=message.encoding, header=message.header), scale - - -def image_from_file(path: str | Path, *, header: Header | None = None) -> Image: - """Decode an image file as generated RGB8 with an explicit optional source header.""" - with PILImage.open(path) as image: - return image_from_array(np.asarray(image.convert("RGB")), encoding="rgb8", header=header) diff --git a/dimos/msgs/pointcloud.py b/dimos/msgs/pointcloud.py deleted file mode 100644 index 1b9c5e420ed..00000000000 --- a/dimos/msgs/pointcloud.py +++ /dev/null @@ -1,382 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""NumPy conversions for generated PointCloud2 messages.""" - -from copy import deepcopy -import math -from typing import Any - -from dimos_cdrgen.registry import decode as cdr_decode, encode as cdr_encode -import numpy as np -from numpy.typing import NDArray - -from dimos.messages.geometry_msgs.msg import TransformStamped -from dimos.messages.sensor_msgs.msg import CameraInfo, Image, PointCloud2, PointField -from dimos.messages.std_msgs.msg import Header -from dimos.msgs.camera_info import intrinsic_matrix -from dimos.msgs.geometry import transform_matrix -from dimos.msgs.image import image_to_rgb, image_view -from dimos.msgs.time import to_nanoseconds - -_FIELD_KINDS = {1: "i1", 2: "u1", 3: "i2", 4: "u2", 5: "i4", 6: "u4", 7: "f4", 8: "f8"} - - -def pointcloud_view(message: PointCloud2) -> NDArray[Any]: - """Borrow read-only structured (height, width) points, including row/field padding. - - Fields retain their declared counts and endianness. The returned array keeps - the message's storage alive. Use ``view.copy()`` for independent mutable data. - """ - if message.width and not message.point_step: - raise ValueError("nonempty point cloud requires a positive point_step") - if message.row_step < message.width * message.point_step: - raise ValueError("point cloud row_step is smaller than its row of points") - if len(message.data) != message.height * message.row_step: - raise ValueError("point cloud data length does not match height * row_step") - names: list[str] = [] - formats: list[Any] = [] - offsets: list[int] = [] - for field in message.fields: - if not field.name or field.name in names: - raise ValueError("point cloud field names must be nonempty and unique") - if field.datatype not in _FIELD_KINDS or not field.count: - raise ValueError(f"invalid point cloud datatype/count for {field.name!r}") - dtype = np.dtype((">" if message.is_bigendian else "<") + _FIELD_KINDS[field.datatype]) - if field.offset + dtype.itemsize * field.count > message.point_step: - raise ValueError(f"point cloud field {field.name!r} exceeds point_step") - names.append(field.name) - formats.append(dtype if field.count == 1 else (dtype, (field.count,))) - offsets.append(field.offset) - dtype = np.dtype( - {"names": names, "formats": formats, "offsets": offsets, "itemsize": message.point_step} - ) - view = np.ndarray( - (message.height, message.width), - dtype=dtype, - buffer=message.data, - strides=(message.row_step, message.point_step), - ) - view.setflags(write=False) - return view - - -def pointcloud_xyz(message: PointCloud2) -> NDArray[np.float64]: - """Copy scalar x/y/z fields into an N-by-3 float64 array, preserving point order.""" - points = pointcloud_view(message) - for name in ("x", "y", "z"): - if name not in (points.dtype.names or ()) or points[name].ndim != 2: - raise ValueError(f"point cloud requires a scalar {name!r} field") - return np.stack([points[name].ravel() for name in ("x", "y", "z")], axis=-1).astype(np.float64) - - -def pointcloud_from_xyz( - points: NDArray[Any], *, header: Header, stamps: NDArray[Any] | None = None -) -> PointCloud2: - """Copy Nx3 or HxWx3 coordinates into a tightly packed little-endian XYZ cloud. - - XYZ fields are float32. NaNs/infinities remain missing-point markers and set - is_dense=False; finite coordinates outside float32 range are rejected. - """ - values = np.asarray(points) - if values.ndim not in (2, 3) or values.shape[-1] != 3: - raise ValueError("XYZ coordinates must have shape (N, 3) or (H, W, 3)") - if values.dtype.kind not in "fiu": - raise ValueError("XYZ coordinates must be numeric") - finite = np.isfinite(values) - if np.any(np.abs(values[finite].astype(np.float64)) > np.finfo(np.float32).max): - raise ValueError("XYZ coordinates exceed float32 range") - packed = np.ascontiguousarray(values, dtype=" NDArray[np.float64] | None: - """Read per-point capture times; absence falls back to the header at the caller.""" - fields = [field for field in message.fields if field.name == "stamps"] - if not fields: - return None - if len(fields) != 1 or fields[0].datatype != PointField.FLOAT64 or fields[0].count != 1: - raise ValueError("Per-point stamps must be a scalar FLOAT64 field") - return np.asarray(pointcloud_view(message)["stamps"].reshape(-1), dtype=np.float64) - - -def select_points(message: PointCloud2, keep: NDArray[np.bool_]) -> PointCloud2: - """Copy selected point records in row order, retaining every field and point byte. - - The result is unorganized with no row padding. Point padding, endianness, - field metadata, source header, and the conservative density flag survive. - """ - pointcloud_view(message) # Validate the full declared layout before selecting bytes. - if keep.dtype != np.bool_ or keep.shape != (message.height * message.width,): - raise ValueError("point selection must be a flat boolean mask matching the point count") - records: NDArray[np.uint8] = np.ndarray( - (message.height, message.width, message.point_step), - dtype=np.uint8, - buffer=message.data, - strides=(message.row_step, message.point_step, 1), - ) - selected = records.reshape(message.height * message.width, message.point_step)[keep] - width = len(selected) - return PointCloud2( - header=deepcopy(message.header), - height=1, - width=width, - fields=deepcopy(message.fields), - is_bigendian=message.is_bigendian, - point_step=message.point_step, - row_step=width * message.point_step, - data=np.asarray(np.frombuffer(selected.tobytes(), dtype=np.uint8).copy(), dtype=np.uint8), - is_dense=message.is_dense, - ) - - -def pointcloud_rgb(message: PointCloud2) -> NDArray[np.uint8] | None: - """Copy RGB bytes from the standard packed rgb/rgba field, if present. - - FLOAT32 fields carry color bits, not numeric floating-point color values. - Both representations preserve the declared byte order and row padding. - """ - points = pointcloud_view(message) - names = points.dtype.names or () - name = next((name for name in ("rgb", "rgba") if name in names), None) - if name is None: - return None - field = points[name] - if field.ndim != 2 or field.dtype.kind not in "fu" or field.dtype.itemsize != 4: - raise ValueError("packed point cloud color must be a scalar FLOAT32 or UINT32 field") - packed = field.view(np.dtype(">u4" if message.is_bigendian else "> shift) & 255 for shift in (16, 8, 0)], axis=-1).astype(np.uint8) - - -def concatenate_clouds(first: PointCloud2, second: PointCloud2) -> PointCloud2: - """Join compatible point records, preserving fields, endian, and point padding. - - Row padding is removed. The first frame and newest exact source stamp survive. - Empty inputs are identities; differing nonempty frames/layouts are rejected. - """ - pointcloud_view(first) - pointcloud_view(second) - if not first.width * first.height: - return cdr_decode(cdr_encode(second), PointCloud2) - if not second.width * second.height: - return cdr_decode(cdr_encode(first), PointCloud2) - if first.header.frame_id != second.header.frame_id: - raise ValueError("cannot concatenate point clouds in different frames") - if ( - first.fields != second.fields - or first.point_step != second.point_step - or first.is_bigendian != second.is_bigendian - ): - raise ValueError("cannot concatenate point clouds with different record layouts") - a = select_points(first, np.ones(first.width * first.height, dtype=np.bool_)) - b = select_points(second, np.ones(second.width * second.height, dtype=np.bool_)) - stamp = max((first.header.stamp, second.header.stamp), key=to_nanoseconds) - return PointCloud2( - header=Header(frame_id=first.header.frame_id, stamp=stamp), - height=1, - width=a.width + b.width, - fields=deepcopy(first.fields), - is_bigendian=first.is_bigendian, - point_step=first.point_step, - row_step=a.row_step + b.row_step, - data=np.asarray(np.concatenate((a.data, b.data)), dtype=np.uint8), - is_dense=first.is_dense and second.is_dense, - ) - - -def transform_cloud(message: PointCloud2, transform: TransformStamped) -> PointCloud2: - """Apply parent←child to XYZ while retaining the complete organized record layout. - - Copy storage before changing coordinates. Keep all other fields, point/row - padding, endian and the cloud's exact source stamp. Frames must match; - integer XYZ is rejected because transformed coordinates would be truncated. - """ - if message.header.frame_id != transform.child_frame_id: - raise ValueError("point cloud frame does not match transform child frame") - view = pointcloud_view(message) - for name in ("x", "y", "z"): - if name not in (view.dtype.names or ()) or view.dtype[name].kind != "f": - raise ValueError("transform requires scalar floating-point XYZ fields") - matrix = transform_matrix(transform.transform) - xyz = pointcloud_xyz(message) - transformed = xyz @ matrix[:3, :3].T + matrix[:3, 3] - for index, name in enumerate(("x", "y", "z")): - values = transformed[:, index] - if np.any(np.abs(values[np.isfinite(values)]) > np.finfo(view.dtype[name]).max): - raise ValueError(f"transformed {name} coordinates exceed field range") - payload = bytearray(bytes(message.data)) - writable = np.ndarray( - (message.height, message.width), - dtype=view.dtype, - buffer=payload, - strides=(message.row_step, message.point_step), - ) - for index, name in enumerate(("x", "y", "z")): - writable[name] = transformed[:, index].reshape(message.height, message.width) - output = cdr_decode(cdr_encode(message), PointCloud2) - output.data = np.frombuffer(payload, dtype=np.uint8).copy() - output.header = Header(stamp=deepcopy(message.header.stamp), frame_id=transform.header.frame_id) - return output - - -def pointcloud_from_xyz_rgb( - points: NDArray[Any], colors: NDArray[np.uint8], *, header: Header -) -> PointCloud2: - """Copy XYZ and RGB8 into a declared XYZ FLOAT32 / packed RGB UINT32 layout.""" - cloud = pointcloud_from_xyz(points, header=header) - rgb = np.asarray(colors) - if rgb.dtype != np.uint8 or rgb.shape != np.asarray(points).shape: - raise ValueError("RGB must be uint8 with the same (..., 3) shape as XYZ") - records = np.empty((cloud.height, cloud.width), dtype=[("xyz", " PointCloud2: - """Project a rectified RGB/depth pair into the depth optical frame. - - Integer depth is multiplied by depth_scale to obtain meters; 32FC1 already - represents meters. Invalid/nonpositive/too-distant samples are removed. - Scale K when calibration resolution differs, retaining the depth header. - """ - if ( - not math.isfinite(depth_scale) - or depth_scale <= 0 - or not math.isfinite(depth_trunc) - or depth_trunc <= 0 - ): - raise ValueError("depth scale and truncation must be finite and positive") - if (color.width, color.height) != (depth.width, depth.height): - raise ValueError("color and depth dimensions do not match") - if depth.encoding not in ("16UC1", "mono16", "32FC1"): - raise ValueError("RGBD requires uint16 or float32 depth") - if calibration.width <= 0 or calibration.height <= 0: - raise ValueError("calibration dimensions must be positive") - matrix = intrinsic_matrix(calibration).copy() - matrix[0, :] *= depth.width / calibration.width - matrix[1, :] *= depth.height / calibration.height - distances = image_view(depth).astype(np.float64) - if depth.encoding != "32FC1": - distances *= depth_scale - valid = np.isfinite(distances) & (distances > 0) & (distances < depth_trunc) - rows = np.broadcast_to(np.arange(depth.height, dtype=np.float64)[:, None], distances.shape) - columns = np.broadcast_to(np.arange(depth.width, dtype=np.float64)[None, :], distances.shape) - z = distances[valid] - xyz = np.column_stack( - ( - (columns[valid] - matrix[0, 2]) * z / matrix[0, 0], - (rows[valid] - matrix[1, 2]) * z / matrix[1, 1], - z, - ) - ) - return pointcloud_from_xyz_rgb(xyz, image_to_rgb(color)[valid], header=depth.header) - - -def voxel_downsample_cloud(message: PointCloud2, voxel_size: float = 0.025) -> PointCloud2: - """Downsample XYZ/RGB clouds with the existing Open3D tensor backend. - - Non-positive sizes and clouds below twenty points retain the existing fast - path. Unsupported extra fields are rejected rather than silently dropped. - Source headers are preserved; generated messages own no numerical methods. - """ - import open3d as o3d # type: ignore[import-untyped] - - if not math.isfinite(voxel_size): - raise ValueError("voxel_size must be finite") - if voxel_size <= 0 or message.width * message.height < 20: - return message - names = {field.name for field in message.fields} - if names not in ({"x", "y", "z"}, {"x", "y", "z", "rgb"}): - raise ValueError("voxel downsampling supports only XYZ and optional RGB fields") - points = pointcloud_xyz(message).astype(np.float32) - if not np.isfinite(points).all(): - raise ValueError("voxel downsampling requires finite XYZ positions") - colors = pointcloud_rgb(message) - - device = o3d.core.Device("CPU:0") - cloud = o3d.t.geometry.PointCloud(o3d.core.Tensor(points, o3d.core.Dtype.Float32, device)) - if colors is not None: - cloud.point["colors"] = o3d.core.Tensor( - colors.astype(np.float32) / 255.0, o3d.core.Dtype.Float32, device - ) - reduced = cloud.voxel_down_sample(voxel_size) - xyz = reduced.point["positions"].numpy() - if colors is None: - return pointcloud_from_xyz(xyz, header=message.header) - rgb = np.clip(reduced.point["colors"].numpy() * 255.0, 0, 255).astype(np.uint8) - return pointcloud_from_xyz_rgb(xyz, rgb, header=message.header) - - -def pointcloud_to_open3d(message: PointCloud2) -> Any: - """Copy generated XYZ/RGB values into a native geometry algorithm input.""" - import open3d as o3d # type: ignore[import-untyped] - - result = o3d.geometry.PointCloud() - result.points = o3d.utility.Vector3dVector(pointcloud_xyz(message)) - colors = pointcloud_rgb(message) - if colors is not None: - result.colors = o3d.utility.Vector3dVector(colors.astype(np.float64) / 255.0) - return result - - -def cloud_bounds_intersect(first: PointCloud2, second: PointCloud2) -> bool: - """Test closed axis-aligned bounds; empty or entirely nonfinite clouds do not intersect.""" - a, b = pointcloud_xyz(first), pointcloud_xyz(second) - a, b = a[np.isfinite(a).all(axis=1)], b[np.isfinite(b).all(axis=1)] - if len(a) == 0 or len(b) == 0: - return False - return bool(np.all(a.min(axis=0) <= b.max(axis=0)) and np.all(a.max(axis=0) >= b.min(axis=0))) diff --git a/dimos/msgs/time.py b/dimos/msgs/time.py deleted file mode 100644 index 3d9197cb58a..00000000000 --- a/dimos/msgs/time.py +++ /dev/null @@ -1,77 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Explicit conversions for generated ROS time, duration, and header fields.""" - -import math -import time -from typing import Any - -from dimos.messages.builtin_interfaces.msg import Duration, Time -from dimos.messages.std_msgs.msg import Header - -NANOSECONDS_PER_SECOND = 1_000_000_000 - - -def to_nanoseconds(stamp: Time | Duration) -> int: - """Convert a normalized ROS time or duration without losing integer precision.""" - if not 0 <= stamp.nanosec < NANOSECONDS_PER_SECOND: - raise ValueError("nanosec must be in [0, 1000000000)") - return stamp.sec * NANOSECONDS_PER_SECOND + stamp.nanosec - - -def to_seconds(stamp: Time | Duration) -> float: - """Convert to floating seconds for APIs that require them.""" - return to_nanoseconds(stamp) / NANOSECONDS_PER_SECOND - - -def time_from_nanoseconds(value: int) -> Time: - sec, nanosec = divmod(value, NANOSECONDS_PER_SECOND) - if not -(2**31) <= sec < 2**31: - raise ValueError("seconds must fit the ROS signed 32-bit field") - return Time(sec=sec, nanosec=nanosec) - - -def duration_from_nanoseconds(value: int) -> Duration: - sec, nanosec = divmod(value, NANOSECONDS_PER_SECOND) - if not -(2**31) <= sec < 2**31: - raise ValueError("seconds must fit the ROS signed 32-bit field") - return Duration(sec=sec, nanosec=nanosec) - - -def _seconds_to_nanoseconds(value: float) -> int: - if not math.isfinite(value): - raise ValueError("seconds must be finite") - sec = math.floor(value) - return sec * NANOSECONDS_PER_SECOND + round((value - sec) * NANOSECONDS_PER_SECOND) - - -def time_from_seconds(value: float) -> Time: - """Round to the nearest nanosecond, normalizing negative and carried fractions.""" - return time_from_nanoseconds(_seconds_to_nanoseconds(value)) - - -def duration_from_seconds(value: float) -> Duration: - return duration_from_nanoseconds(_seconds_to_nanoseconds(value)) - - -def header_now(frame_id: str = "") -> Header: - """Construct a wall-clock header; generated defaults themselves remain zero.""" - return Header(stamp=time_from_nanoseconds(time.time_ns()), frame_id=frame_id) - - -def message_header(message: Any) -> Header | None: - """Return source metadata from a generated stamped value.""" - header = getattr(message, "header", None) - return header if isinstance(header, Header) else None diff --git a/dimos/navigation/global_planner/test_viz.py b/dimos/navigation/global_planner/test_viz.py index 12fd7e4c263..1deb9ba4dcb 100644 --- a/dimos/navigation/global_planner/test_viz.py +++ b/dimos/navigation/global_planner/test_viz.py @@ -19,58 +19,25 @@ import rerun as rr from dimos.mapping.ray_tracing.viz import render_map_region -from dimos.messages.geometry_msgs.msg import Point, PointStamped, Pose, PoseStamped -from dimos.messages.nav_msgs.msg import Path -from dimos.messages.std_msgs.msg import Header -from dimos.msgs.geometry import quaternion_from_euler +from dimos.msgs.geometry_msgs.PointStamped import PointStamped +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.nav_msgs.Path import Path from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 -from dimos.msgs.time import time_from_nanoseconds from dimos.navigation.global_planner import viz from dimos.navigation.global_planner.mls_planner.viz import render_surface_region from dimos.visualization.rerun.bridge import is_keyed_by_seq def test_goal_placeholder_is_not_drawn() -> None: - assert ( - viz.render_goal( - PointStamped( - header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), - point=Point(x=float("nan"), y=0.0, z=0.0), - ) - ) - is None - ) - goal = viz.render_goal( - PointStamped( - header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), - point=Point(x=1.0, y=2.0, z=0.0), - ) - ) + assert viz.render_goal(PointStamped(x=float("nan"), y=0.0, z=0.0)) is None + goal = viz.render_goal(PointStamped(x=1.0, y=2.0, z=0.0)) assert goal is not None assert goal.positions.as_arrow_array().to_pylist() == [[1.0, 2.0, 0.0]] def test_empty_path_keeps_the_last_one_drawn() -> None: - assert ( - viz.render_path( - Path(header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), poses=[]) - ) - is None - ) - path = viz.render_path( - Path( - header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), - poses=[ - PoseStamped( - header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), - pose=Pose( - position=Point(x=x, y=0, z=0), orientation=quaternion_from_euler(0, 0, 0) - ), - ) - for x in [1.0, 2.0] - ], - ) - ) + assert viz.render_path(Path(poses=[])) is None + path = viz.render_path(Path(poses=[PoseStamped(1.0, 0.0, 0.0), PoseStamped(2.0, 0.0, 0.0)])) assert path is not None lift = viz.PATH_Z_LIFT np.testing.assert_allclose( diff --git a/dimos/navigation/global_planner/viz.py b/dimos/navigation/global_planner/viz.py index db509d0bde0..21f720bbd31 100644 --- a/dimos/navigation/global_planner/viz.py +++ b/dimos/navigation/global_planner/viz.py @@ -32,8 +32,8 @@ from numpy.typing import NDArray from rerun._baseclasses import Archetype - from dimos.messages.geometry_msgs.msg import PointStamped - from dimos.messages.nav_msgs.msg import Path + from dimos.msgs.geometry_msgs.PointStamped import PointStamped + from dimos.msgs.nav_msgs.Path import Path from dimos.visualization.rerun.bridge import VisualOverride # rerun is imported inside the renderers: it is heavy and only viewers load it. @@ -66,12 +66,7 @@ def render_path(msg: Path) -> Archetype | None: """The planned route. An empty path means no route, so the last one stays drawn.""" if len(msg.poses) == 0: return None - return path_strip( - np.array( - [[p.pose.position.x, p.pose.position.y, p.pose.position.z] for p in msg.poses], - dtype=np.float32, - ) - ) + return path_strip(np.array([[p.x, p.y, p.z] for p in msg.poses], dtype=np.float32)) def goal_point(xyz: tuple[float, float, float]) -> Archetype: @@ -82,9 +77,9 @@ def goal_point(xyz: tuple[float, float, float]) -> Archetype: def render_goal(msg: PointStamped) -> Archetype | None: """The active goal. NaN is the movement manager's placeholder for none.""" - if any(math.isnan(v) for v in (msg.point.x, msg.point.y, msg.point.z)): + if any(math.isnan(v) for v in (msg.x, msg.y, msg.z)): return None - return goal_point((msg.point.x, msg.point.y, msg.point.z)) + return goal_point((msg.x, msg.y, msg.z)) def robot_body_box(length: float, width: float, height: float) -> Archetype: diff --git a/dimos/navigation/local_planner/profile.py b/dimos/navigation/local_planner/profile.py index eb0fdfa9b1d..e2a2a0a442c 100644 --- a/dimos/navigation/local_planner/profile.py +++ b/dimos/navigation/local_planner/profile.py @@ -25,15 +25,13 @@ import numpy as np from numpy.typing import NDArray -from dimos.messages.nav_msgs.msg import Path as CdrPath from dimos.msgs.nav_msgs.Path import Path -from dimos.msgs.time import NANOSECONDS_PER_SECOND, to_nanoseconds from dimos.navigation.embodiment.base import Embodiment _FAN_EPS = 1e-6 # segment shorter than this is a rotation, not a move -def decode_ceilings(path: Path | CdrPath, lo: float, hi: float) -> NDArray[np.float64] | None: +def decode_ceilings(path: Path, lo: float, hi: float) -> NDArray[np.float64] | None: """Per-waypoint speed ceiling (m/s) from the stamps; None if unstamped. Unstamped = flat or non-monotone ts. Fan segments inherit the previous @@ -42,15 +40,11 @@ def decode_ceilings(path: Path | CdrPath, lo: float, hi: float) -> NDArray[np.fl n = len(path.poses) if n < 2: return None - if isinstance(path, CdrPath): - stamps = np.asarray([to_nanoseconds(p.header.stamp) for p in path.poses], dtype=np.int64) - dt = np.diff(stamps).astype(np.float64) / NANOSECONDS_PER_SECOND - xy = np.asarray([[p.pose.position.x, p.pose.position.y] for p in path.poses]) - else: - dt = np.diff(np.asarray([p.ts for p in path.poses])) - xy = np.asarray([[p.position.x, p.position.y] for p in path.poses]) + ts = np.array([p.ts for p in path.poses]) + dt = np.diff(ts) if np.any(dt < 0) or not np.any(dt > 0): return None + xy = np.array([[p.position.x, p.position.y] for p in path.poses]).reshape(-1, 2) ds = np.linalg.norm(np.diff(xy, axis=0), axis=1) # a config is free to set min above max; ordering here rather than # trusting the caller keeps the clip a clip diff --git a/dimos/navigation/local_planner/viz.py b/dimos/navigation/local_planner/viz.py index 8297b49571a..b6fea546b86 100644 --- a/dimos/navigation/local_planner/viz.py +++ b/dimos/navigation/local_planner/viz.py @@ -26,9 +26,8 @@ import numpy as np from numpy.typing import NDArray -from dimos.messages.geometry_msgs.msg import PoseStamped -from dimos.messages.nav_msgs.msg import Path -from dimos.msgs.geometry import yaw +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped +from dimos.msgs.nav_msgs.Path import Path from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2 from dimos.navigation.embodiment.base import Embodiment from dimos.navigation.local_planner.profile import ( @@ -49,10 +48,10 @@ def _body_centre(pose: PoseStamped, center_off: float) -> tuple[float, float]: """The body centre for a pose point, offset along the pose's own heading.""" - heading = yaw(pose.pose.orientation) + yaw = pose.yaw return ( - pose.pose.position.x + center_off * np.cos(heading), - pose.pose.position.y + center_off * np.sin(heading), + pose.position.x + center_off * np.cos(yaw), + pose.position.y + center_off * np.sin(yaw), ) @@ -85,18 +84,16 @@ def render_body( p = msg.poses[0] cx, cy = _body_centre(p, center_off) return rr.Boxes3D( - centers=[[cx, cy, p.pose.position.z + height / 2.0 + _Z_LIFT]], + centers=[[cx, cy, p.position.z + height / 2.0 + _Z_LIFT]], half_sizes=[[length / 2.0, width / 2.0, height / 2.0]], - rotation_axis_angles=[ - rr.RotationAxisAngle([0.0, 0.0, 1.0], rr.Angle(rad=yaw(p.pose.orientation))) - ], + rotation_axis_angles=[rr.RotationAxisAngle([0.0, 0.0, 1.0], rr.Angle(rad=p.yaw))], colors=[[255, 60, 60, 200]], radii=[line_radius], fill_mode="majorwireframe", labels=["VETO: no safe route"], ) - xy = np.array([[p.pose.position.x, p.pose.position.y] for p in msg.poses]).reshape(-1, 2) + xy = np.array([[p.position.x, p.position.y] for p in msg.poses]).reshape(-1, 2) seg = np.linalg.norm(np.diff(xy, axis=0), axis=1) arcs = np.concatenate([[0.0], np.cumsum(seg)]) # one box per stride_m of arc, plus the last pose so the goal end is drawn @@ -108,8 +105,8 @@ def render_body( for i in picks: p = msg.poses[int(i)] cx, cy = _body_centre(p, center_off) - centers.append([cx, cy, p.pose.position.z + height / 2.0 + _Z_LIFT]) - angles.append(rr.RotationAxisAngle([0.0, 0.0, 1.0], rr.Angle(rad=yaw(p.pose.orientation)))) + centers.append([cx, cy, p.position.z + height / 2.0 + _Z_LIFT]) + angles.append(rr.RotationAxisAngle([0.0, 0.0, 1.0], rr.Angle(rad=p.yaw))) if clear is None: colors.append([100, 160, 255, 160]) # unstamped: the plan line's own blue else: @@ -160,7 +157,7 @@ def render_plan( return None out: RerunMulti = [ # the bridge only pins single-archetype entities to their tf frame; the boxes are the plan - (entity, rr.Transform3D(parent_frame=f"tf#/{msg.header.frame_id}")), + (entity, rr.Transform3D(parent_frame=f"tf#/{msg.frame_id}")), ] boxes = render_body(msg, emb, stride_m, line_radius) if boxes is not None: diff --git a/dimos/visualization/rerun/_cdr_sensors.py b/dimos/visualization/rerun/_cdr_sensors.py new file mode 100644 index 00000000000..e070364e40c --- /dev/null +++ b/dimos/visualization/rerun/_cdr_sensors.py @@ -0,0 +1,220 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Read generated sensor buffers for Rerun display.""" + +from typing import Any + +import imagecodecs +import numpy as np +from numpy.typing import NDArray + +from dimos.messages.builtin_interfaces.msg import Time +from dimos.messages.sensor_msgs.msg import CompressedImage, Image, PointCloud2 +from dimos.messages.std_msgs.msg import Header + +_FORMATS = { + "rgb8": ("u1", 3), + "bgr8": ("u1", 3), + "rgba8": ("u1", 4), + "bgra8": ("u1", 4), + "mono8": ("u1", 1), + "mono16": ("u2", 1), + "16UC1": ("u2", 1), + "32FC1": ("f4", 1), + "64FC1": ("f8", 1), + "16SC1": ("i2", 1), + "32FC3": ("f4", 3), +} + + +def _image_from_array( + pixels: NDArray[Any], *, encoding: str, header: Header | None = None +) -> Image: + """Copy an array into a generated image with an explicit pixel encoding.""" + if encoding not in _FORMATS: + raise ValueError(f"unsupported image encoding {encoding!r}") + kind, channels = _FORMATS[encoding] + expected = np.dtype(kind) + if pixels.dtype.kind != expected.kind or pixels.dtype.itemsize != expected.itemsize: + raise ValueError(f"array dtype does not match {encoding}") + if (channels == 1 and pixels.ndim != 2) or ( + channels != 1 and (pixels.ndim != 3 or pixels.shape[2] != channels) + ): + raise ValueError(f"array shape does not match {encoding}") + contiguous = np.array(pixels, copy=True, order="C") + return Image( + header=header if header is not None else Header(stamp=Time(sec=0, nanosec=0), frame_id=""), + width=pixels.shape[1], + height=pixels.shape[0], + encoding=encoding, + is_bigendian=int(not pixels.dtype.isnative if np.little_endian else pixels.dtype.isnative), + step=pixels.shape[1] * channels * pixels.dtype.itemsize, + data=np.asarray(contiguous.view(np.uint8).ravel(), dtype=np.uint8), + ) + + +def image_view(msg: Image) -> NDArray[Any]: + """Borrow a read-only array, retaining the message and respecting row padding/endian.""" + if msg.encoding not in _FORMATS: + raise ValueError(f"unsupported image encoding {msg.encoding!r}") + kind, channels = _FORMATS[msg.encoding] + dtype = np.dtype((">" if msg.is_bigendian else "<") + kind) + row_bytes = msg.width * channels * dtype.itemsize + if msg.step < row_bytes or len(msg.data) != msg.height * msg.step: + raise ValueError("image data length/step does not match its dimensions") + shape = (msg.height, msg.width) if channels == 1 else (msg.height, msg.width, channels) + strides = ( + (msg.step, dtype.itemsize) + if channels == 1 + else (msg.step, channels * dtype.itemsize, dtype.itemsize) + ) + view = np.ndarray(shape, dtype=dtype, buffer=msg.data, strides=strides) + view.setflags(write=False) + return view + + +def image_from_compressed(message: CompressedImage) -> Image: + """Decode JPEG/PNG or the JXL extension into explicitly encoded generated pixels.""" + import cv2 + + format_name = message.format.lower() + if "jxl" in format_name: + jxl_pixels = imagecodecs.jpegxl_decode(bytes(message.data)) + if jxl_pixels.ndim == 2: + encoding = { + np.dtype("float32"): "32FC1", + np.dtype("uint16"): "mono16", + np.dtype("uint8"): "mono8", + }.get(jxl_pixels.dtype) + elif jxl_pixels.ndim == 3 and jxl_pixels.shape[2] in (3, 4): + encoding = "rgb8" if jxl_pixels.shape[2] == 3 else "rgba8" + else: + encoding = None + if encoding is None: + raise ValueError("unsupported decoded JXL layout") + if encoding == "mono16" and format_name.startswith("16uc1;"): + encoding = "16UC1" + return _image_from_array(jxl_pixels, encoding=encoding, header=message.header) + if not any(name in format_name for name in ("jpeg", "jpg", "png")): + raise ValueError(f"unsupported compressed image format {message.format!r}") + pixels = cv2.imdecode(np.frombuffer(bytes(message.data), dtype=np.uint8), cv2.IMREAD_UNCHANGED) + if pixels is None: + raise ValueError("invalid compressed image data") + if pixels.ndim == 2: + encoding = "mono16" if pixels.dtype.itemsize == 2 else "mono8" + elif pixels.ndim == 3 and pixels.shape[2] == 3: + encoding = "bgr8" + elif pixels.ndim == 3 and pixels.shape[2] == 4: + encoding = "bgra8" + else: + raise ValueError("unsupported decoded image layout") + if encoding == "mono16" and format_name.startswith("16uc1;"): + encoding = "16UC1" + return _image_from_array(pixels, encoding=encoding, header=message.header) + + +def image_to_rgb(message: Image) -> NDArray[np.uint8]: + """Copy a display image as RGB8; 16-bit grayscale uses its high byte. + + Alpha is discarded. Floating-point depth needs an explicit visualization + scale and is rejected rather than assigning pixel values to meters. + """ + import cv2 + + pixels = image_view(message) + if message.encoding == "rgb8": + return pixels.copy() + if message.encoding in ("mono16", "16UC1"): + return np.asarray( + cv2.cvtColor((pixels / 256).astype(np.uint8), cv2.COLOR_GRAY2RGB), dtype=np.uint8 + ) + codes = { + "bgr8": cv2.COLOR_BGR2RGB, + "rgba8": cv2.COLOR_RGBA2RGB, + "bgra8": cv2.COLOR_BGRA2RGB, + "mono8": cv2.COLOR_GRAY2RGB, + } + if message.encoding not in codes: + raise ValueError(f"Cannot convert {message.encoding!r} to RGB8") + return np.asarray(cv2.cvtColor(pixels, codes[message.encoding]), dtype=np.uint8) + + +_FIELD_KINDS = {1: "i1", 2: "u1", 3: "i2", 4: "u2", 5: "i4", 6: "u4", 7: "f4", 8: "f8"} + + +def pointcloud_view(message: PointCloud2) -> NDArray[Any]: + """Borrow read-only structured (height, width) points, including row/field padding. + + Fields retain their declared counts and endianness. The returned array keeps + the message's storage alive. Use ``view.copy()`` for independent mutable data. + """ + if message.width and not message.point_step: + raise ValueError("nonempty point cloud requires a positive point_step") + if message.row_step < message.width * message.point_step: + raise ValueError("point cloud row_step is smaller than its row of points") + if len(message.data) != message.height * message.row_step: + raise ValueError("point cloud data length does not match height * row_step") + names: list[str] = [] + formats: list[Any] = [] + offsets: list[int] = [] + for field in message.fields: + if not field.name or field.name in names: + raise ValueError("point cloud field names must be nonempty and unique") + if field.datatype not in _FIELD_KINDS or not field.count: + raise ValueError(f"invalid point cloud datatype/count for {field.name!r}") + dtype = np.dtype((">" if message.is_bigendian else "<") + _FIELD_KINDS[field.datatype]) + if field.offset + dtype.itemsize * field.count > message.point_step: + raise ValueError(f"point cloud field {field.name!r} exceeds point_step") + names.append(field.name) + formats.append(dtype if field.count == 1 else (dtype, (field.count,))) + offsets.append(field.offset) + dtype = np.dtype( + {"names": names, "formats": formats, "offsets": offsets, "itemsize": message.point_step} + ) + view = np.ndarray( + (message.height, message.width), + dtype=dtype, + buffer=message.data, + strides=(message.row_step, message.point_step), + ) + view.setflags(write=False) + return view + + +def pointcloud_xyz(message: PointCloud2) -> NDArray[np.float64]: + """Copy scalar x/y/z fields into an N-by-3 float64 array, preserving point order.""" + points = pointcloud_view(message) + for name in ("x", "y", "z"): + if name not in (points.dtype.names or ()) or points[name].ndim != 2: + raise ValueError(f"point cloud requires a scalar {name!r} field") + return np.stack([points[name].ravel() for name in ("x", "y", "z")], axis=-1).astype(np.float64) + + +def pointcloud_rgb(message: PointCloud2) -> NDArray[np.uint8] | None: + """Copy RGB bytes from the standard packed rgb/rgba field, if present. + + FLOAT32 fields carry color bits, not numeric floating-point color values. + Both representations preserve the declared byte order and row padding. + """ + points = pointcloud_view(message) + names = points.dtype.names or () + name = next((name for name in ("rgb", "rgba") if name in names), None) + if name is None: + return None + field = points[name] + if field.ndim != 2 or field.dtype.kind not in "fu" or field.dtype.itemsize != 4: + raise ValueError("packed point cloud color must be a scalar FLOAT32 or UINT32 field") + packed = field.view(np.dtype(">u4" if message.is_bigendian else "> shift) & 255 for shift in (16, 8, 0)], axis=-1).astype(np.uint8) diff --git a/dimos/visualization/rerun/bridge.py b/dimos/visualization/rerun/bridge.py index 7a7308f3470..819cafa663d 100644 --- a/dimos/visualization/rerun/bridge.py +++ b/dimos/visualization/rerun/bridge.py @@ -66,7 +66,6 @@ from dimos.msgs.sensor_msgs.CameraInfo import CameraInfo from dimos.msgs.sensor_msgs.Image import Image from dimos.msgs.tf2_msgs.TFMessage import TfFrameTree, TFMessage -from dimos.msgs.time import to_seconds from dimos.protocol.pubsub.impl.lcmpubsub import LCM from dimos.protocol.pubsub.impl.zenohpubsub import Topic as ZenohTopic, Zenoh from dimos.protocol.pubsub.patterns import Glob, pattern_matches @@ -491,7 +490,7 @@ def _on_message(self, msg: Any, topic: Any) -> None: ), frame_id=t.header.frame_id, child_frame_id=t.child_frame_id, - ts=to_seconds(t.header.stamp), + ts=t.header.stamp.sec + t.header.stamp.nanosec / 1_000_000_000, ) for t in msg.transforms ] diff --git a/dimos/visualization/rerun/cdr.py b/dimos/visualization/rerun/cdr.py index 4c3f48c99dd..5ea8f801b11 100644 --- a/dimos/visualization/rerun/cdr.py +++ b/dimos/visualization/rerun/cdr.py @@ -28,8 +28,13 @@ from dimos.messages.nav_msgs.msg import Odometry, Path from dimos.messages.sensor_msgs.msg import CameraInfo, CompressedImage, Image, PointCloud2 from dimos.messages.tf2_msgs.msg import TFMessage -from dimos.msgs.image import image_from_compressed, image_to_rgb, image_view -from dimos.msgs.pointcloud import pointcloud_rgb, pointcloud_xyz +from dimos.visualization.rerun._cdr_sensors import ( + image_from_compressed, + image_to_rgb, + image_view, + pointcloud_rgb, + pointcloud_xyz, +) if TYPE_CHECKING: import rerun as rr diff --git a/dimos/visualization/rerun/test_cdr.py b/dimos/visualization/rerun/test_cdr.py index 7456d961ee1..566e49e6b20 100644 --- a/dimos/visualization/rerun/test_cdr.py +++ b/dimos/visualization/rerun/test_cdr.py @@ -38,12 +38,15 @@ Vector3, ) from dimos.messages.nav_msgs.msg import Odometry -from dimos.messages.sensor_msgs.msg import Image +from dimos.messages.sensor_msgs.msg import ( + CameraInfo, + Image, + PointCloud2, + PointField, + RegionOfInterest, +) from dimos.messages.std_msgs.msg import Header from dimos.messages.tf2_msgs.msg import TFMessage -from dimos.msgs.camera_info import camera_info_from_intrinsics -from dimos.msgs.image import image_from_array -from dimos.msgs.pointcloud import pointcloud_from_xyz_rgb from dimos.protocol.rpc.zenohrpc import ZenohRPC from dimos.visualization.rerun.bridge import Config, RerunBridgeModule, _resolve_pubsubs from dimos.visualization.rerun.cdr import ( @@ -56,6 +59,56 @@ from dimos.web.relay_bridge.gen_cdr_fixtures import build_messages +def image_from_array(pixels, *, encoding, header=None): + return Image( + header=header or Header(stamp=Time(sec=0, nanosec=0), frame_id=""), + width=pixels.shape[1], + height=pixels.shape[0], + encoding=encoding, + is_bigendian=int(pixels.dtype.byteorder == ">"), + step=pixels.strides[0], + data=np.frombuffer(pixels.tobytes(), dtype=np.uint8), + ) + + +def camera_info_from_intrinsics(fx, fy, cx, cy, width, height, *, header): + return CameraInfo( + distortion_model="plumb_bob", + d=np.zeros(5, dtype=np.float64), + r=np.eye(3, dtype=np.float64).ravel(), + p=np.zeros(12, dtype=np.float64), + binning_x=0, + binning_y=0, + roi=RegionOfInterest(x_offset=0, y_offset=0, height=0, width=0, do_rectify=False), + header=header, + width=width, + height=height, + k=np.asarray([fx, 0, cx, 0, fy, cy, 0, 0, 1], dtype=np.float64), + ) + + +def pointcloud_from_xyz_rgb(points, colors, *, header): + data = np.empty(len(points), dtype=[("x", " None: - assert isinstance(image_archetype(compressed_image_from_image(rgb_image)), rr.EncodedImage) + archetype = image_archetype(message) + assert isinstance(archetype, rr.EncodedImage) + assert bytes(archetype.blob.as_arrow_array().to_pylist()[0]) == bytes(message.data) @pytest.mark.parametrize("codec", ["png", "jxl"]) -def test_compressed_uint16_depth_keeps_units_and_rerun_depth(codec: str) -> None: +def test_compressed_uint16_depth_keeps_values_and_units(codec): pixels = np.arange(80, dtype=np.uint16).reshape(10, 8) - source = image_from_array( - pixels, encoding="16UC1", header=Header(frame_id="depth", stamp=Time(sec=0, nanosec=0)) + encoded = ( + imagecodecs.png_encode(pixels) + if codec == "png" + else imagecodecs.jpegxl_encode(pixels, lossless=True) + ) + message = CompressedImage( + header=Header(frame_id="depth", stamp=Time(sec=0, nanosec=0)), + format=f"16UC1; {codec}", + data=np.frombuffer(encoded, dtype=np.uint8), + ) + archetype = image_archetype(message) + assert isinstance(archetype, rr.DepthImage) + assert archetype.meter.as_arrow_array().to_pylist() == [1000.0] + np.testing.assert_array_equal( + np.frombuffer(bytes(archetype.buffer.as_arrow_array().to_pylist()[0]), dtype=np.uint16), + pixels.ravel(), ) - compressed = compressed_image_from_image(source, format=codec) - decoded = image_from_compressed(compressed) - assert decoded.encoding == "16UC1" - assert decoded.header == source.header - np.testing.assert_array_equal(image_view(decoded), pixels) - assert isinstance(image_archetype(compressed), rr.DepthImage) diff --git a/dimos/visualization/rerun/test_cdr_sensor_docs.py b/dimos/visualization/rerun/test_cdr_sensor_docs.py deleted file mode 100644 index 29aa0452bd7..00000000000 --- a/dimos/visualization/rerun/test_cdr_sensor_docs.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from pathlib import Path -import re - -import numpy as np - -from dimos.msgs.image import image_to_rgb - - -def test_documented_sensor_blocks_run_as_written() -> None: - guide = Path(__file__).resolve().parents[3] / "docs/usage/visualization.md" - section = guide.read_text().split("## Arrays and compression for generated sensors\n", 1)[1] - blocks = re.findall(r"```python\n(.*?)```", section, re.DOTALL) - assert len(blocks) == 3 - namespace = {} - for block in blocks: - exec(compile(block, str(guide), "exec"), namespace) - np.testing.assert_allclose( - namespace["xyz"], [[-0.25, -0.25, 1], [0.25, -0.25, 1], [-0.25, 0.25, 1], [0.25, 0.25, 1]] - ) - np.testing.assert_array_equal(image_to_rgb(namespace["restored"]), namespace["rgb"]) - assert namespace["cloud"].header == namespace["image"].header - assert not namespace["pixels"].flags.writeable diff --git a/docs/usage/visualization.md b/docs/usage/visualization.md index edeabaf6b8d..fcfe09e94e5 100644 --- a/docs/usage/visualization.md +++ b/docs/usage/visualization.md @@ -148,98 +148,36 @@ Rerun on `dev` is **module-driven**: modules decide what to log, and `Blueprint. To use a custom websocket port, set `--rerunwebsocketserver.port`; the bridge in the same blueprint takes the viewer URL from that server. `--rerunbridgemodule.websocket-url` points a bridge at a websocket server elsewhere. Set `host` on each module to override its bind address; both default to the shared `listen_host`. -## Generated CDR geometry - -Generated message classes are plain values. The Rerun bridge supplies the -PoseStamped/PointStamped adapters, including pose axes and a text view of every -field, plus raw/compressed image, depth, CameraInfo and colored point-cloud -adapters. An explicit CDR subscription is independent of legacy pubsub discovery: - -```python -from dimos.core.cdr_transport import CdrTransport -from dimos.messages.geometry_msgs.msg import PoseStamped -from dimos.visualization.rerun.bridge import RerunBridgeModule - -viewer = RerunBridgeModule(pubsubs=[], cdr_transports={ - "measured_pose": CdrTransport("measured_pose", PoseStamped, backend="zenoh"), -}) -viewer.start() -# Stop the bridge when the application shuts down: -viewer.stop() -``` - -The producer must use the matching explicit CDR channel and schema. Each -transport instance in `cdr_transports` belongs to the bridge; use a separate -instance for the producer. Header stamps use a source timeline with exact -nanoseconds, including zero. Header frames remain named `tf#/` frames; -they are not silently reinterpreted as world coordinates. The existing TF tree -must locate that frame for a shared spatial view. CameraInfo attaches to images -in the same Header frame in either arrival order. Mapping/control and remaining -generated domains still need their own adapters; transport defaults are unchanged. - -To see an actual offline exchange, from this checkout with its normal installed -DimOS environment: - -```sh -python examples/message-viewer/demo_cdr_rerun.py pose.rrd -rerun pose.rrd -``` - -The example sends generated pose, RGB image, CameraInfo and RGBD-projected cloud bytes over -discovery-disabled loopback Zenoh/CDR, then writes a real Rerun recording. Its -layout shows pose axes, colored points, exact message values and RGB pixels. -For this demonstration only, it explicitly -places the message's map frame at the viewer origin. It refuses an existing -output, needs no robot, and does not start the module coordinator or change host -configuration. CI runs this exact example and reads the RRD back with the -pinned Rerun CLI to check geometry, axes, image/point buffers and nanosecond timestamps. - -## Arrays and compression for generated sensors - -Image values hold ROS fields; array operations are separate helpers: - -```python -import numpy as np -from dimos.msgs.image import image_from_array, image_view -from dimos.msgs.time import header_now - -rgb = np.array([[[255, 0, 0], [0, 255, 0]], [[0, 0, 255], [255, 255, 255]]], dtype=np.uint8) -image = image_from_array(rgb, encoding="rgb8", header=header_now("camera_optical")) -pixels = image_view(image) -``` - -`image_from_array` copies its input. `image_view` borrows a read-only view that -keeps storage alive and respects row padding and byte order. Use `pixels.copy()` -when you need independent writable data. These helpers require generated -`dimos.messages` values; legacy convenience classes retain their old path. - -Project a matching color/depth pair using explicit camera calibration and units: - -```python -from dimos.msgs.camera_info import camera_info_from_intrinsics -from dimos.msgs.pointcloud import pointcloud_from_rgbd, pointcloud_xyz - -depth = image_from_array(np.full((2, 2), 1000, dtype=np.uint16), encoding="16UC1", header=image.header) -camera = camera_info_from_intrinsics(2, 2, 0.5, 0.5, 2, 2, header=image.header) -cloud = pointcloud_from_rgbd(image, depth, camera, depth_scale=0.001) -xyz = pointcloud_xyz(cloud) -``` - -Integer depth is scaled to meters; `32FC1` depth is already meters. Invalid, -nonpositive or truncated depths are excluded. The cloud keeps the depth Header -and RGB colors. Structured cloud views retain field counts, endian and row -padding; selection and transforms preserve extra fields rather than rebuilding -only XYZ. Calibration dimensions and focal lengths are validated. +## Generated CDR viewer demo (WIP) -Compression is explicit and keeps source metadata: +The CDR viewer is under development. Existing legacy messages remain supported. +Generated messages use viewer adapters for geometry, images, depth, camera +calibration and point clouds. Business producers have not all migrated. -```python -from dimos.msgs.image import compressed_image_from_image, image_from_compressed +From the repository root, install this checkout and the viewer: -encoded = compressed_image_from_image(image, format="png") -restored = image_from_compressed(encoded) +```sh skip +DIMOS_ALLOW_MISSING_COCKPIT=1 uv sync --locked --extra visualization +uv run --extra visualization python -I examples/message-viewer/demo_cdr_rerun.py cdr-viewer.rrd +uv run --extra visualization rerun cdr-viewer.rrd ``` -JPEG is lossy color/gray; PNG preserves uint16 depth. JXL is an explicit consumer -extension and preserves uint16/float32 depth losslessly. The Rerun adapter keeps -depth units and normalizes byte order without changing source CDR values. +The first command builds this checkout, including its generated messages. The +headless build flag omits browser assets. It does not disable message generation. +Use a new output filename for each run. The demo refuses to overwrite a file. + +The demo sends four generated messages through a local Zenoh/CDR connection and +logs the received values with the Rerun adapters. It prints a receipt summary. +It needs no robot or ROS installation. In Rerun, check: + +- Pose axes at `[1.5, -2.5, 0.25]`. +- Four colored points and a 2-by-2 red, green, blue and white image. +- Camera calibration on the image. +- Exact pose fields, including `nanosec: 123456789`, in the text panel. + +The demo explicitly places its `map` frame at the viewer origin. Runtime +producers must provide the TF frames needed by their scene. This demo tests the +transport-to-renderer path; it does not start the module coordinator or exercise +bridge discovery. The existing end-to-end test runs this example and checks the +recording with the Rerun CLI. The browser decoder fixture is described in +[Web bridge](/docs/web/bridge.md#generated-cdr-messages-wip). diff --git a/docs/web/bridge.md b/docs/web/bridge.md index e8eb6f4177d..589e684310f 100644 --- a/docs/web/bridge.md +++ b/docs/web/bridge.md @@ -40,7 +40,7 @@ The relay flags are `dimos run` options. Most of the rest are fields of the brid ## Channels -### Generated CDR messages +### Generated CDR messages (WIP) An installed generated type can be sent as CDR with its complete ROS2 definition in the manifest. Declare the encoding explicitly in this proposal: @@ -56,8 +56,7 @@ viewer = cockpit(channels=[Channel( ``` Compose `viewer` with the module producing `measured_pose: Out[PoseStamped]`. -Bind both ports to the same explicit `CdrTransport` when using the opt-in native -CDR transport. The manifest embeds PoseStamped, Header and Time definitions from +Generated ports use the shared CDR codec on the selected LCM or Zenoh transport. The manifest embeds PoseStamped, Header and Time definitions from the installed message owner; the browser needs neither ROS nor another generated message package. Registered custom web encoders continue to take precedence. Existing LCM/JSON channels and default transport selection are unchanged here. diff --git a/examples/message-viewer/demo_cdr_rerun.py b/examples/message-viewer/demo_cdr_rerun.py index d894462778d..8b927ab1be1 100644 --- a/examples/message-viewer/demo_cdr_rerun.py +++ b/examples/message-viewer/demo_cdr_rerun.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Record generated pose, image and RGBD cloud over loopback CDR into Rerun.""" +"""Record generated pose, image and colored cloud over loopback CDR into Rerun.""" from __future__ import annotations @@ -30,11 +30,14 @@ from dimos.core.transport import ZenohTransport from dimos.messages.builtin_interfaces.msg import Time from dimos.messages.geometry_msgs.msg import Point, Pose, PoseStamped, Quaternion -from dimos.messages.sensor_msgs.msg import CameraInfo +from dimos.messages.sensor_msgs.msg import ( + CameraInfo, + Image, + PointCloud2, + PointField, + RegionOfInterest, +) from dimos.messages.std_msgs.msg import Header -from dimos.msgs.camera_info import camera_info_from_intrinsics -from dimos.msgs.image import image_from_array -from dimos.msgs.pointcloud import pointcloud_from_rgbd from dimos.protocol.pubsub.impl.zenohpubsub import Topic as ZenohTopic from dimos.visualization.rerun.cdr import camera_pinhole, generated_archetypes @@ -49,16 +52,52 @@ def run(output: Path) -> None: position=Point(x=1.5, y=-2.5, z=0.25), orientation=Quaternion(x=0, y=0, z=0.6, w=0.8) ), ) - image = image_from_array( - np.array([[[255, 0, 0], [0, 255, 0]], [[0, 0, 255], [255, 255, 255]]], dtype=np.uint8), + pixels = np.array([[[255, 0, 0], [0, 255, 0]], [[0, 0, 255], [255, 255, 255]]], dtype=np.uint8) + image = Image( + header=message.header, + width=2, + height=2, encoding="rgb8", + is_bigendian=0, + step=6, + data=pixels.ravel(), + ) + calibration = CameraInfo( + distortion_model="plumb_bob", + d=np.zeros(5, dtype=np.float64), + r=np.eye(3, dtype=np.float64).ravel(), + p=np.zeros(12, dtype=np.float64), + binning_x=0, + binning_y=0, + roi=RegionOfInterest(x_offset=0, y_offset=0, height=0, width=0, do_rectify=False), header=message.header, + width=2, + height=2, + k=np.array([2, 0, 0.5, 0, 2, 0.5, 0, 0, 1], dtype=np.float64), ) - depth = image_from_array( - np.full((2, 2), 1000, dtype=np.uint16), encoding="16UC1", header=message.header + points = np.array( + [ + (-0.25, -0.25, 1, 0xFF0000), + (0.25, -0.25, 1, 0x00FF00), + (-0.25, 0.25, 1, 0x0000FF), + (0.25, 0.25, 1, 0xFFFFFF), + ], + dtype=[("x", " None: output, default_blueprint=rrb.Blueprint( rrb.Horizontal( - rrb.Spatial3DView(origin="world", name="Pose axes and RGBD cloud"), + rrb.Spatial3DView(origin="world", name="Pose axes and colored cloud"), rrb.Vertical( rrb.TextDocumentView( origin="world/pose/message", name="Exact message values" @@ -130,6 +169,9 @@ def receive(value: Any, *, topic: str) -> None: if errors: raise errors[0] recording.flush(timeout_sec=10) + print("Received PoseStamped, Image, PointCloud2 and CameraInfo through Zenoh/CDR.") + print("Source timestamp: 1700000000.123456789; frame: map") + print("Expect pose [1.5, -2.5, 0.25], red/green/blue/white pixels and four colored points.") def main() -> None: From 5a917f6f914a6e7cbc3b26696d2a2c737525a51c Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 20 Oct 2026 22:01:24 -0700 Subject: [PATCH 4/8] test(viewers): demonstrate full bridge, MCAP playback and live browser CDR --- dimos/visualization/rerun/test_cdr_e2e.py | 63 +++- docs/usage/visualization.md | 58 ++-- docs/web/bridge.md | 40 ++- examples/message-viewer/demo_cdr_browser.py | 113 ++++++ examples/message-viewer/demo_cdr_rerun.py | 366 ++++++++++++++++---- web/sdk/fixture/demo_cdr.html | 3 +- web/sdk/fixture/demo_cdr.ts | 191 +++++++--- 7 files changed, 663 insertions(+), 171 deletions(-) create mode 100644 examples/message-viewer/demo_cdr_browser.py diff --git a/dimos/visualization/rerun/test_cdr_e2e.py b/dimos/visualization/rerun/test_cdr_e2e.py index b05a1a6d120..2dcb2815670 100644 --- a/dimos/visualization/rerun/test_cdr_e2e.py +++ b/dimos/visualization/rerun/test_cdr_e2e.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Run the exact offline example and inspect its real RRD with the SDK's CLI.""" +"""Run the reviewer example and inspect its live-bridge and MCAP-playback RRDs.""" from pathlib import Path import subprocess @@ -21,7 +21,7 @@ import pytest -def test_actual_loopback_example_preserves_pose_axes_values_and_nanoseconds( +def test_live_bridge_and_mcap_playback_cover_viewer_scene( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: # The pinned Rerun CLI disables analytics when CI is present. This changes @@ -42,18 +42,53 @@ def test_actual_loopback_example_preserves_pose_axes_values_and_nanoseconds( text=True, timeout=30, ) - assert "TransformAxes3D:axis_length" in result.stdout - assert "[[1.5, -2.5, 0.25]]" in result.stdout - assert "[[0.0, 0.0, 0.6, 0.8]]" in result.stdout - assert "tf#/map" in result.stdout - assert "2023-11-14T22:13:20.123456789" in result.stdout - assert '"nanosec": 123456789' in result.stdout - assert "Exact message values" in result.stdout - assert "Points3D:positions" in result.stdout - assert "Points3D:colors" in result.stdout - assert "Image:buffer" in result.stdout - assert "Pinhole:image_from_camera" in result.stdout - assert "Generated RGB pixels" in result.stdout + for marker in ( + "TransformAxes3D:axis_length", + "[[1.5, -2.5, 0.25]]", + "[[0.0, 0.0, 0.6, 0.8]]", + "tf#/map", + "tf#/camera", + "2023-11-14T22:13:20.123456789", + '"nanosec": 123456789', + "Exact message values", + "Points3D:positions", + "Points3D:colors", + "Boxes3D:centers", + "Image:buffer", + "Pinhole:image_from_camera", + "DepthImage:meter", + "EncodedImage:blob", + "world/path", + "world/odometry", + "world/entities", + "demo person", + "world/legacy_pose", + "world/late_image", + "world/cloud_boxes", + "world/cloud_height", + "Generated RGB pixels", + ): + assert marker in result.stdout, marker + replay = output.with_name(output.stem + "-replay.rrd") + playback = subprocess.run( + [str(rerun), "rrd", "print", "-vvv", str(replay)], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + for marker in ( + "world/pose", + "world/path", + "world/entities", + "tf#/camera", + "Image:buffer", + "DepthImage:meter", + "Pinhole:image_from_camera", + ): + assert marker in playback.stdout, marker + recording = output.with_suffix(".mcap") + assert recording.read_bytes().startswith(b"\x89MCAP") repeated = subprocess.run(command, capture_output=True, text=True, timeout=30) assert repeated.returncode != 0 assert "FileExistsError" in repeated.stderr diff --git a/docs/usage/visualization.md b/docs/usage/visualization.md index fcfe09e94e5..e8781ec344c 100644 --- a/docs/usage/visualization.md +++ b/docs/usage/visualization.md @@ -150,34 +150,42 @@ To use a custom websocket port, set `--rerunwebsocketserver.port`; the bridge in ## Generated CDR viewer demo (WIP) -The CDR viewer is under development. Existing legacy messages remain supported. -Generated messages use viewer adapters for geometry, images, depth, camera -calibration and point clouds. Business producers have not all migrated. +The CDR viewer is under development. This example runs the real Rerun bridge +with local Zenoh transports. It needs no robot or ROS installation. -From the repository root, install this checkout and the viewer: +From the repository root: ```sh skip -DIMOS_ALLOW_MISSING_COCKPIT=1 uv sync --locked --extra visualization -uv run --extra visualization python -I examples/message-viewer/demo_cdr_rerun.py cdr-viewer.rrd +DIMOS_ALLOW_MISSING_COCKPIT=1 uv sync --locked --extra visualization --extra web +uv run --extra visualization --extra web python -I examples/message-viewer/demo_cdr_rerun.py cdr-viewer.rrd uv run --extra visualization rerun cdr-viewer.rrd +uv run --extra visualization rerun cdr-viewer-replay.rrd ``` -The first command builds this checkout, including its generated messages. The -headless build flag omits browser assets. It does not disable message generation. -Use a new output filename for each run. The demo refuses to overwrite a file. - -The demo sends four generated messages through a local Zenoh/CDR connection and -logs the received values with the Rerun adapters. It prints a receipt summary. -It needs no robot or ROS installation. In Rerun, check: - -- Pose axes at `[1.5, -2.5, 0.25]`. -- Four colored points and a 2-by-2 red, green, blue and white image. -- Camera calibration on the image. -- Exact pose fields, including `nanosec: 123456789`, in the text panel. - -The demo explicitly places its `map` frame at the viewer origin. Runtime -producers must provide the TF frames needed by their scene. This demo tests the -transport-to-renderer path; it does not start the module coordinator or exercise -bridge discovery. The existing end-to-end test runs this example and checks the -recording with the Rerun CLI. The browser decoder fixture is described in -[Web bridge](/docs/web/bridge.md#generated-cdr-messages-wip). +Use a new output filename for each run. The example also creates +`cdr-viewer.mcap`. All three destinations must be new. The build flag permits +installation without prebuilt browser assets; message generation still runs. + +The live recording uses `RerunBridgeModule.start()`, its typed subscriptions and +rendering callbacks. A receipt signal waits for each message before shutdown. +The MCAP contains the source scene encoded as CDR with complete ROS2 schemas. +`CdrMcapStore` and `render_store` read that file to create the replay recording. +The MCAP is example input for playback, not a capture by the native recorder. + +| PR capability | Demo scene | What to check | +| --- | --- | --- | +| TF and source metadata | `tf`, `pose` | Map/camera frames, pose axes at `[1.5, -2.5, 0.25]`, `nanosec: 123456789` in the text tab | +| Geometry adapters | `point`, `odometry`, `path`, `entities` | Point, three-waypoint path, odometry axes, labeled person and object | +| Camera calibration | `image`, `late_image` | Pinhole on each image, whether calibration arrives first or last | +| Sensor buffers | `image`, `depth`, `compressed`, `compressed_depth` | Four RGB pixels, big-endian uint16 depth, PNG image and JXL depth in the sensor tab | +| Cloud rendering | `cloud`, `cloud_boxes`, `cloud_height` | RGB points, boxes and height-colored spheres | +| Legacy rendering | `legacy_pose` | Existing pose rendering alongside generated messages | +| MCAP playback | `cdr-viewer-replay.rrd` | The same generated scene loaded from its embedded schemas; cloud display uses the replay defaults | + +The example anchors its `world` TF frame to the viewer root. Business producers +must supply their own TF tree. It does not demonstrate robot graph migration or +automatic CDR type discovery. The existing integration test runs this example, +reads both RRDs with the Rerun CLI, and checks the listed data and components. + +For the live browser SDK and manifest path, run the +[Web bridge demo](/docs/web/bridge.md#generated-cdr-messages-wip). diff --git a/docs/web/bridge.md b/docs/web/bridge.md index 589e684310f..f2f96e83dc7 100644 --- a/docs/web/bridge.md +++ b/docs/web/bridge.md @@ -77,19 +77,43 @@ with variable-length arrays require a visible panel subscription so a preview does not pull a large image/cloud buffer. A decoder alone does not add a typed camera, map or control panel. -To inspect actual generated Python bytes in a browser from this checkout: +To run a real generated-message source, cockpit bridge and local relay, from +the repository root in terminal 1: -```sh +```sh skip +DIMOS_ALLOW_MISSING_COCKPIT=1 uv sync --locked --extra visualization --extra web +uv run --extra web python -I examples/message-viewer/demo_cdr_browser.py +``` + +In terminal 2: + +```sh skip cd web/sdk deno task fixture --host 127.0.0.1 ``` -Open `http://127.0.0.1:5174/demo_cdr.html`. Select a message and byte order to see -its decoded fields and full schema; the image case also displays its RGB pixels. -This fixture uses checked-in packets generated by the installed CDR codecs, not -a live robot stream. Regenerate them with -`python -m dimos.web.relay_bridge.gen_cdr_fixtures`; Python tests check that the -packets still match those codecs, and SDK tests decode both byte orders. +Open `http://127.0.0.1:5174/demo_cdr.html?live=1`. Wait for `connected`, +`Received 8/8 channels`, increasing frame counts and zero decode errors. +Select each channel. Check its fields and expand its embedded ROS2 definition: + +- `pose_stamped`: position `[1.5, -2.5, 0.25]` and `nanosec: 500000123`. +- `custom_segments`: custom nested message fields. +- `image`: two near-black/near-white pixels, with bytes `[0, 1, 2, 253, 254, 255]`. +- `imu`, `joints`: fixed and variable arrays. +- `int64`: `-9007199254740993n`, with no JavaScript number rounding. +- `empty`, `string`: an empty message and Unicode text. + +This path uses actual generated Python messages, typed Zenoh transport, +`cockpit()` manifest creation, relay framing, SDK subscriptions and CDR decoding. +The page explicitly subscribes to all demo channels, including arrays. It does +not add domain-specific cockpit panels or browser-to-robot CDR publishing. +Stop terminal 1 with Ctrl-C. Use `--port` to select a different relay port and +open the exact URL printed by the program. + +Without `?live=1`, the same page uses fixed packets to inspect little- and +big-endian decoding. Those packets are generated with +`python -m dimos.web.relay_bridge.gen_cdr_fixtures`. SDK tests check both byte +orders; the fixture does not validate a live connection. ### Existing channels diff --git a/examples/message-viewer/demo_cdr_browser.py b/examples/message-viewer/demo_cdr_browser.py new file mode 100644 index 00000000000..0a3d5353a44 --- /dev/null +++ b/examples/message-viewer/demo_cdr_browser.py @@ -0,0 +1,113 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Publish generated messages through the real cockpit bridge and local relay. + +Keep publishing for the browser page until Ctrl-C. +""" + +from __future__ import annotations + +import argparse +from contextlib import ExitStack +import threading +from typing import Any + +from dimos.core.transport import ZenohTransport +from dimos.protocol.pubsub.impl.zenohpubsub import Topic +from dimos.protocol.rpc.zenohrpc import ZenohRPC +from dimos.web.cockpit import Channel, cockpit +from dimos.web.relay_bridge.config import RelayBridgeConfig +from dimos.web.relay_bridge.gen_cdr_fixtures import build_messages + + +class LoopbackRPC(ZenohRPC): + def __init__(self, **kwargs: Any) -> None: + super().__init__( + mode="peer", + listen=["tcp/127.0.0.1:0"], + scouting=False, + multicast=False, + gossip=False, + **kwargs, + ) + + +def run(port: int) -> None: + messages = dict(build_messages()) + channels = [ + Channel(name, type(value), encoding=value.__msgtype__ + ".cdr.v1", max_hz=5) + for name, value in messages.items() + ] + (atom,) = cockpit(channels=channels).blueprints + config = dict( + mode="peer", listen=["tcp/127.0.0.1:0"], scouting=False, multicast=False, gossip=False + ) + stopped = threading.Event() + with ExitStack() as cleanup: + module = atom.module( + **atom.kwargs, + robot_id="cdr-demo", + robot_name="CDR demo", + rpc_transport=LoopbackRPC, + local_port=port, + open_browser=False, + rtc=False, + ) + cleanup.callback(module.stop) + publishers = {} + for name, value in messages.items(): + receiver = ZenohTransport(Topic(name, type(value)), **config) + cleanup.callback(receiver.stop) + module.inputs[name].transport = receiver + publishers[name] = ZenohTransport(Topic(name, type(value)), **config) + cleanup.callback(publishers[name].stop) + module.start() + + def publish() -> None: + while not stopped.is_set(): + for name, value in messages.items(): + publishers[name].broadcast(None, value) + stopped.wait(0.2) + + thread = threading.Thread(target=publish, name="cdr-demo-publisher", daemon=True) + thread.start() + + def stop_publisher() -> None: + stopped.set() + thread.join(timeout=5) + + cleanup.callback(stop_publisher) + print("From web/sdk run: deno task fixture --host 127.0.0.1", flush=True) + print( + f"Open http://127.0.0.1:5174/demo_cdr.html?live=1&relay={module._url}", + flush=True, + ) + try: + stopped.wait() + except KeyboardInterrupt: + pass + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--port", type=int, default=RelayBridgeConfig.model_fields["local_port"].default + ) + args = parser.parse_args() + run(args.port) + + +if __name__ == "__main__": + main() diff --git a/examples/message-viewer/demo_cdr_rerun.py b/examples/message-viewer/demo_cdr_rerun.py index 8b927ab1be1..e4440a02b5c 100644 --- a/examples/message-viewer/demo_cdr_rerun.py +++ b/examples/message-viewer/demo_cdr_rerun.py @@ -12,40 +12,64 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Record generated pose, image and colored cloud over loopback CDR into Rerun.""" +"""Demonstrate live Rerun bridge rendering and CDR MCAP playback without a robot.""" from __future__ import annotations import argparse from contextlib import ExitStack +from copy import deepcopy from functools import partial from pathlib import Path +import socket import threading from typing import Any +from dimos_cdrgen.registry import encode, schema +import imagecodecs +from mcap.writer import Writer import numpy as np import rerun as rr import rerun.blueprint as rrb from dimos.core.transport import ZenohTransport +from dimos.memory.cli.render import render_store +from dimos.memory.store.cdr_mcap import CdrMcapStore from dimos.messages.builtin_interfaces.msg import Time -from dimos.messages.geometry_msgs.msg import Point, Pose, PoseStamped, Quaternion +from dimos.messages.dimos_msgs.msg import EntityMarker, EntityMarkers +from dimos.messages.geometry_msgs.msg import ( + Point, + PointStamped, + Pose, + PoseStamped, + PoseWithCovariance, + Quaternion, + Transform, + TransformStamped, + Twist, + TwistWithCovariance, + Vector3, +) +from dimos.messages.nav_msgs.msg import Odometry, Path as MessagePath from dimos.messages.sensor_msgs.msg import ( CameraInfo, + CompressedImage, Image, PointCloud2, PointField, RegionOfInterest, ) from dimos.messages.std_msgs.msg import Header -from dimos.protocol.pubsub.impl.zenohpubsub import Topic as ZenohTopic -from dimos.visualization.rerun.cdr import camera_pinhole, generated_archetypes +from dimos.messages.tf2_msgs.msg import TFMessage +from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped as LegacyPoseStamped +from dimos.protocol.pubsub.impl.zenohpubsub import Topic +from dimos.protocol.pubsub.patterns import Glob +from dimos.protocol.rpc.zenohrpc import ZenohRPC +from dimos.visualization.rerun.bridge import RerunBridgeModule +from dimos.visualization.rerun.cdr import cloud_archetype -def run(output: Path) -> None: - # Reserve a new destination exclusively; never replace a user's recording. - with output.open("xb"): - pass +def scene_messages() -> dict[str, Any]: message = PoseStamped( header=Header(stamp=Time(sec=1700000000, nanosec=123456789), frame_id="map"), pose=Pose( @@ -98,88 +122,286 @@ def run(output: Path) -> None: ], data=np.frombuffer(points.tobytes(), dtype=np.uint8), ) - messages: dict[str, Any] = { + camera_header = Header(stamp=message.header.stamp, frame_id="camera") + image.header = camera_header + calibration.header = camera_header + depth_pixels = np.array([[1, 256], [1000, 2000]], dtype=">u2") + depth = Image( + header=camera_header, + width=2, + height=2, + encoding="16UC1", + is_bigendian=1, + step=4, + data=np.frombuffer(depth_pixels.tobytes(), dtype=np.uint8), + ) + compressed = CompressedImage( + header=camera_header, + format="png", + data=np.frombuffer(imagecodecs.png_encode(pixels), dtype=np.uint8), + ) + compressed_depth = CompressedImage( + header=camera_header, + format="16UC1; jxl", + data=np.frombuffer( + imagecodecs.jpegxl_encode(depth_pixels.astype(np.uint16), lossless=True), dtype=np.uint8 + ), + ) + zero = Vector3(x=0, y=0, z=0) + identity = Quaternion(x=0, y=0, z=0, w=1) + tf = TFMessage( + transforms=[ + TransformStamped( + header=Header(stamp=message.header.stamp, frame_id="world"), + child_frame_id="map", + transform=Transform(translation=zero, rotation=identity), + ), + TransformStamped( + header=message.header, + child_frame_id="camera", + transform=Transform(translation=Vector3(x=0, y=0, z=1), rotation=identity), + ), + ] + ) + route = MessagePath( + header=message.header, + poses=[ + PoseStamped( + header=message.header, + pose=Pose(position=Point(x=x, y=y, z=0), orientation=identity), + ) + for x, y in [(0, 0), (1, 0), (1, 1)] + ], + ) + odometry = Odometry( + header=message.header, + child_frame_id="base", + pose=PoseWithCovariance(pose=message.pose, covariance=np.zeros(36, dtype=np.float64)), + twist=TwistWithCovariance( + twist=Twist(linear=zero, angular=zero), covariance=np.zeros(36, dtype=np.float64) + ), + ) + entities = EntityMarkers( + header=message.header, + markers=[ + EntityMarker( + entity_id="E1", + label="demo person", + entity_type="person", + position=Point(x=2, y=1, z=0.3), + ), + EntityMarker( + entity_id="E2", + label="demo object", + entity_type="object", + position=Point(x=2, y=2, z=0.3), + ), + ], + ) + # Ordered publication makes both camera/image arrival orders reproducible. + late_image = deepcopy(image) + late_image.header = Header(stamp=message.header.stamp, frame_id="late_camera") + late_calibration = deepcopy(calibration) + late_calibration.header = late_image.header + tf.transforms.append( + TransformStamped( + header=message.header, + child_frame_id="late_camera", + transform=Transform(translation=Vector3(x=0, y=1, z=1), rotation=identity), + ) + ) + boxes = deepcopy(cloud) + box_points = points.copy() + box_points["x"] += 2 + boxes.data = np.frombuffer(box_points.tobytes(), dtype=np.uint8) + heights = np.empty(4, dtype=[("x", " rrb.Blueprint: + return rrb.Blueprint( + rrb.Tabs( + rrb.Spatial3DView( + origin="world", + name="Geometry, TF and colored cloud", + contents=[ + "world/pose/**", + "world/point/**", + "world/odometry/**", + "world/path/**", + "world/entities/**", + "world/cloud/**", + "world/cloud_boxes/**", + "world/cloud_height/**", + "world/legacy_pose/**", + "world/tf/**", + "tf#/**", + ], + ), + rrb.Grid( + rrb.Spatial2DView(origin="world/image", name="Generated RGB pixels"), + rrb.Spatial2DView(origin="world/depth", name="Big endian depth"), + rrb.Spatial2DView(origin="world/compressed", name="PNG pixels"), + rrb.Spatial2DView(origin="world/compressed_depth", name="JXL depth"), + rrb.Spatial2DView(origin="world/late_image", name="Calibration arriving last"), + grid_columns=3, + ), + rrb.TextDocumentView(origin="world/pose/message", name="Exact message values"), + ) ) - recording = rr.RecordingStream("dimos-cdr-pose") - received = threading.Event() - received_topics: set[str] = set() - lock = threading.Lock() - errors: list[Exception] = [] - def receive(value: Any, *, topic: str) -> None: + +class DemoBridge(RerunBridgeModule): + """A real bridge with a receipt signal so the demo can flush before exiting.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.received = threading.Event() + self.errors: list[Exception] = [] + + def _on_cdr_message(self, msg: Any, *, topic: str) -> None: try: - stamp = value.header.stamp - recording.set_time( - "source", timestamp=np.datetime64(stamp.sec * 1_000_000_000 + stamp.nanosec, "ns") - ) - if isinstance(value, CameraInfo): - # A spatial image needs its calibration, not just pixel bytes. - recording.log("world/image", camera_pinhole(value), strict=True) - else: - entries = generated_archetypes(value, "world/" + topic) - assert entries is not None - for entry in entries: - recording.log(entry[0], entry[1], strict=True) + super()._on_cdr_message(msg, topic=topic) except Exception as error: - errors.append(error) + self.errors.append(error) finally: - with lock: - received_topics.add(topic) - if errors or received_topics == set(messages): - received.set() + self.received.set() + +class LoopbackRPC(ZenohRPC): + def __init__(self, **kwargs: Any) -> None: + super().__init__( + mode="peer", + listen=["tcp/127.0.0.1:0"], + scouting=False, + multicast=False, + gossip=False, + **kwargs, + ) + + +def run(output: Path) -> None: + # Reserve destinations before starting sockets. Existing recordings are never overwritten. + mcap = output.with_suffix(".mcap") + replay = output.with_name(output.stem + "-replay.rrd") with ExitStack() as cleanup: - cleanup.callback(recording.disconnect) - recording.save( - output, - default_blueprint=rrb.Blueprint( - rrb.Horizontal( - rrb.Spatial3DView(origin="world", name="Pose axes and colored cloud"), - rrb.Vertical( - rrb.TextDocumentView( - origin="world/pose/message", name="Exact message values" - ), - rrb.Spatial2DView(origin="world/image", name="Generated RGB pixels"), - ), - ) - ), + for destination in (output, mcap, replay): + cleanup.enter_context(destination.open("xb")) + config: dict[str, Any] = dict( + mode="peer", listen=["tcp/127.0.0.1:0"], scouting=False, multicast=False, gossip=False + ) + messages = scene_messages() + # Select a free local Rerun port instead of attaching to another user's viewer. + with socket.socket() as reserve: + reserve.bind(("127.0.0.1", 0)) + port = reserve.getsockname()[1] + bridge = DemoBridge( + pubsubs=[], + cdr_transports={ + topic: ZenohTransport(Topic(topic, type(value)), **config) + for topic, value in messages.items() + }, + rpc_transport=LoopbackRPC, + rerun_open="none", + rerun_web=False, + connect_url=f"rerun+http://127.0.0.1:{port}/proxy", + tf_axes=0.25, + blueprint=viewer_layout, + visual_override={ + Glob("world/cloud_boxes"): partial(cloud_archetype, mode="boxes"), + Glob("world/cloud_height"): partial( + cloud_archetype, mode="spheres", height_colors=True + ), + }, ) - # This demo explicitly identifies map with its viewer origin. Runtime - # bridges use the producer's named TF frame; no coordinates are rewritten. - recording.log("world", rr.Transform3D(child_frame="tf#/map"), static=True) - publishers: dict[str, ZenohTransport[Any]] = {} - for topic, value in messages.items(): - subscriber = ZenohTransport(ZenohTopic(topic, type(value)), **config) - cleanup.callback(subscriber.stop) - cleanup.callback(subscriber.subscribe(partial(receive, topic=topic))) - publisher = ZenohTransport(ZenohTopic(topic, type(value)), **config) - cleanup.callback(publisher.stop) - publishers[topic] = publisher - for topic, value in messages.items(): - publishers[topic].broadcast(None, value) - if not received.wait(timeout=10): - raise TimeoutError(f"Missing loopback CDR topics: {set(messages) - received_topics}") - if errors: - raise errors[0] - recording.flush(timeout_sec=10) - print("Received PoseStamped, Image, PointCloud2 and CameraInfo through Zenoh/CDR.") - print("Source timestamp: 1700000000.123456789; frame: map") - print("Expect pose [1.5, -2.5, 0.25], red/green/blue/white pixels and four colored points.") + cleanup.callback(bridge.stop) + bridge.start() + rr.save(output) + cleanup.callback(rr.disconnect) + rr.send_blueprint(viewer_layout()) + rr.log("world", rr.Transform3D(child_frame="tf#/world"), static=True) + with mcap.open("wb") as stream: + writer = Writer(stream) + writer.start(profile="ros2") + try: + for index, (topic, value) in enumerate(messages.items()): + with ExitStack() as sender: + transport = ZenohTransport(Topic(topic, type(value)), **config) + sender.callback(transport.stop) + bridge.received.clear() + transport.broadcast(None, value) + if not bridge.received.wait(timeout=10): + raise TimeoutError(f"Bridge did not receive {topic}") + if bridge.errors: + raise bridge.errors[0] + schema_id = writer.register_schema( + value.__msgtype__, "ros2msg", schema(value.__msgtype__).encode() + ) + channel = writer.register_channel(topic, "cdr", schema_id) + timestamp = 1_700_000_000_123_456_789 + index * 1_000_000 + writer.add_message( + channel, log_time=timestamp, publish_time=timestamp, data=encode(value) + ) + print(f"Bridge rendered {topic}: {value.__msgtype__}") + finally: + writer.finish() + # Existing messages still take the bridge's ordinary rendering path. + bridge._on_message(LegacyPoseStamped(position=(0, -1, 0), frame_id="map"), "/legacy_pose") + recording = rr.get_global_data_recording() + if recording is not None: + recording.flush() + with CdrMcapStore(path=str(mcap)) as store: + print(store.summary()) + render_store(store, out=str(replay), no_gui=True, root="world") + recording = rr.get_global_data_recording() + if recording is not None: + recording.flush() + rr.disconnect() + print(f"Live bridge: rerun {output}") + print(f"MCAP playback: rerun {replay}") + print(f"Reusable recording: {mcap}") + print("Source timestamp: 1700000000.123456789; pose [1.5, -2.5, 0.25].") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("output", type=Path, help="new .rrd file (must not exist)") + parser.add_argument( + "output", type=Path, help="new .rrd filename; also creates .mcap and -replay.rrd" + ) args = parser.parse_args() run(args.output) - print(f"Recorded actual loopback CDR; open with: rerun {args.output}") if __name__ == "__main__": diff --git a/web/sdk/fixture/demo_cdr.html b/web/sdk/fixture/demo_cdr.html index 9e891fec8c5..1ecaca6dc93 100644 --- a/web/sdk/fixture/demo_cdr.html +++ b/web/sdk/fixture/demo_cdr.html @@ -55,7 +55,8 @@

Generated messages in the browser

Python-generated CDR bytes, decoded by the DimOS browser SDK from their embedded ROS2 - definitions. No ROS installation or browser message package. + definitions. Add ?live=1 to receive actual relay frames. Without it, this page + uses fixed packets for byte-order checks. No ROS installation or browser message package.