core,cli: Add IR support via modality-aware matching + dual-camera enroll
ArcFace is RGB-trained, so an IR-captured face embedding lives in a
different region of the 512-d space than the RGB embedding of the
same face. Comparing across modalities produces near-zero similarities
even for the same person — fail-safe but unusable. Making IR auth
actually work meant moving five pieces together: the matching layer
had to filter by modality, enroll had to capture both sensors,
`open_camera` had to choose the right pixel format for each kind,
the V4L2 stream had to be primed before the first capture would
return real data, and the dual-camera capture loop had to open each
sensor on demand rather than holding both simultaneously.
# Matching (core)
`Enrollment::best_match` and `Enrollment::authenticate` now take a
`CameraKind` parameter representing the live query's modality. Only
same-kind samples are considered for the best-of-N selection. The
property tests already establish that best-of-N is monotonically
improving in N; adding the modality filter doesn't change that
property within either pool.
A new `MatchOutcome::NoSamplesForModality { kind }` variant
distinguishes 'user has samples but not of this kind' from
`NoEnrollment` ('user has no samples at all'). They sort to the
same auth decision (no match possible) but have different
remediations: enroll vs enroll-for-this-modality. The PAM glue in M5
will route them differently in logs.
# Dual-camera enroll (cli)
`pareidolia enroll` gains `--rgb-camera` (default /dev/video0,
renamed from `--camera`) and `--ir-camera` (optional). When both
are set, each capture iteration pulls one frame from each sensor:
the RGB frame goes into the enrollment tagged Rgb, the IR frame
tagged Ir. Same user pose roughly across both, but slight motion
between them is fine — each modality's samples live in their own
pool and benefit from minor pose variation within that pool.
# Camera format selection by kind
`open_camera` picks the V4L2 FourCC by camera kind: YUYV for RGB
cameras, GREY for IR. Hardcoding YUYV across the board caused IR
captures to either negotiate to garbage or silently mislabel a GREY
stream as YUYV, which then propagated through the pipeline as
gibberish data and produced 'no face detected' for every IR sample.
Most laptop and USB IR sensors advertise GREY (8-bit grayscale) and
nothing else.
# Stream priming
`open_camera` captures and discards one frame immediately after
opening. Two unrelated problems both want the first frame thrown
away:
1. The `v4l` crate's `Stream::next()` runs `VIDIOC_STREAMON` on
the first call and returns a buffer slot without the QBUF/DQBUF
cycle that would fill it with real captured data. The first
frame from a fresh source is therefore whatever was in the mmap
buffer at REQBUFS time — typically zeros, sometimes garbage.
2. IR illuminators on UVC cameras are commonly gated on the device
entering streaming state and take a frame or two after STREAMON
to reach full brightness. The first captured IR frame can be
too dark for the detector even when the crate's behaviour is
perfect.
Priming once after open sidesteps both. The ~33 ms cost is paid
once per source, not per capture.
# Open-on-demand for dual-modality enroll
The original dual-camera enroll loop held both V4L2 sources open
for the whole session to amortise the per-open cost. This wedges
some UVC dual-sensor devices: sample 1 succeeds, then the next
DQBUF on either sibling video node blocks indefinitely. UVC
bandwidth is allocated statically at STREAMON and dual-sensor
devices vary in how gracefully they cope with two concurrent
sustained streams.
Cameras are now opened **per modality, per sample**: open RGB,
prime, capture, drop; sleep a sibling-open cooldown; open IR,
prime, capture, drop. The two streams are never alive at the same
time. Cost is ~1s per sample of open-overhead, paid only during
enrollment (a once-per-user operation), and the pattern matches
exactly what the daemon will do for PAM auth requests in M5.
# Test subcommand
`pareidolia test` gains the same flag pair, but mutually exclusive:
either you test against RGB (`--rgb-camera`, default) or IR
(`--ir-camera`), since a single test is a single match attempt.
The query's modality is now passed through to
`Enrollment::authenticate` and the matcher filters appropriately.
A new printable path handles `NoSamplesForModality` cleanly with
an actionable suggestion ('re-run `pareidolia enroll` with
--ir-camera to add them').
# Tests
Two new unit tests pin the modality filter:
- `best_match_filters_to_query_kind_ignoring_cross_modality_samples`
- `authenticate_with_only_other_modality_samples_yields_no_samples_for_modality`
One new utility (`Enrollment::count_by_kind`) with its own unit
test; used by the enroll CLI for the user-facing 'saved X RGB and Y
IR samples' summary.
One new integration smoke test:
- `public_cross_modality_query_surfaces_no_samples_for_modality`
All existing tests updated to pass `CameraKind::Rgb` to
`best_match`/`authenticate` (existing samples are tagged Rgb by
default in the test fixture, so behaviour is unchanged for them).