Files
Behavision/tests/test_api_cameras.py
Suriyakumarvijayanayagam e262fc8482 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
2026-09-11 16:28:05 +05:30

228 lines
7.8 KiB
Python

"""Camera CRUD over HTTP against a stub engine (no models, no camera).
Needs httpx for starlette's TestClient; skipped if absent so a bare checkout
still runs the suite.
"""
import threading
import pytest
pytest.importorskip("httpx")
from fastapi.testclient import TestClient # noqa: E402
from behavision.api import create_app # noqa: E402
from behavision.cameras import CameraStore # noqa: E402
from behavision.config import Config # noqa: E402
from behavision.engine import Engine # noqa: E402
SECRET = "sup3r-s3cret-rtsp-pw"
class FakeWorker:
def __init__(self, cam_cfg):
self.cam_cfg = cam_cfg
self.rcfg = Config().recognition.merged(cam_cfg.tuning)
self.commission = None
def start(self): pass
def stop(self): pass
def is_alive(self): return True
def join(self, timeout=None): pass
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
def client(tmp_path, monkeypatch):
monkeypatch.setattr("behavision.engine.CameraWorker",
lambda cam, *a, **kw: FakeWorker(cam))
monkeypatch.setattr("behavision.engine.FaceDetector", lambda *a, **kw: object())
eng = object.__new__(Engine)
eng.cfg = Config()
eng.cfg.app.data_dir = tmp_path
eng.cfg.api.username = eng.cfg.api.password = "" # loopback: no auth
eng.encoder = eng.gallery = eng.bus = eng.attributes = eng.store = None
eng._lock = threading.RLock()
eng.workers, eng.detectors = {}, {}
eng.started_at, eng._running = 1.0, True
eng.camera_store = CameraStore(tmp_path / "cameras.json")
return TestClient(create_app(eng)), eng
def _add(c, cid="cam1", **kw):
body = dict(id=cid, host="192.168.0.138", path="/ch0_0.264",
username="admin", password=SECRET)
body.update(kw)
return c.post("/api/cameras", json=body)
def test_add_camera_starts_it_without_a_restart(client):
c, eng = client
r = _add(c)
assert r.status_code == 201
assert cid_in(c, "cam1")
assert "cam1" in eng.workers # running, not just stored
def cid_in(c, cid):
return any(x["id"] == cid for x in c.get("/api/cameras").json())
def test_password_never_appears_in_any_response(client):
"""The assertion this whole phase exists to satisfy."""
c, _ = client
_add(c)
for resp in (c.get("/api/cameras"),
c.patch("/api/cameras/cam1", json={"port": 8554}),
c.post("/api/cameras", json={"id": "cam2", "host": "10.0.0.5",
"password": SECRET})):
assert SECRET not in resp.text, resp.url
def test_listing_reports_password_presence_not_the_value(client):
c, _ = client
_add(c)
entry = c.get("/api/cameras").json()[0]
assert entry["has_password"] is True
assert "password" not in entry
assert "*****" in entry["url"]
def test_duplicate_id_conflicts(client):
c, _ = client
_add(c)
assert _add(c).status_code == 409
def test_camera_with_no_source_is_rejected(client):
c, eng = client
assert c.post("/api/cameras", json={"id": "bad"}).status_code == 400
assert eng.camera_store.list() == [] # nothing persisted
def test_id_is_required(client):
c, _ = client
assert c.post("/api/cameras", json={"host": "10.0.0.1"}).status_code == 400
def test_edit_restarts_the_worker(client):
c, eng = client
_add(c)
first = eng.workers["cam1"]
r = c.patch("/api/cameras/cam1", json={"host": "10.0.0.77"})
assert r.status_code == 200
assert eng.workers["cam1"] is not first
assert eng.camera_store.get("cam1").host == "10.0.0.77"
def test_edit_keeps_the_password_when_not_resent(client):
"""The UI never receives the password, so it cannot echo it back on save."""
c, eng = client
_add(c)
c.patch("/api/cameras/cam1", json={"port": 8554})
assert eng.camera_store.get("cam1").password == SECRET
def test_delete_removes_from_store_and_engine(client):
c, eng = client
_add(c)
assert c.delete("/api/cameras/cam1").status_code == 200
assert eng.camera_store.get("cam1") is None
assert "cam1" not in eng.workers
assert c.delete("/api/cameras/cam1").status_code == 404
def test_edit_unknown_camera_404s(client):
c, _ = client
assert c.patch("/api/cameras/nope", json={"port": 1}).status_code == 404
def test_test_endpoint_does_not_save(client):
c, eng = client
r = c.post("/api/cameras/test", json={"id": "probe", "host": "127.0.0.1",
"port": 1, "path": "/none"})
assert r.status_code == 200
assert r.json()["ok"] is False # nothing listening on port 1
assert eng.camera_store.list() == [] # and nothing was persisted
def test_test_endpoint_reports_bad_input_instead_of_raising(client):
c, _ = client
body = c.post("/api/cameras/test", json={"id": "probe"}).json()
assert body["ok"] is False and "url, host or webcam" in body["error"]
# -- placement commissioning ------------------------------------------------
def test_commission_starts_and_reports_progress(client):
c, eng = client
_add(c)
r = c.post("/api/cameras/cam1/commission", json={"seconds": 30})
assert r.status_code == 200
assert r.json()["verdict"] == "running"
assert eng.workers["cam1"].commission is not None
again = c.get("/api/cameras/cam1/commission")
assert again.status_code == 200
assert again.json()["camera_id"] == "cam1"
def test_commission_judges_against_the_cameras_own_gate(client):
"""A per-camera loosened gate must drive the verdict, or the wizard would
grade an overhead camera against a threshold it never runs under."""
c, eng = client
_add(c, cid="overhead", tuning={"min_enroll_quality": 0.40})
r = c.post("/api/cameras/overhead/commission", json={"seconds": 30})
assert r.json()["gate"] == 0.40
def test_commission_on_unknown_camera_404s(client):
c, _ = client
assert c.post("/api/cameras/nope/commission",
json={"seconds": 5}).status_code == 404
assert c.get("/api/cameras/nope/commission").status_code == 404
def test_reading_a_check_that_never_ran_404s(client):
c, _ = client
_add(c)
assert c.get("/api/cameras/cam1/commission").status_code == 404
def test_commission_can_be_cancelled(client):
c, eng = client
_add(c)
c.post("/api/cameras/cam1/commission", json={"seconds": 600})
assert c.delete("/api/cameras/cam1/commission").status_code == 200
assert eng.workers["cam1"].commission.report()["cancelled"] is True
def test_tuning_round_trips_through_add_and_edit(client):
"""model_copy(update=...) does not coerce, so a `tuning` dict arriving as
JSON used to be stored raw and then fail the first time the camera asked
it for thresholds. It must come back as a real CameraTuning."""
from behavision.config import CameraTuning
c, eng = client
_add(c, cid="overhead", tuning={"min_enroll_quality": 0.40})
assert c.get("/api/cameras").json()[0]["tuning"]["min_enroll_quality"] == 0.40
r = c.patch("/api/cameras/overhead",
json={"tuning": {"min_enroll_quality": 0.35}})
assert r.status_code == 200
stored = eng.camera_store.get("overhead")
assert isinstance(stored.tuning, CameraTuning)
assert stored.tuning.min_enroll_quality == 0.35
def test_an_inverted_per_camera_pair_is_rejected_not_stored(client):
"""enroll >= match would make one camera contradict every other one, and
they all write into the same gallery."""
c, _ = client
r = _add(c, cid="bad",
tuning={"enroll_threshold": 0.8, "match_threshold": 0.5})
assert r.status_code == 400
assert c.get("/api/cameras").json() == []