Skip to content

r1pro: Rust stereo depth for the head at camera rate, with calibration - #4230

Draft
jeff-hykin wants to merge 13 commits into
jeff/feat/r1_pointliofrom
jeff/feat/r1_stereo
Draft

jeff-hykin wants to merge 13 commits into
jeff/feat/r1_pointliofrom
jeff/feat/r1_stereo

Conversation

@jeff-hykin

Copy link
Copy Markdown
Member

A Rust stereo matcher for the R1's head (denoise chain, depth and height cutoffs, 34-36 fps on an Orin), the calibration file it reads, the recorder and fitter that produce it, and r1pro-stereo to look at the result.

Two bugs, both silent, both in the ROS <-> LCM field copy. Together they meant
every CameraInfo crossing the bridge arrived useless -- any ROS camera, not just
the R1's.

ROS 1 spelled CameraInfo's matrices D K R P. ROS 2 lowercased them to d k r p
and the LCM definitions kept the capitals, so the copy loops asked
hasattr(lcm_msg, "k"), got False, and skipped the field. Measured off an R1 Pro:
K came back [0.0, 0.0, 0.0, 0.0, 0.0, 0.0] and D empty, from a topic publishing
real intrinsics. _lcm_field_for falls back to the upper-case spelling, and only
when the exact name is absent, so it cannot pull a differently-named field into
place.

With the names resolving, K then arrived as [65.0, 141.0, 141.0, 86.0, 186.0,
164.0] with D 64 entries long -- the raw IEEE bytes of the matrix. ROS 2 hands
`k` over as a numpy float64 ndarray, which has tobytes() exactly like the uint8
image buffers, so it fell into the branch meant for image data. itemsize is the
discriminator: image data is array.array("B") or a uint8 ndarray, itemsize 1, and
still wants tobytes().

Nothing raised in either case. A camera whose intrinsics are all zero, or are
byte values, is not obviously broken until something unprojects through it --
which is how this surfaced, as StereoCloud refusing every frame pair with "Head
CameraInfo has no usable intrinsics".

After both, on the robot: fx=253.298, horizontal_fov_deg=86.91, against an
expected 1012.6/4 = 253.15 and ~87 degrees.

Claude-Session: https://claude.ai/code/session_01ExBQYh7BJX5bnGHTKpNVDy
(cherry picked from commit e343fd0c9fe554fb9b04ecd6b939d8115d1211c5)
…e chain and a height cutoff

The two RGB eyes matched into a depth image and a cloud by semi-global
matching, in a native module, with what a week of measuring against the
chassis lidar settled: the rectification takes how the right eye is aimed,
the depth is denoised by a chainable filter set (median, plane fit in inverse
depth, hole fill, speckle, steep, bilateral, coarse, mean; the shipped chain
is median:8+plane:16:1+fill:8), and the cloud can be gated by height above
the robot's base through a configured camera pose, so a map that has a lidar
for the rest can take only the band the lidar cannot see.

Built for camera rate on an Orin: the eyes are decoded at 1/8 scale inside
libjpeg's IDCT and in parallel, and the window median is a sliding
histogram rather than a per-pixel sort, which took the chain from 16 ms to
8 on the Orin. stereo_bench runs the module's exact per-frame path over a
directory of recorded pairs; on dimensional-orin-095 (8 cores, idle) the
R1 config runs 27.6-29.7 ms a frame median over eight runs of 150 pairs,
34-36 fps, worst frame 40 ms. Every stage is timed and logged every 5 s.

Built through the cargo workspace like the other Rust native modules.
…te the cameras run

The connection puts the head's per-eye intrinsics on the wire (the robot
self-reports them on /calib), publishes the head and wrist camera links by
forward kinematics off the live joint angles so a cloud in the head's frame
can be placed, publishes the colour streams at the ~28 Hz they actually
arrive at instead of a 5 Hz cap, and lets a run drop the wrist cameras. Wire
topics are picked in one table (topics.py) rather than a hand-kept dict.
raw_replay reads a recording back stream by stream, which the calibration
fitter needs.
…a rate check

