The live picture was chained to the recognition pipeline

Reported from the first Windows install: the camera feed lags. It did,
and not because of the network, the proxy or the webview.

The MJPEG stream served _annotated_jpeg - the frame the pipeline had
most recently FINISHED with, encoded after detection, quality scoring,
tracking and identification had all run on it. On a modest shop PC that
is a few frames a second, and every picture was already as old as that
processing. It looked like lag because it was lag. On the fast machine
it was developed on the pipeline kept up with the stream's own 10 fps
cap, which is why nobody here ever saw it.

Two more things compounded it. Every processed frame was JPEG-encoded
whether or not a viewer existed - CPU spent on precisely the machine
short of it. And ffmpeg ran its RTSP demuxer with default buffering,
which holds a comfortable queue of frames before handing over the first:
half a second to two seconds a live view can never recover.

Now the picture and the boxes are decoupled. latest_jpeg_since takes the
capture thread's freshest frame at the camera's own rate and draws the
boxes from the last processed frame over it - encoded on demand, per
request, so a camera nobody watches costs no encode at all. The stream
sends a frame only when the camera has a newer one, capped at 15 fps;
nothing is sent twice. Boxes older than a second are not drawn, so a
stalled pipeline cannot leave one floating over an empty spot.
_publish_annotated becomes _remember_tracks: a handful of tuples under
the lock, no copy, no encode. ffmpeg gets nobuffer / low_delay /
max_delay.

Measured on cam2's sub-stream, same machine, ten seconds each:

  before   99 frames sent,  98 distinct    9.8 new pictures/s
  after   141 frames sent, 141 distinct   14.0 new pictures/s

against a 15 fps camera, with the pipeline still processing 166 of 181
captured frames alongside - and engine CPU DOWN from 90% with no viewer
to 62% with one attached.

Engine version 1.0.0 -> 1.1.0 so a re-run of setup reinstalls it rather
than pip deciding the requirement is already satisfied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-11 16:28:05 +05:30
parent b59e667a68
commit e262fc8482
6 changed files with 230 additions and 23 deletions

View File

@@ -31,6 +31,7 @@ class FakeWorker:
def stats(self): return {"camera_id": self.cam_cfg.id, "connected": True,
"url": self.cam_cfg.safe_url()}
def latest_jpeg(self): return None
def latest_jpeg_since(self, known_ts): return None, known_ts
@pytest.fixture

144
tests/test_live_picture.py Normal file
View File

@@ -0,0 +1,144 @@
"""The live picture is the camera's latest frame, not the pipeline's.
Until this, the MJPEG stream served the frame the recognition pipeline had
most recently *finished* - so on a shop PC where detection plus identification
ran a few times a second, the live view ran a few times a second too, and every
picture it showed was already as old as that processing. It read as lag
because it was. These tests pin the decoupling: the picture comes from the
capture thread at its own rate, the boxes come from the pipeline at theirs,
and nothing is encoded for a camera nobody is watching.
"""
import time
import numpy as np
from behavision.config import CameraConfig, Config
from behavision.engine import CameraWorker
from behavision.tracking import Track
class StillSource:
"""A capture thread stand-in that hands out whatever frame it is given."""
def __init__(self):
self.frame = None
self.ts = 0.0
def set(self, frame):
self.frame, self.ts = frame, time.time()
def latest(self):
return self.frame, self.ts
def latest_since(self, known_ts):
if self.frame is None or self.ts <= known_ts:
return None, known_ts
return self.frame, self.ts
def stop(self):
pass
def make_worker(tmp_path):
cfg = Config()
cfg.app.data_dir = tmp_path
cam = CameraConfig(id="cam1", host="127.0.0.1", port=1, path="/none")
w = CameraWorker(cam, cfg, detector=None, encoder=None, gallery=None,
bus=None, attrs=None)
w.source = StillSource()
return w
def grey(v=90):
return np.full((120, 160, 3), v, dtype=np.uint8)
def resolved_track(box=(30, 30, 90, 100), label="Priya"):
t = Track(id=1, box=box, kps=np.zeros((5, 2), dtype=np.float32), score=0.9)
t.state, t.label, t.similarity = "resolved", label, 0.71
return t
def test_the_picture_advances_with_the_camera_not_the_pipeline(tmp_path):
w = make_worker(tmp_path)
# Nothing captured yet: nothing to show, and no encode happened.
assert w.latest_jpeg_since(0.0) == (None, 0.0)
# A frame arrives from the camera. The pipeline has not touched it - and
# the live view must not wait for it to.
w.source.set(grey(80))
jpeg1, ts1 = w.latest_jpeg_since(0.0)
assert jpeg1 is not None and ts1 > 0
# Same frame again: the stream asks "anything newer than ts1?" and the
# answer is no. This is what stops duplicates going down the wire.
assert w.latest_jpeg_since(ts1) == (None, ts1)
# The camera produces a new frame; the pipeline still has not run.
time.sleep(0.002)
w.source.set(grey(160))
jpeg2, ts2 = w.latest_jpeg_since(ts1)
assert jpeg2 is not None and ts2 > ts1 and jpeg2 != jpeg1
def test_boxes_from_the_last_processed_frame_are_drawn_on_the_fresh_one(tmp_path):
w = make_worker(tmp_path)
w.source.set(grey())
plain, _ = w.latest_jpeg_since(0.0)
# The pipeline finishes a frame with one recognised person in it.
w._remember_tracks([resolved_track()])
# The NEXT camera frame - which the pipeline has not seen - still carries
# the box, because a person does not vanish between two frames.
time.sleep(0.002)
w.source.set(grey())
boxed, _ = w.latest_jpeg_since(0.0)
assert boxed != plain, "a resolved track should be drawn on the live picture"
def test_a_stale_overlay_is_not_drawn(tmp_path):
"""A stalled pipeline must not leave a box floating over an empty spot."""
w = make_worker(tmp_path)
w.source.set(grey())
plain, _ = w.latest_jpeg_since(0.0)
w._remember_tracks([resolved_track()])
# Pretend the pipeline last ran a while ago.
with w._lock:
w._overlay_ts = time.time() - 2.0
time.sleep(0.002)
w.source.set(grey())
fresh, _ = w.latest_jpeg_since(0.0)
assert fresh == plain, "boxes older than a second should not be drawn"
def test_only_tracks_matched_in_the_frame_are_drawn(tmp_path):
"""A track being coasted on misses has no face under it right now."""
w = make_worker(tmp_path)
missed = resolved_track()
missed.misses = 3
w._remember_tracks([missed])
assert w._overlay == []
seen = resolved_track()
w._remember_tracks([seen])
assert len(w._overlay) == 1
(box, _color, text) = w._overlay[0]
assert box == (30, 30, 90, 100) and text.startswith("Priya (")
def test_remembering_tracks_does_not_encode_or_copy(tmp_path):
"""The whole CPU argument: recording what to draw is a few tuples, and the
frame is never touched. A shop PC with no viewer pays nothing."""
w = make_worker(tmp_path)
tracks = [resolved_track() for _ in range(5)]
t0 = time.perf_counter()
for _ in range(1000):
w._remember_tracks(tracks)
per_call_us = (time.perf_counter() - t0) / 1000 * 1e6
# A JPEG encode of even a small frame is hundreds of microseconds; this
# should be an order of magnitude under that.
assert per_call_us < 100, f"remembering tracks took {per_call_us:.0f}us"