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.
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`.
cli: Retain the IR burst from capture for liveness
`capture_ir_face` previously discarded every burst frame except the
chosen lit one. It now returns an `IrCapture` that owns the whole burst
plus the lit-frame index and an optional dark-frame index, with a
`liveness_input()` accessor producing a `LivenessInput` for assessment.
The dark frame is identified *from the data*, not from calibration, by
`detect_dark_frame`: the dimmest burst frame, reported (`Some`) only
when it's at least `STROBE_DARK_CONTRAST` (25/255) darker than the lit
frame — i.e. the sensor is actually strobing. On a constant-brightness
sensor all frames are similarly bright, so `dark_index` stays `None` and
any strobed-only liveness check gates itself off. Strobed differencing
stays additive, never required.
`detect_dark_frame` is deliberately a small isolated function with a
prominent KNOWN-LIMITATION note: its absolute brightness gap is only
valid under the (indoor) ambient conditions it was tuned in. Outdoors,
broadband sunlight floods both lit and dark frames with near-IR, shrinks
the absolute gap, and could disable differencing on a genuinely strobed
sensor. The proper fix (scale-invariant detection, or deferring the
strobed-vs-constant verdict to calibration) is not attempted yet — no
daylight IR captures exist to tune against. The decision is quarantined
here so the future change is local; callers see only the `Option<usize>`,
and a wrong `None` degrades safely.
Pure plumbing otherwise: `enroll` and `test` take `.outcome` and behave
exactly as before; no liveness check runs yet.
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.
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.