Skip to content

feat(transformations): support custom reference timestamp in motion (de)compensation#1

Closed
janickm wants to merge 1 commit into
fresh-salamanderfrom
motion-compensator-reference-time
Closed

feat(transformations): support custom reference timestamp in motion (de)compensation#1
janickm wants to merge 1 commit into
fresh-salamanderfrom
motion-compensator-reference-time

Conversation

@janickm

@janickm janickm commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Summary

Add an optional reference_timestamp_us parameter to MotionCompensator.motion_compensate_points and motion_decompensate_points, so callers can choose which in-frame timestamp the points are motion-compensated to.

It defaults to frame_end_timestamp_us, preserving the existing end-of-frame behavior (KITTI / nuScenes) exactly -- the default code path is unchanged and the existing idempotence test still passes.

Note: this PR is stacked on NVIDIA#145 (the Argoverse 2 converter). The base branch is fresh-salamander; review after NVIDIA#145. The only files unique to this PR are ncore/impl/common/transformations.py, ncore/impl/common/transformations_test.py, and the AV2 converter's switch to the new API.

Motivation

Argoverse 2 motion-compensates each lidar sweep to the sweep reference timestamp, which is the start of the sweep (offset_ns runs forward from it), not the end of the frame. The previous API hard-coded the end-of-frame reference, so the AV2 converter (NVIDIA#145) needed a bespoke _decompensate_to_pointtime helper -- using the end reference applied the full intra-sweep ego motion (~1 m) as a systematic error to every point.

This generalizes the compensator so the convention is an explicit parameter, and removes the converter-local helper.

Changes

  • motion_compensate_points / motion_decompensate_points: new reference_timestamp_us: Optional[int] = None (defaults to frame_end_timestamp_us); asserted to lie within [frame_start, frame_end].

  • motion_decompensate_points is reimplemented to evaluate the per-point sensor pose relative to the reference sensor pose via the pose graph:

    T_sensorPointtime_sensorRef = inv(T_sensor_world[point_ts]) @ T_sensor_world[reference_ts]
    

    instead of linearly interpolating the end->start relative transform. This generalizes to any reference and is at least as accurate (it uses the real trajectory at each point time rather than a two-point linear approximation of the relative transform).

  • AV2 converter now calls motion_decompensate_points(..., reference_timestamp_us=sweep_ts); the local _decompensate_to_pointtime helper is deleted.

Testing

bazel test //ncore/impl/common:test_3_11
AV2_DIR=/path/to/argoverse2/sensor AV2_SPLIT=val \
    bazel test //tools/data_converter/argoverse2:pytest_converter_3_11

New tests:

  • test_idempotence_custom_reference_timestamp -- compensate to a non-default reference (frame start) and decompensate back recovers the original points.
  • test_decompensate_default_reference_matches_explicit_end -- reference_timestamp_us=frame_end equals the default (None).

The existing test_idempotence (default end-of-frame reference) still passes, and the AV2 integration test's lidar/cuboid alignment guard passes with the converter switched to the generalized API.

…de)compensation

Add an optional `reference_timestamp_us` parameter to
`MotionCompensator.motion_compensate_points` and
`motion_decompensate_points`. It defaults to `frame_end_timestamp_us`,
preserving the existing KITTI/nuScenes end-of-frame behavior exactly
(verified: the default path is unchanged and the existing idempotence test
still passes).

Datasets that motion-compensate to a different reference can now express
that directly. Argoverse 2 compensates each sweep to the sweep reference
timestamp, which is the *start* of the sweep (offset_ns runs forward from
it), not the end. Previously the AV2 converter needed a bespoke
`_decompensate_to_pointtime` helper because the API hard-coded the
end-of-frame reference; using the end reference applied the full intra-sweep
ego motion (~1 m) as an error.

The decompensation is reimplemented to evaluate the per-point sensor pose
relative to the reference sensor pose via the pose graph
(`inv(T_sensor_world[pt]) @ T_sensor_world[ref]`) instead of linearly
interpolating the relative end->start transform; this generalizes cleanly to
any reference and is at least as accurate (it uses the real trajectory at
each point time).

The AV2 converter now calls `motion_decompensate_points(...,
reference_timestamp_us=sweep_ts)` and the local helper is removed.

Adds tests: a custom-reference compensate/decompensate round-trip and a
check that an explicit `reference_timestamp_us=frame_end` matches the default.
def motion_decompensate_points(
self,
sensor_id: str,
xyz_sensorend: np.ndarray,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be named xyz_reference or maybe even better xyz_reftime

xyz_s_sensorend: (
np.ndarray
) # motion-compensated ray segment start points, relative to *sensor end frame*, [N,3]
xyz_e_sensorend: np.ndarray # motion-compensated ray segment end points , relative to *sensor end frame* [N,3]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sensorend is now not correct anymore - please rename as needed - we should likely add the reference timestamp to this struct unconditionally

timestamp_unique, dtype=np.float32
)
T_world_sensor_pointtime_unique = self._pose_graph.evaluate_poses("world", sensor_id, timestamp_unique)
T_sensor_pointtime_sensor_ref_unique = (T_world_sensor_pointtime_unique @ T_sensor_world_ref).astype(np.float32)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the frame names in the T_from_to convention are a bit off - can you please check and refine appropriately so that everything is fully consistent?

# Relative pose mapping the reference-time sensor frame to each point's own
# sensor frame. Point at its own timestamp must remain fixed, so we evaluate
# the per-point sensor pose relative to the reference sensor pose:
# T_sensorPointtime_sensorRef = inv(T_sensor_world[pt]) @ T_sensor_world[ref]

@janickm janickm Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while it groups things better, using camera-case is not consistent here - let's stick to T_<source-frame-name>[_time]_<target-frame-name>[_time][_suffix] (with suffix=unique or so)

# Lidar
# -------------------------------------------------------------------------

@staticmethod

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's make this free of the argoverse stuff - we put this on top afterwards


@dataclass
class MotionCompensationResult:
xyz_s_sensorend: (

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please check everywhere for stray "sensorend" (both in code + comments) that need to be updated now

@janickm

janickm commented Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Superseded: re-created against NVIDIA:main as a standalone branch (off main, no Argoverse 2 changes), with the review feedback addressed (xyz_reftime naming, reference_timestamp_us on the result struct, snake_case T_ convention, stray sensorend cleanup).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant