Skip to content

feat(msgs): typed CompressedImage sensor msg#2814

Open
spomichter wants to merge 14 commits into
mainfrom
feat/compressed-image-transport
Open

feat(msgs): typed CompressedImage sensor msg#2814
spomichter wants to merge 14 commits into
mainfrom
feat/compressed-image-transport

Conversation

@spomichter

@spomichter spomichter commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Problem

dimos has no first-class compressed image type. jpeg support is bolted onto individual transports (JpegLcmTransport, JpegShmTransport, nothing for zenoh/webrtc), JpegShmTransport loses timestamps on decode, and every new transport needs its own jpeg variant. meanwhile raw images are big — 720p rgb is 2.76MB per frame, ~40MB/s at camera rate. thats fine for shared memory on one host but it kills lcm the moment you leave it: one dropped udp fragment loses the whole frame.

CompressedImage is a first-class msg (jpeg/png bytes + timestamp + frame_id, wraps the dimos_lcm binding that already existed):

CompressedImage.from_image(img, format="jpeg", quality=75, max_width=None)  # encode
ci.decode()                     # -> Image, ts/frame_id survive the round trip
ci.lcm_encode() / lcm_decode()  # typed wire msg on any transport
ci.to_rerun()                   # rr.EncodedImage — viewer renders jpeg with NO pixel decode

depth/16-bit images can't go through jpeg — use format="png" (lossless, covers GRAY16). float DEPTH raises on both.

Benchmark report: raw Image vs CompressedImage