calibration.json (R1StereoCalibration: baseline, the right eye's roll/pitch/
yaw, per-eye intrinsics, provenance) is loaded by config.stereo_calibration()
from ~/.dimos/r1pro/calibration.json or DIMOS_R1_STEREO_CALIBRATION, with the
committed rig numbers as the fallback. r1pro-calibration-recorder records
both eyes and infos at 30 fps with Point-LIO's cloud, odometry and tf;
calibrate_stereo fits the eye rotation against the lidar's floor from such a
recording and writes the file, refusing with a specific message and its own
exit code when the recording is too short, barely moved, has no floor, is
missing a stream, or the matcher is not built. recording_rates reports Hz
per stream of a recording and can be told what to require.
One place for what belongs to this head -- the frame, the range, the
resolution the Orin can afford, the camera's pose in base_link for the
height cutoff (from the URDF, checked against FK in the test) -- and a
blueprint that runs it beside Point-LIO so the depth can be looked at in
the viewer before anything plans on it.
…tification on every CameraInfo

The module held one frame per eye, and the two eyes are two free-running
streams, so a left frame's partner was often still in flight when the next
left landed and evicted it. Replaying a 30 Hz recording paired 14.7 fps and
dropped four frames in five, at 10 ms of work a frame -- the pipeline was
idle, waiting on a pairing rule. Each eye now keeps its last eight frames
and the closest-stamped pair within max_pair_skew_s is matched, everything
older than the pair dropped. The same replay pairs 30 fps with nothing
dropped.

A CameraInfo that repeats the last one no longer discards the rectification:
the robot republishes its calibration at a fixed rate, and rebuilding the
maps each time was the module's most expensive step, done for nothing.
@codecov

codecov Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.50772% with 96 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
dimos/robot/galaxea/r1pro/connection.py 57.84% 41 Missing and 2 partials ⚠️
dimos/robot/galaxea/r1pro/stereo_fit.py 93.70% 8 Missing and 10 partials ⚠️
dimos/memory/raw_replay.py 85.71% 6 Missing and 7 partials ⚠️
dimos/robot/galaxea/r1pro/calibrate_stereo.py 95.92% 6 Missing and 7 partials ⚠️
dimos/robot/galaxea/r1pro/topics.py 85.71% 3 Missing ⚠️
dimos/protocol/pubsub/impl/rospubsub_conversion.py 85.71% 0 Missing and 2 partials ⚠️
.../protocol/pubsub/impl/test_rospubsub_conversion.py 96.77% 2 Missing ⚠️
dimos/robot/galaxea/r1pro/recording_rates.py 98.19% 1 Missing and 1 partial ⚠️
@@                    Coverage Diff                    @@
##           jeff/feat/r1_pointlio    #4230      +/-   ##
=========================================================
+ Coverage                  79.73%   79.98%   +0.24%     
=========================================================
  Files                       1511     1532      +21     
  Lines                     142713   144811    +2098     
  Branches                   12210    12359     +149     
=========================================================
+ Hits                      113791   115821    +2030     
- Misses                     25483    25523      +40     
- Partials                    3439     3467      +28     
Components Coverage Δ
Tests 95.48% <99.79%> (+0.07%) ⬆️
Flag Coverage Δ
OS-ubuntu-24.04-arm 75.69% <95.46%> (+0.32%) ⬆️
OS-ubuntu-latest 76.49% <95.46%> (+0.31%) ⬆️
Py-3.10 76.27% <95.46%> (+0.30%) ⬆️
Py-3.11 76.46% <95.46%> (+0.30%) ⬆️
Py-3.12 76.48% <95.46%> (+0.30%) ⬆️
SelfHosted-Large 30.65% <32.33%> (+0.02%) ⬆️
SelfHosted-Linux 36.59% <32.51%> (-0.07%) ⬇️
SelfHosted-macOS 36.92% <32.33%> (-0.08%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...imos/hardware/sensors/camera/depth_cloud/module.py 100.00% <100.00%> (ø)
...ors/camera/depth_cloud/test_stereo_cloud_config.py 100.00% <100.00%> (ø)
dimos/memory/test_raw_replay.py 100.00% <100.00%> (ø)
dimos/robot/all_blueprints.py 100.00% <ø> (ø)
...alaxea/r1pro/blueprints/basic/r1pro_coordinator.py 75.00% <100.00%> (-4.55%) ⬇️
...t/galaxea/r1pro/blueprints/basic/r1pro_recorder.py 100.00% <100.00%> (ø)
...bot/galaxea/r1pro/blueprints/basic/r1pro_stereo.py 100.00% <100.00%> (ø)
...axea/r1pro/blueprints/basic/test_r1pro_recorder.py 100.00% <100.00%> (ø)
dimos/robot/galaxea/r1pro/config.py 100.00% <100.00%> (ø)
dimos/robot/galaxea/r1pro/stereo.py 100.00% <100.00%> (ø)
... and 16 more

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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