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:
146
behavision/paths.py
Normal file
146
behavision/paths.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Where the code lives versus where the code may write.
|
||||
|
||||
In a checkout these are the same directory, which is why everything resolved
|
||||
against the repo root until now. Installed, they are not: the code sits under
|
||||
`Program Files`, which is read-only for a normal user and for a service running
|
||||
as LocalSystem, while the database, logs, camera list and downloaded models all
|
||||
have to be written somewhere that survives an upgrade.
|
||||
|
||||
Three roots, resolved in one place so nothing else has to know it is frozen:
|
||||
|
||||
- `install_root()` — the code and the bundled default config. Read-only.
|
||||
- `state_root()` — everything we write. `%PROGRAMDATA%\\Behavision` when
|
||||
frozen on Windows.
|
||||
- `config_path()` — the YAML actually loaded.
|
||||
|
||||
Models live under `state_root()`, not next to the code: they are ~200 MB and
|
||||
are downloaded on first run rather than bundled (a 300 MB installer that has to
|
||||
be re-signed for a model change is a bad trade), so they must land somewhere
|
||||
writable.
|
||||
|
||||
`BEHAVISION_DATA_DIR` and `BEHAVISION_CONFIG` override everything, which is
|
||||
what makes the installed layout testable from a checkout and lets one machine
|
||||
run two instances.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
APP_NAME = "Behavision"
|
||||
|
||||
|
||||
def is_frozen() -> bool:
|
||||
"""True inside a PyInstaller bundle."""
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def install_root() -> Path:
|
||||
"""Directory holding the code and bundled data files.
|
||||
|
||||
Frozen, that is the folder containing the .exe — PyInstaller's one-folder
|
||||
layout — not `_MEIPASS`, which for onefile is a temp dir that vanishes.
|
||||
"""
|
||||
if is_frozen():
|
||||
return Path(sys.executable).resolve().parent
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _os_family() -> str:
|
||||
"""Which install layout applies.
|
||||
|
||||
A seam, not decoration: a test cannot monkeypatch `os.name` to exercise the
|
||||
Windows layout on another host, because `pathlib` dispatches on it and
|
||||
every `Path()` in the process starts raising.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
return "windows"
|
||||
if sys.platform == "darwin":
|
||||
return "macos"
|
||||
return "linux"
|
||||
|
||||
|
||||
def state_root() -> Path:
|
||||
"""Directory we may write to. Created by the caller, not here."""
|
||||
override = os.environ.get("BEHAVISION_DATA_DIR", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
if not is_frozen():
|
||||
# A checkout keeps everything together; that is the whole convenience
|
||||
# of developing from one.
|
||||
return install_root()
|
||||
family = _os_family()
|
||||
if family == "windows":
|
||||
base = os.environ.get("PROGRAMDATA") or r"C:\ProgramData"
|
||||
return Path(base) / APP_NAME
|
||||
if family == "macos":
|
||||
return Path.home() / "Library" / "Application Support" / APP_NAME
|
||||
return Path(
|
||||
os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
|
||||
) / APP_NAME.lower()
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
"""The YAML to load.
|
||||
|
||||
An installed system must be configurable without editing anything under
|
||||
`Program Files`, so a copy in the state root wins over the bundled one.
|
||||
`ensure_config()` puts it there on first run.
|
||||
"""
|
||||
override = os.environ.get("BEHAVISION_CONFIG", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
local = state_root() / "config" / "default.yaml"
|
||||
if local.is_file():
|
||||
return local
|
||||
return install_root() / "config" / "default.yaml"
|
||||
|
||||
|
||||
def env_file() -> "Path | None":
|
||||
"""`.env`, preferring the writable copy. Returns None when there is none —
|
||||
it is optional, and an installed system keeps its secrets in the config
|
||||
and the camera store instead."""
|
||||
for candidate in (state_root() / ".env", install_root() / ".env"):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def ensure_config() -> Path:
|
||||
"""Seed an editable config in the state root on first run, and return the
|
||||
path that will be loaded.
|
||||
|
||||
Copied, never symlinked, and never overwritten: an upgrade must not
|
||||
silently revert an operator's thresholds.
|
||||
"""
|
||||
override = os.environ.get("BEHAVISION_CONFIG", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
|
||||
local = state_root() / "config" / "default.yaml"
|
||||
if local.is_file():
|
||||
return local
|
||||
bundled = install_root() / "config" / "default.yaml"
|
||||
if not bundled.is_file():
|
||||
# Nothing to seed. Return the bundled path so the caller's "no such
|
||||
# file" names the place the file was supposed to be.
|
||||
return bundled
|
||||
if local.parent.exists() and local.resolve() == bundled.resolve():
|
||||
# A checkout: install root and state root are the same directory, so
|
||||
# the "copy" would be a file onto itself.
|
||||
return bundled
|
||||
local.parent.mkdir(parents=True, exist_ok=True)
|
||||
local.write_text(bundled.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
return local
|
||||
|
||||
|
||||
def describe() -> dict:
|
||||
"""For /api/health and the tray - "where is my database" must be
|
||||
answerable without reading the source."""
|
||||
return {
|
||||
"frozen": is_frozen(),
|
||||
"install_root": str(install_root()),
|
||||
"state_root": str(state_root()),
|
||||
"config": str(config_path()),
|
||||
}
|
||||
Reference in New Issue
Block a user