Skip to content

feat(stereo-split): re-time IMU samples onto the device grid - #230

Merged
shark0F0497 merged 2 commits into
main-v2from
feat/stereo-split-imu-retiming
Sep 17, 2026
Merged

shark0F0497 merged 2 commits into
main-v2from
feat/stereo-split-imu-retiming

Conversation

@shark0F0497

Copy link
Copy Markdown
Collaborator

What

/decxin/imu is currently written with the host time at which each message was emitted, and one sample is repeated at every packet boundary. A 600 Hz sensor therefore arrives as 661 messages per second in bursts of eleven, and the payload header carries a per-frame interpolation that is about 13% off the real sample rate. Anything that resamples IMU on log_time, or counts messages as samples, gets a wrong time base.

This PR re-times the IMU stream onto the device's own rigid sampling grid, using the metadata barcode that is already in every joined frame.

  • jobs/stereo-split/imu_retiming.py (new) measures the grid per file — spacing, offset from the frame's exposure end, and the frame period — and then verifies all of them on every decoded barcode before committing to anything.
  • convert_mcap_stereo_h264.py reads those barcodes in the analysis pass it already runs for colour fitting, so no extra decode is needed, and the conversion pass rewrites the IMU payload header together with its MCAP timestamps while dropping the repeated samples.
  • imu_decoder.py now exposes the exposure start/end values that the barcode header already carried.
  • Split inputs are untouched: they have no metadata column, so the stream passes through exactly as before.

Evidence

The device grid is rigidly locked to the camera frame strobe. Measured on a real joined capture (1106 frames), every one of 1072 decodable barcodes agrees to the microsecond:

Invariant Value
In-packet spacing 1664 µs (single value)
Frame period 33280 µs = 20 × spacing
First sample − exposure end −16717 µs
Last sample − exposure end −77 µs

Ten different Ego Portal Stereo devices were checked separately (10/10): identical spacing of 1664 µs, identical frame period, identical exposure-end anchor, 100% barcode decode rate, and 22 IMU messages per frame on every device.

Result of the replay through the job on the same 1106-frame capture:

Check Source After re-timing
Messages 24794 22541 (2253 repeats dropped)
Steps between samples 10–180 µs bursts every ~16.6 ms every step exactly 1664 µs
Unique sample rate 661 msg/s (misleading) 601.0 Hz
Header stamp vs log time −79 … −23 ms apart identical everywhere
Sample values every surviving row is an ordered subset of the source

The job also reports grid_spacing_us, frame_period_us, clock_scale_ns_per_us (999.96), clock_residual_p95_ms (5.24) and the number of extrapolated frames in both the manifest and the output metadata.

Safety

Re-timing is all-or-nothing. resolve() returns either a plan that covers the whole stream or a skip decision that leaves the source timestamps alone; a skip is reported with its reason (insufficient_barcodes, spacing_not_constant, anchor_not_constant, packet_alignment_failed, barcode_grid_mismatch, clock_fit_failed, …). Frames whose barcode fails to decode are placed on the same rigid grid, and the conversion pass refuses to finish unless the plan covered every source message.

Tests

python3 -m unittest discover -s jobs/stereo-split/tests -p 'test_*.py'85 tests pass (12 new).

New coverage: grid fitting with a rigid synthetic stream, uniform monotonic output steps, repeats being the only dropped messages, frames without a barcode placed on the grid, and skip paths for every failure reason — plus converter-level tests for the re-timed output, the fail-safe pass-through when barcodes are unusable, and the disabled flag.

Cost

The barcode decode adds about 5 s to the analysis pass and the IMU analysis and payload rewrite about 4 s, so the job grows by roughly 13% on the regression clip.

Follow-ups (not in this PR)

  • Keystone QA still validates only the IMU message count, so the manifest's grid fields are informational for now.
  • The barcode read (≈4.8 ms/frame) can be reduced by decoding it on a sampled subset of frames, since the grid is rigid.

