Skip to content

native CompressedImage across all go2 blueprints - #2879

Draft
spomichter wants to merge 14 commits into
mainfrom
feat/go2-compressed-native
Draft

native CompressedImage across all go2 blueprints#2879
spomichter wants to merge 14 commits into
mainfrom
feat/go2-compressed-native

Conversation

@spomichter

@spomichter spomichter commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Problem

decided to cahgne connections to output CompressedImage directly, producers pick the encoding, consumers decode only when they need pixels. This PR does that migration for every go2 blueprint. Stacked on #2814 (the CompressedImage type).

What changes

ProducerGO2Connection.color_image: Out[CompressedImage]. jpeg encode happens once at the publish edge (config.image_quality, default 75). frames that are already CompressedImage pass through untouched — which is also why old replay datasets keep working with zero conversion: replay feeds raw Image frames through the same edge and they get encoded like live frames. Go2FleetConnection inherits the change.

Consumers — decode at point of use, and CompressedImage.decode() is memoized (one decode per frame max, cache never pickles):

  • go2-only modules flip in place: PerceiveLoopSkill, PersonFollowSkillContainer, TemporalMemory, Go2Memory, Go2Mid360Recorder, VLMAgent, VlmStreamTester, Go2TeleopModule
  • modules shared with g1/xarm/desk graphs get port-retype subclasses so those robots keep raw Image until they migrate: CompressedSpatialMemory, CompressedNavigationSkillContainer, CompressedDetection3DModule, CompressedMarkerDetectionStreamModule, HostedTwistTeleopModule (retyped in place — hosted-go2 only)
  • note for review: the port retypes need # type: ignore[assignment] (deliberate LSP violation — that IS the wiring mechanism). 5 of them, each commented.

Zero-decode paths (the structural wins over the codec wrapper):

  • VLMAgent sends the jpeg wire bytes straight to the model (CompressedImage.agent_encode()) — the old per-query re-encode is gone
  • quest teleop _push_jpeg forwards wire bytes to the headset — per-frame re-encode gone
  • CameraVideoTrack (hosted teleop webrtc) decodes at recv(), so only frames actually sent to the operator pay a decode
  • rerun viewer renders the jpeg via rr.EncodedImage, no pixel decode
  • recorders store CompressedImage → new datasets ~39x smaller

Misc: sensor_msgs.CompressedImage added to zenoh latest-wins QoS; bench harness gets --sink decode|bytes and per-nic wire sampling; bench blueprint drops the viewer bridge (it was hijacking any open viewer on the box).

