Skip to content

H264InputMixin and H264DecoderModule - #3308

Open
leshy wants to merge 13 commits into
mainfrom
ivan/feat/h264-input-mixin
Open

H264InputMixin and H264DecoderModule#3308
leshy wants to merge 13 commits into
mainfrom
ivan/feat/h264-input-mixin

Conversation

@leshy

@leshy leshy commented Jul 31, 2026

Copy link
Copy Markdown
Member

Adds a mixin that gives any module with an In[Image] an H.264 video input, decoding internally and feeding the frames into that image port's own transport, downstream code cannot tell them from wire traffic.

Ports declared in a mixin are collected like any other, so adapting an existing consumer is one declaration:

class VideoMarkerDetectionModule(H264InputMixin, MarkerDetectionStreamModule):
    config: VideoMarkerDetectionModuleConfig

The image port ducks both ways - an In (retrofit an existing consumer) or an Out (a standalone decoder), which is all H264DecoderModule is.

Which one to use

The mixin is one module fewer in the graph and transport, but each video-capable module owns its own decoder. Decoding is extremely cheap though. Before running dedicated decoder module, ensure that transport costs due to higher traffic don't overwhelm per-module decoding costs 0.3-1ms 2-4% of one core

…d video

Ports declared in a mixin are collected like any other (annotations merge
across the MRO), and a port is fed through its transport — so a mixin can
add an H.264 video input and inject decoded frames into the host's own
image port, indistinguishable from wire traffic. The image port ducks
both ways: an In (retrofit an existing consumer) or an Out (a standalone
decoder). Both users are one declaration each:

    class VideoMarkerDetectionModule(H264InputMixin, MarkerDetectionStreamModule):
        config: VideoMarkerDetectionModuleConfig

    class H264DecoderModule(H264InputMixin, Module):
        config: H264InputConfig
        color_image: Out[Image]

Decoding never skips — H.264 reference frames don't survive that — but
only the newest picture reaches the consumer, at decode_hz. Take the
mixin for a single consumer; keep the standalone decoder when several
share one decode, since the mixin's decoder is per-module.

MarkerDetectionStreamModule's port-count guard loosens to what the code
actually requires (one Out; inputs are wired by name), so auxiliary mixin
inputs are legal.
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Patch % Lines
dimos/stream/video/h264.py 81.63% 9 Missing ⚠️
dimos/stream/video/test_h264.py 96.62% 2 Missing and 1 partial ⚠️
@@            Coverage Diff             @@
##             main    #3308      +/-   ##
==========================================
+ Coverage   75.34%   75.36%   +0.02%     
==========================================
  Files        1149     1151       +2     
  Lines      110476   110613     +137     
  Branches    10007    10041      +34     
==========================================
+ Hits        83234    83363     +129     
- Misses      24378    24384       +6     
- Partials     2864     2866       +2     
Flag Coverage Δ
OS-ubuntu-24.04-arm 69.33% <91.48%> (+0.02%) ⬆️
OS-ubuntu-latest 71.39% <91.48%> (+0.03%) ⬆️
Py-3.10 71.38% <91.48%> (+0.02%) ⬆️
Py-3.11 71.38% <91.48%> (+0.02%) ⬆️
Py-3.12 71.38% <91.48%> (+0.01%) ⬆️
Py-3.13 71.38% <91.48%> (+0.02%) ⬆️
Py-3.14 71.39% <91.48%> (+0.02%) ⬆️
Py-3.14t 71.38% <91.48%> (+0.02%) ⬆️
SelfHosted-Large 29.39% <35.46%> (+<0.01%) ⬆️
SelfHosted-Linux 35.81% <35.46%> (-0.02%) ⬇️

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

Files with missing lines Coverage Δ
...ception/fiducial/marker_detection_stream_module.py 81.48% <100.00%> (+3.43%) ⬆️
dimos/robot/all_blueprints.py 100.00% <ø> (ø)
dimos/robot/test_all_blueprints_generation.py 85.03% <100.00%> (ø)
dimos/stream/video/test_h264.py 96.62% <96.62%> (ø)
dimos/stream/video/h264.py 81.63% <81.63%> (ø)

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

@leshy leshy changed the title H264InputMixin — any Image consumer takes compressed video H264InputMixin - any Image consumer takes compressed video Jul 31, 2026
@leshy
leshy marked this pull request as draft July 31, 2026 11:09
leshy added 9 commits July 31, 2026 14:19
…rker

The mixin carried four `type: ignore`s because it declared no relationship
to the module it lives in. Type it as a Module under TYPE_CHECKING and the
host's `start`, `register_disposable` and `config` all resolve; at runtime
it stays a plain object, or it would be collected as a module in its own
right. The decoder is narrowed at its one use site, so the decode result
is checked as list[VideoFrame] rather than Any.