The recorder stamps /decxin/imu with the host time at which each message was
emitted and repeats one sample at every packet boundary, so a 600 Hz sensor
arrives as ~661 messages per second in bursts of eleven, and the payload header
carries a per-frame interpolation whose rate is about 13% off.

The image metadata column still carries the device's own microsecond timestamps
for one packet per camera frame, and that packet plus the rigid link between the
device grid and the frame strobe is enough to reconstruct the whole stream.

- imu_retiming.py measures the grid spacing, the offset from the frame's
  exposure end, and the frame period per file, then checks all of them on every
  decoded barcode before it commits to re-timing anything.
- convert_mcap_stereo_h264.py reads those barcodes in the analysis pass it
  already runs for colour fitting, and the conversion pass rewrites the IMU
  payload header and its MCAP timestamps, dropping the repeated samples.
- The manifest gains a lean imu_retiming summary and the metadata keeps the
  full report, so a skip is always explainable. Split inputs are untouched.

Measured on a real 1106-frame capture: 24794 -> 22541 messages, every step
exactly 1664 us, 601.0 Hz unique rate, every surviving sample an ordered subset
of the source, and header stamps equal to log times.
Replaying five fresh Ego Portal Stereo captures surfaced two ways the grid
check was stricter than the device actually is:

- Device timestamps are whole microseconds, so a frame's samples can sit one
  microsecond off the ideal grid and a repeated sample can land one
  microsecond after its predecessor. The grid check now allows a two
  microsecond tolerance and treats anything closer than half a grid step as a
  repeat, instead of demanding exact equality.
- A capture does not always contain a whole number of 11-sample packets
  (84606 = 11 * 7691 + 5 on the newest one), so the tail is now continued on
  the same rigid grid instead of being left uncovered, which used to abort the
  conversion.
@shark0F0497

Copy link
Copy Markdown
Collaborator Author

Replay on five fresh captures from Keystone

Fetched the newest five distinct Ego Portal Stereo captures from production Keystone (recorded 2026-09-16 23:46Z … 2026-09-17 02:43Z) and ran the job on them locally end to end.

Device Duration Frames Job IMU in → out Dropped Unique rate Step spread header = log serial
EP-000241 129.6 s 3820 165 s 84606 → 76916 9.09 % 601.0 Hz ≤ 1 µs yes preserved
EP-000210 50.0 s 1468 42 s 32593 → 29640 9.06 % 601.1 Hz 0 µs yes preserved
EP-000110 88.1 s 2618 113 s 58113 → 52832 9.09 % 601.0 Hz 0 µs yes preserved
EP-000172 72.6 s 2150 100 s 47454 → 43143 9.08 % 601.0 Hz 0 µs yes preserved
EP-000205 50.3 s 1478 62 s 32813 → 29836 9.07 % 601.0 Hz 0 µs yes preserved

On all five: the retiming decision was applied with reason grid_validated, the grid spacing measured 1664 µs, every surviving sample is an ordered subset of the source (nothing was invented), and serial_number is byte-identical to the source. The fitted device clock landed between 999.83 and 1000.02 ns/µs on every capture, so the sensors' microsecond clocks are accurate to about 0.02 %.

The replay is what caught two cases the unit fixtures did not cover, both fixed in 77cf414:

  • device timestamps round to whole microseconds, so a repeated sample can land 1 µs after its predecessor and a frame can sit 1 µs off the ideal grid;
  • a capture may not contain a whole number of 11-sample packets (84606 = 11 × 7691 + 5 on EP-000241), and the tail is now continued on the same grid instead of aborting the conversion.

Colour was applied on four of the five; EP-000210 was reported as rejected with validation_not_improved, because its eyes were already close (held-out median CIEDE2000 5.01 → 4.04, a 19.4 % improvement, just under the 20 % gate). That is the gate doing its job, and it is worth a separate discussion whether 20 % is the right threshold.

Each capture also reports clock_residual_p95_ms between 5.0 and 34.8 ms; that residual is the video log-time axis jitter the IMU is anchored to, not IMU noise.

@shark0F0497
shark0F0497 merged commit fd6ade5 into main-v2 Sep 17, 2026
7 of 8 checks passed
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