(numbers produced with the bench harness in this PR; the codec pin is the bench mechanism, the wins are the type's)

full grid run on this dev box (x86_64, 32 cpus), 6 runs x 10 frames per cell, sequential round-trips. what "latency" measures: one broadcast() call until the subscriber callback fires, on localhost — so it includes the jpeg encode+decode cpu for the codec path but near-zero wire cost. thats why codec shows higher latency here: raw is two memcpys, codec pays ~5ms encode + ~5ms decode of real cpu. localhost is raw's best case — on a real 1Gbps link the raw frame pays ~22ms of transmit time alone vs <1ms for the jpeg, and over webrtc/wan its not close. what you buy for those ms: 8-40x less wire and the difference between frames arriving or not (see drops).

jpeg codec cpu cost (median of 20, ms)

                    raw        jpeg       ratio      encode      decode
480p  q75        0.92MB        49KB         19x         3.0         3.1
720p  q75        2.76MB       142KB         19x         5.1         5.2
1080p q75        6.22MB       315KB         20x         7.6         6.6
720p  q50        2.76MB        69KB         40x         4.7         4.6
720p  q90        2.76MB       345KB          8x         5.0         5.5

round-trip latency, median ms (LOST = zero frames ever arrived)

                     480p        720p       1080p
lcm   raw            LOST        LOST         5.5
lcm   codec           6.4        10.3        17.9
zenoh raw             0.7         0.9         6.9
zenoh codec           6.4         9.6        15.2
pshm  raw             0.2         0.6         1.6
pshm  codec           6.2         9.4        15.3

round-trip latency, p95 ms

                     480p        720p       1080p
lcm   raw            LOST        LOST         6.5
lcm   codec           7.0        11.8        21.2
zenoh raw             0.7         1.0         8.6
zenoh codec           6.8        11.4        18.8
pshm  raw             0.4         1.4         3.3
pshm  codec           6.8        11.3        17.4

dropped frames (of 60)

                     480p        720p       1080p
lcm   raw           60/60       60/60       43/60
lcm   codec          0/60        0/60       37/60
zenoh raw            0/60        0/60        0/60
zenoh codec          0/60        0/60        0/60
pshm  raw            0/60        0/60        0/60
pshm  codec          0/60        0/60        0/60

lcm raw 480p/720p lost every single frame on this box (untuned kernel udp buffers — CI=1 skips the lcm autoconf sysctl tuning, so this is what a fresh install sees). note the 1080p codec drops too: 315KB @ q75 is already past what untuned lcm handles reliably — thats what the quality/max_width knobs are for (q50 = 69KB, zero drops).

jpeg quality sweep (lcm, 720p)

             wire      median         fps       drops
q50          69KB         9.2         109        0/60
q75         142KB        10.3          97        0/60
q90         345KB        13.7          73       40/60

sustained 14Hz 720p camera feed for 8s (the go2 workload)

              delivered hz (target 14)
lcm   raw     ▊ 0.8
lcm   codec   ██████████████ 14.0
zenoh raw     ██████████████ 14.0
zenoh codec   ██████████████ 14.0

this is the headline: publish a go2-style camera feed at 14Hz over lcm and the raw pipeline delivers 0.8 fps. the codec pipeline delivers all 112 frames.

throughput capacity, fps (1 / median latency)

                     480p        720p       1080p
lcm   raw            LOST        LOST         183
lcm   codec           156          97          56
zenoh raw            1484        1098         145
zenoh codec           157         105          66
pshm  raw            6239        1765         639
pshm  codec           162         107          65

codec capacity is cpu-bound (~1/encode+decode time) and the same on every transport — ~100fps at 720p, 7x a 14Hz camera. pshm raw shows why shm stays the on-host raw escape hatch: 1765fps at 720p, zero encode cost.

end-to-end replay benchmark (full nav stack, jetson-class 4-core profile: raw delivers 6.5 of 14 fps, compressed delivers all of it, with LESS total cpu) is in #2831 with charts.

notes: webrtc isnt in the grid because its loopback provider is an in-memory dict (stack overhead only, no real SCTP — thats P1 of the webrtc backend spec) and the cloudflare path is cred-gated. the vlm/agent blueprints (spatial, temporal-memory, agentic) are the biggest indirect winners — they re-encode jpeg per VLM call today, with option 5 they get wire bytes for free.

follow-ups (not this PR): option 5 migration per #2831 (GO2Connection -> Out[CompressedImage] + consumers, needs a replay-dataset compat answer since recorded datasets store raw Image), migrating JpegLcmTransport/JpegShmTransport onto CompressedImage, webrtc ImageDecimation emitting this type.

Breaking Changes

None

How to Test

CI=1 uv run pytest dimos/msgs/sensor_msgs/test_CompressedImage.py dimos/protocol/pubsub/benchmark/test_replay_bench.py -s

14 tests incl. real lcm+zenoh round-trips (skipped where native libturbojpeg is missing — and ci.yml now installs it so CI cant skip silently). benchmark alone, prints one grid at the end:

CI=1 uv run pytest dimos/protocol/pubsub/benchmark/test_replay_bench.py -k benchmark -s

e2e replay bench: python -m dimos.protocol.pubsub.benchmark.tool_replay_bench --blueprint unitree-go2-basic --mode codec --duration 30 --out /tmp/bench (re-ran post-trim: 473 frames / 33s = 14.3fps, full delivery). full suite + mypy run clean locally.

Contributor License Agreement

  • I have read and approved the CLA

…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
@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Patch % Lines
...mos/protocol/pubsub/benchmark/tool_replay_bench.py 32.14% 93 Missing and 2 partials ⚠️
dimos/msgs/sensor_msgs/CompressedImage.py 88.00% 5 Missing and 4 partials ⚠️
...mos/protocol/pubsub/benchmark/test_replay_bench.py 93.33% 4 Missing and 4 partials ⚠️
@@            Coverage Diff             @@
##             main    #2814      +/-   ##
==========================================
+ Coverage   72.40%   72.42%   +0.02%     
==========================================
  Files         998     1003       +5     
  Lines       89235    89665     +430     
  Branches     8122     8301     +179     
==========================================
+ Hits        64610    64941     +331     
- Misses      22427    22519      +92     
- Partials     2198     2205       +7     
Flag Coverage Δ
OS-ubuntu-24.04-arm 65.28% <71.77%> (+0.05%) ⬆️
OS-ubuntu-latest 67.69% <72.26%> (+0.05%) ⬆️
Py-3.10 67.67% <72.26%> (+0.04%) ⬆️
Py-3.11 67.68% <71.77%> (+0.05%) ⬆️
Py-3.12 67.67% <72.26%> (+0.03%) ⬆️
Py-3.13 67.67% <72.26%> (+0.05%) ⬆️
Py-3.14 67.69% <72.26%> (+0.06%) ⬆️
Py-3.14t 67.67% <72.26%> (+0.05%) ⬆️
SelfHosted-Large 30.12% <26.76%> (-0.03%) ⬇️
SelfHosted-Linux 37.51% <26.52%> (-0.06%) ⬇️
SelfHosted-macOS 36.36% <26.76%> (-0.05%) ⬇️

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

Files with missing lines Coverage Δ
dimos/conftest.py 82.35% <100.00%> (+1.10%) ⬆️
dimos/memory2/codecs/test_codecs.py 88.17% <100.00%> (+0.39%) ⬆️
dimos/msgs/sensor_msgs/test_CompressedImage.py 100.00% <100.00%> (ø)
...mos/protocol/pubsub/benchmark/test_replay_bench.py 93.33% <93.33%> (ø)
dimos/msgs/sensor_msgs/CompressedImage.py 88.00% <88.00%> (ø)
...mos/protocol/pubsub/benchmark/tool_replay_bench.py 32.14% <32.14%> (ø)

... and 7 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.

… 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.
@spomichter spomichter changed the title feat(msgs): typed CompressedImage + CodecTransport feat(msgs): typed CompressedImage sensor msg Jul 11, 2026
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).
@spomichter spomichter marked this pull request as ready for review July 11, 2026 20:00
@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a typed compressed-image message and benchmark support. The main changes are:

  • JPEG and PNG CompressedImage encode/decode support.
  • LCM serialization and Rerun encoded-image conversion.
  • Benchmark-only compressed transport wrapper and CI TurboJPEG setup.