`_decoder` keeps a runtime-safe annotation deliberately: class annotations
are evaluated to collect ports, so naming an `av` type there would turn an
optional dependency into a hard import.

dedicated_worker moves onto the mixin. It was set on H264DecoderModule, so
VideoMarkerDetectionModule — the retrofit case the mixin exists for — was
silently sharing a worker while doing H.264 decode. The mixin leads the
MRO, so a host that wants otherwise still wins by setting it on itself.
ModuleBase already declares the type; every other module that opts in
writes the bare assignment.
Measured, not assumed: H.264 decode is ~0.34 ms/frame at 720p and ~2.5 ms
at 1080p, and the bgr24 conversion only runs at decode_hz. A 30 fps stream
therefore costs 1-8% of one core. PyAV drops the GIL for both the decode
and the reformat, so neither half of dedicated_worker's rationale — CPU
and GIL contention — applies.

This also restores H264DecoderModule to the shared pool; it claimed a
process on the same bad assumption.
h264_decode() is now the whole implementation — an operator from a
CompressedVideo observable to an Image one, with the decoder living per
subscription. Attaching it is all the module layer does, and both ways to
attach share it: the mixin subscribes into the host's own color_image In,
H264DecoderModule publishes on an Out.

Three things fall out:

  - The mixin declares color_image itself. Annotations merge across the
    MRO, so it is the consumer's own port, not a name looked up through
    getattr. The publish/transport.publish ducking goes with it.
  - throttle_first replaces the hand-rolled _last_fed bookkeeping.
  - Decode is testable without a Module, so the tests now run a real
    encoded stream through the operator instead of a stubbed decoder.

Fixes a latent corruption: the pipeline subscribed through observable(),
which is latest-wins backpressured and drops intermediate messages. A
dropped packet costs every frame until the next keyframe. It reads from
pure_observable() now and throttles after decode, never before.
The decoder was guessing at a rate on the consumer's behalf. Emit every
decoded frame instead and let consumers thin the stream: observable() is
latest-wins backpressured, and memory2 transforms cover the rest.

Cheap enough to be uncontroversial: an Image over a decoded frame is
0.6 us and shares the buffer, so frames a consumer ignores cost only the
bgr24 conversion the decode needed anyway (~1% of a core at 720p, ~3.4%
at 1080p for a 30 fps stream).

Takes the config with it. H264InputConfig existed only to carry the knob,
and VideoMarkerDetectionModuleConfig only to compose it with the host's —
so the mixin now needs no config at all, and its user is one line.
color_image was hardcoded once the mixin started declaring the port
itself — the earlier image_port string was configurable, this was not.
An overridable image_in property restores that without the string: a host
whose image In is named otherwise overrides it and gets a typed port back,
not a getattr.

The declaration moves under TYPE_CHECKING so the mixin expects the host's
port rather than contributing one. Class annotations inside that guard
never reach __annotations__, so a renamed host no longer inherits a stray
color_image it would have to leave unwired.
H264InputMixin typed itself as a Module under TYPE_CHECKING and stayed a
plain object at runtime, purely so the blueprint scanner wouldn't collect
it. That is a workaround in the wrong place: the mixin genuinely is a
Module, it just has no graph of its own to run in.

Subclass Module for real and give the scanner the rule instead — a *Mixin
is not deployable, same spirit as the leading-underscore skip already
there. Nothing else in the registry ends in Mixin, so the rule is inert
today and does the right thing for the next one.

Checked before trusting it: with the mixin registered, all 195 blueprint
and blueprint-kwargs checks still passed, so the entry was cosmetic rather
than breaking — the cost was a module in the registry that would fail on
deploy, having no image port of its own.
Not part of this branch — the blueprint scanner walks the filesystem, so
regenerating in a checkout with untracked modules picks them up. It points
at dimos/navigation/path_heading/module.py, which nothing on this branch
ships, so the entry would fail to import for anyone else.
@leshy
leshy marked this pull request as ready for review July 31, 2026 13:01
@leshy leshy changed the title H264InputMixin - any Image consumer takes compressed video H264InputMixin and H264 decoder module Jul 31, 2026
@leshy leshy changed the title H264InputMixin and H264 decoder module H264InputMixin and H264DecoderModule Jul 31, 2026
Decoding a codec produces the same pixels the encoder had — no meaning is
extracted, so perception was the wrong shelf. dimos/stream is the reactive
media package: video_provider already exposes video sources as observables
and audio/ is a reactive node graph, so a video/ sibling is where an rx
decode operator belongs.

It also gives the three copies of this decode somewhere neutral to
converge. The memory2 transform in go2/dds/video.py and the eventual
transport codec under protocol/pubsub can both import from stream/video;
neither could reasonably have imported from perception.
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jul 31, 2026
@github-actions github-actions Bot added ready-to-merge Required CI checks have passed on this PR and removed ready-to-merge Required CI checks have passed on this PR labels Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant