Files
Behavision/behavision/paths.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

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()),
}