Files
2026-07-13 13:05:14 +08:00

327 lines
12 KiB
Python

from __future__ import annotations
import math
from pathlib import Path
import pyarrow as pa
import pytest
import rerun as rr
import rerun.urdf as rru
from rerun.experimental import Chunk, DeriveLens, LazyChunkStream, RrdReader, Selector, StreamingReader
REPO_ROOT = Path(__file__).resolve().parents[3]
URDF_PATH = REPO_ROOT / "examples" / "rust" / "animated_urdf" / "data" / "so100.urdf"
def test_urdf_tree_loading() -> None:
tree = rru.UrdfTree.from_file_path(URDF_PATH)
assert tree.name == "so_arm100"
joints = tree.joints()
assert len(joints) == 6
joint = tree.get_joint_by_name("1")
assert joint is not None
assert joint.joint_type == "revolute"
assert joint.parent_link == "base"
assert joint.child_link == "shoulder"
assert joint.axis == pytest.approx((0.0, 1.0, 0.0))
assert joint.limit_lower == pytest.approx(-2.0)
assert joint.limit_upper == pytest.approx(2.0)
child_link = tree.get_joint_child(joint)
assert child_link.name == "shoulder"
# We expect flat, neighboring paths for visual and collision geometries of links,
# queryable by either link name or link object.
visual_paths = tree.get_visual_geometry_paths(child_link)
assert visual_paths[0] == "/so_arm100/visual_geometries/shoulder/visual_0"
visual_paths = tree.get_visual_geometry_paths("wrist")
assert visual_paths[0] == "/so_arm100/visual_geometries/wrist/visual_0"
collision_paths = tree.get_collision_geometry_paths(child_link)
assert collision_paths[0] == "/so_arm100/collision_geometries/mesh/shoulder/collision_0"
collision_paths = tree.get_collision_geometry_paths("wrist")
assert collision_paths[0] == "/so_arm100/collision_geometries/mesh/wrist/collision_0"
root_link = tree.root_link()
assert root_link.name == "base"
def test_urdf_tree_transform() -> None:
tree = rru.UrdfTree.from_file_path(URDF_PATH)
joint = tree.get_joint_by_name("1")
assert joint is not None
clamped_angle = joint.limit_upper + 1.0
with pytest.warns(UserWarning, match="outside limits"):
transform = joint.compute_transform(clamped_angle)
expected_angle = joint.limit_upper
origin_roll, origin_pitch, origin_yaw = joint.origin_rpy
quat_origin = _euler_to_quat(origin_roll, origin_pitch, origin_yaw)
axis_x, axis_y, axis_z = joint.axis
half_angle = expected_angle / 2.0
sin_half = math.sin(half_angle)
cos_half = math.cos(half_angle)
quat_dynamic = [
axis_x * sin_half,
axis_y * sin_half,
axis_z * sin_half,
cos_half,
]
expected_quat = _quat_multiply(quat_origin, quat_dynamic)
assert transform.translation is not None
assert transform.quaternion is not None
assert_translation_expected(transform.translation, joint.origin_xyz)
assert_quat_equivalent(transform.quaternion, expected_quat)
assert transform.parent_frame == rr.components.TransformFrameIdBatch(
rr.components.TransformFrameId(joint.parent_link)
)
assert transform.child_frame == rr.components.TransformFrameIdBatch(
rr.components.TransformFrameId(joint.child_link)
)
def test_urdf_tree_frame_prefix() -> None:
prefix = "left_arm/"
tree = rru.UrdfTree.from_file_path(URDF_PATH, frame_prefix=prefix)
assert tree.frame_prefix == prefix
joint = tree.get_joint_by_name("1")
assert joint is not None
# parent_link and child_link should still return unprefixed URDF link names.
assert joint.parent_link == "base"
assert joint.child_link == "shoulder"
# But compute_transform should produce prefixed frame IDs.
transform = joint.compute_transform(0.0)
assert transform.parent_frame == rr.components.TransformFrameIdBatch(
rr.components.TransformFrameId(f"{prefix}{joint.parent_link}")
)
assert transform.child_frame == rr.components.TransformFrameIdBatch(
rr.components.TransformFrameId(f"{prefix}{joint.child_link}")
)
# compute_transform_columns should also produce prefixed frame IDs.
columns = joint.compute_transform_columns([0.0, 0.5], clamp=True)
from rerun._baseclasses import ComponentColumnList
assert isinstance(columns, ComponentColumnList)
def test_urdf_tree_no_frame_prefix() -> None:
tree = rru.UrdfTree.from_file_path(URDF_PATH)
assert tree.frame_prefix is None
def test_urdf_tree_log() -> None:
rec = rr.RecordingStream("rerun_example_test_urdf_tree_log", make_default=False, make_thread_default=False)
rec.memory_recording()
tree = rru.UrdfTree.from_file_path(URDF_PATH)
tree.log_urdf_to_recording(rec)
# Also test with frame_prefix
tree_prefixed = rru.UrdfTree.from_file_path(URDF_PATH, frame_prefix="left/")
tree_prefixed.log_urdf_to_recording(rec)
def test_urdf_tree_custom_static_transform_entity_path(tmp_path: Path) -> None:
rrd_path = tmp_path / "urdf_static_transforms.rrd"
with rr.RecordingStream(
"test_urdf_tree_custom_static_transform_entity_path",
make_default=False,
make_thread_default=False,
) as rec:
rec.save(rrd_path)
tree = rru.UrdfTree.from_file_path(URDF_PATH, static_transform_entity_path="custom_tf_static")
tree.log_urdf_to_recording(rec)
paths = RrdReader(rrd_path).stream().collect().schema().entity_paths()
assert "/custom_tf_static" in paths
assert "/tf_static" not in paths
def test_urdf_tree_stream() -> None:
tree = rru.UrdfTree.from_file_path(URDF_PATH)
assert isinstance(tree, StreamingReader)
chunks = tree.stream().to_chunks()
entity_paths = {chunk.entity_path for chunk in chunks}
assert "/so_arm100" in entity_paths
assert "/so_arm100/visual_geometries/shoulder/visual_0" in entity_paths
assert "/tf_static" in entity_paths
def test_urdf_tree_stream_custom_static_transform_entity_path() -> None:
tree = rru.UrdfTree.from_file_path(URDF_PATH, static_transform_entity_path="custom_tf_static")
entity_paths = {chunk.entity_path for chunk in tree.stream().to_chunks()}
assert "/custom_tf_static" in entity_paths
assert "/tf_static" not in entity_paths
def test_urdf_tree_stream_without_joint_transforms() -> None:
tree = rru.UrdfTree.from_file_path(URDF_PATH)
entity_paths = {chunk.entity_path for chunk in tree.stream(include_joint_transforms=False).to_chunks()}
assert "/tf_static" not in entity_paths
def test_urdf_compute_transform_columns() -> None:
tree = rru.UrdfTree.from_file_path(URDF_PATH)
joint = tree.get_joint_by_name("1")
assert joint is not None
values = [0.0, 0.5, 1.0]
columns = joint.compute_transform_columns(values, clamp=True)
from rerun._baseclasses import ComponentColumnList
assert isinstance(columns, ComponentColumnList)
assert len(columns) > 0
# Verify the result is usable with send_columns by checking that
# the individual transform matches what compute_transform returns.
single = joint.compute_transform(0.5, clamp=True)
assert single.translation is not None
assert single.quaternion is not None
# Verify that out-of-range values produce warnings.
with pytest.warns(UserWarning, match="outside limits"):
joint.compute_transform_columns([joint.limit_upper + 1.0], clamp=True)
def test_urdf_transform_batches_in_lens_pipeline() -> None:
"""Integration test for computing transform batches from joint states in a chunk pipeline using lenses."""
frame_prefix = "prefix_"
urdf_tree = rru.UrdfTree.from_file_path(URDF_PATH, frame_prefix=frame_prefix)
# Create a chunk with joint names and values.
messages = pa.StructArray.from_arrays(
[
# Note: "1", "2" are joint names from the test URDF file we use here.
pa.array([["1", "2"], ["1"]], type=pa.list_(pa.string())),
pa.array([[0.0, 0.5], [1.0]], type=pa.list_(pa.float64())),
],
names=["joint_names", "actuator_readings"],
)
chunk = Chunk.from_columns(
"/joint_states",
indexes=[rr.TimeColumn("frame", sequence=[0, 1])],
columns=rr.DynamicArchetype.columns(
archetype="schemas.SomeCustomJointState",
components={"message": messages},
),
)
# Apply a two-stage pipeline using lenses:
# 1. Compute joint transforms in batch using `UrdfTree.compute_joint_transform_batches`.
# N input rows with multiple joint states per row -> N output rows with multiple transforms per row.
compute_joints = DeriveLens("schemas.SomeCustomJointState:message").to_component(
"rerun.urdf.JointTransformBatch",
Selector(".").pipe(
lambda joint_state_messages: urdf_tree.compute_joint_transform_batches(
names=Selector(".joint_names").execute(joint_state_messages),
values=Selector(".actuator_readings").execute(joint_state_messages),
)
),
)
# 2. Scatter the batch data into final `Transform3D` rows.
# N input rows with multiple transforms per row -> one output row per transform.
output_transforms = (
DeriveLens("rerun.urdf.JointTransformBatch", output_entity="/tf", scatter=True)
.to_component(rr.Transform3D.descriptor_translation(), Selector(".[].translation"))
.to_component(rr.Transform3D.descriptor_quaternion(), Selector(".[].quaternion"))
.to_component(rr.Transform3D.descriptor_parent_frame(), Selector(".[].parent_frame"))
.to_component(rr.Transform3D.descriptor_child_frame(), Selector(".[].child_frame"))
)
chunks = LazyChunkStream.from_iter([chunk]).lenses(compute_joints).lenses(output_transforms).to_chunks()
assert len(chunks) == 1
assert chunks[0].entity_path == "/tf"
assert chunks[0].num_rows == 3
batch = chunks[0].to_record_batch()
assert batch.column("frame").to_pylist() == [0, 0, 1]
assert set(batch.column_names) >= {
"Transform3D:translation",
"Transform3D:quaternion",
"Transform3D:parent_frame",
"Transform3D:child_frame",
}
joint1 = urdf_tree.get_joint_by_name("1")
joint2 = urdf_tree.get_joint_by_name("2")
assert joint1 is not None and joint2 is not None
assert batch.column("Transform3D:parent_frame").to_pylist() == [
[f"{frame_prefix}{joint1.parent_link}"],
[f"{frame_prefix}{joint2.parent_link}"],
[f"{frame_prefix}{joint1.parent_link}"],
]
assert batch.column("Transform3D:child_frame").to_pylist() == [
[f"{frame_prefix}{joint1.child_link}"],
[f"{frame_prefix}{joint2.child_link}"],
[f"{frame_prefix}{joint1.child_link}"],
]
def assert_quat_equivalent(actual: rr.components.RotationQuatBatch, expected: list[float]) -> None:
actual_values = actual.pa_array.to_pylist()[0]
dot = sum(a * b for a, b in zip(actual_values, expected, strict=False))
if dot < 0.0:
expected = [-value for value in expected]
assert actual_values == pytest.approx(expected)
def assert_translation_expected(actual: rr.components.Translation3DBatch, expected: tuple[float, float, float]) -> None:
actual_values = actual.pa_array.to_pylist()[0]
assert actual_values == pytest.approx(expected)
def _euler_to_quat(roll: float, pitch: float, yaw: float) -> list[float]:
"""Convert Euler angles (RPY) to quaternion (XYZW)."""
cr = math.cos(roll * 0.5)
sr = math.sin(roll * 0.5)
cp = math.cos(pitch * 0.5)
sp = math.sin(pitch * 0.5)
cy = math.cos(yaw * 0.5)
sy = math.sin(yaw * 0.5)
w = cr * cp * cy + sr * sp * sy
x = sr * cp * cy - cr * sp * sy
y = cr * sp * cy + sr * cp * sy
z = cr * cp * sy - sr * sp * cy
return [x, y, z, w]
def _quat_multiply(q1: list[float], q2: list[float]) -> list[float]:
"""Multiply two quaternions in XYZW format."""
x1, y1, z1, w1 = q1
x2, y2, z2, w2 = q2
w = w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2
x = w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2
y = w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2
z = w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2
return [x, y, z, w]