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
This commit is contained in:
112
tests/test_faces.py
Normal file
112
tests/test_faces.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""The face outbox — the one place the engine writes a picture of a person.
|
||||
|
||||
The default-off behaviour is what most of these assert. With images off this
|
||||
machine holds templates and timestamps and nothing resembling a photograph, and
|
||||
that is a data-protection position rather than a missing feature, so a change
|
||||
that quietly starts writing JPEGs should fail a test.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from behavision.config import Config
|
||||
from behavision.faces import FaceOutbox, _loose_crop
|
||||
|
||||
|
||||
def frame(h=240, w=320):
|
||||
f = np.zeros((h, w, 3), np.uint8)
|
||||
f[:] = 40
|
||||
return f
|
||||
|
||||
|
||||
def test_disabled_by_default_and_writes_nothing(tmp_path):
|
||||
assert Config().app.store_faces is False, (
|
||||
"storing face images must stay off unless someone turns it on")
|
||||
|
||||
box = FaceOutbox(tmp_path, enabled=False)
|
||||
assert box.crop(frame(), (100, 60, 220, 200)) is None
|
||||
assert box.save(np.zeros((10, 10, 3), np.uint8)) is None
|
||||
# Not even the directory: an empty `outbox` in a backup invites the
|
||||
# question of what used to be in it.
|
||||
assert not (tmp_path / "outbox").exists()
|
||||
|
||||
|
||||
def test_enabled_writes_one_jpeg_per_save(tmp_path):
|
||||
box = FaceOutbox(tmp_path, enabled=True)
|
||||
crop = box.crop(frame(), (100, 60, 220, 200))
|
||||
assert crop is not None
|
||||
|
||||
path = box.save(crop)
|
||||
assert path is not None
|
||||
written = list((tmp_path / "outbox").glob("*.jpg"))
|
||||
assert len(written) == 1
|
||||
assert written[0].read_bytes()[:2] == b"\xff\xd8", "not a JPEG"
|
||||
# No half-written files left behind: the agent watches this directory and
|
||||
# would upload a truncated image that nothing ever corrects.
|
||||
assert not list((tmp_path / "outbox").glob("*.part"))
|
||||
|
||||
|
||||
def test_crop_is_looser_than_the_detection_box(tmp_path):
|
||||
box = FaceOutbox(tmp_path, enabled=True)
|
||||
face = (100, 60, 220, 200) # 120x140
|
||||
crop = box.crop(frame(480, 640), face)
|
||||
# The aligned 112x112 chip is built for ArcFace and is nearly useless to a
|
||||
# human trying to recognise a customer. This is the frame a person looks
|
||||
# at, so it gets the same 1.5x head crop the attribute models use.
|
||||
assert crop.shape[0] > 140 and crop.shape[1] > 140
|
||||
|
||||
|
||||
def test_crop_survives_a_face_at_the_edge_of_frame(tmp_path):
|
||||
box = FaceOutbox(tmp_path, enabled=True)
|
||||
# Runs off the top-left corner; replicate padding must keep it square
|
||||
# rather than throwing or returning an empty array.
|
||||
crop = box.crop(frame(), (-30, -20, 60, 80))
|
||||
assert crop is not None and crop.size > 0
|
||||
assert box.save(crop) is not None
|
||||
|
||||
|
||||
def test_crop_is_downscaled_for_a_shop_uplink(tmp_path):
|
||||
box = FaceOutbox(tmp_path, enabled=True)
|
||||
crop = box.crop(np.zeros((1296, 2304, 3), np.uint8), (100, 100, 1200, 1200))
|
||||
assert max(crop.shape[:2]) <= 320
|
||||
path = box.save(crop)
|
||||
# Small enough that a shop on a slow uplink can send one per visitor
|
||||
# without the queue backing up behind it.
|
||||
assert (tmp_path / "outbox" / path.rsplit("/", 1)[-1]).stat().st_size < 60_000
|
||||
|
||||
|
||||
def test_crop_does_not_alias_the_capture_buffer(tmp_path):
|
||||
box = FaceOutbox(tmp_path, enabled=True)
|
||||
f = frame()
|
||||
crop = box.crop(f, (100, 60, 220, 200))
|
||||
before = crop.copy()
|
||||
# The capture thread overwrites its buffer in place; a crop that is a view
|
||||
# onto it would change under us between here and the save.
|
||||
f[:] = 255
|
||||
assert np.array_equal(crop, before)
|
||||
|
||||
|
||||
def test_outbox_is_bounded_so_a_stalled_agent_cannot_fill_the_disk(tmp_path):
|
||||
box = FaceOutbox(tmp_path, enabled=True, max_files=5)
|
||||
crop = box.crop(frame(), (100, 60, 220, 200))
|
||||
for _ in range(12):
|
||||
box.save(crop)
|
||||
# If nothing is collecting - agent not running, no credentials, server
|
||||
# unreachable for a week - this must not fill a shop's disk with pictures
|
||||
# of its customers.
|
||||
assert len(list((tmp_path / "outbox").glob("*.jpg"))) <= 5
|
||||
|
||||
|
||||
def test_a_broken_frame_never_takes_the_worker_down(tmp_path):
|
||||
box = FaceOutbox(tmp_path, enabled=True)
|
||||
# A missing image is a cosmetic loss; a stalled recognition thread is the
|
||||
# product. Every one of these must return None rather than raise.
|
||||
assert box.crop(np.zeros((0, 0, 3), np.uint8), (0, 0, 10, 10)) is None
|
||||
assert box.save(None) is None
|
||||
assert box.crop(frame(), (10, 10, 10, 10)) is not None or True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("box_in", [(0, 0, 10, 10), (300, 220, 340, 260)])
|
||||
def test_loose_crop_always_returns_something_usable(box_in):
|
||||
out = _loose_crop(frame(), box_in)
|
||||
assert out.size > 0
|
||||
Reference in New Issue
Block a user