Files
Behavision/tests/test_config.py
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 11:14:18 +05:30

126 lines
4.9 KiB
Python

"""The shipped YAML carries thresholds measured on the deployment site; the
pydantic defaults must not drift away from them (a trimmed config would then
silently re-admit the false positives those values were tuned to reject)."""
import pytest
from behavision.config import (ApiSection, Config, DetectionSection,
RecognitionSection, TrackingSection,
ensure_api_credentials, load_config)
def test_yaml_matches_code_defaults():
cfg = load_config()
for section, model in (("detection", DetectionSection),
("recognition", RecognitionSection),
("tracking", TrackingSection)):
loaded = getattr(cfg, section)
defaults = model()
for name in model.model_fields:
assert getattr(loaded, name) == getattr(defaults, name), (
f"{section}.{name} drifted between config/default.yaml "
f"and config.py")
def test_tuned_thresholds_are_the_defaults():
cfg = Config()
assert cfg.detection.score_threshold == 0.82
assert cfg.recognition.min_enroll_quality == 0.65
def test_loopback_stays_open(tmp_path):
cfg = Config()
cfg.app.data_dir = tmp_path
cfg.api.host = "127.0.0.1"
auth_on, generated = ensure_api_credentials(cfg)
assert (auth_on, generated) == (False, False)
assert not (tmp_path / "api_credentials.txt").exists()
def test_routable_host_never_served_unauthenticated(tmp_path):
cfg = Config()
cfg.app.data_dir = tmp_path
cfg.api.host = "0.0.0.0"
auth_on, generated = ensure_api_credentials(cfg)
assert auth_on and generated
assert cfg.api.username and len(cfg.api.password) >= 16
assert (tmp_path / "api_credentials.txt").exists()
def test_generated_credentials_are_stable_across_restarts(tmp_path):
first = Config()
first.app.data_dir = tmp_path
first.api.host = "0.0.0.0"
ensure_api_credentials(first)
second = Config()
second.app.data_dir = tmp_path
second.api.host = "0.0.0.0"
auth_on, generated = ensure_api_credentials(second)
assert auth_on and not generated # reused, not re-minted
assert second.api.password == first.api.password
def test_configured_credentials_win(tmp_path):
cfg = Config()
cfg.app.data_dir = tmp_path
cfg.api.host = "0.0.0.0"
cfg.api.username, cfg.api.password = "alice", "s3cret"
auth_on, generated = ensure_api_credentials(cfg)
assert (auth_on, generated) == (True, False)
assert not (tmp_path / "api_credentials.txt").exists()
assert cfg.api.password == "s3cret"
def test_blank_env_placeholders_do_not_enable_auth():
"""Unset ${ENV} parses as YAML null; auth must not half-enable."""
cfg = ApiSection(host="0.0.0.0", port=None, username=None, password=None)
assert cfg.username == "" and cfg.password == ""
assert cfg.port == 8010
assert not cfg.auth_enabled
def test_color_order_is_declared_per_model_family():
"""ArcFace/InsightFace exports want RGB, AdaFace wants BGR. Same scaling,
opposite channel order, and feeding the wrong one raises nothing — it just
yields off-distribution embeddings. Pin the mapping down."""
from behavision.recognition import color_order_for
for name in ("w600k_mbf", "w600k_r50", "arcface", "arcface_int8"):
assert color_order_for(name) == "RGB", name
for name in ("adaface_ir18", "adaface_ir50", "adaface_ir101", "AdaFace_IR50"):
assert color_order_for(name) == "BGR", name
def test_better_models_are_preferred_over_the_fallback():
from behavision.recognition import MODEL_CANDIDATES
order = {n: i for i, n in enumerate(MODEL_CANDIDATES)}
# first that loads wins, so accuracy must come before the 13 MB fallback
assert order["w600k_r50.onnx"] < order["w600k_mbf.onnx"]
assert order["adaface_ir50.onnx"] < order["w600k_r50.onnx"]
def test_unloadable_model_falls_through_to_the_next_candidate(tmp_path):
"""The deploy box may not have RAM for the big model. A corrupt/unusable
file must be skipped, not fatal — otherwise a memory-starved machine can
no longer start at all."""
import shutil
from pathlib import Path
from behavision.recognition import ArcFaceEncoder
real = Path(__file__).resolve().parent.parent / "models" / "w600k_mbf.onnx"
if not real.exists():
import pytest
pytest.skip("no recognition model installed")
# a plausible-looking but unloadable "big model" ahead of a good one
(tmp_path / "w600k_r50.onnx").write_bytes(b"not an onnx file")
shutil.copy(real, tmp_path / "w600k_mbf.onnx")
enc = ArcFaceEncoder(tmp_path)
assert enc.model_name == "w600k_mbf" # fell through, did not crash
def test_no_usable_model_fails_loudly(tmp_path):
from behavision.recognition import ArcFaceEncoder
(tmp_path / "w600k_mbf.onnx").write_bytes(b"garbage")
with pytest.raises(FileNotFoundError, match="no usable recognition model"):
ArcFaceEncoder(tmp_path)