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.
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.
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.
cli: Add enroll/list/remove subcommands
Three new subcommands that drive the enrollment store from the user
side:
# enroll
Runs the full pipeline locally: V4L2 capture → format convert → SCRFD
detection → affine align → ArcFace embed → append to store. The
daemon will eventually drive this same flow via IPC for the PAM auth
path, but enrolment is infrequent and user-initiated, so a
self-contained CLI is the simplest design and the matching algorithm
already handles the append-not-replace case via best-of-N monotonicity
(see enrollment.rs).
Loads any existing enrolment first and appends; this is the default
flow precisely because the matching layer's best-of-N is monotonically
improving in N, so re-running enrol on a day you look different
(lighting, beard, baggy eyes, glasses) just widens the set of poses the
matcher will accept. A future --replace flag is a clean addition.
Feature-gated on both v4l2 and inference. The CLI's default-features
now includes both, mirroring the daemon's shape. CI's
--no-default-features build picks up a fallback that surfaces
CliError::FeatureDisabled with the missing feature name so the message
is actionable.
Each enrolled sample stores a SHA-256 fingerprint of the SCRFD model
file. Matching against samples from a different model than the one
currently loaded is meaningless and would silently degrade accuracy;
M5+ will use this fingerprint to refuse such matches.
# list / remove
Both are feature-flag-free (only need the store, not models or
camera). `list` defaults to one-name-per-line output for scripting;
`--verbose` loads each enrollment to include the sample count (which
HMAC-verifies every file, so it's not free at scale). `remove`
deletes the whole enrollment by default; `--sample-id N` removes
just that sample.
# Shared store config
`--store-root` and `--key-file` are global args. Both default
to XDG_STATE_HOME (or $HOME/.local/state fallback). The key file is
auto-generated from /dev/urandom on first use with mode 0o600, which
keeps the "just works in nix develop" UX without burying the
biometric-data PII concerns under a default that silently writes to
`./pareidolia` or similar surprise paths.
# clap env feature
Workspace clap dep now includes the `env` feature so subcommand args
can fall back to environment variables (heavily used here for model
paths and store config). Pure Rust, no system deps.
# Tests
Two integration smoke tests cover the dispatch + store wiring against
in-process `run()`:
- list against an empty store root → succeeds (catches subcommand
wiring regressions before any data exists).
- remove of a never-enrolled user → succeeds (idempotency holds at
the CLI layer too).
Plus four unit tests on the store_config helper module covering store
root creation, key file generation + permissions, key file idempotency,
and wrong-sized key file rejection.
Real enrolment requires the user's face and a working camera, so it's
covered by manual testing rather than automation — appropriate for a
user-initiated subcommand.