ROS 2 tf Frame Offset Doubled? Check static_transform_publisher
I published a static transform with a 0.2 m x offset (standing in for base_link → laser) into tf2 on ROS 2 Jazzy, then asked tf2 where the child frame’s origin was in the parent. Published as intended, tf2 returned [0.2, 0.0, 0.0]. With frame_id and child_frame_id swapped, it returned [-0.2, 0.0, 0.0]: a gap of 0.4 m, ratio 2.0. Keeping the frames right but flipping the sign of the translation gave exactly the same gap and ratio. A 1.0 m vertical offset published backwards gave a gap of 2.0 m. All 5 runs printed the same values.
One case didn’t produce a doubled offset at all. When the base frame already had odom as its parent and the laser transform was published backwards (laser → base), the lookup from odom to the laser succeeded 0 times out of 120. Every attempt raised ConnectivityException: “Could not find a connection between ‘odom’ and ‘clash_laser’ because they are not part of the same tree.Tf has two or more unconnected trees.”
So a backwards transform does put the child at exactly twice the offset from where you meant it, but only when there’s no rotation and only when the frame isn’t already attached to a tree. With rotation the ratio can be anything from 0 to 2. Inside a normal odom → base_link tree, a swapped sensor transform usually shows up as a broken tree, not as a sensor drawn in the wrong place.
This article is for ROS 2 developers whose TF axes in RViz sit away from where the robot model says they should be, and who wonder whether they got frame_id and child_frame_id the wrong way round. You’ll get one measurement to take before you change anything, the reasons behind it, the full experiment, and the cases where the rule doesn’t apply. It helps to know what a tf tree, a parent frame and a static transform are, and to be able to multiply a 2×2 rotation matrix.
The short answer: divide the gap by the offset first
Pick the one static transform you suspect. Write down its parent (frame_id), child (child_frame_id), translation with signs, and rotation. Then compare where the child origin should be with where tf2 says it is, and divide the distance by the offset you published.
| What you see (no rotation in the transform) | What it points to | Next step |
|---|---|---|
| Gap ÷ offset = 2 | The inverse transform was published | Look at the tree to tell a parent/child swap from a sign error |
| Gap ÷ offset = 1 | Not a direction mistake | Check the value itself: a missing or mis-measured offset, the URDF joint <origin>, or the mesh/CAD origin |
| Lookup fails with “not part of the same tree” | A frame lost its link to the main tree, possibly a backwards transform under a frame that already has a parent | Check which frame is the parent in every transform that touches it |
| The transform has a rotation | The ratio tells you nothing | Compute the correct inverse (Rᵀ, −Rᵀt) and compare it directly |
Direction is a good first hypothesis to test because one division either confirms it or rules it out. It isn’t always the cause. In one Robotics Stack Exchange question about a tf origin offset from base_link, the accepted answer blamed the SolidWorks assembly origin used when exporting the URDF, not the transform direction.
What the numbers in a TransformStamped actually mean
A geometry_msgs/msg/TransformStamped describes where the child frame sits inside the parent frame. The message definition says it “expresses a transform from coordinate frame header.frame_id to the coordinate frame child_frame_id” and that the translation and rotation are those “of child_frame_id from header.frame_id” (TransformStamped.msg, L1-L18). So x = 0.2 under frame_id=base_link, child_frame_id=laser means “the laser origin is 0.2 m forward of the base_link origin”. A URDF joint <origin> follows the same convention: it’s “the transform from the parent link to the child link” (URDF joint specification).
The same relationship can be written in two directions, and that’s where people get confused. The tf2 documentation calls the value that moves the frame itself the frame formulation, and the value that converts data measured in one frame into another frame’s coordinates the data formulation. It says “these values are directly inverse”, that published transforms “represent the frame formulation”, and warns: “Keep this in mind when debugging published transforms” (About tf2, L46-L57).
When your code calls lookup_transform or transform(), tf2 inverts transforms for you as it walks the tree. Direction mistakes happen where a person types numbers: static_transform_publisher arguments, a hand-filled TransformStamped, or a URDF <origin>. The Rolling static broadcaster tutorial names both frames explicitly on the command line (Writing a static broadcaster (Python), L424-L452):
ros2 run tf2_ros static_transform_publisher --x 0 --y 0 --z 1 --yaw 0 --pitch 0 --roll 0 --frame-id world --child-frame-id mystaticturtle
--frame-id is the parent and --child-frame-id is the child. I took that syntax from the Rolling docs and didn’t check it on each distribution’s CLI.
Why backwards means exactly twice the offset
Use a floor A and a shelf B 1 m above it. The tf2 docs use the same one-dimensional case: if A is one meter below B, the transform from A to B is positive, and data moved from B to A gets the height added (About tf2, L80-L93).
- Intended:
frame_id=A,child_frame_id=B, z = +1 m. B’s origin is at +1 m in A. - Parent and child swapped:
frame_id=B,child_frame_id=A, z = +1 m. The message now says A’s origin is 1 m above B, so B’s origin is at −1 m in A. - Sign flipped:
frame_id=A,child_frame_id=B, z = −1 m. B’s origin is at −1 m in A.
Either mistake leaves B at −1 m instead of +1 m, a difference of 1 − (−1) = 2 m. With no rotation, every point attached to B moves by the same vector. A cup 0.5 m above the shelf should be at 1.5 m in A but lands at −0.5 m, also 2 m off. In general, without rotation, a backwards transform puts the child at −t instead of +t, and the distance between them is 2|t|.
I first checked these cases with a small standard-library Python script (example.py, expected values in verification.json). That’s arithmetic, though. It assumes I read the tf2 definitions correctly, and it can’t tell you what happens when the backwards frame already has a parent. So I ran the same cases through tf2 itself.
Publishing into tf2 and reading the result back
One rclpy node publishes every scenario on the real /tf_static topic with a StaticTransformBroadcaster. A TransformListener in the same node fills a Buffer from that topic. Each scenario uses its own frame names (<scenario>_parent, <scenario>_child) so they can’t interfere. For each one the script calls buffer.lookup_transform(intended_parent, intended_child, Time()). The translation that comes back is where the child origin sits in the intended parent. How each mistake is published:
def published(sid, xyz, yaw, kind, stamp):
parent, child = f"{sid}_parent", f"{sid}_child"
if kind == "intended":
return make(stamp, parent, child, xyz, yaw)
if kind == "swap":
return make(stamp, child, parent, xyz, yaw)
if kind == "sign":
return make(stamp, parent, child, tuple(-c for c in xyz), yaw)
if kind == "omit":
return make(stamp, parent, child, (0.0, 0.0, 0.0), yaw)
if kind == "swap_true_inverse":
t, r = inverse(xyz, yaw)
return make(stamp, child, parent, t, r)
if kind == "swap_neg_t":
return make(stamp, child, parent, tuple(-c for c in xyz), -yaw)
make(stamp, parent, child, ...) sets header.frame_id=parent and child_frame_id=child. omit is a control case where the offset is simply left out, which is a value error rather than a direction error. The script also reads each intended child’s parent from buffer.all_frames_as_yaml() and reports it as cpar, or root when the frame has no parent.
Environment (from results.json):
- Image:
ros:jazzy-ros-base, digestros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca - ROS distribution: jazzy; Python 3.12.3; rclpy 7.1.12-1noble.20260902.053513
- RMW: rmw_fastrtps_cpp (default)
- Network: none; limits: 1 CPU, 1 GiB
- Runs: 5, all with exit code 0, taking 4.57 to 4.68 s each. Each run published 21 static transforms (19 scenarios plus 2 for the tree test). Apart from timestamps, all 5 runs printed identical lines.
No rotation. Predictions are from plan.md. “Origin” is the child origin tf2 returned in the intended parent. The probe point is a point fixed in the child frame (z = 0.5 m for z1m, x = 0.5 m for laser02) moved into the parent with the looked-up transform.
| Scenario | Offset (m) | Predicted | Origin returned | Gap (m) | Ratio | Probe point in parent | Child’s parent |
|---|---|---|---|---|---|---|---|
| z1m_intended | 1.0 | z = +1, gap 0 | [0.0, 0.0, 1.0] | 0.0 | 0.0 | [0.0, 0.0, 1.5] | parent |
| z1m_swap | 1.0 | z = −1, gap 2, ratio 2, point at −0.5 | [0.0, 0.0, -1.0] | 2.0 | 2.0 | [0.0, 0.0, -0.5] | root |
| z1m_sign | 1.0 | z = −1, gap 2, ratio 2, point at −0.5 | [0.0, 0.0, -1.0] | 2.0 | 2.0 | [0.0, 0.0, -0.5] | parent |
| laser02_intended | 0.2 | x = +0.2, gap 0 | [0.2, 0.0, 0.0] | 0.0 | 0.0 | [0.7, 0.0, 0.0] | parent |
| laser02_swap | 0.2 | x = −0.2, gap 0.4, ratio 2 | [-0.2, 0.0, 0.0] | 0.4 | 2.0 | [0.3, 0.0, 0.0] | root |
| laser02_sign | 0.2 | x = −0.2, gap 0.4, ratio 2 | [-0.2, 0.0, 0.0] | 0.4 | 2.0 | [0.3, 0.0, 0.0] | parent |
| laser02_omit | 0.2 | gap 0.2, ratio 1 | [0.0, 0.0, 0.0] | 0.2 | 1.0 | [0.5, 0.0, 0.0] | parent |
The probe-point gap matched the origin gap in every row: 2.0 for the backwards z1m cases, 0.4 for the backwards laser02 cases, 0.2 for laser02_omit.
With rotation (yaw about z, translation (0.2, 0, 0), offset 0.2 m). Predictions are from plan.md.
| Yaw | Published as | Predicted ratio | Origin returned | Gap (m) | Ratio | Child’s parent |
|---|---|---|---|---|---|---|
| 90° | swap | about 1.414 | [0.0, 0.2, 0.0] | 0.282843 | 1.414214 | root |
| 90° | sign | 2 | [-0.2, 0.0, 0.0] | 0.4 | 2.0 | parent |
| 90° | swap_true_inverse | 0 | [0.2, 0.0, 0.0] | 0.0 | 0.0 | root |
| 90° | swap_neg_t | about 1.414 | [0.0, 0.2, 0.0] | 0.282843 | 1.414214 | root |
| 120° | swap | 1 | [0.1, 0.173205, 0.0] | 0.2 | 1.0 | root |
| 120° | sign | 2 | [-0.2, 0.0, 0.0] | 0.4 | 2.0 | parent |
| 120° | swap_true_inverse | 0 | [0.2, 0.0, 0.0] | 0.0 | 0.0 | root |
| 120° | swap_neg_t | about 1.732 | [-0.1, 0.173205, 0.0] | 0.34641 | 1.732051 | root |
| 180° | swap | 0 | [0.2, 0.0, 0.0] | 0.0 | 0.0 | root |
| 180° | sign | 2 | [-0.2, 0.0, 0.0] | 0.4 | 2.0 | parent |
| 180° | swap_true_inverse | 0 | [0.2, 0.0, 0.0] | 0.0 | 0.0 | root |
| 180° | swap_neg_t | 2 | [-0.2, 0.0, 0.0] | 0.4 | 2.0 | root |
Tree clash. odom → ok_base and odom → clash_base were published on /tf every 0.05 s with x = 1.0 m. Statically, ok_base → ok_laser was published the right way round and clash_laser → clash_base backwards, both with 0.2 m. Before odom arrived, clash_base‘s parent was clash_laser. After that, the script sampled the tree for 3 seconds. Predicted: ok_laser at x = 1.2 in odom; no prediction for the clash case.
| Case | Lookups from odom to laser | Successful | laser x in odom | Base frame’s parent in samples | Errors |
|---|---|---|---|---|---|
| ok | 120 | 120 | 1.2 | odom (120) | none |
| clash | 120 | 0 | none | odom (120) | ConnectivityException (120) |
Raw output lines (run 1):
{"s": "laser02_swap", "yaw": 0.0, "off": 0.2, "org": [-0.2, 0.0, 0.0], "gap": 0.4, "ratio": 2.0, "cpar": "root", "pt": [0.5, 0.0, 0.0], "pt_par": [0.3, 0.0, 0.0], "pt_gap": 0.4}
{"s": "laser02_sign", "yaw": 0.0, "off": 0.2, "org": [-0.2, 0.0, 0.0], "gap": 0.4, "ratio": 2.0, "cpar": "parent", "pt": [0.5, 0.0, 0.0], "pt_par": [0.3, 0.0, 0.0], "pt_gap": 0.4}
{"s": "rot120_swap", "yaw": 120.0, "off": 0.2, "org": [0.1, 0.173205, 0.0], "gap": 0.2, "ratio": 1.0, "cpar": "root"}
{"event": "tree_before_odom", "ok_base_parent": "missing", "clash_base_parent": "clash_laser", "odom_known": false, "t_s": 0.103}
{"event": "tree_clash_sample", "case": "clash", "odom_to_base_x_m": 1.0, "static_offset_m": 0.2, "laser_x_in_odom_min": null, "laser_x_in_odom_max": null, "tries": 120, "success": 0, "parents": {"odom": 120}, "errors": {"ConnectivityException": 120}, "first_error": "Could not find a connection between 'odom' and 'clash_laser' because they are not part of the same tree.Tf has two or more unconnected trees.", "t_s": 3.15}
Every prediction in plan.md held, including the rotated ones that the arithmetic script never covered. The rest of this article is my reading of those numbers.
A ratio of 2 doesn’t tell you whether you swapped the frames or flipped the sign
The swapped and sign-flipped cases returned identical origins, gaps and probe points. If all you measure is distance, you can’t tell them apart. The tree can. After a swap, the frame you meant as the child had no parent (root), because tf2 stores each transform under its child_frame_id and records the frame_id as that frame’s parent (buffer_core.cpp, L271-L292). After a sign flip, the tree looked normal and only the number was wrong.
On your robot, that means looking at the tree before you edit numbers. ros2 run tf2_tools view_frames draws it (Introduction to tf2, L86-L123). If the arrow between the two frames points the wrong way, fix the frame order. If the tree is right, fix the sign. I read the tree with all_frames_as_yaml() in the experiment and didn’t run view_frames.
With rotation, the ratio rule breaks
The inverse of a transform (R, t) is (Rᵀ, −Rᵀt), not (R, −t). Once the transform rotates, swapping parent and child no longer puts the child at −t. For a yaw θ and a translation perpendicular to the rotation axis, the measured gaps match 2|t|·cos(θ/2) for a plain swap. That’s how the 90°, 120° and 180° predictions in plan.md were derived, and tf2 agreed with all three.
Two of those results matter in practice:
- At 120°, a swap gave a ratio of 1. That’s the same ratio as the “offset left out” control. If you used the 1× rule on a rotated sensor mount, you’d rule out a direction mistake when it was the actual cause.
- At 180°, a swap gave a gap of 0. A 180° yaw with a perpendicular translation is its own inverse, so the swapped transform describes the same pose. Position can’t reveal the mistake here. Only the tree (
rootinstead ofparent) shows it.
The swap_neg_t case is the common hand-inversion mistake: swap the frames, then negate both the yaw and the translation. That’s still wrong whenever there’s rotation (ratio about 1.414 at 90°, about 1.732 at 120°, 2 at 180°). Only swap_true_inverse, which writes (Rᵀ, −Rᵀt), put the origin back at the right spot. The sign flip, on the other hand, kept a ratio of 2 at every angle, because it only changes t and leaves the frame order alone.
So if the suspect transform has any rotation, skip the ratio. Compute (Rᵀ, −Rᵀt) by hand and compare it with what tf2 returns.
“Could not find a connection between ‘odom’ and ‘laser'”: when base_link already has a parent
On a real robot, base_link is almost never a root. Per REP 105 it hangs under odom, and “each coordinate frame has one parent coordinate frame”. Publish laser → base_link instead of base_link → laser, and you’re asking base_link to have two parents.
In the experiment, the backwards static transform briefly made clash_laser the parent of clash_base. Once the dynamic odom transform arrived, the buffer listed odom as the parent in every sample. The laser frame had never had a parent, and its one link to the base was no longer the base’s recorded parent, so nothing connected it to the odom tree. Every odom → laser lookup then failed with:
Could not find a connection between 'odom' and 'clash_laser' because they are not part of the same tree.Tf has two or more unconnected trees.
So if you see this error for a sensor frame, list every transform in which that frame or its intended parent appears as child_frame_id. A sensor frame that shows up as someone’s parent but never as a child is a strong sign the static transform was written backwards. I only observed this in one order (static first, dynamic odom after) on Jazzy, and I didn’t check what RViz draws in this state.
Checking your own transform
- Find the static transform: the launch file, the node that fills
TransformStamped, or the URDF joint. Write downframe_id,child_frame_id, the translation with signs, and the rotation. - Write the child origin you expect in the parent from the design, for example
laserat x = +0.2 m inbase_link. - Ask tf2 where the child origin actually is, using the same call as the experiment.
lookup_transform(parent, child, ...)returns the child origin expressed in the parent. - No rotation and ratio 2: check the tree, then fix the frame order or the sign. Ratio 1: drop the direction idea and check the value, the URDF
<origin>and the mesh/CAD origin. Any rotation: compare with (Rᵀ, −Rᵀt) directly. A connectivity error: check for a second parent.
Here’s a minimal script for step 3. It uses the same calls as experiment.py, but I haven’t run this cut-down version on its own. Replace the frame names and the expected value with your own.
import math
import time
import rclpy
from rclpy.time import Time
from tf2_ros import Buffer, TransformListener
PARENT, CHILD = "base_link", "laser"
EXPECTED = (0.2, 0.0, 0.0) # child origin in parent, from your design
rclpy.init()
node = rclpy.create_node("tf_direction_check")
buffer = Buffer()
TransformListener(buffer, node, spin_thread=False)
end = time.monotonic() + 5.0
while time.monotonic() < end and not buffer.can_transform(PARENT, CHILD, Time()):
rclpy.spin_once(node, timeout_sec=0.05)
tf = buffer.lookup_transform(PARENT, CHILD, Time()) # raises if the frames aren't connected
t = tf.transform.translation
origin = (t.x, t.y, t.z)
gap = math.dist(origin, EXPECTED)
offset = math.dist((0.0, 0.0, 0.0), EXPECTED)
print("origin", origin, "gap", round(gap, 6), "ratio", round(gap / offset, 6))
print("rotation", tf.transform.rotation) # if not identity, don't use the ratio
node.destroy_node()
rclpy.shutdown()
If the lookup raises an exception rather than returning a pose, read its message. That exception is the result you’re looking for. If you’re checking a physical robot, do it with the drives disabled and follow the manufacturer’s safety procedure before you change any transform the controllers depend on.
What this experiment doesn’t show
- It measures the coordinates tf2’s
Bufferreturns. It doesn’t measure what RViz draws, and it doesn’t involve a simulator or a robot. - Rotation was a single yaw about z with the translation perpendicular to it. Other axes and combined rotations weren’t tested. Use the (Rᵀ, −Rᵀt) comparison, not the angle table.
- The tree clash result comes from one publishing order (static first, dynamic
odomafter) on Jazzy’s tf2. I wouldn’t assume the same behavior for other orders or distributions. - Everything ran in one process with the default RMW. Multi-machine setups, clock problems and late static transforms weren’t part of it.
- A ratio of 2 means the inverse was published. It doesn’t tell you which line of your code did it.
Reproduce it
The code, the plan with predictions, and the raw output from all 5 runs are at github.com/sungpyo9053/blog/experiments/tf-frame-offset-transform-direction-double-error. The individual files: experiment.py, plan.md (written in Korean) and results.json. From the directory containing experiment.py:
docker run --rm --network none --cpus 1 --memory 1g -v "$PWD":/work ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca \
bash -c "source /opt/ros/jazzy/setup.bash && python3 /work/experiment.py"
Sources
- ROS 2 documentation (Rolling), About tf2, L46-L57: frame vs data formulation, which one is published
- ROS 2 documentation (Rolling), About tf2, L80-L93: the one-meter example
- geometry_msgs/msg/TransformStamped.msg, L1-L18
- Writing a static broadcaster (Python), L424-L452
- Introduction to tf2, L86-L123:
view_frames - tf2 buffer_core.cpp, L271-L292: transforms stored per child frame with the parent recorded
- REP 105: Coordinate Frames for Mobile Platforms
- URDF joint XML specification