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
147 lines
5.2 KiB
Python
147 lines
5.2 KiB
Python
"""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()),
|
|
}
|