Confidence Score: 4/5

ROS bridge conversion and alpha-bearing PNG decoding can return invalid message data.

  • ROS CompressedImage messages do not follow the bridge's flattened-header conversion contract.
  • Valid PNG images with alpha are returned with a mismatched format tag.
  • Benchmark shutdown can write incomplete or duplicated result records.

dimos/msgs/sensor_msgs/CompressedImage.py; dimos/protocol/pubsub/benchmark/tool_replay_bench.py

Important Files Changed

Filename Overview
dimos/msgs/sensor_msgs/CompressedImage.py Adds compressed image encoding, decoding, LCM serialization, and Rerun conversion; ROS conversion and alpha-PNG handling are incomplete.
dimos/protocol/pubsub/benchmark/tool_replay_bench.py Adds benchmark codec and replay instrumentation; shutdown can race callback-side buffer flushing.
dimos/msgs/sensor_msgs/test_CompressedImage.py Adds JPEG, PNG, LCM, metadata, resize, validation, and Rerun coverage.
.github/workflows/ci.yml Installs the TurboJPEG system library for Ubuntu test jobs.
dimos/conftest.py Adds TurboJPEG availability detection and test marker registration.

Reviews (1): Last reviewed commit: "Merge branch 'main' into feat/compressed..." | Re-trigger Greptile

