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
145 lines
4.9 KiB
Python
145 lines
4.9 KiB
Python
"""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"
|