Skip to content

Commit a9b460d

Browse files
hakuturu583claude
andcommitted
fix: use TF static transform for PandarScan sensor-to-base_link conversion
The Hesai sensor frame uses native coordinates and the /tf_static tree includes the full rotation from sensor frame to base_link. Revert the incorrect Hesai-to-ROS axis swap in the decoder and instead read /tf_static from the rosbag to compute the composed transform. Add frame_id parameter to TopicMapping so users can specify the sensor's TF frame (e.g. "hesai_top") for automatic coordinate transformation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f6a90e5 commit a9b460d

5 files changed

Lines changed: 109 additions & 17 deletions

File tree

docs/tutorials/render.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ For `PointCloud2` topics, a simple dict mapping is sufficient:
6060
... )
6161
```
6262

63-
For `PandarScan` topics, use `TopicMapping` with `sensor_type` to specify the Hesai sensor model (`"XT32"` or `"OT128"`):
63+
For `PandarScan` topics, use `TopicMapping` with `sensor_type` to specify the Hesai sensor model (`"XT32"` or `"OT128"`). Use `frame_id` to specify the TF frame so that the decoded point cloud is automatically transformed to `base_link`:
6464

6565
```python
6666
>>> from t4_devkit import T4Devkit
@@ -74,6 +74,7 @@ For `PandarScan` topics, use `TopicMapping` with `sensor_type` to specify the He
7474
... channel="LIDAR_CONCAT",
7575
... topic="/sensing/lidar/top/pandar_packets",
7676
... sensor_type="OT128",
77+
... frame_id="hesai_top",
7778
... ),
7879
... ],
7980
... )
@@ -82,7 +83,7 @@ For `PandarScan` topics, use `TopicMapping` with `sensor_type` to specify the He
8283
>>> t4.render_pointcloud()
8384
```
8485

85-
If `topic_mapping` is omitted, `PointCloud2` topics are auto-detected from the rosbag. `PandarScan` topics always require explicit `topic_mapping` with `sensor_type`.
86+
If `topic_mapping` is omitted, `PointCloud2` topics are auto-detected from the rosbag. `PandarScan` topics always require explicit `topic_mapping` with `sensor_type`. The `frame_id` parameter is optional but recommended — without it, points remain in the sensor's native coordinate frame.
8687

8788
### Save Recording
8889

t4_devkit/rosbag/pandar_decoder.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,8 @@ def _decode_packet(
181181
config: Model config for the sensor type.
182182
183183
Returns:
184-
Points array with shape ``(4, N)`` in ROS sensor frame
185-
(x=forward, y=left, z=up).
184+
Points array with shape ``(4, N)`` in Hesai native frame
185+
(x=d*cos*sin, y=d*cos*cos, z=d*sin).
186186
187187
Raises:
188188
ValueError: If the packet channel count doesn't match the config.
@@ -264,11 +264,11 @@ def _decode_packet(
264264

265265
azimuths_rad = np.radians(azimuths_raw.astype(np.float32) / 100.0)
266266

267-
# Convert from Hesai native (x=right, y=forward, z=up) to
268-
# ROS sensor frame (x=forward, y=left, z=up).
267+
# Hesai native frame: x = d*cos(el)*sin(az), y = d*cos(el)*cos(az), z = d*sin(el)
268+
# The TF tree (hesai_top -> base_link) handles the frame conversion.
269269
xy_dist = distances * cos_el
270-
x = xy_dist * np.cos(azimuths_rad[:, np.newaxis])
271-
y = -xy_dist * np.sin(azimuths_rad[:, np.newaxis])
270+
x = xy_dist * np.sin(azimuths_rad[:, np.newaxis])
271+
y = xy_dist * np.cos(azimuths_rad[:, np.newaxis])
272272
z = distances * sin_el
273273

274274
return np.stack([x[valid], y[valid], z[valid], reflectivities[valid]], axis=0)

t4_devkit/rosbag/reader.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from pathlib import Path
77
from typing import TYPE_CHECKING
88

9+
import numpy as np
910
from rosbags.rosbag2 import Reader
1011
from rosbags.typesys import Stores, get_typestore
1112

@@ -26,12 +27,73 @@
2627

2728
_POINTCLOUD2_MSGTYPE = "sensor_msgs/msg/PointCloud2"
2829
_PANDARSCAN_MSGTYPE = "pandar_msgs/msg/PandarScan"
30+
_TF_STATIC_TOPIC = "/tf_static"
2931

3032
_SUPPORTED_MSGTYPES = {_POINTCLOUD2_MSGTYPE, _PANDARSCAN_MSGTYPE}
3133

3234
logger = logging.getLogger(__name__)
3335

3436

37+
def _quat_to_matrix(x: float, y: float, z: float, w: float) -> np.ndarray:
38+
"""Convert quaternion (x, y, z, w) to a 3x3 rotation matrix."""
39+
return np.array([
40+
[1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)],
41+
[2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)],
42+
[2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)],
43+
])
44+
45+
46+
def _build_tf_to_base(typestore: object, reader: Reader) -> dict[str, tuple[np.ndarray, np.ndarray]]:
47+
"""Read /tf_static and compute transforms from each frame to base_link.
48+
49+
Returns:
50+
Dict mapping ``frame_id`` to ``(R, t)`` where ``R`` is a 3x3 rotation
51+
matrix and ``t`` is a 3-element translation vector, representing the
52+
transform from that frame to ``base_link``.
53+
"""
54+
tf_conns = [c for c in reader.connections if c.topic == _TF_STATIC_TOPIC]
55+
if not tf_conns:
56+
return {}
57+
58+
# Parse the TF tree: child_frame -> (parent_frame, R, t)
59+
tree: dict[str, tuple[str, np.ndarray, np.ndarray]] = {}
60+
for conn, _ts, rawdata in reader.messages(connections=tf_conns):
61+
msg = typestore.deserialize_cdr(rawdata, conn.msgtype)
62+
for tf in msg.transforms:
63+
parent = tf.header.frame_id
64+
child = tf.child_frame_id
65+
tr = tf.transform.translation
66+
rot = tf.transform.rotation
67+
R = _quat_to_matrix(rot.x, rot.y, rot.z, rot.w)
68+
t = np.array([tr.x, tr.y, tr.z])
69+
tree[child] = (parent, R, t)
70+
71+
# Compose transforms from each frame to base_link
72+
result: dict[str, tuple[np.ndarray, np.ndarray]] = {}
73+
result["base_link"] = (np.eye(3), np.zeros(3))
74+
75+
def _resolve(frame: str) -> tuple[np.ndarray, np.ndarray] | None:
76+
if frame in result:
77+
return result[frame]
78+
if frame not in tree:
79+
return None
80+
parent, R_child, t_child = tree[frame]
81+
parent_tf = _resolve(parent)
82+
if parent_tf is None:
83+
return None
84+
R_parent, t_parent = parent_tf
85+
# T_base = T_parent * T_child: p_base = R_parent*(R_child*p + t_child) + t_parent
86+
R = R_parent @ R_child
87+
t = R_parent @ t_child + t_parent
88+
result[frame] = (R, t)
89+
return result[frame]
90+
91+
for frame in tree:
92+
_resolve(frame)
93+
94+
return result
95+
96+
3597
class Rosbag2Reader:
3698
"""Reader for rosbag2 files that provides LiDAR point cloud data.
3799
@@ -122,6 +184,25 @@ def __init__(
122184
for conn in self._connections:
123185
self._topic_connections.setdefault(conn.topic, []).append(conn)
124186

187+
# Read /tf_static and build transforms to base_link
188+
self._tf_to_base = _build_tf_to_base(self._typestore, self._reader)
189+
190+
# For PandarScan topics with frame_id specified, look up TF transform
191+
self._channel_tf: dict[str, tuple[np.ndarray, np.ndarray]] = {}
192+
if topic_mapping is not None:
193+
for m in topic_mapping:
194+
if m.frame_id is None:
195+
continue
196+
if m.frame_id in self._tf_to_base:
197+
self._channel_tf[m.channel] = self._tf_to_base[m.frame_id]
198+
else:
199+
logger.warning(
200+
"Channel '%s': frame_id='%s' not found in /tf_static. "
201+
"Points will be in sensor frame.",
202+
m.channel,
203+
m.frame_id,
204+
)
205+
125206
# Build timestamp index: channel -> sorted list of timestamp_ns
126207
# Also build a cached list of timestamp_us per channel for bisect lookups
127208
self._timestamp_ns: dict[str, list[int]] = {}
@@ -230,7 +311,13 @@ def get_pointcloud(
230311
):
231312
msg = self._typestore.deserialize_cdr(rawdata, conn.msgtype)
232313
if conn.msgtype == _PANDARSCAN_MSGTYPE:
233-
return pandarscan_to_lidar(msg, self._channel_to_sensor_type[channel])
314+
pc = pandarscan_to_lidar(msg, self._channel_to_sensor_type[channel])
315+
tf = self._channel_tf.get(channel)
316+
if tf is not None:
317+
R, t = tf
318+
xyz = pc.points[:3, :]
319+
pc.points[:3, :] = R @ xyz + t[:, np.newaxis]
320+
return pc
234321
return pointcloud2_to_lidar(msg)
235322

236323
raise ValueError(

t4_devkit/rosbag/topic_mapping.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,15 @@ class TopicMapping:
2121
sensor_type (str | None): Hesai sensor model name for PandarScan topics
2222
(e.g. ``"OT128"``, ``"XT32"``).
2323
Required when the topic type is ``pandar_msgs/msg/PandarScan``.
24+
frame_id (str | None): TF frame ID of the sensor (e.g. ``"hesai_top"``).
25+
When specified and ``/tf_static`` is available in the rosbag,
26+
the decoded point cloud is transformed from this frame to ``base_link``.
2427
"""
2528

2629
channel: str = field(validator=[validators.instance_of(str), validators.min_len(1)])
2730
topic: str = field(validator=[validators.instance_of(str), _validate_topic_name])
2831
sensor_type: str | None = field(default=None)
32+
frame_id: str | None = field(default=None)
2933

3034
@staticmethod
3135
def from_dict(mapping: dict[str, str]) -> list[TopicMapping]:

tests/rosbag/test_pandar_decoder.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,9 @@ def test_decode_single_block(self) -> None:
8888
assert points.shape[0] == 4
8989
assert points.shape[1] == laser_num
9090

91-
# At azimuth=0° in ROS frame: x = d*cos(el)*cos(0) = d*cos(el), y = -d*cos(el)*sin(0) = 0
92-
# All y values should be ~0
93-
np.testing.assert_array_almost_equal(points[1, :], 0.0, decimal=5)
91+
# At azimuth=0°: x = d*cos(el)*sin(0) = 0, y = d*cos(el)*cos(0) = d*cos(el)
92+
# All x values should be ~0
93+
np.testing.assert_array_almost_equal(points[0, :], 0.0, decimal=5)
9494
# All intensities should be 128
9595
np.testing.assert_array_almost_equal(points[3, :], 128.0)
9696

@@ -113,7 +113,7 @@ def test_decode_zero_distance_filtered(self) -> None:
113113
assert points.shape[1] == 0
114114

115115
def test_decode_azimuth_90(self) -> None:
116-
"""Test decoding at azimuth=90° - y should be negative (left-hand convention)."""
116+
"""Test decoding at azimuth=90° - x should be positive for 0° elevation."""
117117
laser_num = 32
118118
distances = [[2500] * laser_num] # 10m
119119
reflectivities = [[100] * laser_num]
@@ -129,16 +129,16 @@ def test_decode_azimuth_90(self) -> None:
129129
config = HESAI_MODELS["XT32"]
130130
points = _decode_packet(packet, config)
131131

132-
# At azimuth=90° in ROS frame: x = d*cos(el)*cos(90°) ≈ 0, y = -d*cos(el)*sin(90°) = -d*cos(el)
133-
# Channel 15 (elevation=0°): x 0, y = -10.0
132+
# At azimuth=90°: x = d*cos(el)*sin(90°) = d*cos(el), y = d*cos(el)*cos(90°) ≈ 0
133+
# Channel 15 (elevation=0°): x = 10.0, y 0
134134
ch15_idx = None
135135
for i in range(points.shape[1]):
136136
if abs(points[2, i]) < 0.01: # z ≈ 0 means elevation ≈ 0
137137
ch15_idx = i
138138
break
139139
assert ch15_idx is not None
140-
assert points[0, ch15_idx] == pytest.approx(0.0, abs=0.1)
141-
assert points[1, ch15_idx] == pytest.approx(-10.0, abs=0.1)
140+
assert points[0, ch15_idx] == pytest.approx(10.0, abs=0.1)
141+
assert points[1, ch15_idx] == pytest.approx(0.0, abs=0.1)
142142

143143
def test_multiple_blocks(self) -> None:
144144
"""Test decoding a packet with multiple blocks."""

0 commit comments

Comments
 (0)