@dataclass
class CompressedImage(Timestamped):
"""Compressed image bytes (JPEG/PNG) — ROS sensor_msgs/CompressedImage."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 ROS Header Conversion Is Missing

This type flattens the ROS header into frame_id and ts, but it is not handled by the ROS bridge's complex-message conversion path. When a sensor_msgs.CompressedImage crosses that bridge, generic field copying cannot rebuild the ROS header or decode it back into the flattened fields, so ROS-backed publishers or subscribers can receive malformed messages or fail conversion.

Comment on lines +102 to +105
if arr.ndim == 2:
fmt = ImageFormat.GRAY16 if arr.dtype == np.uint16 else ImageFormat.GRAY
else:
fmt = ImageFormat.BGR

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Alpha PNG Is Labeled BGR

cv2.IMREAD_UNCHANGED returns a four-channel array for a PNG with alpha, but this branch marks every color PNG as BGR. Downstream image operations then see a three-channel format with four-channel data and can skip a needed conversion or fail on the unexpected shape.

Suggested change
if arr.ndim == 2:
fmt = ImageFormat.GRAY16 if arr.dtype == np.uint16 else ImageFormat.GRAY
else:
fmt = ImageFormat.BGR
if arr.ndim == 2:
fmt = ImageFormat.GRAY16 if arr.dtype == np.uint16 else ImageFormat.GRAY
elif arr.shape[2] == 4:
fmt = ImageFormat.BGRA
else:
fmt = ImageFormat.BGR

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +109 to +112

@rpc
def stop(self) -> None:
self._flush()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Shutdown Flush Races Callbacks

stop() flushes _buf without _lock, while the LCM receiver thread can append and flush the same buffer under that lock. If a callback is in flight during coordinator shutdown, the two flushes can duplicate, lose, or interleave JSONL records, making the benchmark's delivered-frame output inaccurate.

@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jul 11, 2026
Comment on lines +137 to +140
try:
db_path = get_data("go2_short.db")
except RuntimeError:
# 84MB LFS pull — refused by the CI LFS guard on standard runners

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We either need this for the test, in which case it should be self_hosted, or we don't, in which case don't try and fetch it at all.

Comment thread dimos/conftest.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think me and Paul have differing opinions on this. But, I'd use an importorskip and rely on coverage to catch regressions in CI testing. It makes it easier for downstream packagers when testing.



@dataclass
class CompressedImage(Timestamped):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

you invented a message type? you cannot write custom msgs to sensor msgs like this. also likely no need to create a new msg type - see what ros ecosystem uses and respect their message format, I think it's some FoxgloveCompressedImage msg we already have in our types

@leshy leshy Jul 13, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would also recommend to integrate this end-to-end, so add a go2 build that doesn't decompress incoming stream, passes straight into rerun, with some module in the middle able to use image

@TomCC7 TomCC7 Jul 13, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ROS has official compressed image type: https://docs.ros2.org/foxy/api/sensor_msgs/msg/CompressedImage.html I believe this impl is mostly aligned, the header field misalignment is exposed by greptile comments below.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

#string format # Specifies the format of the data
# # Acceptable values:
# # jpeg, png

this seems to be only jpeg/png but aparently people abuse and put h264 there since it's string


from dimos.msgs.sensor_msgs.Image import Image

CompressionFormat = Literal["jpeg", "png"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

h264 here no?

@leshy leshy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This definitely needs h264 support. h265 encoders are almost always in hardware and much cheaper

@TomCC7

TomCC7 commented Jul 13, 2026

Copy link
Copy Markdown
Member

This definitely needs h264 support. h265 encoders are almost always in hardware and much cheaper

H264/265 are video codec. I don't think the abstraction fits here? Unless every frame is a key frame but it's very inefficient.

@leshy

leshy commented Jul 13, 2026

Copy link
Copy Markdown
Member

This definitely needs h264 support. h265 encoders are almost always in hardware and much cheaper

H264/265 are video codec. I don't think the abstraction fits here? Unless every frame is a key frame but it's very inefficient.

every frame is a compressed video frame with occasional keyframes (same as any other h264 transport) it's a responsibility of a recevier to reconstruct full images if needed with a small keyframe aware reconstructor

discussion on this here - #2831

would be something like self.compressed_input.pipe(frame_decoder).subscribe( ... you have image here

also btw if h264 doesn't fit our abstraction - I'd argue the abstraction is wrong since obviously h264 is the correct format to pass video streams in the system (but I think our abstraction is ok)

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

Labels

ready-to-merge Required CI checks have passed on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants