Files
DarkRoom/ui/dr-ui/src/faces.rs
T
dtourolle 5c00942b84 One completeness job over a registry of repairs, and a re-index button
A library's records are never all complete at once. A face found before
its quality was kept has no quality; one found before the eye models
existed has no reading; one adopted from a peer's shard has no crop; an
image the fast detector examined on a 1024 px proxy has boxes the current
detector would not have drawn; an image the scan stat'ed has no capture
date. On the reference library that is 17,762 faces under the bare
w600k_mbf id with no quality, no reading and no dense landmarks, 4,144 of
them without a crop, beside 12,217 images the fast detector examined and
found nothing in. Every one of those gaps was its own pass — V14's
measuring pass, §17.5's eye pass, the sweep's proxy repair, the sweep's
detector upgrade — with its own work list, its own count and its own idea
of done, and adding a per-face field meant adding a pass. There was no
pass at all for the case the library is actually in: boxes and landmarks
drawn by a weaker detector on a proxy, which every later per-face pass
would have read from.

dr_ui::repairs replaces them with one job over a registry. A Repair names
one thing a record can lack — the predicate that says which images still
owe it, the input its handler needs (a header, the original, or a native
render), the handler, and what to record for an image that can never be
done. The job unions the predicates into one work list, fetches each
image once at the most any claimant asks for, renders it at most once,
and runs every handler whose predicate that image still matches, checked
again before each because a detection writes every field a per-face
handler would fill. The registry today: face-proxy, face-quality,
face-eyes, face-crop, face-detection, face-upgrade, metadata — the last
there to say that this is not a face job. Adding a field is one entry.

A repair's predicate is the only definition of its work: the count the
settings page shows, the list the job fetches and the check before its
handler run are one predicate, so the job converges. That is why the
registry is cut to what the device can do rather than listing what it
skips — an entry is a count and a set of originals to fetch — and why an
eye reading that cannot be cut is not a criterion.

The catalog side is generic to match: record_updates writes whichever
fields a FaceUpdate carries and re-marks the image so the shards export
it; faces_needing and count_needing answer a predicate the caller
supplies, replacing the measuring pass's three special cases.

Two buttons on the settings page run the job and differ in one
predicate. "Index faces" converges on coverage: has anything examined
this image. "Re-index every face" converges on provenance: face-detection
claims every image with no marker under the chosen detector, in either
of its forms (FaceDetector::model_ids, so a desktop in f32 and a tablet
on the Hexagon do not re-index each other's work), and a marker saying a
weaker one looked is not that. An original over the fetch budget is left
exactly as it was under the re-index, where the sweep marks it examined:
a re-detection with nothing found would delete the faces, and "cannot
fetch" is not "no faces".
2026-09-19 18:52:13 +02:00

2215 lines
85 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | NFR-ARCH-2 | NFR-SEC-5
//! Face indexing and clustering, as background passes over the library.
//!
//! `dr-face` knows how to find a face in a buffer and `dr-catalog` knows how to
//! store one. This is the pass that connects them: read the proxy the grid
//! already built, detect, align, embed, write, and — once detection has gone
//! quiet — group what was found into people.
//!
//! # Detection runs on proxies, never on originals
//!
//! FR-CULL-8 pins this to the FR-CULL-2 ladder, and the consequence is the
//! thing that makes the feature affordable: a library that has been browsed has
//! already paid for its proxies, so face indexing adds **no RAW decodes that
//! were not already happening**. The tier is `ThumbSize::Large` — 1024 on the
//! long edge — and docs/faces.md §7 has the table of what the embedder actually
//! receives at that resolution.
//!
//! # Why clustering is a separate pass and not a job
//!
//! Detection is per image and parallel, so it is a job. Clustering is a
//! *whole-library* operation over the embeddings detection produced: it has no
//! natural `subject_id`, and running it per photograph would rebuild the world
//! on every one. It therefore runs debounced, when detection has been idle and
//! the face count has moved materially (catalog.md §10.2).
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender};
use dr_catalog::faces::{self, DetectedFace};
use dr_catalog::Catalog;
use dr_face::{
align, Calibration, DetectOptions, Detection, Detector, Embedder, EyeModels, EyeReading,
ModelId,
};
use dr_thumbs::{ThumbSize, ThumbStore};
use dr_types::settings::FaceSettings;
use dr_types::ImageId;
/// The tier a stored face crop is cut from. See the module note.
///
/// **No longer a tier faces are found or cropped at**, and the distinction is
/// the whole of `docs/faces.md` §7: cutting an already-located face out of a
/// stored 1024px proxy so the People screen can draw a thumbnail of it is
/// fine, because that crop is only ever looked at. Sampling the *embedder's*
/// 112×112 from a buffer this size is what left 47% of the reference library's
/// faces upsampled, and FR-CULL-8 now requires that crop to come from the
/// native render.
pub const FACE_TIER: ThumbSize = ThumbSize::Large;
/// Progress from an indexing sweep.
#[derive(Debug, Clone, PartialEq)]
pub enum FaceSweepMessage {
/// How many images will be visited. Sent once, before any work.
Total(usize),
/// One image finished, with the faces found in it.
Indexed { image: ImageId, faces: usize },
/// Images the pass gave up on, counted since the last message.
///
/// **Progress has to advance on these or it does not advance at all.**
/// The count moved only on `Indexed`, so a run where every image failed
/// sat at 0/169 from the first tick to the last and then reported
/// success: the receiver was told the total, told nothing, and told the
/// pass had ended. That is exactly what happened on the reference library
/// for a day, and it is why the sweep was thought to be hanging when it
/// was in fact finishing in fourteen seconds.
///
/// A batch rather than one message per image, because failures come back
/// lane-sized and the interesting number is how many. The reason for each
/// is logged where it happens.
Failed { images: usize },
/// The pass ended.
Finished {
images: usize,
faces: usize,
failed: usize,
},
}
/// An image waiting to be indexed.
#[derive(Debug, Clone, PartialEq)]
pub struct FaceRequest {
pub image_id: ImageId,
/// The thumbnail store's key — `oc:fileid`, stable across a server-side
/// move and the same id every other client sees (FR-NC-5).
pub file_id: u64,
}
/// Images that have a usable proxy and have not been through this model.
///
/// Asks `face_index` — the *run* marker — rather than asking whether the image
/// has any faces. Those are different questions, and confusing them is the
/// difference between a pass that converges and one that does not: a
/// photograph with no face in it would otherwise look identical to one never
/// examined, so every landscape in the library would be re-detected on every
/// run, for ever. See the V9 migration.
///
/// Keyed on the embedder, so an embedder upgrade re-indexes rather than
/// leaving the library half-described by weights that are no longer
/// comparable — and a detector change, which keeps the embedder, does not.
pub fn faces_outstanding(
catalog: &Catalog,
store: &ThumbStore,
model_id: &str,
) -> Result<Vec<FaceRequest>, dr_catalog::CatalogError> {
let mut stmt = catalog.connection().prepare(&format!(
"SELECT i.id, r.file_id
FROM images i
JOIN remote r ON r.image_id = i.id
WHERE r.file_id IS NOT NULL
AND i.trashed_at IS NULL
AND i.shadowed_by IS NULL
AND NOT EXISTS (
SELECT 1 FROM face_index fi
WHERE fi.image_id = i.id AND {} = ?1
)
ORDER BY i.id",
faces::embedder_sql("fi.model_id")
))?;
let rows = stmt
.query_map([faces::embedder_of(model_id)], |r| {
Ok(FaceRequest {
image_id: ImageId(r.get::<_, i64>(0)? as u64),
file_id: r.get::<_, i64>(1)? as u64,
})
})?
.filter_map(Result::ok)
// This pass reads the store and only the store, so an image without a
// proxy is not work it can do.
//
// **Not because FR-CULL-8 forbids it.** That requirement keeps indexing
// off the *full decode* and says the opposite about proxies — "where no
// proxy exists, the job requests one at background priority". An
// earlier comment here read it the other way round, and the result was
// a whole-library button that could only reach photographs the user had
// personally zoomed into. `repairs::spawn` is that
// requirement implemented; this one is the local-only variant.
.filter(|req| store.contains(req.file_id, FACE_TIER))
.collect();
Ok(rows)
}
/// What a coverage check found.
///
/// The catalog can say how many images have been through the model; only this
/// layer can say *why* the rest have not, because the reason usually lives in
/// the thumbnail store rather than the catalog.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct IndexAudit {
pub coverage: faces::Coverage,
/// Outstanding, with a proxy already on disk.
///
/// **Not "ready to index".** It was, when a pass existed that detected on
/// the stored proxy; that proxy is 1024 and detection now refuses it
/// (§7c), so these need a fetch exactly like the others do. Kept as a
/// separate number only because it says something true about the cache.
pub ready: u64,
/// Outstanding with no proxy on disk. Costs a range request, same as the
/// ones above now do.
pub awaiting_proxy: u64,
/// TRACES: FR-CULL-8a
/// What each repair in the registry still lists, by its label
/// (`crate::repairs::counts`) — the faces stored without their quality,
/// without an eye reading on a device with the eye models, without a
/// crop; the images a weaker detector indexed. Work that is not visible
/// in the coverage figure, since every one of these images carries its
/// run marker, and that has to be counted here or the screen calls the
/// library finished and takes the button away that would finish it.
///
/// Detection's own entry is left out: it is the outstanding figure
/// above, split by proxy.
pub owed: Vec<(&'static str, u64)>,
}
impl IndexAudit {
/// One line, for a log or a status strip.
pub fn summary(&self) -> String {
let c = &self.coverage;
if c.images == 0 {
return "no images in the library".into();
}
// Whole numbers read fine at 40% and lie at 0.47%, which rounds to
// "0%" beside a count of 110 — a figure that says the feature is
// broken when it is merely early. One decimal below ten percent, and
// a floor so real progress never displays as none.
let pct = c.fraction() * 100.0;
let shown = if c.indexed > 0 && pct < 0.1 {
"<0.1%".to_string()
} else if pct < 10.0 {
format!("{pct:.1}%")
} else {
format!("{pct:.0}%")
};
let mut s = format!(
"{}/{} images indexed ({shown}), {} face(s), {} image(s) with none",
c.indexed, c.images, c.faces, c.without_faces,
);
// One number, not two. The split used to matter because one of the
// two passes could only do the images that already had a proxy; now
// that detection refuses that proxy's size, both halves cost the same
// fetch and telling the user "169 ready, 0 to fetch" only implies a
// distinction that no longer decides anything.
let outstanding = self.ready + self.awaiting_proxy;
if outstanding > 0 {
s.push_str(&format!("; {outstanding} to index"));
}
for (label, n) in &self.owed {
if *n > 0 {
s.push_str(&format!("; {n} {label}"));
}
}
s
}
/// Whether anything but detection is left: the state an already-indexed
/// library is in the day the eye models arrive, where the button reads
/// "Read eye state" rather than promising to index.
pub fn has_repairs(&self) -> bool {
self.owed.iter().any(|(_, n)| *n > 0)
}
/// Whether the sweep has nothing left to do — nothing to index *and*
/// nothing to measure. The screen hides the button on this, so it has to
/// be false while the measuring pass has work, or the eye readings of an
/// already-indexed library could never be filled in.
pub fn is_complete(&self) -> bool {
self.coverage.is_complete() && !self.has_repairs()
}
}
/// Check every library image for a face-detection run marker.
///
/// The batch pass that answers "has face recognition been over all of this",
/// and the one to run before deciding whether to start a sweep. Cheap: two
/// counts and one indexed scan, no decoding and no inference.
///
/// `repairs` is the registry this device would run (`crate::repairs::
/// registry`), which decides what counts as work: a device without the eye
/// models has no eye repair and so lists no faces to read.
pub fn audit(
catalog: &Catalog,
store: &ThumbStore,
model_id: &str,
repairs: &[crate::repairs::Repair],
) -> Result<IndexAudit, dr_catalog::CatalogError> {
let conn = catalog.connection();
let coverage = faces::coverage(conn, model_id)?;
let owed = crate::repairs::counts(catalog, store, repairs)?
.into_iter()
.zip(repairs.iter())
.filter(|(_, r)| r.name != "face-detection")
.map(|(c, _)| c)
.collect();
// Split the outstanding set by whether a proxy exists. This is the query
// `faces_outstanding` runs without the store filter, so the two cannot
// disagree about what is outstanding. Shadowed images are excluded here
// for the same reason `faces::coverage` excludes them: they are the JPEG
// half of a pair, no sweep will ever index one, and counting them makes
// the outstanding figure a number that cannot reach zero.
let mut stmt = conn.prepare(&format!(
"SELECT r.file_id
FROM images i
JOIN remote r ON r.image_id = i.id
WHERE r.file_id IS NOT NULL
AND i.trashed_at IS NULL
AND i.shadowed_by IS NULL
AND NOT EXISTS (
SELECT 1 FROM face_index fi
WHERE fi.image_id = i.id AND {} = ?1
)",
faces::embedder_sql("fi.model_id")
))?;
let (mut ready, mut awaiting) = (0u64, 0u64);
for file_id in stmt
.query_map([faces::embedder_of(model_id)], |r| r.get::<_, i64>(0))?
.filter_map(Result::ok)
{
if store.contains(file_id as u64, FACE_TIER) {
ready += 1;
} else {
awaiting += 1;
}
}
Ok(IndexAudit {
coverage,
ready,
awaiting_proxy: awaiting,
owed,
})
}
/// TRACES: FR-CULL-8a
/// Read one face's eyes from the buffer its crop came from, if this device
/// can.
///
/// From the same pixels the embedder saw the face in, so what the models
/// see is the eye at the resolution the crop had — and `dr_face::eyes`'
/// readability floors are judged against real pixels rather than a proxy's
/// idea of them. `bbox` is the detector's `(x0, y0, x1, y1)` and `landmarks`
/// its five points, both in this buffer's pixels. `None` on a device without
/// the models, which is the ordinary state of one that has not been given
/// them, and `None` — logged — where the models refuse: a face the embedder
/// could use is not lost for the want of an eye reading.
///
/// Returns the reading and the dense landmarks it was read from, the
/// latter already packed for the catalog (`landmarks_dense`, normalised by
/// `long_edge` like the five points).
fn read_eyes(
models: Option<&mut EyeModels>,
px: dr_face::Pixels<'_>,
width: usize,
height: usize,
bbox: (f32, f32, f32, f32),
landmarks: &[(f32, f32); 5],
long_edge: f32,
) -> (Option<EyeReading>, Vec<u8>) {
let Some(models) = models else {
return (None, Vec::new());
};
match models.read(px, width, height, bbox, landmarks) {
Ok(Some((reading, dense))) => (Some(reading), dense.to_packed_bytes(long_edge)),
Ok(None) => (None, Vec::new()),
Err(e) => {
log::debug!("eye reading failed: {e}");
(None, Vec::new())
}
}
}
/// Detect and embed every face in one decoded proxy.
///
/// Coordinates come back **normalised to the long edge**, which is what the
/// catalog stores: a face must survive the proxy it was found on being evicted
/// and regenerated at a different size.
///
/// A face whose landmarks are degenerate is dropped rather than stored with a
/// junk embedding. That happens — a detector firing on a motion-blurred profile
/// can put all five landmarks on a line — and one junk embedding in the
/// clustering graph can bridge two real people.
pub fn index_proxy(
detector: &mut Detector,
embedder: &mut Embedder,
mut eye_models: Option<&mut EyeModels>,
rgb: &[f32],
width: usize,
height: usize,
options: &DetectOptions,
) -> Result<Vec<DetectedFace>, dr_face::FaceError> {
let long_edge = width.max(height) as f32;
if long_edge <= 0.0 {
return Ok(Vec::new());
}
let dets = detector.detect(rgb, width, height, options)?;
let mut out = Vec::with_capacity(dets.len());
for d in &dets {
let Some(aligned) = align::warp(rgb, width, height, &d.landmarks) else {
log::debug!("face with degenerate landmarks skipped");
continue;
};
// The size floor, on the crop rather than the box. `min_face_px` has
// already thrown away the hopeless; this is the real gate, and it is
// here because `source_px` is only known once the warp has fixed the
// scale. A face below it was upsampled to reach the embedder, and no
// amount of upsampling puts back detail the sensor never recorded.
if aligned.source_px() < options.min_source_px {
log::debug!(
"face skipped: {:.0} source px below {:.0}",
aligned.source_px(),
options.min_source_px
);
continue;
}
// The blur gate, and the reason it is here rather than in the
// detector: sharpness is a property of the *aligned* crop, so it
// cannot be known until the warp has run.
//
// A motion-blurred face detects confidently, aligns cleanly and embeds
// to a perfectly ordinary-looking vector. Nothing downstream can tell
// it apart from a real one — and because blurs resemble each other
// more than they resemble the people they were, they cluster together
// and weld unrelated identities into one group. Dropping it costs a
// face the user could not have identified anyway.
let sharpness = aligned.sharpness();
if sharpness < options.min_sharpness {
log::debug!(
"face at {:.0}px skipped: sharpness {sharpness:.4} below {:.4}",
aligned.source_px(),
options.min_sharpness
);
continue;
}
let embedded = embedder.embed(&aligned)?;
let (eyes, landmarks_dense) = read_eyes(
eye_models.as_deref_mut(),
dr_face::Pixels::RgbF32(rgb),
width,
height,
d.bbox,
&d.landmarks,
long_edge,
);
out.push(DetectedFace {
x: d.bbox.0 / long_edge,
y: d.bbox.1 / long_edge,
w: d.width() / long_edge,
h: d.height() / long_edge,
landmarks: normalise_landmarks(&d.landmarks, long_edge),
confidence: d.confidence,
embedding: embedded.to_f16_bytes(),
crop_px: aligned.source_px(),
quality: Some(embedded.quality),
eyes,
landmarks_dense,
model_id: embedder.model().as_str().to_string(),
// Cut here, while the buffer is still in hand. This is the only
// moment in the whole pipeline where the pixels are free.
crop: cut_crop(rgb, width, height, d).unwrap_or_default(),
});
}
Ok(out)
}
/// TRACES: FR-CULL-8
/// Long edge the detector's input is reduced to, in pixels.
///
/// Detection is indifferent above this — `dr_face::detect` letterboxes into a
/// fixed 640×640 whatever it receives, so a face is the same size to the model
/// from a 1600px buffer as from a 6000px one (faces.md §4.1). What the
/// reduction buys is the aliasing that a single bilinear step from native to
/// 640 would introduce: a 9× decimation samples one pixel in nine and drops
/// small faces into the gaps between samples. Box-filtering to 1600 first, and
/// letting the letterbox take the remaining 2.5×, keeps every source pixel in
/// the average.
///
/// It also bounds the buffer that has to exist as `f32`: 1600×1067 is 20 MB
/// against 288 MB for a native 24 MP frame.
const DETECT_EDGE: usize = 1600;
/// TRACES: FR-CULL-8 | NFR-RES-2
/// Detect and embed every face in one **native-resolution** render.
///
/// The pass FR-CULL-8 specifies, and the two resolutions in it are the whole
/// point:
///
/// - the **detector** is handed a box-filtered reduction, because it discards
/// anything above its own 640px input anyway;
/// - the **crop** is warped out of the native buffer, because that is the one
/// place source resolution becomes embedding quality.
///
/// Boxes and landmarks come back in the reduction's coordinates and are scaled
/// to native before a single crop pixel is read. Getting that scaling wrong
/// does not fail loudly — it yields faces in plausible-looking places with
/// crops taken from beside them — so it is one multiply applied in one place
/// rather than at each use.
///
/// `rgba` is the native render, tightly packed 8-bit RGBA. It is never
/// converted wholesale: [`dr_face::Pixels`] samples it where the warp and the
/// stored crop actually touch it.
pub fn index_native(
detector: &mut Detector,
embedder: &mut Embedder,
mut eye_models: Option<&mut EyeModels>,
rgba: &[u8],
width: usize,
height: usize,
options: &DetectOptions,
) -> Result<Vec<DetectedFace>, dr_face::FaceError> {
if !index_native_shape_ok(rgba, width, height) {
return Ok(Vec::new());
}
let long_edge = width.max(height) as f32;
let native = dr_face::Pixels::Rgba8(rgba);
// One reduction, reused for every face on the image.
let (dw, dh, small) = reduce_for_detection(rgba, width, height);
if dw == 0 || dh == 0 {
return Ok(Vec::new());
}
let dets = detector.detect(&small, dw, dh, options)?;
// Detector coordinates to native. Separate factors rather than one, because
// the reduction rounds each axis independently and assuming they match puts
// every landmark a fraction of a face off on the shorter one.
let (sx, sy) = (width as f32 / dw as f32, height as f32 / dh as f32);
let mut out = Vec::with_capacity(dets.len());
for d in &dets {
let landmarks = scale_landmarks(&d.landmarks, sx, sy);
let Some(aligned) = dr_face::warp_pixels(native, width, height, &landmarks) else {
log::debug!("face with degenerate landmarks skipped");
continue;
};
// Both gates read the aligned crop, so they mean what they say only now
// that the crop comes from native pixels — `source_px` is the real
// count, not the proxy's idea of it. See `index_proxy` for why each
// one is here rather than in the detector.
if aligned.source_px() < options.min_source_px {
log::debug!(
"face skipped: {:.0} source px below {:.0}",
aligned.source_px(),
options.min_source_px
);
continue;
}
let sharpness = aligned.sharpness();
if sharpness < options.min_sharpness {
log::debug!(
"face at {:.0}px skipped: sharpness {sharpness:.4} below {:.4}",
aligned.source_px(),
options.min_sharpness
);
continue;
}
let embedded = embedder.embed(&aligned)?;
let (bx, by) = (d.bbox.0 * sx, d.bbox.1 * sy);
let (bw, bh) = (d.width() * sx, d.height() * sy);
// From the native buffer, box and landmarks scaled like the crop's
// — the eye is a fortieth of the face, and it is here that the
// native render pays for itself twice.
let (eyes, landmarks_dense) = read_eyes(
eye_models.as_deref_mut(),
native,
width,
height,
(bx, by, bx + bw, by + bh),
&landmarks,
long_edge,
);
out.push(DetectedFace {
x: bx / long_edge,
y: by / long_edge,
w: bw / long_edge,
h: bh / long_edge,
landmarks: normalise_landmarks(&landmarks, long_edge),
confidence: d.confidence,
embedding: embedded.to_f16_bytes(),
crop_px: aligned.source_px(),
quality: Some(embedded.quality),
eyes,
landmarks_dense,
model_id: embedder.model().as_str().to_string(),
crop: cut_crop_native(native, width, height, (bx, by, bw, bh)).unwrap_or_default(),
});
}
Ok(out)
}
/// What re-embedding the faces already on one image produced.
#[derive(Debug, Default, PartialEq)]
pub struct Measured {
pub measured: Vec<faces::FaceUpdate>,
/// Faces whose stored landmarks no longer make a warp. See
/// `dr_catalog::faces::record_updates` for what becomes of them.
pub dropped: Vec<faces::FaceId>,
}
/// TRACES: FR-CULL-8 | FR-CULL-9
/// Embed the faces already found on one image again, from its native render.
///
/// The `face-quality` repair (`crate::repairs`), for faces stored before their
/// quality was kept (schema V14). No detector: the boxes and landmarks in the catalog are
/// taken as read, scaled back from the long edge they were normalised to, and
/// each face is warped out of the native frame and embedded exactly as
/// [`index_native`] would have done on the day. What comes back is the raw
/// vector and its length, to be written over the old unit one.
///
/// The size and sharpness gates are deliberately not re-applied. They decide
/// whether a face is worth *storing*, and these are stored; what is being
/// established now is how much the model can make of each, which is the
/// quality itself, and a face that would have failed a gate is precisely one
/// that should come out short and stop vouching for anyone.
///
/// The eyes are read on the same pass where the device has the models, for
/// the faces that have no reading yet (schema V16): the pixels are in hand,
/// and the eye is a window on the same landmarks. A face that already has
/// one keeps it.
pub fn measure_native(
embedder: &mut Embedder,
mut eye_models: Option<&mut EyeModels>,
rgba: &[u8],
width: usize,
height: usize,
faces: &[faces::Face],
) -> Result<Measured, dr_face::FaceError> {
let mut out = Measured::default();
if !index_native_shape_ok(rgba, width, height) {
return Ok(out);
}
let long_edge = width.max(height) as f32;
let native = dr_face::Pixels::Rgba8(rgba);
for f in faces {
let mut landmarks = [(0.0_f32, 0.0_f32); 5];
for (o, &(x, y)) in landmarks.iter_mut().zip(f.landmarks.iter()) {
*o = (x * long_edge, y * long_edge);
}
let Some(aligned) = dr_face::warp_pixels(native, width, height, &landmarks) else {
log::debug!("face {:?} has degenerate landmarks, dropped", f.id);
out.dropped.push(f.id);
continue;
};
let embedded = embedder.embed(&aligned)?;
let (eyes, landmarks_dense) = if f.eyes.is_some() {
(None, Vec::new())
} else {
let bbox = (
f.x * long_edge,
f.y * long_edge,
(f.x + f.w) * long_edge,
(f.y + f.h) * long_edge,
);
read_eyes(
eye_models.as_deref_mut(),
native,
width,
height,
bbox,
&landmarks,
long_edge,
)
};
out.measured.push(faces::FaceUpdate {
embedding: Some((embedded.to_f16_bytes(), embedded.quality)),
eyes: eyes.map(|e| (e, landmarks_dense)),
..faces::FaceUpdate::for_face(f.id)
});
}
Ok(out)
}
/// Whether a buffer is the native frame it claims to be.
///
/// Checked before anything expensive, and before the detector above all: a
/// mismatched buffer would otherwise be read past its end by the reduction.
fn index_native_shape_ok(rgba: &[u8], width: usize, height: usize) -> bool {
width > 0 && height > 0 && rgba.len() == width * height * 4
}
/// Landmarks from the detector's reduction into native coordinates.
fn scale_landmarks(lm: &[(f32, f32); 5], sx: f32, sy: f32) -> [(f32, f32); 5] {
let mut out = [(0.0_f32, 0.0_f32); 5];
for (o, &(x, y)) in out.iter_mut().zip(lm.iter()) {
*o = (x * sx, y * sy);
}
out
}
/// Box-filter a native RGBA render down to [`DETECT_EDGE`], as packed `f32` RGB.
///
/// Averaging rather than sampling. The detector's job is to find small faces,
/// and a point-sampled reduction is exactly the operation that removes them:
/// at a 4× decimation fifteen of every sixteen pixels are discarded, and a
/// 40px face survives or not depending on where it happens to sit relative to
/// the sample grid.
fn reduce_for_detection(rgba: &[u8], width: usize, height: usize) -> (usize, usize, Vec<f32>) {
reduce_to(rgba, width, height, DETECT_EDGE)
}
/// [`reduce_for_detection`], to a stated long edge.
///
/// Split out so the averaging can be tested at a size a test can reason about
/// by hand, rather than by building a 1600px fixture.
fn reduce_to(rgba: &[u8], width: usize, height: usize, target: usize) -> (usize, usize, Vec<f32>) {
let long = width.max(height);
if long <= target {
// Already small enough; convert without resampling rather than round
// -tripping through a scale of 1.
let mut out = vec![0.0_f32; width * height * 3];
for (i, px) in rgba.chunks_exact(4).enumerate() {
for c in 0..3 {
out[i * 3 + c] = px[c] as f32 / 255.0;
}
}
return (width, height, out);
}
let scale = target as f32 / long as f32;
let (dw, dh) = (
((width as f32 * scale).round() as usize).max(1),
((height as f32 * scale).round() as usize).max(1),
);
let mut out = vec![0.0_f32; dw * dh * 3];
for oy in 0..dh {
// Source span of this destination row, as a half-open range, so
// adjacent rows tile the source exactly and no row is counted twice.
let y0 = oy * height / dh;
let y1 = (((oy + 1) * height) / dh).max(y0 + 1).min(height);
for ox in 0..dw {
let x0 = ox * width / dw;
let x1 = (((ox + 1) * width) / dw).max(x0 + 1).min(width);
let mut acc = [0.0_f32; 3];
let mut n = 0.0_f32;
for y in y0..y1 {
for x in x0..x1 {
let i = (y * width + x) * 4;
for (c, a) in acc.iter_mut().enumerate() {
*a += rgba[i + c] as f32;
}
n += 1.0;
}
}
let o = (oy * dw + ox) * 3;
for c in 0..3 {
out[o + c] = acc[c] / n / 255.0;
}
}
}
(dw, dh, out)
}
/// The stored face thumbnail, cut from the native buffer.
///
/// Same framing as [`cut_crop`] and deliberately a separate function rather
/// than a generalisation of it: this one takes a box already in native
/// coordinates, and blurring that distinction is how a crop ends up sampled
/// from the wrong scale.
pub(crate) fn cut_crop_native(
px: dr_face::Pixels<'_>,
width: usize,
height: usize,
bbox: (f32, f32, f32, f32),
) -> Option<Vec<u8>> {
if width == 0 || height == 0 {
return None;
}
let (bx, by, bw, bh) = bbox;
let cx = bx + bw * 0.5;
let cy = by + bh * 0.5;
let half = bw.max(bh) * 0.5 * (1.0 + CROP_MARGIN);
if !(half.is_finite() && half > 0.5 && cx.is_finite() && cy.is_finite()) {
return None;
}
let edge = STORED_CROP_EDGE;
let mut out = vec![0u8; (edge * edge * 4) as usize];
let step = (half * 2.0) / edge as f32;
for oy in 0..edge {
let sy = cy - half + (oy as f32 + 0.5) * step;
for ox in 0..edge {
let sx = cx - half + (ox as f32 + 0.5) * step;
let o = ((oy * edge + ox) * 4) as usize;
for c in 0..3 {
let v = px.channel(width, height, sx as isize, sy as isize, c);
out[o + c] = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
}
out[o + 3] = 255;
}
}
match dr_thumbs::encode_rgba(edge, edge, &out) {
Ok(bytes) => Some(bytes),
Err(e) => {
log::debug!("encoding a face crop: {e}");
None
}
}
}
fn normalise_landmarks(lm: &[(f32, f32); 5], long_edge: f32) -> [(f32, f32); 5] {
let mut out = [(0.0_f32, 0.0_f32); 5];
for (o, &(x, y)) in out.iter_mut().zip(lm.iter()) {
*o = (x / long_edge, y / long_edge);
}
out
}
/// Index every image whose proxy is **already on this disk**, in the background.
///
/// Not the whole-library pass — that is `repairs::spawn`, which
/// fetches what it has not got. This one never touches the network, which makes
/// it the right shape for a tool run against a local store (see
/// `examples/face_index.rs`) and the wrong shape for a user pressing "index my
/// library", because for an unbrowsed library the work list is nearly empty.
///
/// Strictly background work: it competes with thumbnailing, not with rendering
/// (NFR-ARCH-2), and it is interruptible simply by dropping the receiver — the
/// next run resumes from what is already in the catalog, because
/// [`faces_outstanding`] asks the catalog what is missing rather than keeping a
/// cursor. That is what makes it survive process death (FR-PLAT-AND-3) with no
/// repeated work beyond the in-flight image.
#[allow(clippy::too_many_arguments)]
pub fn spawn_store_face_sweep(
catalog_path: PathBuf,
store_dir: PathBuf,
models: crate::FaceModelPaths,
model_id: String,
options: DetectOptions,
) -> Receiver<FaceSweepMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let finish_empty = |tx: &Sender<FaceSweepMessage>| {
let _ = tx.send(FaceSweepMessage::Finished {
images: 0,
faces: 0,
failed: 0,
});
};
let catalog = match Catalog::open(&catalog_path) {
Ok(c) => c,
Err(e) => {
log::warn!("face sweep: cannot open catalog: {e}");
finish_empty(&tx);
return;
}
};
let store = match ThumbStore::open(&store_dir) {
Ok(s) => s,
Err(e) => {
log::warn!("face sweep: cannot open the thumbnail store: {e}");
finish_empty(&tx);
return;
}
};
// Models first: they are the expensive failure, and there is no point
// listing ten thousand images before discovering the weights are
// missing. This is also the path a library with face indexing enabled
// but no model downloaded takes (docs/faces.md §2.2), so it must be a
// quiet return rather than an error.
let mut detector = match Detector::from_path(&models.detector) {
Ok(d) => d,
Err(e) => {
log::warn!("face sweep: cannot load the detector: {e}");
finish_empty(&tx);
return;
}
};
let mut embedder =
match Embedder::from_path(&models.embedder, ModelId::new(model_id.clone())) {
Ok(e) => e,
Err(e) => {
log::warn!("face sweep: cannot load the embedder: {e}");
finish_empty(&tx);
return;
}
};
let mut eye_models = models.load_eyes();
// Nothing below this point can succeed, so do not pretend to try.
//
// This pass detects on what the store holds, and the store's largest
// tier is `FACE_TIER` — 1024, which is below the floor. Left to run it
// would fetch nothing, decode every proxy it has, and report every
// single image as failed: twenty thousand refusals that all say the
// same thing. The pass is not repairable here either, because there is
// no larger tier for it to read; the pixels it needs have to come off
// the server, which is `repairs::spawn`'s job.
if FACE_TIER.edge() < dr_face::MIN_CROP_EDGE {
log::warn!(
"face sweep: the local store's largest tier is {}px, below the {}px \
crop floor — use the library pass, which renders at native resolution",
FACE_TIER.edge(),
dr_face::MIN_CROP_EDGE,
);
finish_empty(&tx);
return;
}
let wanted = match faces_outstanding(&catalog, &store, &model_id) {
Ok(w) => w,
Err(e) => {
log::warn!("face sweep: {e}");
finish_empty(&tx);
return;
}
};
let total = wanted.len();
if total == 0 {
log::info!("face sweep: every image with a proxy is already indexed");
finish_empty(&tx);
return;
}
log::info!("face sweep: {total} image(s) to index");
if tx.send(FaceSweepMessage::Total(total)).is_err() {
return;
}
let (mut images, mut found, mut failed) = (0usize, 0usize, 0usize);
for req in wanted {
let thumb = match store.get(req.file_id, FACE_TIER) {
Ok(Some(t)) => t,
// Evicted between the listing and now. Not a failure: the next
// pass will find it, or the thumbnail sweep will rebuild it.
Ok(None) => continue,
Err(e) => {
log::debug!("face sweep: reading proxy for {:?}: {e}", req.image_id);
failed += 1;
continue;
}
};
let (w, h, rgba) = match dr_thumbs::codec::decode_rgba(&thumb.bytes) {
Ok(v) => v,
Err(e) => {
log::debug!("face sweep: decoding proxy for {:?}: {e}", req.image_id);
failed += 1;
continue;
}
};
let rgb = rgba_to_rgb_f32(&rgba);
let faces = match index_proxy(
&mut detector,
&mut embedder,
eye_models.as_mut(),
&rgb,
w as usize,
h as usize,
&options,
) {
Ok(f) => f,
Err(e) => {
log::debug!("face sweep: indexing {:?}: {e}", req.image_id);
failed += 1;
continue;
}
};
if let Err(e) = faces::record_detections(
catalog.connection(),
req.image_id,
&model_id,
w.max(h),
&faces,
) {
log::warn!("face sweep: storing faces for {:?}: {e}", req.image_id);
failed += 1;
continue;
}
images += 1;
found += faces.len();
if tx
.send(FaceSweepMessage::Indexed {
image: req.image_id,
faces: faces.len(),
})
.is_err()
{
// Receiver dropped: the window closed, or the user turned face
// indexing off. Stop, leaving everything written so far.
log::info!("face sweep: cancelled after {images} image(s)");
return;
}
}
log::info!("face sweep: {found} face(s) across {images} image(s), {failed} failed");
let _ = tx.send(FaceSweepMessage::Finished {
images,
faces: found,
failed,
});
});
rx
}
/// Everything a grouping pass reads before it decides anything.
///
/// Split out because two callers need exactly this and must agree on it: the
/// pass that writes, and the preview that reports what the pass *would* do. A
/// preview built from a second, subtly different reading — anchors omitted,
/// say — would answer a question about a library nobody has.
struct Population {
cal: Calibration,
candidates: Vec<dr_face::Candidate>,
/// Catalog ids, parallel to `candidates`.
ids: Vec<faces::FaceId>,
/// Faces the user has ruled on, and who they are. See [`Population::read`].
anchors: std::collections::HashMap<faces::FaceId, faces::PersonId>,
}
impl Population {
/// Takes the catalog and not its connection: `rusqlite` is `dr-catalog`'s
/// dependency and not this crate's, and reaching for the connection type by
/// name here would drag it across a layer that has kept clear of it.
fn read(catalog: &Catalog, model_id: &str) -> Result<Self, dr_catalog::CatalogError> {
let conn = catalog.connection();
// No valid calibration is not a reason to refuse to cluster — it is a
// reason not to *display* a confidence (FR-CULL-9). `Calibration::
// default` is the reference implementation's fitted curve with `valid`
// false, which is a documented operating point rather than an invented
// one.
let cal = faces::calibration(conn, model_id)?
.map(|(c, _)| c)
.unwrap_or_else(Calibration::default);
// Which faces the user has already ruled on, so they enter as anchors.
//
// Anything the user has ruled on anchors, and there are three ways of
// ruling — only the first of which is obvious.
//
// A **confirmation** is the plain case. **Setting a group aside** is one
// too, and the faces it covers are only ever suggestions, so anchoring
// confirmations alone let every ignored group scatter into fresh unnamed
// groups that were not ignored, and the strangers came straight back.
//
// And so is **giving a group a name**. That was the omission that did
// the most damage, because it is silent. Naming a cluster does not
// confirm its faces — they stay suggestions — so the next Regroup cut
// them loose, regrouped them into a brand new person, and left the named
// one holding nothing. `prune_empty_unnamed` will not remove it, because
// it has a name. Name the new group the same thing and it happens again.
// That is how one library came to hold sixteen people called Catherine,
// fourteen of them empty, with her faces split across the two that were
// not.
//
// A name is a judgement about *this group* (FR-CULL-12), exactly as an
// ignore is. Anchoring them all also does one better: a newly indexed
// face that matches a named person now merges *into* them rather than
// arriving as a stranger.
let mut anchors = std::collections::HashMap::new();
for p in faces::people(conn)? {
let ruled_on = p.ignored || !p.name.trim().is_empty();
for f in faces::for_person(conn, p.id, ruled_on)? {
anchors.insert(f.id, p.id);
}
}
let stored = faces::embeddings(conn, model_id)?;
let model = ModelId::new(model_id.to_string());
let mut candidates = Vec::with_capacity(stored.len());
let mut ids = Vec::with_capacity(stored.len());
for f in stored {
let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &f.embedding) else {
log::warn!("face {:?} has a malformed embedding, skipped", f.face);
continue;
};
candidates.push(dr_face::Candidate {
face: f.face.0,
image: f.image.0,
embedding: emb.v.to_vec(),
crop_px: f.crop_px,
quality: f.quality,
confirmed_person: anchors.get(&f.face).map(|p| p.0),
});
ids.push(f.face);
}
Ok(Self {
cal,
candidates,
ids,
anchors,
})
}
}
/// What a grouping pass would produce, without producing it.
///
/// The numbers `dr_face::cluster`'s own tuning table is built from, for one
/// setting rather than ten — because the question a photographer is actually
/// asking of the dials is "what does *my* library look like at this value", and
/// the doc-comment table answers it for a library that is not theirs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct GroupingPreview {
/// Faces that went in.
pub faces: usize,
/// Groups that would survive the smallest-group rule.
pub groups: usize,
/// Faces those groups would hold.
pub grouped: usize,
/// The biggest group. The tell for over-merging: it is the number that runs
/// away when the confidence is set too low.
pub largest: usize,
}
/// Report what a grouping pass would do, writing nothing.
///
/// Read-only by construction — it never opens a transaction — which is what
/// makes it safe to run repeatedly while the user moves a slider. Comparing two
/// settings by *applying* both would leave the second one's answer polluted by
/// the first one's suggestions.
pub fn preview_grouping(
catalog: &Catalog,
model_id: &str,
grouping: &FaceSettings,
) -> Result<GroupingPreview, dr_catalog::CatalogError> {
let min_group = grouping.min_group_size.max(1) as usize;
let pop = Population::read(catalog, model_id)?;
if pop.candidates.is_empty() {
return Ok(GroupingPreview::default());
}
let clusters = dr_face::cluster(&pop.candidates, &pop.cal, grouping.merge_probability);
// The same rule the writing pass applies, so the preview and the result
// cannot disagree about what counts as a person.
let kept: Vec<_> = clusters
.iter()
.filter(|c| c.members.len() >= min_group || c.person.is_some())
.collect();
Ok(GroupingPreview {
faces: pop.candidates.len(),
groups: kept.len(),
grouped: kept.iter().map(|c| c.members.len()).sum(),
largest: kept.iter().map(|c| c.members.len()).max().unwrap_or(0),
})
}
/// Group the library's faces into people, writing suggestions.
///
/// Confirmations are never touched: they enter the clusterer as anchors and
/// come back out unchanged, which is the invariant FR-CULL-10 turns on. What
/// this writes is the *suggested* half, and it may be re-run at any time.
///
/// Returns how many suggestions were written and how many new unnamed people
/// were created.
pub fn recluster(
catalog: &Catalog,
model_id: &str,
grouping: &FaceSettings,
) -> Result<(usize, usize), dr_catalog::CatalogError> {
let min_probability = grouping.merge_probability;
let min_group = grouping.min_group_size.max(1) as usize;
let conn = catalog.connection();
let Population {
cal,
candidates,
ids,
anchors: confirmed,
} = Population::read(catalog, model_id)?;
if candidates.is_empty() {
return Ok((0, 0));
}
let dr_face::Grouping {
clusters,
confidence,
} = dr_face::cluster_scored(&candidates, &cal, min_probability);
let mut suggested = 0usize;
let mut created = 0usize;
// Every face this pass actually placed. What is *not* in here at the end is
// a face the previous pass had an opinion about and this one does not, and
// it has to be let go — see below.
let mut placed = std::collections::HashSet::with_capacity(ids.len());
for c in &clusters {
// A group of one is not a person, and the user says how much bigger
// than one it has to be (`FaceSettings::min_group_size`). Naming every
// stray face would fill the People view with noise they then have to
// dismiss one entry at a time.
//
// Only ever applied to a group the system invented. A group with a
// `person` is one the user has already confirmed, named or set aside,
// and a display preference does not overrule a judgement (FR-CULL-12).
if c.members.len() < min_group && c.person.is_none() {
continue;
}
let person = match c.person {
Some(p) => faces::PersonId(p),
None => {
created += 1;
// Unnamed: FR-CULL-10 has the user name a group, and a group
// the system named would be a guess wearing a fact's clothes.
faces::create_person(conn, "")?
}
};
for &m in &c.members {
let face = ids[m];
placed.insert(face);
if confirmed.contains_key(&face) {
continue;
}
// The probability the user is shown is this identity's share of
// the evidence for the face, against every other identity that
// could plausibly claim it (dr_face::assign) — not the single best
// edge, which cannot tell a sole match from a coin toss between
// two siblings.
let p = confidence[m];
if faces::suggest(conn, face, person, p)? {
suggested += 1;
}
}
}
// Faces the previous pass placed and this one did not.
//
// Without this the parameters above are only half connected to the screen.
// Raise the smallest group to three and the pass stops *creating* groups of
// two — but last pass's group of two still holds its two suggestions, so it
// is not empty, so the prune below leaves it, and the rail does not change.
// The setting would appear to do nothing until the library was reindexed.
//
// Suggestions are this pass's own output (FR-CULL-10), so withdrawing one it
// no longer stands behind is exactly what it is entitled to do. Anchors are
// skipped by construction: a confirmed, named or ignored face is in
// `confirmed`, enters as an anchor, and comes back out inside a group that
// is never dropped.
let mut released = 0usize;
for face in &ids {
if placed.contains(face) || confirmed.contains_key(face) {
continue;
}
faces::unassign(conn, *face)?;
released += 1;
}
if released > 0 {
log::info!("reclustering released {released} face(s) it no longer groups");
}
// Groups the *previous* pass created that this one left empty. Without
// this, every press of Regroup adds a rail entry per group it no longer
// believes in, and the screen fills with "Unnamed (0 faces)" — which is
// what made pressing the button twice look like it had broken something.
match faces::prune_empty_unnamed(conn) {
Ok(0) => {}
Ok(n) => log::info!("reclustering removed {n} empty group(s) from the previous pass"),
Err(e) => log::warn!("pruning empty groups: {e}"),
}
log::info!(
"reclustered {} face(s) into {} group(s): {suggested} suggestion(s), {created} new",
candidates.len(),
clusters.len()
);
Ok((suggested, created))
}
/// The answer from a preview pass.
#[derive(Debug, Clone, PartialEq)]
pub enum PreviewMessage {
Ready(GroupingPreview),
Failed(String),
}
/// Report what a grouping pass would do, **on a worker thread**.
///
/// Same reasoning as [`spawn_recluster`], and the same cost: a preview is a
/// clustering pass that throws its answer away, so it is exactly as unbounded
/// as the pass it is previewing and exactly as unwelcome on the UI thread.
///
/// Cancellation is dropping the receiver — so moving the slider again while one
/// is in flight abandons it, which is the behaviour a dial with a preview
/// button needs.
pub fn spawn_grouping_preview(
catalog_path: PathBuf,
model_id: String,
grouping: FaceSettings,
) -> Receiver<PreviewMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let msg = match Catalog::open(&catalog_path) {
Ok(catalog) => match preview_grouping(&catalog, &model_id, &grouping) {
Ok(p) => PreviewMessage::Ready(p),
Err(e) => PreviewMessage::Failed(e.to_string()),
},
Err(e) => PreviewMessage::Failed(format!("cannot open catalog: {e}")),
};
let _ = tx.send(msg);
});
rx
}
/// Progress from a regrouping pass.
#[derive(Debug, Clone, PartialEq)]
pub enum ReclusterMessage {
/// How many faces went in. Sent once, before the arithmetic starts.
Started { faces: usize },
/// It finished.
Finished { suggested: usize, created: usize },
/// It did not.
Failed(String),
}
/// Group the library's faces into people, **on a worker thread**.
///
/// The reason this exists rather than callers just invoking [`recluster`]: it
/// used to run inside the Slint callback, on the UI thread, and clustering a
/// real library is not something a callback can do. The window froze for as
/// long as it took, with no progress, no cancel and no repaint — the button
/// looked broken because from the outside it was indistinguishable from broken.
///
/// It is much faster now (see [`dr_face::cluster`]), but *fast* is not the same
/// as *bounded*: the work grows with the library and the one thing that must
/// not grow with the library is how long the window stops answering. So it runs
/// where every other long pass in this module runs.
///
/// Cancellation is dropping the receiver, exactly as with the indexing sweep.
/// Nothing is left half-written: [`recluster`] does its work in the catalog's
/// own transactions, and a pass abandoned partway simply leaves the previous
/// grouping in place to be redone.
pub fn spawn_recluster(
catalog_path: PathBuf,
model_id: String,
grouping: FaceSettings,
) -> Receiver<ReclusterMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let catalog = match Catalog::open(&catalog_path) {
Ok(c) => c,
Err(e) => {
let _ = tx.send(ReclusterMessage::Failed(format!(
"cannot open catalog: {e}"
)));
return;
}
};
// Announced before the work so the screen can say what it is chewing
// on. Cheap: it is a count, not the embeddings themselves.
let count = faces::embeddings(catalog.connection(), &model_id)
.map(|e| e.len())
.unwrap_or(0);
if tx.send(ReclusterMessage::Started { faces: count }).is_err() {
return;
}
let msg = match recluster(&catalog, &model_id, &grouping) {
Ok((suggested, created)) => ReclusterMessage::Finished { suggested, created },
Err(e) => ReclusterMessage::Failed(e.to_string()),
};
let _ = tx.send(msg);
});
rx
}
/// A face cut out of its photograph, ready to draw.
#[derive(Debug, Clone, PartialEq)]
pub struct FaceCrop {
pub width: u32,
pub height: u32,
/// Tightly packed RGBA.
pub rgba: Vec<u8>,
}
/// How much of the surrounding frame a face crop keeps, per side.
///
/// A face cut exactly to its detection box reads as a mugshot: no hair, no
/// chin, no context, and a row of them is genuinely hard to tell apart — which
/// matters, because telling them apart is the entire task the People screen
/// asks of the user. A third on each side gives back the head.
const CROP_MARGIN: f32 = 0.35;
/// Cut one face out of its proxy.
///
/// The box is normalised to the long edge (the catalog's convention), so this
/// works whatever size the proxy happens to be now — the property that made
/// normalising worth the trouble. The thumbnail cache is entitled to evict a
/// proxy and regenerate it at another resolution, and a face stored in pixels
/// would then point at the wrong part of the picture.
pub fn crop_face(
rgba: &[u8],
width: u32,
height: u32,
face: &faces::Face,
out_edge: u32,
) -> Option<FaceCrop> {
if width == 0 || height == 0 || out_edge == 0 {
return None;
}
let long_edge = width.max(height) as f32;
// Square, centred on the face: the grid draws square cells, and cropping to
// a square here rather than letterboxing there means the face fills the
// cell instead of floating in it.
let cx = (face.x + face.w * 0.5) * long_edge;
let cy = (face.y + face.h * 0.5) * long_edge;
let half = (face.w.max(face.h) * long_edge * 0.5) * (1.0 + CROP_MARGIN);
if !(half.is_finite() && half > 0.5) {
return None;
}
let mut out = vec![0u8; (out_edge * out_edge * 4) as usize];
let step = (half * 2.0) / out_edge as f32;
for oy in 0..out_edge {
let sy = cy - half + (oy as f32 + 0.5) * step;
for ox in 0..out_edge {
let sx = cx - half + (ox as f32 + 0.5) * step;
let o = ((oy * out_edge + ox) * 4) as usize;
// Nearest neighbour: this is a downscale of an already-small proxy
// shown at ~96 px, and a bilinear tap would cost four reads per
// pixel for a difference nobody can see at that size. Outside the
// frame stays transparent, so a face at the very edge of the
// picture is drawn short rather than smeared.
if sx < 0.0 || sy < 0.0 || sx >= width as f32 || sy >= height as f32 {
continue;
}
let i = ((sy as u32 * width + sx as u32) * 4) as usize;
if i + 4 <= rgba.len() {
out[o..o + 4].copy_from_slice(&rgba[i..i + 4]);
}
}
}
Some(FaceCrop {
width: out_edge,
height: out_edge,
rgba: out,
})
}
/// Edge of the crop stored with a face.
///
/// Above both [`crate::identity::FACE_CROP_EDGE`] (128) and
/// `COVER_CROP_EDGE` (80), so the stored image is downsampled to draw and never
/// upsampled — a stored crop the same size as the grid cell would go soft the
/// moment either constant grew. 160 px of JPEG is a few KB, which is nothing
/// beside the 250 KB proxy it saves decoding.
pub const STORED_CROP_EDGE: u32 = 160;
/// Cut one detected face out of the buffer it was found in, as a JPEG.
///
/// The same square [`crop_face`] would cut — centred on the box, widened by
/// [`CROP_MARGIN`] — so a face drawn from its stored crop and one drawn the old
/// way from the proxy are the same picture. Working in pixels rather than
/// normalised coordinates because at this point in the pipeline that is what
/// there is; the normalising happens afterwards.
///
/// **Out-of-frame samples clamp to the edge rather than going transparent.**
/// [`crop_face`] leaves them clear, which is right when the caller can composite
/// them; JPEG has no alpha, so the same choice here would bake a black bar into
/// every face near the edge of its photograph — and then drag that face's mean
/// luma down far enough for `load_cover` to reject it as too dark.
///
/// `None` where the geometry is degenerate, which is the caller's cue to store
/// nothing and fall back to the proxy.
fn cut_crop(rgb: &[f32], width: usize, height: usize, d: &Detection) -> Option<Vec<u8>> {
if width == 0 || height == 0 {
return None;
}
let cx = d.bbox.0 + d.width() * 0.5;
let cy = d.bbox.1 + d.height() * 0.5;
let half = d.width().max(d.height()) * 0.5 * (1.0 + CROP_MARGIN);
if !(half.is_finite() && half > 0.5 && cx.is_finite() && cy.is_finite()) {
return None;
}
let edge = STORED_CROP_EDGE;
let mut out = vec![0u8; (edge * edge * 4) as usize];
let step = (half * 2.0) / edge as f32;
for oy in 0..edge {
let sy = cy - half + (oy as f32 + 0.5) * step;
let sy = (sy.max(0.0) as usize).min(height - 1);
for ox in 0..edge {
let sx = cx - half + (ox as f32 + 0.5) * step;
let sx = (sx.max(0.0) as usize).min(width - 1);
let i = (sy * width + sx) * 3;
let o = ((oy * edge + ox) * 4) as usize;
if i + 3 > rgb.len() {
continue;
}
for c in 0..3 {
out[o + c] = (rgb[i + c].clamp(0.0, 1.0) * 255.0).round() as u8;
}
out[o + 3] = 255;
}
}
match dr_thumbs::encode_rgba(edge, edge, &out) {
Ok(bytes) => Some(bytes),
Err(e) => {
log::debug!("encoding a face crop: {e}");
None
}
}
}
/// Detect and embed every face in a decoded preview.
///
/// The counterpart to [`index_proxy`] for the fetching sweep, which holds a
/// `dr_decode::Preview` rather than a thumbnail out of the store. The preview
/// arrives **already turned the right way up** — `fetch_preview` applies the
/// orientation before returning — so the coordinates this produces are in the
/// photograph's space, which is the space the catalog stores and the develop
/// overlay draws in. Nothing here has to know about the sensor.
///
/// Returns the faces and the long edge they were normalised against, which is
/// what `record_detections` stores so a proxy regenerated at another size does
/// not move them.
pub fn index_preview(
detector: &mut Detector,
embedder: &mut Embedder,
eye_models: Option<&mut EyeModels>,
preview: &dr_decode::Preview,
options: &DetectOptions,
) -> Result<(Vec<DetectedFace>, u32), dr_face::FaceError> {
let rgb = rgba_to_rgb_f32(&preview.rgba);
let faces = index_proxy(
detector,
embedder,
eye_models,
&rgb,
preview.width as usize,
preview.height as usize,
options,
)?;
Ok((faces, preview.width.max(preview.height)))
}
/// `dr-thumbs` decodes to RGBA; `dr-face` reads packed `f32` RGB.
fn rgba_to_rgb_f32(rgba: &[u8]) -> Vec<f32> {
let mut out = Vec::with_capacity(rgba.len() / 4 * 3);
for px in rgba.chunks_exact(4) {
out.push(px[0] as f32 / 255.0);
out.push(px[1] as f32 / 255.0);
out.push(px[2] as f32 / 255.0);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_detector_reduction_averages_rather_than_samples() {
// Four source pixels per destination pixel, with one bright pixel in
// each group. A point sampler returns either 255 or 0 depending on
// which corner it lands on; the average is the same every time, and
// that stability is what stops a small face vanishing on a grid
// alignment it has no control over.
let (w, h) = (4usize, 4usize);
let mut rgba = vec![0u8; w * h * 4];
for y in 0..h {
for x in 0..w {
let i = (y * w + x) * 4;
let bright = x % 2 == 0 && y % 2 == 0;
for c in 0..3 {
rgba[i + c] = if bright { 255 } else { 0 };
}
rgba[i + 3] = 255;
}
}
// Force a 2x reduction regardless of DETECT_EDGE by reducing by hand
// through the same helper on a source larger than the target.
let (dw, dh, out) = reduce_to(&rgba, w, h, 2);
assert_eq!((dw, dh), (2, 2));
for v in out.chunks_exact(3) {
assert!(
(v[0] - 0.25).abs() < 1e-6,
"each destination pixel averages one bright of four, got {}",
v[0]
);
}
}
#[test]
fn the_reduction_keeps_the_aspect_ratio_it_was_given() {
let (w, h) = (400usize, 100usize);
let rgba = vec![128u8; w * h * 4];
let (dw, dh, out) = reduce_to(&rgba, w, h, 100);
assert_eq!(dw, 100);
assert_eq!(dh, 25);
assert_eq!(out.len(), dw * dh * 3);
}
#[test]
fn landmarks_scale_back_to_the_native_frame() {
// The failure this guards is silent: landmarks left in the detector's
// coordinates put every crop near the top-left corner of the frame,
// which produces faces that look like faces of something else.
let lm = [
(10.0, 20.0),
(30.0, 40.0),
(50.0, 60.0),
(70.0, 80.0),
(90.0, 100.0),
];
let out = scale_landmarks(&lm, 4.0, 2.0);
assert_eq!(out[0], (40.0, 40.0));
assert_eq!(out[4], (360.0, 200.0));
}
/// The measuring path end to end, with the real embedder where one is on
/// this machine: landmarks come back from the long edge they were
/// normalised to, the warp is built from them, and what is stored is the
/// raw vector, whose length is the quality reported beside it.
#[test]
fn measuring_stores_the_raw_vector_at_the_quality_it_reports() {
let dir = crate::library::shared_face_models_dir();
let path = dir.join("arcface_mbf_b1.onnx");
if !path.is_file() {
eprintln!("no embedder at {}, skipping", path.display());
return;
}
let model = ModelId::new("w600k_mbf");
let mut embedder = Embedder::from_path(&path, model.clone()).expect("load embedder");
// A 600×400 frame with a gradient in it, and one face whose landmarks
// are the ArcFace template scaled up and placed in the middle -- not
// a face, but an unambiguous warp.
let (w, h) = (600usize, 400usize);
let mut rgba = vec![0u8; w * h * 4];
for y in 0..h {
for x in 0..w {
let i = (y * w + x) * 4;
rgba[i] = (x * 255 / w) as u8;
rgba[i + 1] = (y * 255 / h) as u8;
rgba[i + 2] = ((x + y) % 256) as u8;
rgba[i + 3] = 255;
}
}
let long_edge = w.max(h) as f32;
let mut landmarks = [(0.0_f32, 0.0_f32); 5];
for (o, &(tx, ty)) in landmarks.iter_mut().zip(dr_face::ARCFACE_TEMPLATE.iter()) {
// Two and a half times the template, offset into the frame, then
// normalised to the long edge as the catalog stores it.
*o = (
(tx * 2.5 + 150.0) / long_edge,
(ty * 2.5 + 60.0) / long_edge,
);
}
let stored = faces::Face {
id: faces::FaceId(7),
image_id: ImageId(1),
x: 0.25,
y: 0.15,
w: 0.5,
h: 0.7,
landmarks,
confidence: 0.9,
crop_px: 280.0,
quality: None,
eyes: None,
landmarks_dense: Vec::new(),
model_id: "w600k_mbf".into(),
person: None,
probability: 0.0,
confirmed: false,
};
let stored_copy = stored.clone();
let out = measure_native(&mut embedder, None, &rgba, w, h, &[stored]).expect("measure");
assert!(out.dropped.is_empty());
assert_eq!(out.measured.len(), 1);
let m = &out.measured[0];
assert_eq!(m.face, faces::FaceId(7));
let (embedding, quality) = m.embedding.as_ref().expect("a vector");
assert!(*quality > 0.0);
let (_, length) = dr_face::read_f16_bytes(model, embedding).expect("decode");
assert!(
(length - quality).abs() < 0.05 * quality,
"stored length {length} against reported quality {quality}"
);
// Degenerate landmarks -- five points on one spot, which no
// similarity can be fitted to -- are dropped, not embedded.
let junk = faces::Face {
id: faces::FaceId(8),
landmarks: [(0.3, 0.3); 5],
..stored_copy
};
let out = measure_native(&mut embedder, None, &rgba, w, h, &[junk]).expect("measure");
assert!(out.measured.is_empty());
assert_eq!(out.dropped, vec![faces::FaceId(8)]);
}
#[test]
fn a_buffer_that_is_not_the_stated_size_indexes_nothing() {
// No model is loaded here, so reaching the detector would panic. The
// point is that it does not: the shape check comes first.
let rgba = vec![0u8; 10];
assert!(!index_native_shape_ok(&rgba, 100, 100));
assert!(!index_native_shape_ok(&rgba, 0, 0));
assert!(index_native_shape_ok(&vec![0u8; 4 * 100 * 100], 100, 100));
}
#[test]
fn an_audit_summary_counts_the_outstanding_as_one_number() {
let a = IndexAudit {
coverage: faces::Coverage {
images: 100,
indexed: 60,
without_faces: 40,
faces: 35,
},
ready: 30,
awaiting_proxy: 10,
owed: Vec::new(),
};
let s = a.summary();
assert!(s.contains("60/100"), "{s}");
assert!(s.contains("60%"), "{s}");
assert!(!s.contains("60.0%"), "whole numbers above ten percent: {s}");
// One number, not two. The split described a pass that could index
// whatever already had a proxy; detection now refuses that proxy's
// size (§7), so both halves cost the same fetch and "30 ready, 10 to
// fetch" would imply a distinction that decides nothing.
assert!(s.contains("40 to index"), "{s}");
assert!(!s.contains("ready"), "the split should be gone: {s}");
assert!(!s.contains("to fetch"), "the split should be gone: {s}");
}
#[test]
fn a_complete_audit_mentions_neither() {
let a = IndexAudit {
coverage: faces::Coverage {
images: 10,
indexed: 10,
without_faces: 7,
faces: 4,
},
ready: 0,
awaiting_proxy: 0,
owed: Vec::new(),
};
let s = a.summary();
assert!(!s.contains("ready"), "{s}");
assert!(!s.contains("awaiting"), "{s}");
assert!(!s.contains("measure"), "{s}");
assert!(s.contains("10/10"), "{s}");
assert!(a.is_complete());
}
/// TRACES: FR-CULL-8a
/// A fully indexed library whose faces have not been read is not
/// finished: the screen hides the button on `is_complete`, and this is
/// the only way the readings of an old library ever get filled in.
#[test]
fn faces_left_to_measure_keep_the_audit_incomplete() {
let a = IndexAudit {
coverage: faces::Coverage {
images: 10,
indexed: 10,
without_faces: 7,
faces: 4,
},
ready: 0,
awaiting_proxy: 0,
owed: vec![
("images with faces to read for quality", 4),
("images with faces to read for eye state", 0),
],
};
assert!(a.coverage.is_complete());
assert!(!a.is_complete());
assert!(
a.summary()
.ends_with("; 4 images with faces to read for quality"),
"{}",
a.summary()
);
}
/// The figure the real library actually produced: 110 of 23,528 rounds to
/// "0%" at whole-number precision, which reads as nothing having happened.
#[test]
fn early_progress_does_not_display_as_zero() {
let a = IndexAudit {
coverage: faces::Coverage {
images: 23_528,
indexed: 110,
without_faces: 64,
faces: 125,
},
ready: 69,
awaiting_proxy: 23_349,
owed: Vec::new(),
};
let s = a.summary();
assert!(s.contains("0.5%"), "{s}");
assert!(!s.contains("(0%)"), "{s}");
}
#[test]
fn a_single_image_in_a_huge_library_still_shows_something() {
let a = IndexAudit {
coverage: faces::Coverage {
images: 100_000,
indexed: 1,
without_faces: 1,
faces: 0,
},
ready: 99_999,
awaiting_proxy: 0,
owed: Vec::new(),
};
assert!(a.summary().contains("<0.1%"), "{}", a.summary());
}
#[test]
fn an_empty_library_says_so_rather_than_reporting_zero_of_zero() {
assert_eq!(IndexAudit::default().summary(), "no images in the library");
}
#[test]
fn landmarks_normalise_against_the_long_edge() {
let lm = [
(512.0, 256.0),
(0.0, 0.0),
(1024.0, 512.0),
(10.0, 20.0),
(5.0, 5.0),
];
let n = normalise_landmarks(&lm, 1024.0);
assert!((n[0].0 - 0.5).abs() < 1e-6);
assert!((n[0].1 - 0.25).abs() < 1e-6);
assert!((n[2].0 - 1.0).abs() < 1e-6);
}
fn gradient(width: u32, height: u32) -> Vec<u8> {
let mut v = vec![0u8; (width * height * 4) as usize];
for (i, px) in v.chunks_exact_mut(4).enumerate() {
// A gradient, so a mis-placed crop shows up as the wrong value
// rather than as more of the same colour.
px[0] = (i % 251) as u8;
px[1] = 40;
px[2] = 90;
px[3] = 255;
}
v
}
fn stored_face(x: f32, y: f32, w: f32, h: f32) -> faces::Face {
faces::Face {
id: faces::FaceId(1),
image_id: ImageId(1),
x,
y,
w,
h,
landmarks: [(0.0, 0.0); 5],
confidence: 0.9,
crop_px: 120.0,
quality: None,
eyes: None,
landmarks_dense: Vec::new(),
model_id: "w600k_mbf".into(),
person: None,
probability: 0.0,
confirmed: false,
}
}
#[test]
fn a_face_crop_is_square_and_the_size_asked_for() {
let px = gradient(1024, 683);
let c = crop_face(&px, 1024, 683, &stored_face(0.3, 0.2, 0.1, 0.15), 96).unwrap();
assert_eq!((c.width, c.height), (96, 96));
assert_eq!(c.rgba.len(), 96 * 96 * 4);
}
#[test]
fn a_degenerate_box_yields_no_crop_rather_than_a_panic() {
let px = gradient(64, 64);
assert!(crop_face(&px, 64, 64, &stored_face(0.5, 0.5, 0.0, 0.0), 96).is_none());
assert!(crop_face(&px, 0, 0, &stored_face(0.1, 0.1, 0.2, 0.2), 96).is_none());
assert!(crop_face(&px, 64, 64, &stored_face(0.1, 0.1, 0.2, 0.2), 0).is_none());
}
/// A face at the very edge of the frame is drawn short, not smeared: the
/// out-of-frame margin stays transparent.
#[test]
fn a_face_at_the_edge_keeps_a_transparent_margin() {
let px = gradient(200, 200);
let c = crop_face(&px, 200, 200, &stored_face(0.0, 0.0, 0.1, 0.1), 32).unwrap();
assert_eq!(c.rgba[3], 0, "outside the frame should be transparent");
let centre = ((16 * 32 + 16) * 4 + 3) as usize;
assert_eq!(c.rgba[centre], 255, "the face itself should be opaque");
}
/// The normalised box means a proxy regenerated at another resolution still
/// crops the same part of the picture — what the catalog's normalisation is
/// for.
#[test]
fn the_same_face_crops_the_same_region_at_two_proxy_sizes() {
let face = stored_face(0.25, 0.25, 0.2, 0.2);
let small = crop_face(&gradient(400, 400), 400, 400, &face, 16).unwrap();
let large = crop_face(&gradient(800, 800), 800, 800, &face, 16).unwrap();
assert!(small.rgba.chunks_exact(4).all(|p| p[3] == 255));
assert!(large.rgba.chunks_exact(4).all(|p| p[3] == 255));
}
#[test]
fn the_crop_keeps_margin_around_the_detection_box() {
// A 0.1-wide face in a 1000px frame is 100px; with the margin the crop
// spans 100 * 1.35 = 135px of source.
let face = stored_face(0.4, 0.4, 0.1, 0.1);
let c = crop_face(&gradient(1000, 1000), 1000, 1000, &face, 135).unwrap();
assert_eq!(c.width, 135);
// Fully inside the frame, so nothing is transparent.
assert!(c.rgba.chunks_exact(4).all(|p| p[3] == 255));
}
#[test]
fn rgba_becomes_packed_rgb_dropping_alpha() {
let rgba = [255u8, 128, 0, 255, 0, 0, 0, 128];
let rgb = rgba_to_rgb_f32(&rgba);
assert_eq!(rgb.len(), 6);
assert!((rgb[0] - 1.0).abs() < 1e-6);
assert!((rgb[1] - 128.0 / 255.0).abs() < 1e-6);
assert!((rgb[2] - 0.0).abs() < 1e-6);
assert!(rgb[3..6].iter().all(|&v| v == 0.0));
}
// ── setting a group aside has to survive regrouping ───────────────────
const TEST_MODEL: &str = "w600k_mbf";
/// A catalog holding `n` images and nothing else.
fn catalog_with(n: usize) -> Catalog {
let c = Catalog::in_memory().unwrap();
let conn = c.connection();
conn.execute(
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
[],
)
.unwrap();
for i in 0..n {
conn.execute(
&format!(
"INSERT INTO images(id, root_id, source_ref, added_at)
VALUES ({}, 1, 'IMG_{i}.CR3', 0)",
i + 1
),
[],
)
.unwrap();
}
c
}
/// A unit embedding pointing at `identity`, `cosine` of the way there.
fn embedding(identity: usize, cosine: f32) -> Vec<u8> {
let mut v = Box::new([0.0_f32; dr_face::EMBEDDING_DIM]);
v[identity * 2] = cosine;
v[identity * 2 + 1] = (1.0 - cosine * cosine).max(0.0).sqrt();
dr_face::Embedding {
model: ModelId::new(TEST_MODEL.to_string()),
v,
}
.to_f16_bytes()
}
fn put_face(catalog: &Catalog, image: u64, identity: usize, cosine: f32) {
let f = DetectedFace {
x: 0.1,
y: 0.1,
w: 0.2,
h: 0.2,
landmarks: [(0.0, 0.0); 5],
confidence: 0.9,
embedding: embedding(identity, cosine),
crop_px: 150.0,
quality: None,
eyes: None,
landmarks_dense: Vec::new(),
model_id: TEST_MODEL.to_string(),
crop: Vec::new(),
};
faces::record_detections(
catalog.connection(),
ImageId(image),
TEST_MODEL,
1024,
std::slice::from_ref(&f),
)
.unwrap();
}
/// The bug this pins: "not interested" only ever covered *suggested* faces,
/// and reclustering anchored confirmations alone. So the next Regroup cut
/// the ignored group's faces loose, built fresh unnamed groups out of them,
/// and every stranger the user had dismissed came straight back.
#[test]
fn a_group_set_aside_does_not_come_back_on_the_next_regroup() {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let people = faces::people(catalog.connection()).unwrap();
assert_eq!(people.len(), 1, "the two faces should have grouped");
let stranger = people[0].id;
assert_eq!(people[0].suggested_faces, 2);
faces::set_ignored(catalog.connection(), stranger, true).unwrap();
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(
after.len(),
1,
"regrouping resurrected the group that was set aside: {after:?}"
);
assert_eq!(after[0].id, stranger);
assert!(after[0].ignored, "the group stopped being set aside");
assert_eq!(
after[0].suggested_faces, 2,
"the faces left the group they were set aside in"
);
}
/// And it holds as the library grows: a stranger photographed again joins
/// the group that was set aside rather than arriving as somebody new.
#[test]
fn a_new_face_joins_the_group_it_matches_even_when_that_group_is_set_aside() {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let stranger = faces::people(catalog.connection()).unwrap()[0].id;
faces::set_ignored(catalog.connection(), stranger, true).unwrap();
// The same person turns up in a third photograph.
put_face(&catalog, 3, 0, 0.98);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1, "a new face made a second group: {after:?}");
assert!(after[0].ignored);
assert_eq!(after[0].suggested_faces, 3);
}
/// The other half of the promise: bringing them back really does.
#[test]
fn bringing_a_group_back_makes_it_ordinary_again() {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let id = faces::people(catalog.connection()).unwrap()[0].id;
faces::set_ignored(catalog.connection(), id, true).unwrap();
faces::set_ignored(catalog.connection(), id, false).unwrap();
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1);
assert!(!after[0].ignored);
}
/// Naming a group does not confirm its faces, so before this they were
/// still only suggestions — and the next Regroup cut them loose, built a
/// new person out of them, and left the named one empty. Do that a few
/// times and the rail fills with same-named people holding nothing while
/// the faces sit under whichever one was made last.
#[test]
fn a_named_group_keeps_its_faces_through_the_next_regroup() {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let people = faces::people(catalog.connection()).unwrap();
assert_eq!(people.len(), 1);
let her = people[0].id;
assert_eq!(people[0].suggested_faces, 2);
// Named, and nothing else — no confirmations, which is what a user who
// types a name and moves on has done.
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(
after.len(),
1,
"regrouping left a second person behind: {after:?}"
);
assert_eq!(after[0].id, her);
assert_eq!(after[0].name, "Catherine");
assert_eq!(
after[0].suggested_faces, 2,
"the named group lost the faces it was named for"
);
}
/// And a face found later joins the person it matches rather than arriving
/// as somebody new — the same benefit anchoring gives an ignored group.
#[test]
fn a_new_face_joins_a_named_person_rather_than_starting_a_rival() {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let her = faces::people(catalog.connection()).unwrap()[0].id;
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
put_face(&catalog, 3, 0, 0.98);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1, "a second Catherine appeared: {after:?}");
assert_eq!(after[0].suggested_faces, 3);
}
/// `dr-types` sits below the face engine and cannot name its constant, so
/// it restates the number. This is the thing that stops the two drifting:
/// a settings page marking 0.80 as "default" while the engine had moved to
/// 0.75 would put the reset dot on a value nothing else agreed with.
#[test]
fn the_settings_default_is_the_engines_own_tuned_value() {
assert_eq!(
FaceSettings::default().merge_probability,
dr_face::DEFAULT_MERGE_PROBABILITY
);
}
/// The smallest-group setting has to change what is on the rail, not just
/// what the *next* pass would build. Before the release step in
/// [`recluster`], raising it left the previous pass's small groups sitting
/// there full of suggestions — not empty, so not pruned — and the control
/// looked broken.
#[test]
fn raising_the_smallest_group_takes_the_small_groups_off_the_rail() {
let catalog = catalog_with(5);
// One pair, and one trio: two groups at the default of two.
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
put_face(&catalog, 3, 1, 1.0);
put_face(&catalog, 4, 1, 0.99);
put_face(&catalog, 5, 1, 0.98);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
assert_eq!(
faces::people(catalog.connection()).unwrap().len(),
2,
"the two groups should have formed at the default"
);
recluster(
&catalog,
TEST_MODEL,
&FaceSettings {
min_group_size: 3,
..FaceSettings::default()
},
)
.unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(
after.len(),
1,
"the pair survived a smallest-group of three: {after:?}"
);
assert_eq!(after[0].suggested_faces, 3, "the trio lost members");
}
/// And the setting does not overrule the user. A group they named is theirs
/// (FR-CULL-12), however few faces it holds.
#[test]
fn a_named_group_survives_a_smallest_group_it_is_under() {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let her = faces::people(catalog.connection()).unwrap()[0].id;
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
recluster(
&catalog,
TEST_MODEL,
&FaceSettings {
min_group_size: 6,
..FaceSettings::default()
},
)
.unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1, "Catherine was dropped: {after:?}");
assert_eq!(after[0].name, "Catherine");
assert_eq!(after[0].suggested_faces, 2);
}
/// Down at one, every stray becomes a group of its own — which is what a
/// user hunting for a face the grouping missed has asked for.
#[test]
fn a_smallest_group_of_one_shows_the_strays() {
let catalog = catalog_with(2);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 1, 1.0);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
assert!(
faces::people(catalog.connection()).unwrap().is_empty(),
"two unrelated faces made a person at the default"
);
recluster(
&catalog,
TEST_MODEL,
&FaceSettings {
min_group_size: 1,
..FaceSettings::default()
},
)
.unwrap();
assert_eq!(faces::people(catalog.connection()).unwrap().len(), 2);
}
}