cli: Add guided `calibrate-liveness` command
The guided live-vs-spoof profiling workflow that turns the liveness
checks' guessed thresholds into data-derived ones. Incremental and
GUI-shaped — each invocation does one thing, accumulating into a
persisted profile:
- `capture <class> [--label .. --append --conditions ..]` — capture a
labeled batch (live / printed-photo / screen / other). Opens the IR
camera, runs the same detect → liveness-policy pipeline auth uses,
harvests each check's sub-score `detail` into a feature vector, and
folds the batch into the profile (replacing the class, or appending).
- `status` — per-class sample counts.
- `report` — per-feature live-vs-spoof separation (means ± std, d-prime).
- `suggest-threshold` — per-feature threshold separating live from each
spoof, with the false-reject / false-accept it would incur.
- `reset [<class>]` — clear all, or one class.
Each verb is one core operation, so a future GUI can drive the same
flow — and because core ingests caller-supplied frames/features, a GUI
that owns the camera for preview can feed its own frames rather than
contend for the device.
Features are namespaced by check (e.g. `nir_reflectance.brightness`) so
multiple checks' sub-scores don't collide. A no-face capture (e.g. a
screen that's dark in IR) is skipped and counted — and an all-skipped
batch errors with a note that no-face is itself the dual-modality
defense. Gated on v4l2+inference with an arg-compatible fallback;
verified the --no-default-features build still compiles.
core: Add liveness profile store (atomic CBOR)
`LivenessProfileStore`: a single-file CBOR store for the accumulating
guided-calibration profile. Like the camera calibration store and unlike
the enrollment store, no HMAC — it holds tuning data, not secrets, and
liveness degrades gracefully on an absent/unreadable profile. Atomic
writes (temp + fsync + rename) so a crash never leaves a torn file;
versioned (refuses unknown future versions). `load` returns None when
absent (uncalibrated, normal), `load_or_default` for read-modify-write
verbs, plus `save`/`remove`.
Tested: missing-is-None, save/load round-trip, idempotent remove, no
temp-file leakage, an arbitrary-profile round-trip proptest, and a
16-thread concurrent-writer stress test asserting the file is never torn
(every post-race load decodes; last-writer-wins on content).
core,nix: Add OnnxSession + pinned model weights behind inference feature
The real Detector and Embedder implementations need ONNX Runtime; this
commit wires up the dependency, the Nix devShell, the pinned model
weights, and a thin session wrapper they'll build on top. The actual
SCRFD detector and ArcFace embedder land in subsequent sub-milestones
(M3.3, M3.5).
Feature shape mirrors v4l2: `pareidolia-core` keeps `inference` opt-in
(default = []); the daemon enables it via its own default-on `inference`
feature that proxies to core. CI's --no-default-features keeps libv4l +
libonnxruntime out of the Nixery image while normal builds get the full
pipeline.
`ort`'s `load-dynamic` feature is the cleanest fit for Nix: the crate
dlopens libonnxruntime at runtime via ORT_DYLIB_PATH rather than linking
against it at build time. The devShell wires ORT_DYLIB_PATH to nixpkgs's
`.so` so both build and run work without bundling C++ libs.
`OnnxSession` exposes the underlying `ort::Session` directly rather
than wrapping every ort API behind a Pareidolia-flavoured alternative —
chasing ort's API as it evolves is more churn than the abstraction is
worth. The boundary is at error types: `ort::Error` is mapped to
`PipelineError::Backend` so the rest of the pipeline stays
backend-agnostic.
Model weights: the M6 plan had Nix-driven model fetching land alongside
the NixOS module, but the M3 sub-milestones need real ONNX files for
their hardware tests, so the fetching infrastructure moves forward.
`nix/models.nix` pins SCRFD-10G and ArcFace glintr100 from the
`immich-app/buffalo_l` HuggingFace mirror (a maintained mirror of the
InsightFace bundle, stable since 2023). The devShell exports
PAREIDOLIA_TEST_{MODEL,SCRFD,ARCFACE} pointing at the materialised store
paths so `just test-hardware` runs end-to-end with no manual setup.
The pinned weights are also exposed as flake packages
(`nix build .#scrfd-10g`) for ad-hoc use outside the devShell.
core: Add per-device IR calibration model and store
Calibration records how a given IR camera delivers illuminated frames so
capture can be optimized per device. It is purely an optimization and
capability layer: `capture_ir_face` already works without it (capture a
burst, keep the brightest), so a missing/unmatched/corrupt record just
falls back to the safe mode-agnostic burst — never an error. That's why
this store, unlike the enrollment store, needs no HMAC.
# Device identity (card + bus)
Records are keyed by `CameraIdentity { card, bus }`. Neither field alone
is a usable key: `/dev/videoN` renumbers across reboots, the card name
isn't unique across identical models, and the bus (port topology)
changes when a device is replugged elsewhere. `resolve_calibration`
matches leniently, returning a typed `CalibrationMatch`:
- `Exact` — card + bus both match.
- `MovedPort` — card matches, bus differs, and exactly one connected
camera has that card name, so the device just moved ports: safe to
adopt and refresh the stored bus.
- `Ambiguous` — card matches but several connected cameras share that
name, so we can't guess which: the caller should ask the user.
- `None` — no record; fall back to mode-agnostic capture.
This mirrors the decisions-in-core / rendering-in-CLI shape of
`camera::select::IrCameraResolution`.
# Store
`CalibrationStore` persists all records in one small CBOR file (the set
is tiny and the matching API wants it whole). Writes are atomic
(temp-file + fsync + rename), matching the enrollment store's crash
discipline. `upsert` replaces by exact identity; `replace_by_card` drops
a stale-bus record when a device moved ports.
# CameraInfo.bus
`CameraInfo` gains a `bus: Option<String>`, filled by `enumerate()` from
the V4L2 bus info and left `None` for mock/synthetic sources and the
by-path `open()` path. This is the input `CameraIdentity::from_info`
reads. Calibration shares the `v4l2` feature gate since it depends on the
backend's `IrCaptureMode`.