Native e2e benchmark (same protocol as #2831: go2_short replay ~14Hz 720p, 60s x 2 reps)

cell                     frames   fps     lag  cpu%  rss MB  wire/frame
basic  clean  bytes         855  14.3   -52ms     2    1836        71KB
basic  clean  decode        854  14.3   -51ms     2    1893        71KB
go2    clean  bytes         854  14.3   -51ms     4    4732        71KB
go2    clean  decode        855  14.3   -69ms     4    4967        71KB
go2    jetson bytes         853  14.3   -55ms     2    5455        71KB
go2    jetson decode        854  14.3   -53ms     2    5528        71KB
heavy  clean  decode        855  14.3   -52ms     6    5102        71KB
heavy  jetson decode        854  14.3   -39ms     3    5878        71KB

against the #2831 baselines (same box, same dataset):

cell raw codec pin native
go2 / jetson fps 6.5 14.3 14.3
heavy / jetson fps 11.4 14.3 14.3
basic / clean fps 3.3 14.3 14.3
go2 / clean cpu 17% 4% 4%
heavy / jetson cpu 13% 3% 3%
wire per frame 2.76MB ~140KB (synthetic) 71KB (real frames)

native matches the codec pin on delivery and cpu and beats raw everywhere, with no wrapper in any blueprint — the encode lives in the connection where option 5 wants it. real dataset frames compress to 71KB at q75 (39x), better than the synthetic 720p estimate.

pending: network sweep (rate-limit + loss profiles via tc netem) — needs sudo on the bench box; raw/codec network baselines run from 3f2fc05ad where the harness still has --mode raw|codec.

Breaking Changes

  • GO2Connection.color_image is now Out[CompressedImage] — anything outside this repo wiring ("color_image", Image) against a go2 graph must retype or decode.
  • go2 blueprints referencing the shared perception modules now use the Compressed* subclasses.

How to Test

CI=1 uv run pytest   # full suite green locally (2507 passed)
PYTEST_VERSION=1 uv run python -m dimos.protocol.pubsub.benchmark.tool_replay_bench \
  --blueprint unitree-go2-basic --sink decode --duration 30 --out /tmp/bench

dimos --replay run unitree-go2 shows the camera in rerun via EncodedImage (no pixel decode in the viewer path).

Contributor License Agreement

  • I have read and approved the CLA

spomichter added 14 commits July 7, 2026 23:26
…ges on any transport

adds sensor_msgs.CompressedImage wrapping the existing dimos_lcm binding
(jpeg/png, ts+frame_id preserved, rr.EncodedImage viz) and a CodecTransport
that compresses Image->CompressedImage over any inner transport (lcm, zenoh,
shm, webrtc). benchmark test compares raw image vs compressed on lcm+zenoh:
19.5x wire reduction, raw 720p over lcm drops most frames while comressed
delivers all
… compressed image transport

go2 basic with CodecTransport on color_image (jpeg q80 on the wire istead of
2.76MB raw frames). readme gets a short compressed-images section with the
publish path and how to run the raw-vs-compressed benchmark
the ubuntu-arm runner doesnt have libturbojpeg installed, same optional-dep
treatment as test_cli.py / test_all_blueprints.py
…issing

root cause of the arm failures: the tests job only apt-installed portaudio,
x64 runners had libturbojpeg by accident from the runner image. install it
explicitly and turn the silent skip into a CI assert (same treatment as the
webrtc aiortc guard from #2048), skip stays for local no-syslib installs
collect rows across the lcm/zenoh params in a module fixture and print a
single table (wire/median/max/fps/drops) like tool_benchmark does, plus a
note on what the latency number actually measures
installing libturbojpeg on the arm runner unblocked _jpeg_case, which then
hit the CI LFS download cap at collection time. treat unavailable data like
unavailable turbojpeg (case returns None)
runs a real blueprint in replay mode with BenchSink consumer modules
(per-frame arrival log + optional synthetic detector load) and a host
sampler (cpu/rss/loopback rate). used for the raw-vs-codec report on
unitree-go2; tool_ prefix keeps it out of pytest and the module registry
'codec' already means the memory2 storage codecs (JpegCodec/Lz4Codec) and
the generic name promised flexibility the class doesnt have — its hardwired
Image<->CompressedImage. new name matches the msg type and the existing
JpegLcmTransport/WebRTCVideoTransport naming style
the import-time CI assert broke the self-hosted ros job which collects these
files but deselects them by marker. new form: skip locally when the native
lib is missing, run (and fail loudly) in CI — same intent, respects
deselection like the aiortc guard test does
Team direction is option 5 (connections output CompressedImage directly);
the transport wrapper was rejected as public API. Keep it inside
tool_replay_bench.py so raw-vs-codec benchmark cells stay reproducible,
move its tests alongside, drop the unitree-go2-compressed-image blueprint.
CompressedCodec is bench-only now: drop format/max_width/decode params and
the double-wrap guard the bench never uses; rename its tests to
test_replay_bench.py to match. Turbojpeg guard moves to conftest as a
skipif_no_turbojpeg marker (skip locally, run-and-fail-loudly in CI).
…tion 5)

GO2Connection publishes Out[CompressedImage]: encode once at the publish
edge (config.image_quality, default 75), already-compressed dataset frames
pass through — old replay datasets keep working unconverted.

Consumers decode only where pixels are needed; CompressedImage.decode()
is memoized (decode cache never pickles). VLM agents and quest teleop
send the jpeg wire bytes directly — their per-frame re-encode is gone.
Shared-with-other-robots modules get Compressed* port-retype subclasses
(SpatialMemory, NavigationSkillContainer, Detection3DModule,
MarkerDetectionStreamModule, HostedTwistTeleopModule); go2-only modules
flip in place. CameraVideoTrack decodes at recv so webrtc video pays
decode only for frames actually sent.

Bench harness: --sink decode|bytes flavors, per-nic wire sampling,
viewer bridge disabled (was hijacking any open viewer on the box).
CompressedImage joins zenoh latest-wins QoS types.
@codecov

codecov Bot commented Jul 12, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
2847 1 2846 89
View the full list of 1 ❄️ flaky test(s)
dimos.e2e_tests.test_dimsim_spatial_memory::test_go_to_the_bed

Flake rate in main: 17.27% (Passed 115 times, Failed 24 times)

Stack Traces | 525s run time
lcm_spy = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7a72994111c0>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x7a7297deb600>
human_input = <function human_input.<locals>.send_human_input at 0x7a7297deb6a0>
dim_sim = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x7a729917bb00>
explore_house = <function explore_house.<locals>.explore at 0x7a7297debba0>

    @pytest.mark.self_hosted_large
    def test_go_to_the_bed(lcm_spy, start_blueprint, human_input, dim_sim, explore_house) -> None:
        start_blueprint(
            "run",
            "unitree-go2-agentic",
            simulator="dimsim",
        )
        lcm_spy.save_topic(".../McpClient/on_system_modules/res")
        lcm_spy.wait_for_saved_topic(".../McpClient/on_system_modules/res", timeout=1200.0)
    
        explore_house()
    
        human_input("go to the bed")
    
>       lcm_spy.wait_until_odom_position(-3.567, -1.332, threshold=2, timeout=180)

dim_sim    = <dimos.e2e_tests.dim_sim_client.DimSimClient object at 0x7a729917bb00>
explore_house = <function explore_house.<locals>.explore at 0x7a7297debba0>
human_input = <function human_input.<locals>.send_human_input at 0x7a7297deb6a0>
lcm_spy    = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7a72994111c0>
start_blueprint = <function start_blueprint.<locals>.set_name_and_start at 0x7a7297deb600>

dimos/e2e_tests/test_dimsim_spatial_memory.py:32: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
dimos/e2e_tests/lcm_spy.py:167: in wait_until_odom_position
    self.wait_for_message_result(
        predicate  = <function LcmSpy.wait_until_odom_position.<locals>.predicate at 0x7a7297debd80>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7a72994111c0>
        threshold  = 2
        timeout    = 180
        x          = -3.567
        y          = -1.332
dimos/e2e_tests/lcm_spy.py:153: in wait_for_message_result
    wait_until(
        event      = <threading.Event at 0x7a72989b0fe0: unset>
        fail_message = 'Failed to get to position x=-3.567, y=-1.332'
        listener   = <function LcmSpy.wait_for_message_result.<locals>.listener at 0x7a7297debc40>
        predicate  = <function LcmSpy.wait_until_odom_position.<locals>.predicate at 0x7a7297debd80>
        self       = <dimos.e2e_tests.lcm_spy.LcmSpy object at 0x7a72994111c0>
        timeout    = 180
        topic      = '/odom#geometry_msgs.PoseStamped'
        type       = <class 'dimos.msgs.geometry_msgs.PoseStamped.PoseStamped'>
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

predicate = <bound method Event.is_set of <threading.Event at 0x7a72989b0fe0: unset>>

    def wait_until(
        predicate: Callable[[], bool],
        *,
        timeout: float,
        interval: float = 0.1,
        message: str | None = None,
    ) -> None:
        """Poll ``predicate`` until it returns truthy or ``timeout`` elapses."""
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            if predicate():
                return
            time.sleep(interval)
>       raise TimeoutError(message or f"Timed out after {timeout}s waiting for condition")
E       TimeoutError: Failed to get to position x=-3.567, y=-1.332

deadline   = 290068.968953767
interval   = 0.1
message    = 'Failed to get to position x=-3.567, y=-1.332'
predicate  = <bound method Event.is_set of <threading.Event at 0x7a72989b0fe0: unset>>
timeout    = 180

.../utils/testing/waiting.py:35: TimeoutError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@spomichter spomichter changed the title feat(go2): native CompressedImage across all go2 blueprints (#2831 option 5) feat(go2): native CompressedImage across all go2 blueprints Jul 12, 2026
@spomichter spomichter changed the title feat(go2): native CompressedImage across all go2 blueprints native CompressedImage across all go2 blueprints Jul 12, 2026
Base automatically changed from feat/compressed-image-transport to main August 3, 2026 09:20
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