diff --git a/.github/workflows/message-codegen.yml b/.github/workflows/message-codegen.yml index 98d2764be7e..910eb56690b 100644 --- a/.github/workflows/message-codegen.yml +++ b/.github/workflows/message-codegen.yml @@ -26,6 +26,8 @@ on: - dimos/navigation/spec.py - dimos/navigation/local_planner/** - dimos/navigation/trajectory_follower/** + - dimos/navigation/global_planner/viz.py + - dimos/navigation/global_planner/test_viz.py - dimos/navigation/test_tf_pose.py - dimos/navigation/movement_manager/** - dimos/navigation/go2/replanning_a_star/** @@ -329,3 +331,8 @@ jobs: export DIMOS_NATIVE_FOLLOWER_BINARY="$PWD/dimos/navigation/trajectory_follower/fancy/rust/target/debug/trajectory_follower" .native-review/bin/python -I -c 'from dimos.navigation.trajectory_follower.fancy import native; import pytest; import sys; assert "site-packages" in str(native.__file__); raise SystemExit(pytest.main(sys.argv[1:]))' --noconftest --import-mode=importlib -o addopts='' -q "$PWD/dimos/navigation/trajectory_follower/fancy/test_generated_native.py" MYPYPATH="$PWD/build/message-source/python" .native-review/bin/python -m mypy --follow-imports=silent dimos/navigation/trajectory_follower/fancy/native.py + - name: Verify generated navigation path and goal rendering + if: runner.os == 'Linux' + run: | + .native-review/bin/python -I -c 'from dimos.navigation.local_planner import viz as local; from dimos.navigation.global_planner import viz as global_; import pytest; import sys; assert all("site-packages" in str(value.__file__) for value in (local,global_)); raise SystemExit(pytest.main(sys.argv[1:]))' --noconftest --import-mode=importlib -o addopts='' -q "$PWD/dimos/navigation/local_planner/test_viz.py" "$PWD/dimos/navigation/global_planner/test_viz.py" + MYPYPATH="$PWD/build/message-source/python" .native-review/bin/python -m mypy --follow-imports=silent dimos/navigation/local_planner/viz.py dimos/navigation/global_planner/viz.py diff --git a/dimos/models/embedding/clip.py b/dimos/models/embedding/clip.py index ad5e3e0cc6c..f429dc62a93 100644 --- a/dimos/models/embedding/clip.py +++ b/dimos/models/embedding/clip.py @@ -17,12 +17,12 @@ from functools import cached_property from typing import overload -from dimos.messages.sensor_msgs.msg import Image from PIL import Image as PILImage import torch import torch.nn.functional as functional from transformers import CLIPModel as HFCLIPModel, CLIPProcessor +from dimos.messages.sensor_msgs.msg import Image from dimos.models.base import HuggingFaceModel from dimos.models.embedding.base import Embedding, EmbeddingModel, HuggingFaceEmbeddingModelConfig from dimos.msgs.image import image_to_rgb diff --git a/dimos/models/embedding/dino.py b/dimos/models/embedding/dino.py index 5b0d24fd137..022a442ebf7 100644 --- a/dimos/models/embedding/dino.py +++ b/dimos/models/embedding/dino.py @@ -17,12 +17,12 @@ from functools import cached_property from typing import Any, overload -from dimos.messages.sensor_msgs.msg import Image from PIL import Image as PILImage import torch import torch.nn.functional as functional from transformers import AutoImageProcessor, AutoModel +from dimos.messages.sensor_msgs.msg import Image from dimos.models.base import HuggingFaceModel from dimos.models.embedding.base import Embedding, EmbeddingModel, HuggingFaceEmbeddingModelConfig from dimos.msgs.image import image_to_rgb diff --git a/dimos/models/embedding/siglip.py b/dimos/models/embedding/siglip.py index bb0ed762943..8cabb1afdf4 100644 --- a/dimos/models/embedding/siglip.py +++ b/dimos/models/embedding/siglip.py @@ -17,12 +17,12 @@ from functools import cached_property from typing import overload -from dimos.messages.sensor_msgs.msg import Image from PIL import Image as PILImage import torch import torch.nn.functional as functional from transformers import SiglipModel as HFSiglipModel, SiglipProcessor +from dimos.messages.sensor_msgs.msg import Image from dimos.models.base import HuggingFaceModel from dimos.models.embedding.base import Embedding, EmbeddingModel, HuggingFaceEmbeddingModelConfig from dimos.msgs.image import image_to_rgb diff --git a/dimos/models/embedding/siglip2.py b/dimos/models/embedding/siglip2.py index f8f1fe8861b..c85932f9176 100644 --- a/dimos/models/embedding/siglip2.py +++ b/dimos/models/embedding/siglip2.py @@ -17,12 +17,12 @@ from functools import cached_property from typing import overload -from dimos.messages.sensor_msgs.msg import Image from PIL import Image as PILImage import torch import torch.nn.functional as functional from transformers import SiglipModel as HFSiglipModel, SiglipProcessor +from dimos.messages.sensor_msgs.msg import Image from dimos.models.base import HuggingFaceModel from dimos.models.embedding.base import Embedding, EmbeddingModel, HuggingFaceEmbeddingModelConfig from dimos.msgs.image import image_to_rgb diff --git a/dimos/models/embedding/test_generated_images.py b/dimos/models/embedding/test_generated_images.py index e3c1245ab07..a51f9778ba4 100644 --- a/dimos/models/embedding/test_generated_images.py +++ b/dimos/models/embedding/test_generated_images.py @@ -29,20 +29,25 @@ from dimos.msgs.image import image_from_array, image_view -@pytest.mark.parametrize("model_type", [CLIPModel,DINOModel,SigLIPModel,SigLIP2Model]) -def test_generated_embeddings_keep_zero_source_time_and_convert_rgb(mocker,model_type): - processor,network=mocker.Mock(),mocker.Mock() - features=torch.tensor([[3.0,4.0]]) - processor.return_value=BatchFeature(data={"pixel_values":torch.zeros(1,3,4,8)}) - network.get_image_features.return_value=features - network.return_value=BaseModelOutputWithPooling(last_hidden_state=features[:,None,:],pooler_output=features) - mocker.patch.object(model_type,"_processor",new_callable=PropertyMock,return_value=processor) - mocker.patch.object(model_type,"_model",new_callable=PropertyMock,return_value=network) - image=image_from_array(np.full((4,8,3),[0,0,255],dtype=np.uint8),encoding="bgr8", - header=Header(frame_id="camera",stamp=Time(sec=0,nanosec=0))) - model=model_type(device="cpu") - result=model.embed(image) +@pytest.mark.parametrize("model_type", [CLIPModel, DINOModel, SigLIPModel, SigLIP2Model]) +def test_generated_embeddings_keep_zero_source_time_and_convert_rgb(mocker, model_type): + processor, network = mocker.Mock(), mocker.Mock() + features = torch.tensor([[3.0, 4.0]]) + processor.return_value = BatchFeature(data={"pixel_values": torch.zeros(1, 3, 4, 8)}) + network.get_image_features.return_value = features + network.return_value = BaseModelOutputWithPooling( + last_hidden_state=features[:, None, :], pooler_output=features + ) + mocker.patch.object(model_type, "_processor", new_callable=PropertyMock, return_value=processor) + mocker.patch.object(model_type, "_model", new_callable=PropertyMock, return_value=network) + image = image_from_array( + np.full((4, 8, 3), [0, 0, 255], dtype=np.uint8), + encoding="bgr8", + header=Header(frame_id="camera", stamp=Time(sec=0, nanosec=0)), + ) + model = model_type(device="cpu") + result = model.embed(image) assert result.timestamp == 0.0 - torch.testing.assert_close(result.vector,torch.tensor([0.6,0.8])) - assert processor.call_args.kwargs["images"][0].getpixel((0,0)) == (255,0,0) - np.testing.assert_array_equal(image_view(image)[0,0],[0,0,255]) + torch.testing.assert_close(result.vector, torch.tensor([0.6, 0.8])) + assert processor.call_args.kwargs["images"][0].getpixel((0, 0)) == (255, 0, 0) + np.testing.assert_array_equal(image_view(image)[0, 0], [0, 0, 255]) diff --git a/dimos/models/embedding/treid.py b/dimos/models/embedding/treid.py index ce36b5d004b..3ce7331eec3 100644 --- a/dimos/models/embedding/treid.py +++ b/dimos/models/embedding/treid.py @@ -19,11 +19,11 @@ from functools import cached_property -from dimos.messages.sensor_msgs.msg import Image import torch import torch.nn.functional as functional from torchreid import utils as torchreid_utils +from dimos.messages.sensor_msgs.msg import Image from dimos.models.base import LocalModel from dimos.models.embedding.base import Embedding, EmbeddingModel, EmbeddingModelConfig from dimos.msgs.image import image_to_bgr diff --git a/dimos/models/segmentation/edge_tam.py b/dimos/models/segmentation/edge_tam.py index e240a6529d1..b65977283c0 100644 --- a/dimos/models/segmentation/edge_tam.py +++ b/dimos/models/segmentation/edge_tam.py @@ -20,7 +20,6 @@ import tempfile from typing import TYPE_CHECKING, Any, Protocol, TypedDict -from dimos.messages.sensor_msgs.msg import Image from hydra.utils import instantiate import numpy as np from numpy.typing import NDArray @@ -28,6 +27,7 @@ from PIL import Image as PILImage import torch +from dimos.messages.sensor_msgs.msg import Image from dimos.models.base import default_torch_device from dimos.msgs.image import image_to_bgr, image_to_jpeg from dimos.perception.detection.detectors.base import Detector diff --git a/dimos/models/segmentation/test_yoloe.py b/dimos/models/segmentation/test_yoloe.py index 345fc79ac1c..5d2efaffa50 100644 --- a/dimos/models/segmentation/test_yoloe.py +++ b/dimos/models/segmentation/test_yoloe.py @@ -16,11 +16,11 @@ from typing import Any from unittest.mock import MagicMock -from dimos.messages.sensor_msgs.msg import Image -from dimos.messages.std_msgs.msg import Header import numpy as np from ultralytics.models.yolo.yoloe import YOLOEVPSegPredictor +from dimos.messages.sensor_msgs.msg import Image +from dimos.messages.std_msgs.msg import Header from dimos.models.segmentation.yoloe import YoloeBoxSegmenter from dimos.msgs.image import image_from_array, image_to_bgr from dimos.msgs.time import time_from_seconds, to_seconds diff --git a/dimos/models/segmentation/yoloe.py b/dimos/models/segmentation/yoloe.py index d19ecc15701..fd075227d27 100644 --- a/dimos/models/segmentation/yoloe.py +++ b/dimos/models/segmentation/yoloe.py @@ -19,11 +19,11 @@ from dataclasses import replace from typing import Protocol -from dimos.messages.sensor_msgs.msg import Image import numpy as np from numpy.typing import NDArray from ultralytics.models.yolo.yoloe import YOLOEVPSegPredictor +from dimos.messages.sensor_msgs.msg import Image from dimos.msgs.image import image_to_bgr from dimos.perception.detection.detectors.yoloe import Yoloe2DDetector, YoloePromptMode from dimos.perception.detection.type.detection2d.bbox import Bbox, Detection2DBBox 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/test_viz.py b/dimos/navigation/local_planner/test_viz.py index 03b0fac9f15..e29c76177be 100644 --- a/dimos/navigation/local_planner/test_viz.py +++ b/dimos/navigation/local_planner/test_viz.py @@ -17,13 +17,16 @@ from dataclasses import replace import math +from dimos_cdrgen.registry import decode, encode import numpy as np from numpy.typing import NDArray +import rerun as rr -from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped -from dimos.msgs.geometry_msgs.Quaternion import Quaternion -from dimos.msgs.geometry_msgs.Vector3 import Vector3 -from dimos.msgs.nav_msgs.Path import Path +from dimos.messages.geometry_msgs.msg import Point, 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.time import time_from_nanoseconds, time_from_seconds, to_nanoseconds from dimos.navigation.embodiment.go2 import GO2 from dimos.navigation.local_planner.viz import ( motion_visual_override, @@ -35,21 +38,26 @@ def _pose(x: float, y: float, yaw: float = 0.0) -> PoseStamped: return PoseStamped( - frame_id="odom", - position=Vector3(x, y, 0.0), - orientation=Quaternion.from_euler(Vector3(0, 0, yaw)), + header=Header( + frame_id="odom", + stamp=time_from_nanoseconds(1_700_000_000_000_000_000 + round(x * 10000)), + ), + pose=Pose(position=Point(x=x, y=y, z=0.0), orientation=quaternion_from_euler(0, 0, yaw)), ) def _plan(n: int = 40, yaw: float = 0.0) -> Path: - return Path(frame_id="odom", poses=[_pose(k * 0.1, 0.0, yaw) for k in range(n)]) + return Path( + header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), + poses=[_pose(k * 0.1, 0.0, yaw) for k in range(n)], + ) def _stamp(plan: Path, speeds: NDArray[np.float64]) -> Path: """Per-segment speeds as stamp deltas over the 0.1 m spacing, the planner's dialect.""" ts = np.concatenate([[0.0], np.cumsum(0.1 / speeds)]) for pose, t in zip(plan.poses, ts, strict=True): - pose.ts = float(t) + pose.header.stamp = time_from_seconds(float(t)) return plan @@ -61,6 +69,19 @@ def _rgba(boxes) -> NDArray[np.float64]: ) +def test_cdr_plan_draws_without_mutating_exact_source_values() -> None: + plan = _plan(n=2, yaw=math.pi / 2) + plan.header.stamp = time_from_nanoseconds(1_700_000_000_123_456_789) + payload = encode(plan) + received = decode(payload, Path) + drawn = render_plan(received) + assert drawn is not None + assert drawn[0] == ("world/path", rr.Transform3D(parent_frame="tf#/odom")) + assert len(drawn[1][1].centers.pa_array) == 2 + assert to_nanoseconds(received.header.stamp) == 1_700_000_000_123_456_789 + assert encode(received) == payload + + def test_boxes_are_subsampled_by_arc_not_by_index() -> None: """Every waypoint would be an opaque wall hiding the geometry it shows.""" plan = _plan() # 3.9 m of plan discretised at 0.1 m @@ -92,7 +113,7 @@ def test_colour_tracks_the_precision_the_planner_stamped() -> None: def test_an_unstamped_plan_draws_one_flat_colour() -> None: """No pinch is claimed where no precision was stamped. - Freshly built poses carry wall-clock stamps microseconds apart, so the + The fixture carries deterministic stamps microseconds apart, so the decoder clips the absurd speed to cruise rather than returning None. It must not manufacture a varying profile out of noise: every box comes out the same colour. @@ -106,11 +127,19 @@ def test_an_unstamped_plan_draws_one_flat_colour() -> None: def test_the_veto_stub_is_drawn_rather_than_blanked() -> None: """An empty viewport reads as a dead module; a refusal must look refused.""" - veto = render_body(Path(frame_id="odom", poses=[_pose(1.0, 2.0, 0.5)])) + veto = render_body( + Path( + header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), + poses=[_pose(1.0, 2.0, 0.5)], + ) + ) assert veto is not None assert len(veto.centers.pa_array) == 1 # an EMPTY path is different: hold the last good picture - assert render_body(Path(frame_id="odom", poses=[])) is None + assert ( + render_body(Path(header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), poses=[])) + is None + ) def test_the_box_sits_on_the_body_not_on_the_pose_point() -> None: @@ -123,16 +152,21 @@ def test_the_box_sits_on_the_body_not_on_the_pose_point() -> None: def test_the_plan_pins_its_frame_and_draws_its_body_off_the_one_path_topic() -> None: - import rerun as rr - drawn = render_plan(_plan()) assert drawn is not None by_entity = {e: type(a) for e, a in drawn} assert by_entity == {"world/path": rr.Transform3D, "world/path/body": rr.Boxes3D} assert drawn[0] == ("world/path", rr.Transform3D(parent_frame="tf#/odom")) # a refusal stub is drawn too, so it is not mistaken for a dead module - veto = render_plan(Path(frame_id="odom", poses=[_pose(1.0, 2.0)])) + veto = render_plan( + Path( + header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), poses=[_pose(1.0, 2.0)] + ) + ) assert veto is not None and veto[-1][0] == "world/path/body" # an empty path is "no route": hold the last picture rather than blank it - assert render_plan(Path(frame_id="odom", poses=[])) is None + assert ( + render_plan(Path(header=Header(frame_id="odom", stamp=time_from_nanoseconds(0)), poses=[])) + is None + ) assert set(motion_visual_override(body_dilate_m=-0.03)) == {"world/path"} diff --git a/dimos/navigation/local_planner/viz.py b/dimos/navigation/local_planner/viz.py index 58911bc8c31..f3739d73ab5 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.navigation.embodiment.base import Embodiment from dimos.navigation.embodiment.go2 import GO2 from dimos.navigation.local_planner.profile import ( @@ -46,10 +47,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), ) @@ -82,16 +83,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 @@ -103,8 +106,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: @@ -139,7 +142,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: