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
228 lines
7.8 KiB
Python
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() == []
|