Skip to content

perf(cv): cache last-encoded JPEG per frame in healthd's camera snapshot#61

Merged
rwlove merged 1 commit into
mainfrom
perf/healthd-jpeg-cache
Jul 2, 2026
Merged

perf(cv): cache last-encoded JPEG per frame in healthd's camera snapshot#61
rwlove merged 1 commit into
mainfrom
perf/healthd-jpeg-cache

Conversation

@rwlove

@rwlove rwlove commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Summary

Wall preview tiles poll /api/v1/cameras/{name}/snapshot every ~1s per camera; healthd was running cv2.imencode on every request against the same _latest_frame. Now YOLOPoseSource tracks a monotonic _frame_id and latest_jpeg(q) caches encoded bytes keyed on (frame_id, quality). Capture-loop disconnect drops the cache so a dead stream 404s instead of serving stale JPEG.

Numbers

On a 720p random frame:

  • Cold encode: 6.1 ms
  • Cache hit: 0.6 µs (~10,000× speedup)
  • New frame: 4.7 ms re-encode (expected)

Test plan

  • 7 new unit tests: cache hit, new-frame invalidation, per-quality entries, disconnect clears cache, JPEG round-trip. Full suite 37 pass / 1 skip (was 30 / 1). Ruff clean.

Tag when ready: pump-cv-v0.5.2.

🤖 Generated with Claude Code

The wall page's live-camera tiles poll /api/v1/cameras/{name}/snapshot
every ~1s per camera (plus admin panel + calibration wizard when open),
and healthd re-ran cv2.imencode on every request against the same
_latest_frame — a 6ms JPEG encode of a 720p BGR frame per hit, per
viewer, per poll.

YOLOPoseSource now increments a monotonic _frame_id whenever a frame
lands and exposes a latest_jpeg(quality) that caches the encoded bytes
keyed on (frame_id, quality). The capture loop's finally-block drops
the cache when the camera disconnects so /snapshot 404s instead of
serving stale JPEG from a dead stream.

Benchmark on a 720p random frame: 6.1 ms cold encode → 0.6 µs cache
hit (~10 000× speedup). New unit tests cover cache hit, invalidation
on new frame, quality-parameter differentiation, disconnect
invalidation, and JPEG round-trip. 37 pass / 1 skip (was 30).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@rwlove rwlove merged commit 5a4e932 into main Jul 2, 2026
rwlove added a commit that referenced this pull request Jul 2, 2026
…ve sources) (#64)

For live sources (RTSP, HTTP, anything not is_file), _stream_one_capture
now runs cap.read() and model.predict() as independent asyncio tasks
communicating through a size-1 slot. Previously they were serialised:
capture waited for predict, predict waited for capture, so a 15 fps
camera + 100 ms/frame model ran end-to-end at ~6 fps.

Drop-newest slot semantics: if the reader outpaces the predictor, the
older queued frame is discarded and replaced with the newest one. The
rep detector only ever needs the most recent frame, so backing up a
FIFO would just add latency between what's happening in the gym and
what the pipeline sees. Reader keeps publishing _latest_frame and
_frame_id (and by extension the JPEG cache from PR #61) on every
decode so the wall preview stays fresh even between predictions.

File sources still run the original sequential path (renamed to
_stream_sequential) — file replay is bounded by the predictor rather
than a real-time camera, so double-buffering would race through the
file and drop most frames.

Shutdown carefully: the outer finally-block signals the reader to stop,
drains the slot to unblock any in-flight put(), cancels the reader
task, awaits it, and only THEN releases the VideoCapture — so we never
release cap while a cap.read() is still on the thread pool.

Benchmark (mocked cap + model, wall-clock accurate):
- 15 fps camera + 100 ms predict:  5.98 fps → 12.80 fps (2.14x)
- Fast reader + 200 ms predict:    4.60 fps → 23.93 fps (5.20x)

Also reverts a speculative vectorisation of pose_sequence_to_features
that was in my working tree: benchmarking showed the NumPy version was
0.6-0.7x the loop version at realistic T=150-500 clip lengths (per-call
overhead dominates), and that function is called once per completed set
anyway, not per frame.

5 new async tests cover happy-path yield, drop-newest under a slow
predictor, clean shutdown on aiter close (VideoCapture released, no
zombie reader), EOF sentinel handling, and fps counting still working.
42 pass / 1 skip (was 37/1). Ruff clean.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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