docs/ had 26 developer documents flat beside the manual, and the two audiences are very differently sized: most readers want the manual and the gesture reference, a few want the register, the designs and the measurements. The manual and gestures.md stay at the top; everything for someone changing the code moves to docs/dev/, and the two documents that name their own successors — the v0.1 milestone and the UI-refinement plan — go to docs/dev/archive/ rather than being deleted, since both are still cited. docs/README.md is the index, users first. Every reference follows: code comments, Cargo manifests, the workflows, the pre-commit hook, the bench and traceability tools (which locate the repo root by docs/dev/requirements.md now), packaging, the Docker READMEs, CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level deeper and is regenerated. Links out of the moved documents into the tree gain a level; a link checker over every Markdown file finds none broken.
2852 lines
108 KiB
Rust
2852 lines
108 KiB
Rust
//! TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
|
||
//! People and faces: what was detected, who it is, and who said so.
|
||
//!
|
||
//! The storage half of docs/dev/faces.md. `dr-face` finds faces and turns them into
|
||
//! 512 numbers; this module is where those numbers acquire an identity, and
|
||
//! where the user's corrections outrank the model's guesses.
|
||
//!
|
||
//! # Two kinds of fact, never conflated
|
||
//!
|
||
//! A face is either **suggested** — the system's inference, recomputable at
|
||
//! will — or **confirmed**, the user's judgement, which no later indexing pass
|
||
//! may overwrite (FR-CULL-10). That distinction is a column, not a probability
|
||
//! of 1.0, because collapsing them would lose the ability to recompute
|
||
//! suggestions without touching user data.
|
||
//!
|
||
//! It runs in both directions. [`reject`] records that a face is *not* someone,
|
||
//! and that has to be stored rather than inferred from the absence of an
|
||
//! assignment: without it the next clustering pass re-suggests exactly the face
|
||
//! the user just pushed away.
|
||
//!
|
||
//! # What is derived and what is not
|
||
//!
|
||
//! Everything here is rebuildable by re-indexing except the person's name and
|
||
//! the user's confirmations and rejections. The embeddings are expensive and
|
||
//! reproducible, which is precisely what ARCH §6.12 says belongs in a
|
||
//! disposable index; the name is irreplaceable and goes to the sidecar
|
||
//! (FR-CULL-12), which is not this module's job.
|
||
//!
|
||
//! # One population per embedder, not one per detector
|
||
//!
|
||
//! `faces.model_id` names a pipeline, `detector+embedder`, and a detector
|
||
//! change writes new rows under a new id. What it must *not* do is split the
|
||
//! library in two: the People screen, the clustering pass, the sync merge and
|
||
//! the shard import all read "the faces" — and the faces are every row whose
|
||
//! embedder half matches, because the embedder is what makes two vectors
|
||
//! comparable and the detector only decides where the boxes are. Before this
|
||
//! rule each of those read the exact id, and choosing a better detector
|
||
//! emptied every screen until a 400 GB re-index had run on every device,
|
||
//! while a name confirmed on one device could not reach the other because the
|
||
//! two held the same face under different ids.
|
||
//!
|
||
//! So reads key on [`embedder_of`] the id, via [`embedder_sql`]; writes keep
|
||
//! the exact id, so which detector drew a box stays recorded; and
|
||
//! [`record_detections`] is where the generations meet — an image holds one
|
||
//! pipeline's faces at a time, and a re-detection carries the user's
|
||
//! confirmations onto the boxes that replace them.
|
||
//!
|
||
//! # Privacy is structural here, not policy
|
||
//!
|
||
//! NFR-SEC-5 puts face data under a stricter rule than the rest of the catalog:
|
||
//! it never enters a diagnostics bundle, never reaches a plugin, and is
|
||
//! deletable in one action ([`delete_all_face_data`]). This module has no
|
||
//! network dependency and no path that emits an embedding anywhere but back to
|
||
//! its caller.
|
||
|
||
use rusqlite::{Connection, OptionalExtension};
|
||
|
||
use dr_types::ImageId;
|
||
|
||
use crate::error::CatalogError;
|
||
use dr_face::{Eye, EyeReading};
|
||
|
||
/// The embedder half of a model id: what makes two faces comparable.
|
||
///
|
||
/// `scrfd_10g+w600k_mbf` and `w600k_mbf` are the same embedder behind two
|
||
/// detectors, and their vectors live in one space. A bare id is its own
|
||
/// embedder — the first pipeline was written without a detector prefix, and
|
||
/// every library indexed before the choice existed is under that spelling.
|
||
pub fn embedder_of(model_id: &str) -> &str {
|
||
model_id.rsplit('+').next().unwrap_or(model_id)
|
||
}
|
||
|
||
/// The SQL for [`embedder_of`] over a column, for a `WHERE` that means "the
|
||
/// same population as this pipeline" rather than "this exact pipeline".
|
||
///
|
||
/// Callers pass `embedder_of(model_id)` as the bound value. A `CASE` rather
|
||
/// than `LIKE`, so the bare and the qualified spelling compare as the same
|
||
/// thing without a wildcard that `w600k_mbf_v2` would also match.
|
||
pub fn embedder_sql(column: &str) -> String {
|
||
format!(
|
||
"CASE WHEN instr({column}, '+') > 0
|
||
THEN substr({column}, instr({column}, '+') + 1)
|
||
ELSE {column} END"
|
||
)
|
||
}
|
||
|
||
/// A face's row id. Local to this catalog, like [`crate::keywords::KeywordId`].
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||
pub struct FaceId(pub u64);
|
||
|
||
/// A person's row id. Local; [`Person::uuid`] is what a merge keys on.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||
pub struct PersonId(pub u64);
|
||
|
||
/// A face as detected, before it has an identity.
|
||
///
|
||
/// Coordinates are **normalised to the image's long edge**, so a face outlives
|
||
/// the proxy it was found on being evicted and regenerated at another size.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct DetectedFace {
|
||
pub x: f32,
|
||
pub y: f32,
|
||
pub w: f32,
|
||
pub h: f32,
|
||
/// Five `(x, y)` pairs, normalised the same way.
|
||
pub landmarks: [(f32, f32); 5],
|
||
pub confidence: f32,
|
||
/// 512 × f16, the raw model output — `dr_face::Embedded::to_f16_bytes`.
|
||
///
|
||
/// Raw rather than unit length, so the length ([`Self::quality`]) is in
|
||
/// the blob and not only beside it. Readers re-normalise on load.
|
||
pub embedding: Vec<u8>,
|
||
/// Source pixels across the aligned crop (docs/dev/faces.md §7).
|
||
pub crop_px: f32,
|
||
/// Length of the raw embedding before normalisation — the model's own
|
||
/// reading of how recognisable the crop was, and the gate on whether
|
||
/// this face may be compared *against* (`dr_face::MIN_GALLERY_QUALITY`).
|
||
///
|
||
/// `None` where it was never measured: a face indexed, here or by a peer,
|
||
/// before raw vectors were stored. The unit vector those builds kept has
|
||
/// no length left to read, so the only way to measure one is to embed it
|
||
/// again (schema V14).
|
||
pub quality: Option<f32>,
|
||
/// TRACES: FR-CULL-8a
|
||
/// What the eyes are doing — P(open) per eye and P(sunglasses), read by
|
||
/// `dr_face::classify` from the same aligned crop. `None` where the eye
|
||
/// models were not present at indexing, or the face came from a peer
|
||
/// that had none; the measuring pass fills it in (schema V16).
|
||
pub eyes: Option<EyeReading>,
|
||
/// The 106 dense landmarks the eyes were read from, as
|
||
/// `dr_face::Landmarks::to_packed_bytes` — 424 bytes, or empty where
|
||
/// the face was never read (schema V18). Kept so a later per-face pass
|
||
/// need not fetch the original again.
|
||
pub landmarks_dense: Vec<u8>,
|
||
/// Which model produced the embedding. Comparing across models is the one
|
||
/// mistake that yields plausible garbage rather than an error.
|
||
pub model_id: String,
|
||
/// The face itself, cut out and encoded, ready to draw.
|
||
///
|
||
/// Cut at detection time because that is the one moment the pixels are
|
||
/// already in memory. The alternative -- and what this replaced -- is
|
||
/// re-decoding the whole proxy and cutting the box out again every time
|
||
/// the People screen opens, which makes the screen a derivative of a cache
|
||
/// that is entitled to evict anything at any time.
|
||
///
|
||
/// Empty is allowed and means "not cut": a caller with only a box and an
|
||
/// embedding, such as a face adopted from a peer's shard that predates
|
||
/// crops, stores nothing here and the reader falls back to the proxy.
|
||
pub crop: Vec<u8>,
|
||
}
|
||
|
||
/// A stored face, with whatever identity it has acquired.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct Face {
|
||
pub id: FaceId,
|
||
pub image_id: ImageId,
|
||
pub x: f32,
|
||
pub y: f32,
|
||
pub w: f32,
|
||
pub h: f32,
|
||
pub landmarks: [(f32, f32); 5],
|
||
pub confidence: f32,
|
||
pub crop_px: f32,
|
||
/// See [`DetectedFace::quality`]. `None` for a face indexed before it was
|
||
/// recorded.
|
||
pub quality: Option<f32>,
|
||
/// See [`DetectedFace::eyes`]. `None` for a face never read.
|
||
pub eyes: Option<EyeReading>,
|
||
/// See [`DetectedFace::landmarks_dense`]. Empty for a face never read.
|
||
pub landmarks_dense: Vec<u8>,
|
||
pub model_id: String,
|
||
/// `None` when the face belongs to no one yet.
|
||
pub person: Option<PersonId>,
|
||
/// Calibrated P(this face is this person). Meaningless without `person`.
|
||
pub probability: f32,
|
||
/// Whether the user asserted the assignment, as against the system guessing.
|
||
pub confirmed: bool,
|
||
}
|
||
|
||
/// A person, and how much of them the library holds.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct Person {
|
||
pub id: PersonId,
|
||
pub uuid: String,
|
||
pub name: String,
|
||
/// Faces the user has confirmed.
|
||
pub confirmed_faces: u64,
|
||
/// Faces the system suggests and the user has not ruled on.
|
||
pub suggested_faces: u64,
|
||
/// The user has looked at this group and does not want to identify it.
|
||
///
|
||
/// Distinct from an empty name, which means *not yet looked at*. Most
|
||
/// clusters in a real library are strangers, and without somewhere to put
|
||
/// that judgement the screen never gets shorter however much work the user
|
||
/// does on it.
|
||
pub ignored: bool,
|
||
}
|
||
|
||
/// The FR-CULL-9 calibration, re-exported from where it is fitted.
|
||
///
|
||
/// Deliberately *not* redefined here. The catalog stores five numbers; what
|
||
/// those numbers mean — the sigmoid, the size term, the prior — is
|
||
/// [`dr_face::calibrate`]'s business, and two implementations of one
|
||
/// probability model is precisely the kind of divergence that yields a
|
||
/// plausible number meaning the wrong thing.
|
||
pub use dr_face::Calibration;
|
||
|
||
// ── detection ─────────────────────────────────────────────────────────────
|
||
|
||
/// The cosine above which two vectors from one embedder are taken to be
|
||
/// the same face in the same photograph, for carrying an identity across a
|
||
/// re-detection.
|
||
///
|
||
/// Set at the reference library's P≈0.95 line (docs/dev/faces.md §9's table:
|
||
/// 0.449), which is far above anything two different people in one frame
|
||
/// reach and below what one face re-embedded from a better crop of itself
|
||
/// does. The number is only ever asked about *overlapping* boxes on *one*
|
||
/// image, which is what makes such a loose figure safe: the question is not
|
||
/// "is this the same person" but "is this the same face, now that the box
|
||
/// has moved".
|
||
pub const SAME_FACE_COSINE: f32 = 0.45;
|
||
|
||
/// What one stored face knew about itself, read before a re-detection
|
||
/// replaces it.
|
||
struct Prior {
|
||
x: f32,
|
||
y: f32,
|
||
w: f32,
|
||
h: f32,
|
||
/// Decoded and unit length, under the *embedder* half of its id so a
|
||
/// cosine against a new face from the same embedder is defined.
|
||
embedding: Option<dr_face::Embedding>,
|
||
/// `(person, probability, confirmed)`, where the face had one.
|
||
assignment: Option<(i64, f64, bool)>,
|
||
rejected: Vec<i64>,
|
||
}
|
||
|
||
/// Replace every face on an image with a fresh detection pass.
|
||
///
|
||
/// Replace rather than append, because `DetectFaces` is coalesced per image
|
||
/// (FR-CAT-3) and re-running it must be idempotent — appending would double the
|
||
/// faces on every re-index and silently inflate every cluster.
|
||
///
|
||
/// # What survives the replacement
|
||
///
|
||
/// **The identity of every face the new detection finds again** — the user's
|
||
/// confirmations first of all (FR-CULL-10), and with them the suggestions
|
||
/// the last grouping pass made and the people the user has said a face is
|
||
/// *not*. A re-index with a better model must not discard the user's
|
||
/// labelling, and it should not empty the People screen either: a library
|
||
/// of thirteen thousand suggestions re-detected into thirteen thousand
|
||
/// unassigned faces is correct by the letter of FR-CULL-12 and looks, to
|
||
/// the person who spent an afternoon on it, like the work was thrown away.
|
||
///
|
||
/// A new face is the same face as an old one when the two **overlap in the
|
||
/// frame and either the boxes agree or the embeddings do**: an IoU above 0.5,
|
||
/// which is loose on purpose because a better detector is entitled to move
|
||
/// the box; or a cosine above [`SAME_FACE_COSINE`] between the vectors, for
|
||
/// the box a low-resolution pass drew badly enough that overlap alone would
|
||
/// not claim it. The embedding is also what breaks a tie — two faces side by
|
||
/// side in a group photograph both overlap both new boxes, and the vector
|
||
/// says which is which where the rectangles cannot. Each old face is
|
||
/// carried onto at most one new one, best pair first.
|
||
///
|
||
/// **Every model's faces are replaced, and every other model's marker goes
|
||
/// with them.** An image holds the faces of whichever pipeline looked at it
|
||
/// last, never a mixture — two detectors drawing boxes over the same face is
|
||
/// not two opinions but a duplicate. So the replacement is unconditional on
|
||
/// `model_id`, and the run markers of the pipelines whose faces were just
|
||
/// removed are dropped too: a marker that says "done" over an image with
|
||
/// none of that model's faces is exactly the state that made the V12 repair
|
||
/// necessary, and a user who switches their detector back would otherwise
|
||
/// find those photographs permanently empty.
|
||
pub fn record_detections(
|
||
conn: &Connection,
|
||
image_id: ImageId,
|
||
model_id: &str,
|
||
source_edge: u32,
|
||
faces: &[DetectedFace],
|
||
) -> Result<Vec<FaceId>, CatalogError> {
|
||
let tx = conn.unchecked_transaction()?;
|
||
|
||
// Everything the old faces knew, so it can be carried across the
|
||
// replacement. Read only when there is something to carry it onto: a
|
||
// pass that found nothing has nothing to match, and decoding a vector
|
||
// per face for no reader would be the wasted work.
|
||
let prior = if faces.is_empty() {
|
||
Vec::new()
|
||
} else {
|
||
read_priors(&tx, image_id)?
|
||
};
|
||
|
||
tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?;
|
||
tx.execute(
|
||
"DELETE FROM face_index WHERE image_id = ?1 AND model_id != ?2",
|
||
rusqlite::params![image_id.0 as i64, model_id],
|
||
)?;
|
||
|
||
let carried = match_priors(&prior, faces);
|
||
|
||
let now = now_secs();
|
||
let mut ids = Vec::with_capacity(faces.len());
|
||
for (i, f) in faces.iter().enumerate() {
|
||
tx.execute(
|
||
"INSERT INTO faces
|
||
(image_id, x, y, w, h, landmarks, detector_confidence,
|
||
embedding, crop_px, model_id, detected_at, crop, quality,
|
||
eye_right, eye_right_px, eye_right_sharp,
|
||
eye_left, eye_left_px, eye_left_sharp, sunglasses, landmarks_dense)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13,
|
||
?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)",
|
||
rusqlite::params![
|
||
image_id.0 as i64,
|
||
f.x as f64,
|
||
f.y as f64,
|
||
f.w as f64,
|
||
f.h as f64,
|
||
landmarks_to_blob(&f.landmarks),
|
||
f.confidence as f64,
|
||
f.embedding,
|
||
f.crop_px as f64,
|
||
f.model_id,
|
||
now,
|
||
// NULL rather than an empty blob, so "no crop" is one state in
|
||
// the database rather than two the readers each have to know
|
||
// about.
|
||
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
|
||
f.quality.map(f64::from),
|
||
f.eyes.map(|e| f64::from(e.right.open)),
|
||
f.eyes.map(|e| f64::from(e.right.px)),
|
||
f.eyes.map(|e| f64::from(e.right.sharpness)),
|
||
f.eyes.map(|e| f64::from(e.left.open)),
|
||
f.eyes.map(|e| f64::from(e.left.px)),
|
||
f.eyes.map(|e| f64::from(e.left.sharpness)),
|
||
f.eyes.map(|e| f64::from(e.sunglasses)),
|
||
(!f.landmarks_dense.is_empty()).then_some(f.landmarks_dense.as_slice()),
|
||
],
|
||
)?;
|
||
let id = FaceId(tx.last_insert_rowid() as u64);
|
||
|
||
if let Some(p) = carried[i].map(|at| &prior[at]) {
|
||
if let Some((person, prob, confirmed)) = p.assignment {
|
||
tx.execute(
|
||
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
|
||
VALUES (?1, ?2, ?3, ?4)",
|
||
rusqlite::params![id.0 as i64, person, prob, confirmed],
|
||
)?;
|
||
}
|
||
for person in &p.rejected {
|
||
tx.execute(
|
||
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
|
||
VALUES (?1, ?2)",
|
||
rusqlite::params![id.0 as i64, person],
|
||
)?;
|
||
}
|
||
}
|
||
|
||
ids.push(id);
|
||
}
|
||
|
||
let lost = prior
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(at, p)| p.assignment.is_some() && !carried.contains(&Some(*at)))
|
||
.count();
|
||
if lost > 0 {
|
||
log::debug!(
|
||
"image {}: {lost} assigned face(s) not found again by the re-detection",
|
||
image_id.0
|
||
);
|
||
}
|
||
|
||
// The run marker, written whether or not anything was found. Zero faces is
|
||
// a real answer and recording it is what stops the next pass looking at
|
||
// this photograph again -- see the V9 migration.
|
||
tx.execute(
|
||
"INSERT INTO face_index (image_id, model_id, indexed_at, faces_found, source_edge)
|
||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||
ON CONFLICT(image_id, model_id) DO UPDATE SET
|
||
indexed_at = excluded.indexed_at,
|
||
faces_found = excluded.faces_found,
|
||
source_edge = excluded.source_edge",
|
||
rusqlite::params![
|
||
image_id.0 as i64,
|
||
model_id,
|
||
now,
|
||
faces.len() as i64,
|
||
source_edge as i64,
|
||
],
|
||
)?;
|
||
|
||
tx.commit()?;
|
||
Ok(ids)
|
||
}
|
||
|
||
/// The faces on an image as they stand, with everything a re-detection
|
||
/// carries forward.
|
||
fn read_priors(tx: &Connection, image_id: ImageId) -> Result<Vec<Prior>, CatalogError> {
|
||
let mut out: Vec<(i64, Prior)> = Vec::new();
|
||
{
|
||
let mut q = tx.prepare(
|
||
"SELECT f.id, f.x, f.y, f.w, f.h, f.model_id, f.embedding,
|
||
fp.person_id, fp.probability, fp.confirmed
|
||
FROM faces f
|
||
LEFT JOIN face_person fp ON fp.face_id = f.id
|
||
WHERE f.image_id = ?1
|
||
ORDER BY f.id",
|
||
)?;
|
||
let rows = q.query_map([image_id.0 as i64], |r| {
|
||
let model: String = r.get(5)?;
|
||
let blob: Vec<u8> = r.get(6)?;
|
||
let person: Option<i64> = r.get(7)?;
|
||
Ok((
|
||
r.get::<_, i64>(0)?,
|
||
Prior {
|
||
x: r.get::<_, f64>(1)? as f32,
|
||
y: r.get::<_, f64>(2)? as f32,
|
||
w: r.get::<_, f64>(3)? as f32,
|
||
h: r.get::<_, f64>(4)? as f32,
|
||
embedding: dr_face::Embedding::from_f16_bytes(
|
||
dr_face::ModelId::new(embedder_of(&model).to_string()),
|
||
&blob,
|
||
),
|
||
assignment: match person {
|
||
Some(p) => Some((p, r.get::<_, f64>(8)?, r.get::<_, i64>(9)? != 0)),
|
||
None => None,
|
||
},
|
||
rejected: Vec::new(),
|
||
},
|
||
))
|
||
})?;
|
||
for row in rows {
|
||
out.push(row?);
|
||
}
|
||
}
|
||
{
|
||
let mut q = tx.prepare(
|
||
"SELECT fr.face_id, fr.person_id
|
||
FROM face_person_rejected fr
|
||
JOIN faces f ON f.id = fr.face_id
|
||
WHERE f.image_id = ?1",
|
||
)?;
|
||
let rows = q.query_map([image_id.0 as i64], |r| {
|
||
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
|
||
})?;
|
||
for row in rows {
|
||
let (face, person) = row?;
|
||
if let Some((_, p)) = out.iter_mut().find(|(id, _)| *id == face) {
|
||
p.rejected.push(person);
|
||
}
|
||
}
|
||
}
|
||
Ok(out.into_iter().map(|(_, p)| p).collect())
|
||
}
|
||
|
||
/// Which old face, if any, each new detection replaces — indexed like
|
||
/// `faces`, holding an index into `prior`.
|
||
///
|
||
/// One-to-one, best pair first: every qualifying pair is scored by how much
|
||
/// the boxes and the vectors agree, and pairs are taken in that order until
|
||
/// each side is spoken for. A pair qualifies when the boxes overlap at all
|
||
/// and either the overlap alone says so (IoU above 0.5) or the embeddings do
|
||
/// ([`SAME_FACE_COSINE`]). Overlap is required on both routes, because two
|
||
/// faces of one person in one frame — a mirror, a photograph within the
|
||
/// photograph — have the same vector and must not swap names.
|
||
fn match_priors(prior: &[Prior], faces: &[DetectedFace]) -> Vec<Option<usize>> {
|
||
let mut pairs: Vec<(f32, usize, usize)> = Vec::new();
|
||
for (pi, p) in prior.iter().enumerate() {
|
||
let old = (p.x, p.y, p.w, p.h);
|
||
for (fi, f) in faces.iter().enumerate() {
|
||
let overlap = iou(old, (f.x, f.y, f.w, f.h));
|
||
if overlap <= 0.0 {
|
||
continue;
|
||
}
|
||
let cosine = p
|
||
.embedding
|
||
.as_ref()
|
||
.and_then(|old| {
|
||
let new = dr_face::Embedding::from_f16_bytes(
|
||
dr_face::ModelId::new(embedder_of(&f.model_id).to_string()),
|
||
&f.embedding,
|
||
)?;
|
||
old.cosine(&new)
|
||
})
|
||
.unwrap_or(0.0);
|
||
if overlap > 0.5 || cosine >= SAME_FACE_COSINE {
|
||
pairs.push((overlap + cosine.max(0.0), pi, fi));
|
||
}
|
||
}
|
||
}
|
||
pairs.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||
|
||
let mut carried = vec![None; faces.len()];
|
||
let mut taken = vec![false; prior.len()];
|
||
for (_, pi, fi) in pairs {
|
||
if taken[pi] || carried[fi].is_some() {
|
||
continue;
|
||
}
|
||
taken[pi] = true;
|
||
carried[fi] = Some(pi);
|
||
}
|
||
carried
|
||
}
|
||
|
||
/// What a per-face pass wants written over one stored face.
|
||
///
|
||
/// Each field is `Some` where the pass produced it and `None` where it is
|
||
/// to be left exactly as it was: a device without the eye models writing a
|
||
/// fresh vector must not blank a reading a peer had already made. One
|
||
/// struct for every pass rather than one writer per column, so a new
|
||
/// per-face field is a field here and a handler in `dr_ui::faces::repairs`,
|
||
/// and nothing else.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct FaceUpdate {
|
||
pub face: FaceId,
|
||
/// The raw vector (`dr_face::Embedded::to_f16_bytes`) and its length.
|
||
pub embedding: Option<(Vec<u8>, f32)>,
|
||
/// See [`DetectedFace::eyes`], with the dense landmarks it was read from
|
||
/// (see [`DetectedFace::landmarks_dense`]; empty stores NULL).
|
||
pub eyes: Option<(EyeReading, Vec<u8>)>,
|
||
/// See [`DetectedFace::crop`]. An empty crop is not written.
|
||
pub crop: Option<Vec<u8>>,
|
||
}
|
||
|
||
impl FaceUpdate {
|
||
/// An update that changes nothing yet, for a handler to fill one field of.
|
||
pub fn for_face(face: FaceId) -> Self {
|
||
Self {
|
||
face,
|
||
embedding: None,
|
||
eyes: None,
|
||
crop: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Write what a per-face pass produced over the faces it read, and re-mark
|
||
/// the image as indexed.
|
||
///
|
||
/// The cheaper half of what `record_detections` does, for a face whose box
|
||
/// and landmarks are right and whose identity is the user's work, and which
|
||
/// lacks something a later pass can fill from the native render: its
|
||
/// quality (schema V14), its eye reading and dense landmarks (V16, V18), its
|
||
/// crop. Updating in place is what keeps `face_person` and the face ids
|
||
/// exactly as they were -- a re-detection carries identities across by
|
||
/// matching old faces to new, and a match is a judgement where an update
|
||
/// in place is a fact.
|
||
///
|
||
/// `dropped` are faces whose landmarks turned out to be degenerate -- the
|
||
/// warp could not be built from them. Deleted here, as detection would have
|
||
/// refused to store them (`dr_ui::faces::index_native`), and because a face
|
||
/// that can never be filled would put its image back on the pass's list on
|
||
/// every sweep, at the cost of an original each time.
|
||
///
|
||
/// The run marker is re-written with a fresh time, and that is not
|
||
/// bookkeeping: `face_shard::export_to_shards` re-exports an image whose
|
||
/// marker is newer than the store's copy, which is how what was written
|
||
/// here reaches the other devices.
|
||
///
|
||
/// It is re-written under the pipeline id the **faces carry**, not the one
|
||
/// this pass ran as. `model_id` names the pass only through its embedder;
|
||
/// the detector half of a marker is a statement about who drew the boxes,
|
||
/// and this pass drew none. Every reader takes the two to agree: the export
|
||
/// selects an image's faces by the marker's id, `marker_under` takes a
|
||
/// marker as proof the detector has been over the image, and the shard
|
||
/// store keys each face by it. When the marker was written as
|
||
/// `scrfd_10g+w600k_mbf` over faces still spelled `w600k_mbf`, the export
|
||
/// found no faces under it and sent the other devices an entry saying the
|
||
/// thorough detector had looked and found nothing — over photographs with
|
||
/// named faces on them. With no faces left, the pass's own id is the only
|
||
/// one there is, and the marker says so.
|
||
pub fn record_updates(
|
||
conn: &Connection,
|
||
image_id: ImageId,
|
||
model_id: &str,
|
||
source_edge: u32,
|
||
updates: &[FaceUpdate],
|
||
dropped: &[FaceId],
|
||
) -> Result<(), CatalogError> {
|
||
let tx = conn.unchecked_transaction()?;
|
||
for u in updates {
|
||
if let Some((embedding, quality)) = &u.embedding {
|
||
tx.execute(
|
||
"UPDATE faces SET embedding = ?2, quality = ?3 WHERE id = ?1",
|
||
rusqlite::params![u.face.0 as i64, embedding, f64::from(*quality)],
|
||
)?;
|
||
}
|
||
if let Some((e, dense)) = &u.eyes {
|
||
tx.execute(
|
||
"UPDATE faces
|
||
SET eye_right = ?2, eye_right_px = ?3, eye_right_sharp = ?4,
|
||
eye_left = ?5, eye_left_px = ?6, eye_left_sharp = ?7,
|
||
sunglasses = ?8, landmarks_dense = ?9
|
||
WHERE id = ?1",
|
||
rusqlite::params![
|
||
u.face.0 as i64,
|
||
f64::from(e.right.open),
|
||
f64::from(e.right.px),
|
||
f64::from(e.right.sharpness),
|
||
f64::from(e.left.open),
|
||
f64::from(e.left.px),
|
||
f64::from(e.left.sharpness),
|
||
f64::from(e.sunglasses),
|
||
(!dense.is_empty()).then_some(dense.as_slice()),
|
||
],
|
||
)?;
|
||
}
|
||
if let Some(crop) = u.crop.as_ref().filter(|c| !c.is_empty()) {
|
||
tx.execute(
|
||
"UPDATE faces SET crop = ?2 WHERE id = ?1",
|
||
rusqlite::params![u.face.0 as i64, crop],
|
||
)?;
|
||
}
|
||
}
|
||
for f in dropped {
|
||
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
|
||
}
|
||
let (remaining, found_by): (i64, Option<String>) = tx.query_row(
|
||
&format!(
|
||
"SELECT COUNT(*), MIN(model_id) FROM faces WHERE image_id = ?1 AND {} = ?2",
|
||
embedder_sql("model_id")
|
||
),
|
||
rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|
||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||
)?;
|
||
let marker = found_by.as_deref().unwrap_or(model_id);
|
||
// One marker per embedder: a stale one under another spelling would
|
||
// keep saying that detector had been here, which is the claim the
|
||
// faces' own id is now making in its place.
|
||
tx.execute(
|
||
&format!(
|
||
"DELETE FROM face_index
|
||
WHERE image_id = ?1 AND model_id != ?2 AND {} = ?3",
|
||
embedder_sql("model_id")
|
||
),
|
||
rusqlite::params![image_id.0 as i64, marker, embedder_of(model_id)],
|
||
)?;
|
||
tx.execute(
|
||
"INSERT INTO face_index (image_id, model_id, indexed_at, faces_found, source_edge)
|
||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||
ON CONFLICT(image_id, model_id) DO UPDATE SET
|
||
indexed_at = excluded.indexed_at,
|
||
faces_found = excluded.faces_found,
|
||
source_edge = excluded.source_edge",
|
||
rusqlite::params![
|
||
image_id.0 as i64,
|
||
marker,
|
||
now_secs(),
|
||
remaining,
|
||
source_edge as i64,
|
||
],
|
||
)?;
|
||
tx.commit()?;
|
||
Ok(())
|
||
}
|
||
|
||
/// The faces on one image that a per-face pass still owes something to.
|
||
///
|
||
/// `needs` is SQL over `faces` aliased as `f` -- `f.quality IS NULL`, say --
|
||
/// and it comes from the pass, not from here: which columns a face can lack
|
||
/// is the business of the handlers that fill them (`dr_ui::faces::repairs`),
|
||
/// and the catalog's part is to answer the question exactly as asked, so
|
||
/// that the count a screen shows, the list a sweep fetches and the faces a
|
||
/// handler is given are one predicate and the pass converges.
|
||
///
|
||
/// Keyed on the embedder half of `model_id`, like every other reader: a
|
||
/// face found by another detector in front of the same embedder is one of
|
||
/// this pipeline's faces.
|
||
pub fn faces_needing(
|
||
conn: &Connection,
|
||
image_id: ImageId,
|
||
model_id: &str,
|
||
needs: &str,
|
||
) -> Result<Vec<Face>, CatalogError> {
|
||
let mut q = conn.prepare(&format!(
|
||
"SELECT f.id FROM faces f
|
||
WHERE f.image_id = ?1 AND {} = ?2 AND ({needs})",
|
||
embedder_sql("f.model_id")
|
||
))?;
|
||
let owed: std::collections::HashSet<i64> = q
|
||
.query_map(
|
||
rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|
||
|r| r.get::<_, i64>(0),
|
||
)?
|
||
.collect::<Result<_, _>>()?;
|
||
Ok(for_image(conn, image_id)?
|
||
.into_iter()
|
||
.filter(|f| owed.contains(&(f.id.0 as i64)))
|
||
.collect())
|
||
}
|
||
|
||
/// How many of a model's faces a per-face pass still owes something to.
|
||
/// `needs` is what it is in [`faces_needing`].
|
||
pub fn count_needing(conn: &Connection, model_id: &str, needs: &str) -> Result<u64, CatalogError> {
|
||
conn.query_row(
|
||
&format!(
|
||
"SELECT COUNT(*) FROM faces f WHERE {} = ?1 AND ({needs})",
|
||
embedder_sql("f.model_id")
|
||
),
|
||
[embedder_of(model_id)],
|
||
|r| r.get::<_, i64>(0),
|
||
)
|
||
.map(|n| n as u64)
|
||
.map_err(Into::into)
|
||
}
|
||
|
||
/// How much of the library has been through face detection.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub struct Coverage {
|
||
/// Images that are candidates at all — present, not trashed, and not the
|
||
/// shadowed half of a RAW+JPEG pair. The same population every sweep's
|
||
/// work list is drawn from; see [`coverage`] for why that matters.
|
||
pub images: u64,
|
||
/// Images this model has actually looked at.
|
||
pub indexed: u64,
|
||
/// Of those, how many had no face in them. Usually most of a library, and
|
||
/// worth showing so "0 faces" reads as a finding rather than a failure.
|
||
pub without_faces: u64,
|
||
/// Faces found across the whole library.
|
||
pub faces: u64,
|
||
}
|
||
|
||
impl Coverage {
|
||
/// Images still to look at.
|
||
pub fn outstanding(&self) -> u64 {
|
||
self.images.saturating_sub(self.indexed)
|
||
}
|
||
|
||
pub fn is_complete(&self) -> bool {
|
||
self.outstanding() == 0
|
||
}
|
||
|
||
/// Fraction indexed, `0.0..=1.0`. An empty library is complete, not zero:
|
||
/// there is nothing outstanding, and reporting 0% would read as a stall.
|
||
pub fn fraction(&self) -> f32 {
|
||
if self.images == 0 {
|
||
1.0
|
||
} else {
|
||
self.indexed as f32 / self.images as f32
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Count what has and has not been indexed, for one model.
|
||
///
|
||
/// The question the batch pass asks before it starts and the UI asks to draw a
|
||
/// progress figure. Answerable only because [`record_detections`] writes a run
|
||
/// marker: counting `faces` rows would report how many faces exist, which is a
|
||
/// different number and never reaches the image count.
|
||
///
|
||
/// # Shadowed images are not candidates, and the denominator has to agree
|
||
///
|
||
/// A shadowed image is the JPEG half of a RAW+JPEG pair. It is not a separate
|
||
/// photograph — the grid does not show it, and every sweep that builds a work
|
||
/// list excludes it. Counting it here anyway is not a rounding error: on the
|
||
/// reference library it put 4,424 images into the denominator that no pass is
|
||
/// permitted to touch, so "4,593 outstanding" had a floor of 4,424 that no
|
||
/// amount of indexing could ever bring down, and [`Coverage::is_complete`]
|
||
/// could never once return true. A progress figure that cannot reach its own
|
||
/// target reads exactly like a stuck job, which is what it was taken for.
|
||
pub fn coverage(conn: &Connection, model_id: &str) -> Result<Coverage, CatalogError> {
|
||
let images: i64 = conn.query_row(
|
||
"SELECT COUNT(*) FROM images
|
||
WHERE trashed_at IS NULL AND shadowed_by IS NULL",
|
||
[],
|
||
|r| r.get(0),
|
||
)?;
|
||
// One row per image, whichever compatible pipeline wrote it: an image
|
||
// holds one pipeline's faces at a time (`record_detections`), so the
|
||
// markers of one embedder never double-count a photograph.
|
||
let (indexed, without, faces): (i64, i64, i64) = conn.query_row(
|
||
&format!(
|
||
"SELECT COUNT(*), COALESCE(SUM(faces_found = 0), 0), COALESCE(SUM(faces_found), 0)
|
||
FROM face_index fi
|
||
JOIN images i ON i.id = fi.image_id
|
||
WHERE {} = ?1
|
||
AND i.trashed_at IS NULL AND i.shadowed_by IS NULL",
|
||
embedder_sql("fi.model_id")
|
||
),
|
||
[embedder_of(model_id)],
|
||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||
)?;
|
||
|
||
Ok(Coverage {
|
||
images: images as u64,
|
||
indexed: indexed as u64,
|
||
without_faces: without as u64,
|
||
faces: faces as u64,
|
||
})
|
||
}
|
||
|
||
/// Whether one image has been through this model, or one it shares an
|
||
/// embedder with.
|
||
pub fn is_indexed(
|
||
conn: &Connection,
|
||
image_id: ImageId,
|
||
model_id: &str,
|
||
) -> Result<bool, CatalogError> {
|
||
conn.query_row(
|
||
&format!(
|
||
"SELECT EXISTS(SELECT 1 FROM face_index WHERE image_id = ?1 AND {} = ?2)",
|
||
embedder_sql("model_id")
|
||
),
|
||
rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|
||
|r| r.get(0),
|
||
)
|
||
.map_err(Into::into)
|
||
}
|
||
|
||
/// Forget that an image was indexed, so the next pass looks again.
|
||
///
|
||
/// What a re-index asks for. Separate from deleting the faces because the two
|
||
/// are wanted at different times: clearing the marker alone re-detects and
|
||
/// replaces, which is the ordinary "try again with a better proxy" case.
|
||
pub fn clear_index_marker(
|
||
conn: &Connection,
|
||
image_id: ImageId,
|
||
model_id: &str,
|
||
) -> Result<(), CatalogError> {
|
||
conn.execute(
|
||
&format!(
|
||
"DELETE FROM face_index WHERE image_id = ?1 AND {} = ?2",
|
||
embedder_sql("model_id")
|
||
),
|
||
rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Every face on one image.
|
||
pub fn for_image(conn: &Connection, image_id: ImageId) -> Result<Vec<Face>, CatalogError> {
|
||
let mut q = conn.prepare(
|
||
"SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks,
|
||
f.detector_confidence, f.crop_px, f.model_id,
|
||
fp.person_id, fp.probability, fp.confirmed, f.quality,
|
||
f.eye_right, f.eye_right_px, f.eye_right_sharp,
|
||
f.eye_left, f.eye_left_px, f.eye_left_sharp, f.sunglasses,
|
||
f.landmarks_dense
|
||
FROM faces f
|
||
LEFT JOIN face_person fp ON fp.face_id = f.id
|
||
WHERE f.image_id = ?1
|
||
ORDER BY f.w * f.h DESC",
|
||
)?;
|
||
let rows = q.query_map([image_id.0 as i64], read_face)?;
|
||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||
}
|
||
|
||
/// Faces with no identity yet, for the clustering pass to work on.
|
||
///
|
||
/// Rejections are not exclusions here: a face rejected from one person is still
|
||
/// unassigned and still belongs in the next clustering round, just not in that
|
||
/// person's cluster.
|
||
pub fn unassigned(conn: &Connection, model_id: &str) -> Result<Vec<FaceId>, CatalogError> {
|
||
let mut q = conn.prepare(&format!(
|
||
"SELECT f.id FROM faces f
|
||
LEFT JOIN face_person fp ON fp.face_id = f.id
|
||
WHERE fp.face_id IS NULL AND {} = ?1",
|
||
embedder_sql("f.model_id")
|
||
))?;
|
||
let rows = q.query_map([embedder_of(model_id)], |r| {
|
||
Ok(FaceId(r.get::<_, i64>(0)? as u64))
|
||
})?;
|
||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||
}
|
||
|
||
/// How many faces have no identity yet — [`unassigned`] counted rather than
|
||
/// listed, for a screen that only shows the number.
|
||
pub fn count_unassigned(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
|
||
let n: i64 = conn.query_row(
|
||
&format!(
|
||
"SELECT COUNT(*) FROM faces f
|
||
LEFT JOIN face_person fp ON fp.face_id = f.id
|
||
WHERE fp.face_id IS NULL AND {} = ?1",
|
||
embedder_sql("f.model_id")
|
||
),
|
||
[embedder_of(model_id)],
|
||
|r| r.get(0),
|
||
)?;
|
||
Ok(n as u64)
|
||
}
|
||
|
||
/// One face's stored embedding, as the clustering pass consumes it.
|
||
///
|
||
/// A struct rather than a tuple because it crosses a crate boundary and "the
|
||
/// fourth element" is not a thing anyone should have to remember.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct StoredEmbedding {
|
||
pub face: FaceId,
|
||
pub image: ImageId,
|
||
/// 512 × f16 — `dr_face::Embedding::from_f16_bytes` reads it.
|
||
pub embedding: Vec<u8>,
|
||
pub crop_px: f32,
|
||
/// See [`DetectedFace::quality`].
|
||
pub quality: Option<f32>,
|
||
}
|
||
|
||
/// Embeddings for clustering, oldest first so the pass is deterministic.
|
||
///
|
||
/// Every face this model's embedder produced, whichever detector found it —
|
||
/// see the module note. Returned as raw f16 blobs rather than decoded vectors:
|
||
/// the caller is `dr-face`, which owns the decoding, and a catalog that
|
||
/// widened them here would double the memory of the one operation that holds
|
||
/// them all at once.
|
||
pub fn embeddings(conn: &Connection, model_id: &str) -> Result<Vec<StoredEmbedding>, CatalogError> {
|
||
let mut q = conn.prepare(&format!(
|
||
"SELECT id, image_id, embedding, crop_px, quality FROM faces
|
||
WHERE {} = ?1 ORDER BY id",
|
||
embedder_sql("model_id")
|
||
))?;
|
||
let rows = q.query_map([embedder_of(model_id)], |r| {
|
||
Ok(StoredEmbedding {
|
||
face: FaceId(r.get::<_, i64>(0)? as u64),
|
||
image: ImageId(r.get::<_, i64>(1)? as u64),
|
||
embedding: r.get::<_, Vec<u8>>(2)?,
|
||
crop_px: r.get::<_, f64>(3)? as f32,
|
||
quality: r.get::<_, Option<f64>>(4)?.map(|q| q as f32),
|
||
})
|
||
})?;
|
||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||
}
|
||
|
||
// ── people ────────────────────────────────────────────────────────────────
|
||
|
||
/// Create a person.
|
||
///
|
||
/// A person is a UUID first and a name second (FR-CULL-10): the name is what
|
||
/// the user typed, the uuid is what a cross-device merge keys on, and renaming
|
||
/// changes only the former.
|
||
pub fn create_person(conn: &Connection, name: &str) -> Result<PersonId, CatalogError> {
|
||
let now = now_secs();
|
||
conn.execute(
|
||
"INSERT INTO people (uuid, name, created, revision, modified)
|
||
VALUES (?1, ?2, ?3, 1, ?3)",
|
||
rusqlite::params![new_uuid(), name, now],
|
||
)?;
|
||
Ok(PersonId(conn.last_insert_rowid() as u64))
|
||
}
|
||
|
||
/// Rename a person. The identity is untouched.
|
||
pub fn rename_person(conn: &Connection, person: PersonId, name: &str) -> Result<(), CatalogError> {
|
||
conn.execute(
|
||
"UPDATE people SET name = ?2, revision = revision + 1, modified = ?3
|
||
WHERE id = ?1",
|
||
rusqlite::params![person.0 as i64, name, now_secs()],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Everyone in the library, with their face counts, most-photographed first.
|
||
///
|
||
/// Merged-away people are excluded: they exist as redirects so a sync does not
|
||
/// resurrect them, not as entries in a list.
|
||
pub fn people(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
|
||
people_where(conn, false)
|
||
}
|
||
|
||
/// Everyone who holds a face, carries a name, or was set aside — the people a
|
||
/// screen has a row for.
|
||
///
|
||
/// The rest are the empty, unnamed groups a regrouping pass leaves behind
|
||
/// (`prune_empty_unnamed`), and on the reference library they were 17,000 of
|
||
/// 19,000 rows: read, counted, sorted by name and then thrown away by the
|
||
/// caller on every redraw. Filtered here, in the query, they are never
|
||
/// sorted. The filter is SQL's `trim`, which strips spaces and not every
|
||
/// whitespace character, so a name that is only a tab is listed rather than
|
||
/// hidden — the safe direction for a row the user typed something into.
|
||
pub fn people_in_use(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
|
||
people_where(conn, true)
|
||
}
|
||
|
||
/// [`people`], with face counts aggregated once per person *before* the join
|
||
/// rather than grouped after it: the face table is joined to the 2,000
|
||
/// people it names, not the 19,000 rows of the people table.
|
||
fn people_where(conn: &Connection, in_use: bool) -> Result<Vec<Person>, CatalogError> {
|
||
let filter = if in_use {
|
||
"AND (c.person_id IS NOT NULL OR trim(p.name) <> '' OR p.ignored)"
|
||
} else {
|
||
""
|
||
};
|
||
let mut q = conn.prepare(&format!(
|
||
"SELECT p.id, p.uuid, p.name,
|
||
COALESCE(c.confirmed, 0),
|
||
COALESCE(c.suggested, 0),
|
||
p.ignored
|
||
FROM people p
|
||
LEFT JOIN (SELECT person_id,
|
||
SUM(confirmed = 1) AS confirmed,
|
||
SUM(confirmed = 0) AS suggested
|
||
FROM face_person
|
||
GROUP BY person_id) c ON c.person_id = p.id
|
||
WHERE p.merged_into IS NULL {filter}
|
||
ORDER BY 4 DESC, 5 DESC, p.name"
|
||
))?;
|
||
let rows = q.query_map([], |r| {
|
||
Ok(Person {
|
||
id: PersonId(r.get::<_, i64>(0)? as u64),
|
||
uuid: r.get(1)?,
|
||
name: r.get(2)?,
|
||
confirmed_faces: r.get::<_, i64>(3)? as u64,
|
||
suggested_faces: r.get::<_, i64>(4)? as u64,
|
||
ignored: r.get(5)?,
|
||
})
|
||
})?;
|
||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||
}
|
||
|
||
/// Fold `source` into `target`, keeping `target`'s identity.
|
||
///
|
||
/// The source is not deleted. It is left as a redirect, because a device that
|
||
/// still holds it would otherwise resurrect it on the next sync — the same
|
||
/// hazard collections have, solved the same way (FR-CAT-7).
|
||
///
|
||
/// Confirmations survive the move: a face the user confirmed as the source
|
||
/// person is now a confirmed face of the target, which is what the user meant
|
||
/// by saying they are the same person.
|
||
pub fn merge_people(
|
||
conn: &Connection,
|
||
target: PersonId,
|
||
source: PersonId,
|
||
) -> Result<u64, CatalogError> {
|
||
if target == source {
|
||
return Ok(0);
|
||
}
|
||
let tx = conn.unchecked_transaction()?;
|
||
|
||
// A face already assigned to the target must not gain a second row —
|
||
// `face_person` is keyed by face. Where both hold the same face, the
|
||
// target's row wins and the source's is dropped.
|
||
tx.execute(
|
||
"DELETE FROM face_person
|
||
WHERE person_id = ?2
|
||
AND face_id IN (SELECT face_id FROM face_person WHERE person_id = ?1)",
|
||
rusqlite::params![target.0 as i64, source.0 as i64],
|
||
)?;
|
||
let moved = tx.execute(
|
||
"UPDATE face_person SET person_id = ?1 WHERE person_id = ?2",
|
||
rusqlite::params![target.0 as i64, source.0 as i64],
|
||
)?;
|
||
tx.execute(
|
||
"UPDATE people SET merged_into = ?1, revision = revision + 1, modified = ?3
|
||
WHERE id = ?2",
|
||
rusqlite::params![target.0 as i64, source.0 as i64, now_secs()],
|
||
)?;
|
||
tx.commit()?;
|
||
Ok(moved as u64)
|
||
}
|
||
|
||
/// Follow a merge redirect to the person that outlived it.
|
||
pub fn resolve_person(conn: &Connection, person: PersonId) -> Result<PersonId, CatalogError> {
|
||
let mut at = person;
|
||
// Bounded rather than `loop`: a redirect cycle would otherwise hang the UI
|
||
// thread, and a corrupt index is exactly the case this has to survive.
|
||
for _ in 0..32 {
|
||
let next: Option<i64> = conn
|
||
.query_row(
|
||
"SELECT merged_into FROM people WHERE id = ?1",
|
||
[at.0 as i64],
|
||
|r| r.get(0),
|
||
)
|
||
.optional()?
|
||
.flatten();
|
||
match next {
|
||
Some(n) => at = PersonId(n as u64),
|
||
None => return Ok(at),
|
||
}
|
||
}
|
||
Ok(at)
|
||
}
|
||
|
||
// ── assignment ────────────────────────────────────────────────────────────
|
||
|
||
/// Record the system's guess that a face is a person.
|
||
///
|
||
/// Never overwrites a confirmation. That is the invariant FR-CULL-10 turns on:
|
||
/// a later inference pass may revise every suggestion it likes and may not
|
||
/// touch a single thing the user asserted.
|
||
pub fn suggest(
|
||
conn: &Connection,
|
||
face: FaceId,
|
||
person: PersonId,
|
||
probability: f32,
|
||
) -> Result<bool, CatalogError> {
|
||
// A rejection is a standing instruction, not a one-off: re-suggesting a
|
||
// face the user pushed away from this person is the behaviour that makes
|
||
// the feature feel broken.
|
||
let rejected: bool = conn.query_row(
|
||
"SELECT EXISTS(SELECT 1 FROM face_person_rejected
|
||
WHERE face_id = ?1 AND person_id = ?2)",
|
||
rusqlite::params![face.0 as i64, person.0 as i64],
|
||
|r| r.get(0),
|
||
)?;
|
||
if rejected {
|
||
return Ok(false);
|
||
}
|
||
|
||
let n = conn.execute(
|
||
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
|
||
VALUES (?1, ?2, ?3, 0)
|
||
ON CONFLICT(face_id) DO UPDATE SET
|
||
person_id = excluded.person_id,
|
||
probability = excluded.probability
|
||
WHERE face_person.confirmed = 0",
|
||
rusqlite::params![face.0 as i64, person.0 as i64, probability as f64],
|
||
)?;
|
||
Ok(n > 0)
|
||
}
|
||
|
||
/// The user says this face is this person.
|
||
pub fn confirm(conn: &Connection, face: FaceId, person: PersonId) -> Result<(), CatalogError> {
|
||
let tx = conn.unchecked_transaction()?;
|
||
// Confirming overrides an earlier rejection of the same pair: the user has
|
||
// changed their mind, and the newer judgement is the one that counts.
|
||
tx.execute(
|
||
"DELETE FROM face_person_rejected WHERE face_id = ?1 AND person_id = ?2",
|
||
rusqlite::params![face.0 as i64, person.0 as i64],
|
||
)?;
|
||
tx.execute(
|
||
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
|
||
VALUES (?1, ?2, 1.0, 1)
|
||
ON CONFLICT(face_id) DO UPDATE SET
|
||
person_id = excluded.person_id,
|
||
probability = 1.0,
|
||
confirmed = 1",
|
||
rusqlite::params![face.0 as i64, person.0 as i64],
|
||
)?;
|
||
tx.commit()?;
|
||
Ok(())
|
||
}
|
||
|
||
/// The user says every suggested face of this person is right.
|
||
///
|
||
/// What "Confirm all" runs, and the reason it is not a loop over [`confirm`]:
|
||
/// that is a transaction per face, and on a group of several hundred it was
|
||
/// several hundred commits for one click. Two statements, one commit, and the
|
||
/// same two rules `confirm` applies face by face — an earlier rejection of
|
||
/// the pair is overridden, and a face already confirmed is left alone.
|
||
///
|
||
/// Returns how many suggestions became confirmations.
|
||
pub fn confirm_all(conn: &Connection, person: PersonId) -> Result<u64, CatalogError> {
|
||
let tx = conn.unchecked_transaction()?;
|
||
tx.execute(
|
||
"DELETE FROM face_person_rejected
|
||
WHERE person_id = ?1
|
||
AND face_id IN (SELECT face_id FROM face_person
|
||
WHERE person_id = ?1 AND confirmed = 0)",
|
||
[person.0 as i64],
|
||
)?;
|
||
let n = tx.execute(
|
||
"UPDATE face_person SET confirmed = 1, probability = 1.0
|
||
WHERE person_id = ?1 AND confirmed = 0",
|
||
[person.0 as i64],
|
||
)?;
|
||
tx.commit()?;
|
||
Ok(n as u64)
|
||
}
|
||
|
||
/// The user says these faces are `to`, not `from`.
|
||
///
|
||
/// [`reject`] from one and [`confirm`] onto the other, for every face, in one
|
||
/// transaction — what a split commits. Rejecting first is what stops the
|
||
/// split being undone: without it the next pass sees a face that looks like
|
||
/// `from` and suggests it straight back. Confirmed rather than suggested on
|
||
/// `to`, because the user has just asserted these belong together.
|
||
pub fn reassign(
|
||
conn: &Connection,
|
||
faces: &[FaceId],
|
||
from: PersonId,
|
||
to: PersonId,
|
||
) -> Result<(), CatalogError> {
|
||
let tx = conn.unchecked_transaction()?;
|
||
{
|
||
let mut reject = tx.prepare(
|
||
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
|
||
VALUES (?1, ?2)",
|
||
)?;
|
||
let mut unrejected =
|
||
tx.prepare("DELETE FROM face_person_rejected WHERE face_id = ?1 AND person_id = ?2")?;
|
||
let mut confirm = tx.prepare(
|
||
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
|
||
VALUES (?1, ?2, 1.0, 1)
|
||
ON CONFLICT(face_id) DO UPDATE SET
|
||
person_id = excluded.person_id,
|
||
probability = 1.0,
|
||
confirmed = 1",
|
||
)?;
|
||
for face in faces {
|
||
reject.execute(rusqlite::params![face.0 as i64, from.0 as i64])?;
|
||
unrejected.execute(rusqlite::params![face.0 as i64, to.0 as i64])?;
|
||
confirm.execute(rusqlite::params![face.0 as i64, to.0 as i64])?;
|
||
}
|
||
}
|
||
tx.commit()?;
|
||
Ok(())
|
||
}
|
||
|
||
/// The user says this face is **not** this person.
|
||
///
|
||
/// Stored rather than implied by removal, so the next clustering pass does not
|
||
/// re-suggest it. This is user data in the same sense a confirmation is
|
||
/// (FR-CULL-12) — a judgement, just a negative one.
|
||
pub fn reject(conn: &Connection, face: FaceId, person: PersonId) -> Result<(), CatalogError> {
|
||
let tx = conn.unchecked_transaction()?;
|
||
tx.execute(
|
||
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
|
||
VALUES (?1, ?2)",
|
||
rusqlite::params![face.0 as i64, person.0 as i64],
|
||
)?;
|
||
tx.execute(
|
||
"DELETE FROM face_person WHERE face_id = ?1 AND person_id = ?2",
|
||
rusqlite::params![face.0 as i64, person.0 as i64],
|
||
)?;
|
||
tx.commit()?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Detach a face from whoever it is assigned to, without asserting anything.
|
||
///
|
||
/// Distinct from [`reject`]: this is "I do not know", and the face returns to
|
||
/// the pool for the next clustering pass to place. Rejection is "not this
|
||
/// person", and is remembered.
|
||
pub fn unassign(conn: &Connection, face: FaceId) -> Result<(), CatalogError> {
|
||
conn.execute(
|
||
"DELETE FROM face_person WHERE face_id = ?1",
|
||
[face.0 as i64],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Faces belonging to a person.
|
||
///
|
||
/// `include_suggested` defaults to false at every call site that matters:
|
||
/// FR-CULL-11 requires a saved collection not to change membership silently
|
||
/// when a later indexing pass revises a guess.
|
||
pub fn for_person(
|
||
conn: &Connection,
|
||
person: PersonId,
|
||
include_suggested: bool,
|
||
) -> Result<Vec<Face>, CatalogError> {
|
||
let mut q = conn.prepare(
|
||
"SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks,
|
||
f.detector_confidence, f.crop_px, f.model_id,
|
||
fp.person_id, fp.probability, fp.confirmed, f.quality,
|
||
f.eye_right, f.eye_right_px, f.eye_right_sharp,
|
||
f.eye_left, f.eye_left_px, f.eye_left_sharp, f.sunglasses,
|
||
f.landmarks_dense
|
||
FROM faces f
|
||
JOIN face_person fp ON fp.face_id = f.id
|
||
WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1)
|
||
ORDER BY fp.confirmed DESC, fp.probability DESC",
|
||
)?;
|
||
let rows = q.query_map(
|
||
rusqlite::params![person.0 as i64, include_suggested],
|
||
read_face,
|
||
)?;
|
||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||
}
|
||
|
||
/// The photographs a person appears in — the mapping back to files.
|
||
///
|
||
/// Distinct images, not faces: a person photographed twice in one frame is one
|
||
/// picture of them. This is what [`dr_types::Selector`]'s person term compiles
|
||
/// against and what the grid filters on (FR-CULL-11).
|
||
pub fn images_for_person(
|
||
conn: &Connection,
|
||
person: PersonId,
|
||
include_suggested: bool,
|
||
) -> Result<Vec<ImageId>, CatalogError> {
|
||
let mut q = conn.prepare(
|
||
"SELECT DISTINCT f.image_id
|
||
FROM faces f
|
||
JOIN face_person fp ON fp.face_id = f.id
|
||
WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1)
|
||
ORDER BY f.image_id",
|
||
)?;
|
||
let rows = q.query_map(rusqlite::params![person.0 as i64, include_suggested], |r| {
|
||
Ok(ImageId(r.get::<_, i64>(0)? as u64))
|
||
})?;
|
||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||
}
|
||
|
||
// ── calibration ───────────────────────────────────────────────────────────
|
||
|
||
/// Store a fitted calibration, replacing any previous fit for the model.
|
||
///
|
||
/// Keyed on the embedder: a calibration is a fit over pairwise similarities,
|
||
/// and those are the same space for every detector in front of one embedder.
|
||
/// Keying it on the full pipeline would discard the fit — and the library's
|
||
/// sharpened confidences with it — every time the detector was changed.
|
||
pub fn put_calibration(
|
||
conn: &Connection,
|
||
model_id: &str,
|
||
cal: &Calibration,
|
||
face_set_hash: &str,
|
||
) -> Result<(), CatalogError> {
|
||
conn.execute(
|
||
"INSERT INTO face_calibration
|
||
(model_id, a, b, w_size, valid, positive_pairs, negative_pairs,
|
||
face_set_hash, fitted_at)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||
ON CONFLICT(model_id) DO UPDATE SET
|
||
a = excluded.a, b = excluded.b, w_size = excluded.w_size,
|
||
valid = excluded.valid,
|
||
positive_pairs = excluded.positive_pairs,
|
||
negative_pairs = excluded.negative_pairs,
|
||
face_set_hash = excluded.face_set_hash,
|
||
fitted_at = excluded.fitted_at",
|
||
rusqlite::params![
|
||
embedder_of(model_id),
|
||
cal.a as f64,
|
||
cal.b as f64,
|
||
cal.w_size as f64,
|
||
cal.valid,
|
||
cal.positive_pairs as i64,
|
||
cal.negative_pairs as i64,
|
||
face_set_hash,
|
||
now_secs(),
|
||
],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// The calibration for a model, and the face set it was fitted from.
|
||
///
|
||
/// Returns `None` when there is no fit at all — distinct from a fit that exists
|
||
/// and is [`Calibration::valid`]`== false`, which means it was attempted and
|
||
/// there was not enough evidence. The UI says different things about those two.
|
||
pub fn calibration(
|
||
conn: &Connection,
|
||
model_id: &str,
|
||
) -> Result<Option<(Calibration, String)>, CatalogError> {
|
||
conn.query_row(
|
||
"SELECT a, b, w_size, valid, positive_pairs, negative_pairs, face_set_hash
|
||
FROM face_calibration WHERE model_id = ?1",
|
||
[embedder_of(model_id)],
|
||
|r| {
|
||
Ok((
|
||
Calibration {
|
||
a: r.get::<_, f64>(0)? as f32,
|
||
b: r.get::<_, f64>(1)? as f32,
|
||
w_size: r.get::<_, f64>(2)? as f32,
|
||
valid: r.get(3)?,
|
||
positive_pairs: r.get::<_, i64>(4)? as u64,
|
||
negative_pairs: r.get::<_, i64>(5)? as u64,
|
||
},
|
||
r.get::<_, String>(6)?,
|
||
))
|
||
},
|
||
)
|
||
.optional()
|
||
.map_err(Into::into)
|
||
}
|
||
|
||
// ── the delete-everything control ─────────────────────────────────────────
|
||
|
||
/// Delete every face, person, assignment and calibration in the library.
|
||
///
|
||
/// NFR-SEC-5 requires this to exist as **one action**, reachable without
|
||
/// deleting the catalog or any photograph. It is deliberately not a
|
||
/// re-indexing trigger and deliberately not selective: a user asking to remove
|
||
/// their face data is not asking to keep some of it.
|
||
///
|
||
/// Returns the number of faces removed, so the UI can say what happened rather
|
||
/// than claiming success silently.
|
||
pub fn delete_all_face_data(conn: &Connection) -> Result<u64, CatalogError> {
|
||
let tx = conn.unchecked_transaction()?;
|
||
let faces: i64 = tx.query_row("SELECT COUNT(*) FROM faces", [], |r| r.get(0))?;
|
||
// Order matters only if foreign keys are off, which they may be on an old
|
||
// connection; deleting children first is correct either way.
|
||
tx.execute("DELETE FROM face_index", [])?;
|
||
tx.execute("DELETE FROM face_person_rejected", [])?;
|
||
tx.execute("DELETE FROM face_person", [])?;
|
||
tx.execute("DELETE FROM faces", [])?;
|
||
tx.execute("DELETE FROM people", [])?;
|
||
tx.execute("DELETE FROM face_calibration", [])?;
|
||
tx.commit()?;
|
||
Ok(faces as u64)
|
||
}
|
||
|
||
/// The stored crops for one person's faces.
|
||
///
|
||
/// Read separately from [`for_person`] rather than joined onto it, because the
|
||
/// boxes are wanted in several places that do not draw anything — clustering
|
||
/// anchors, the develop overlay, the segmentation naming pass — and dragging a
|
||
/// few hundred KB of JPEG through those would be pure waste.
|
||
///
|
||
/// A face with no stored crop is simply absent from the map; the caller falls
|
||
/// back to cutting one out of the proxy.
|
||
pub fn crops_for_person(
|
||
conn: &Connection,
|
||
person: PersonId,
|
||
include_suggested: bool,
|
||
) -> Result<std::collections::HashMap<FaceId, Vec<u8>>, CatalogError> {
|
||
let mut q = conn.prepare(
|
||
"SELECT f.id, f.crop
|
||
FROM faces f
|
||
JOIN face_person fp ON fp.face_id = f.id
|
||
WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1)
|
||
AND f.crop IS NOT NULL",
|
||
)?;
|
||
let rows = q.query_map(rusqlite::params![person.0 as i64, include_suggested], |r| {
|
||
Ok((FaceId(r.get::<_, i64>(0)? as u64), r.get::<_, Vec<u8>>(1)?))
|
||
})?;
|
||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||
}
|
||
|
||
/// One face's stored crop, if it has one.
|
||
pub fn crop(conn: &Connection, face: FaceId) -> Result<Option<Vec<u8>>, CatalogError> {
|
||
conn.query_row(
|
||
"SELECT crop FROM faces WHERE id = ?1",
|
||
[face.0 as i64],
|
||
|r| r.get::<_, Option<Vec<u8>>>(0),
|
||
)
|
||
.optional()
|
||
.map(Option::flatten)
|
||
.map_err(Into::into)
|
||
}
|
||
|
||
/// How many of a model's faces still have no stored crop.
|
||
///
|
||
/// The measure of how much of the library is still drawing itself the slow way,
|
||
/// and what a "re-index to fill these in" prompt would be counting.
|
||
pub fn faces_without_crop(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
|
||
conn.query_row(
|
||
&format!(
|
||
"SELECT COUNT(*) FROM faces WHERE {} = ?1 AND crop IS NULL",
|
||
embedder_sql("model_id")
|
||
),
|
||
[embedder_of(model_id)],
|
||
|r| r.get::<_, i64>(0),
|
||
)
|
||
.map(|n| n as u64)
|
||
.map_err(Into::into)
|
||
}
|
||
|
||
/// The user does not want to identify this person.
|
||
///
|
||
/// Reversible, and deliberately not a deletion: deleting the group would only
|
||
/// have it rebuilt by the next clustering pass, since the faces are still there
|
||
/// and still similar to each other. See the V10 migration note.
|
||
pub fn set_ignored(conn: &Connection, person: PersonId, ignored: bool) -> Result<(), CatalogError> {
|
||
conn.execute(
|
||
"UPDATE people
|
||
SET ignored = ?2, revision = revision + 1, modified = ?3
|
||
WHERE id = ?1",
|
||
rusqlite::params![person.0 as i64, ignored, now_secs()],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Whether a person has been set aside.
|
||
pub fn is_ignored(conn: &Connection, person: PersonId) -> Result<bool, CatalogError> {
|
||
conn.query_row(
|
||
"SELECT ignored FROM people WHERE id = ?1",
|
||
[person.0 as i64],
|
||
|r| r.get(0),
|
||
)
|
||
.optional()
|
||
.map(|v| v.unwrap_or(false))
|
||
.map_err(Into::into)
|
||
}
|
||
|
||
/// Delete unnamed people who hold no faces at all.
|
||
///
|
||
/// Clustering creates a person per unanchored group, and a later pass — a new
|
||
/// threshold, more faces indexed, a split undone — can leave the previous run's
|
||
/// group with nothing in it. Those are not tombstones and nothing refers to
|
||
/// them; left alone they accumulate one rail entry per Regroup, which is what
|
||
/// made pressing the button twice look like it had broken the screen.
|
||
///
|
||
/// **Named people are never touched, however empty.** A name is user data
|
||
/// (FR-CULL-12), and a person the user named and then emptied by moving every
|
||
/// face elsewhere is still a person they told us about. Nor is a person that
|
||
/// something was merged into, which has to outlive its faces to keep
|
||
/// redirecting.
|
||
///
|
||
/// Returns how many were removed.
|
||
pub fn prune_empty_unnamed(conn: &Connection) -> Result<usize, CatalogError> {
|
||
let n = conn.execute(
|
||
"DELETE FROM people
|
||
WHERE name = ''
|
||
AND merged_into IS NULL
|
||
AND ignored = 0
|
||
AND NOT EXISTS (SELECT 1 FROM face_person fp WHERE fp.person_id = people.id)
|
||
AND NOT EXISTS (SELECT 1 FROM people o WHERE o.merged_into = people.id)",
|
||
[],
|
||
)?;
|
||
Ok(n)
|
||
}
|
||
|
||
// ── helpers ───────────────────────────────────────────────────────────────
|
||
|
||
fn read_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<Face> {
|
||
let person: Option<i64> = r.get(10)?;
|
||
Ok(Face {
|
||
id: FaceId(r.get::<_, i64>(0)? as u64),
|
||
image_id: ImageId(r.get::<_, i64>(1)? as u64),
|
||
x: r.get::<_, f64>(2)? as f32,
|
||
y: r.get::<_, f64>(3)? as f32,
|
||
w: r.get::<_, f64>(4)? as f32,
|
||
h: r.get::<_, f64>(5)? as f32,
|
||
landmarks: blob_to_landmarks(&r.get::<_, Vec<u8>>(6)?),
|
||
confidence: r.get::<_, f64>(7)? as f32,
|
||
crop_px: r.get::<_, f64>(8)? as f32,
|
||
quality: r.get::<_, Option<f64>>(13)?.map(|q| q as f32),
|
||
eyes: read_eyes(r, 14)?,
|
||
landmarks_dense: r.get::<_, Option<Vec<u8>>>(21)?.unwrap_or_default(),
|
||
model_id: r.get(9)?,
|
||
person: person.map(|p| PersonId(p as u64)),
|
||
probability: r.get::<_, Option<f64>>(11)?.unwrap_or(0.0) as f32,
|
||
confirmed: r.get::<_, Option<bool>>(12)?.unwrap_or(false),
|
||
})
|
||
}
|
||
|
||
/// The seven eye columns at `first` .. `first + 6`, in [`EYE_COLUMNS`]'s
|
||
/// order, as one reading.
|
||
///
|
||
/// All seven or none: they are written together, and a row with one of
|
||
/// them NULL is a row nothing in this crate produced. Read as absent rather
|
||
/// than invented, which is what a reader of a half-written row deserves.
|
||
///
|
||
/// [`EYE_COLUMNS`]: crate::schema::EYE_COLUMNS
|
||
pub(crate) fn read_eyes(
|
||
r: &rusqlite::Row<'_>,
|
||
first: usize,
|
||
) -> rusqlite::Result<Option<EyeReading>> {
|
||
let mut v = [0.0_f32; 7];
|
||
for (i, slot) in v.iter_mut().enumerate() {
|
||
match r.get::<_, Option<f64>>(first + i)? {
|
||
Some(x) => *slot = x as f32,
|
||
None => return Ok(None),
|
||
}
|
||
}
|
||
Ok(Some(EyeReading {
|
||
right: Eye {
|
||
open: v[0],
|
||
px: v[1],
|
||
sharpness: v[2],
|
||
},
|
||
left: Eye {
|
||
open: v[3],
|
||
px: v[4],
|
||
sharpness: v[5],
|
||
},
|
||
sunglasses: v[6],
|
||
}))
|
||
}
|
||
|
||
fn landmarks_to_blob(lm: &[(f32, f32); 5]) -> Vec<u8> {
|
||
let mut out = Vec::with_capacity(40);
|
||
for &(x, y) in lm {
|
||
out.extend_from_slice(&x.to_le_bytes());
|
||
out.extend_from_slice(&y.to_le_bytes());
|
||
}
|
||
out
|
||
}
|
||
|
||
fn blob_to_landmarks(b: &[u8]) -> [(f32, f32); 5] {
|
||
let mut out = [(0.0_f32, 0.0_f32); 5];
|
||
for (i, o) in out.iter_mut().enumerate() {
|
||
let at = i * 8;
|
||
if at + 8 <= b.len() {
|
||
let x = f32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]);
|
||
let y = f32::from_le_bytes([b[at + 4], b[at + 5], b[at + 6], b[at + 7]]);
|
||
*o = (x, y);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 {
|
||
let ix = (a.0 + a.2).min(b.0 + b.2) - a.0.max(b.0);
|
||
let iy = (a.1 + a.3).min(b.1 + b.3) - a.1.max(b.1);
|
||
if ix <= 0.0 || iy <= 0.0 {
|
||
return 0.0;
|
||
}
|
||
let inter = ix * iy;
|
||
let union = a.2 * a.3 + b.2 * b.3 - inter;
|
||
if union <= 0.0 {
|
||
0.0
|
||
} else {
|
||
inter / union
|
||
}
|
||
}
|
||
|
||
pub(crate) fn now_secs() -> i64 {
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs() as i64)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
/// A v4 UUID from the system clock and address-space entropy.
|
||
///
|
||
/// The same approach `collections` takes: a merge needs identifiers that do not
|
||
/// collide across devices, not cryptographic randomness, and this avoids a
|
||
/// dependency for one string per person.
|
||
fn new_uuid() -> String {
|
||
use std::hash::{BuildHasher, Hasher, RandomState};
|
||
let mut h = RandomState::new().build_hasher();
|
||
h.write_u64(
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_nanos() as u64)
|
||
.unwrap_or(0),
|
||
);
|
||
let a = h.finish();
|
||
let mut h2 = RandomState::new().build_hasher();
|
||
h2.write_u64(a);
|
||
h2.write_usize(&h2 as *const _ as usize);
|
||
let b = h2.finish();
|
||
format!(
|
||
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
|
||
(a >> 32) as u32,
|
||
(a >> 16) as u16,
|
||
(a & 0x0fff) as u16,
|
||
((b >> 48) as u16 & 0x3fff) | 0x8000,
|
||
b & 0xffff_ffff_ffff,
|
||
)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::schema;
|
||
|
||
fn db() -> Connection {
|
||
let c = Connection::open_in_memory().unwrap();
|
||
c.execute_batch("PRAGMA foreign_keys = ON").unwrap();
|
||
schema::migrate(&c).unwrap();
|
||
c
|
||
}
|
||
|
||
fn image(c: &Connection, n: i64) -> ImageId {
|
||
c.execute(
|
||
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
c.execute(
|
||
"INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)",
|
||
rusqlite::params![n, format!("IMG_{n}.CR3")],
|
||
)
|
||
.unwrap();
|
||
ImageId(n as u64)
|
||
}
|
||
|
||
fn face(seed: u8) -> DetectedFace {
|
||
DetectedFace {
|
||
x: 0.1,
|
||
y: 0.1,
|
||
w: 0.2,
|
||
h: 0.3,
|
||
landmarks: [
|
||
(0.1, 0.1),
|
||
(0.2, 0.1),
|
||
(0.15, 0.2),
|
||
(0.12, 0.25),
|
||
(0.18, 0.25),
|
||
],
|
||
confidence: 0.9,
|
||
embedding: vec![seed; 1024],
|
||
crop_px: 180.0,
|
||
quality: Some(10.0 + f32::from(seed)),
|
||
eyes: Some(EyeReading {
|
||
right: Eye {
|
||
open: 0.9,
|
||
px: 40.0,
|
||
sharpness: 0.2,
|
||
},
|
||
left: Eye {
|
||
open: 0.8,
|
||
px: 38.0,
|
||
sharpness: 0.3,
|
||
},
|
||
sunglasses: 0.1,
|
||
}),
|
||
landmarks_dense: vec![7; 424],
|
||
model_id: "w600k_mbf".into(),
|
||
crop: Vec::new(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn migration_reaches_the_current_version() {
|
||
let c = db();
|
||
let v: i64 = c
|
||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||
.unwrap();
|
||
assert_eq!(v, crate::schema::SCHEMA_VERSION);
|
||
}
|
||
|
||
#[test]
|
||
fn detections_round_trip_with_their_landmarks() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap();
|
||
let got = for_image(&c, img).unwrap();
|
||
assert_eq!(got.len(), 2);
|
||
assert_eq!(got[0].model_id, "w600k_mbf");
|
||
assert!((got[0].crop_px - 180.0).abs() < 1e-3);
|
||
assert!((got[0].landmarks[2].1 - 0.2).abs() < 1e-5);
|
||
assert!(got[0].person.is_none());
|
||
// Both readers carry the quality, and the one for the grouping pass
|
||
// carries it as the option it is.
|
||
let mut qualities: Vec<Option<f32>> = got.iter().map(|f| f.quality).collect();
|
||
qualities.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||
assert_eq!(qualities, vec![Some(11.0), Some(12.0)]);
|
||
let stored = embeddings(&c, "w600k_mbf").unwrap();
|
||
assert_eq!(stored.len(), 2);
|
||
assert_eq!(stored[0].quality, Some(11.0), "oldest first");
|
||
assert_eq!(stored[1].quality, Some(12.0));
|
||
// And the eyes, as one reading.
|
||
assert_eq!(got[0].eyes.map(|e| e.left.open), Some(0.8));
|
||
assert_eq!(got[0].eyes.map(|e| e.right.sharpness), Some(0.2));
|
||
assert_eq!(
|
||
got[0].landmarks_dense.len(),
|
||
424,
|
||
"the dense landmarks ride along"
|
||
);
|
||
assert_eq!(
|
||
got[0].eyes.map(|e| e.state()),
|
||
Some(dr_face::EyeState::Open)
|
||
);
|
||
}
|
||
|
||
const NEEDS_QUALITY: &str = "f.quality IS NULL";
|
||
const NEEDS_EYES: &str = "f.eye_right IS NULL";
|
||
|
||
/// The seven eye columns are one fact: a face with none of them reads as
|
||
/// unread, and a per-face pass asking by predicate is what fills them.
|
||
#[test]
|
||
fn eyes_are_filled_only_where_a_pass_produced_a_reading() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let unread = DetectedFace {
|
||
eyes: None,
|
||
..face(1)
|
||
};
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[unread, face(2)]).unwrap();
|
||
assert_eq!(for_image(&c, img).unwrap().len(), 2);
|
||
|
||
// Quality is present on both, so a pass that only fills quality has
|
||
// nothing to do here; one that reads eyes has one face.
|
||
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_QUALITY).unwrap(), 0);
|
||
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_EYES).unwrap(), 1);
|
||
assert_eq!(
|
||
faces_needing(&c, img, "w600k_mbf", NEEDS_QUALITY)
|
||
.unwrap()
|
||
.len(),
|
||
0
|
||
);
|
||
let todo = faces_needing(&c, img, "w600k_mbf", NEEDS_EYES).unwrap();
|
||
assert_eq!(todo.len(), 1);
|
||
assert_eq!(todo[0].id, ids[0]);
|
||
|
||
// An update with no reading leaves the columns alone …
|
||
record_updates(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
6000,
|
||
&[FaceUpdate {
|
||
embedding: Some((vec![9; 1024], 21.5)),
|
||
..FaceUpdate::for_face(ids[0])
|
||
}],
|
||
&[],
|
||
)
|
||
.unwrap();
|
||
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_EYES).unwrap(), 1);
|
||
|
||
// … and one with a reading fills them.
|
||
record_updates(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
6000,
|
||
&[FaceUpdate {
|
||
eyes: Some((
|
||
EyeReading {
|
||
right: Eye {
|
||
open: 0.2,
|
||
px: 40.0,
|
||
sharpness: 0.2,
|
||
},
|
||
left: Eye {
|
||
open: 0.9,
|
||
px: 40.0,
|
||
sharpness: 0.2,
|
||
},
|
||
sunglasses: 0.0,
|
||
},
|
||
vec![9; 424],
|
||
)),
|
||
..FaceUpdate::for_face(ids[0])
|
||
}],
|
||
&[],
|
||
)
|
||
.unwrap();
|
||
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_EYES).unwrap(), 0);
|
||
assert_eq!(
|
||
for_image(&c, img).unwrap()[0].landmarks_dense.len(),
|
||
424,
|
||
"written with the reading"
|
||
);
|
||
let got = for_image(&c, img).unwrap();
|
||
let read = got.iter().find(|f| f.id == ids[0]).unwrap();
|
||
assert_eq!(
|
||
read.eyes.map(|e| e.state()),
|
||
Some(dr_face::EyeState::Closed)
|
||
);
|
||
}
|
||
|
||
/// A crop is filled the same way, and an empty one is not written.
|
||
#[test]
|
||
fn a_crop_is_filled_in_place() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
assert_eq!(count_needing(&c, "w600k_mbf", "f.crop IS NULL").unwrap(), 1);
|
||
record_updates(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
6000,
|
||
&[FaceUpdate {
|
||
crop: Some(Vec::new()),
|
||
..FaceUpdate::for_face(ids[0])
|
||
}],
|
||
&[],
|
||
)
|
||
.unwrap();
|
||
assert_eq!(count_needing(&c, "w600k_mbf", "f.crop IS NULL").unwrap(), 1);
|
||
record_updates(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
6000,
|
||
&[FaceUpdate {
|
||
crop: Some(vec![1, 2, 3]),
|
||
..FaceUpdate::for_face(ids[0])
|
||
}],
|
||
&[],
|
||
)
|
||
.unwrap();
|
||
assert_eq!(count_needing(&c, "w600k_mbf", "f.crop IS NULL").unwrap(), 0);
|
||
assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![1, 2, 3]));
|
||
}
|
||
|
||
/// An update writes over the vector and nothing else: the face keeps its
|
||
/// id, its box and whoever the user said it was.
|
||
#[test]
|
||
fn an_update_replaces_the_vector_and_keeps_the_identity() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let unmeasured = DetectedFace {
|
||
quality: None,
|
||
..face(1)
|
||
};
|
||
let ids = record_detections(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
1024,
|
||
&[unmeasured.clone(), unmeasured],
|
||
)
|
||
.unwrap();
|
||
let person = create_person(&c, "Anna").unwrap();
|
||
confirm(&c, ids[0], person).unwrap();
|
||
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_QUALITY).unwrap(), 2);
|
||
assert_eq!(
|
||
faces_needing(&c, img, "w600k_mbf", NEEDS_QUALITY)
|
||
.unwrap()
|
||
.len(),
|
||
2
|
||
);
|
||
let marked_at: i64 = c
|
||
.query_row("SELECT indexed_at FROM face_index", [], |r| r.get(0))
|
||
.unwrap();
|
||
c.execute("UPDATE face_index SET indexed_at = indexed_at - 100", [])
|
||
.unwrap();
|
||
|
||
record_updates(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
6000,
|
||
&[FaceUpdate {
|
||
embedding: Some((vec![9; 1024], 21.5)),
|
||
..FaceUpdate::for_face(ids[0])
|
||
}],
|
||
&[ids[1]],
|
||
)
|
||
.unwrap();
|
||
|
||
let got = for_image(&c, img).unwrap();
|
||
assert_eq!(got.len(), 1, "the degenerate face was kept");
|
||
assert_eq!(got[0].id, ids[0]);
|
||
assert_eq!(got[0].quality, Some(21.5));
|
||
assert_eq!(got[0].person, Some(person));
|
||
assert!(got[0].confirmed);
|
||
let e = embeddings(&c, "w600k_mbf").unwrap();
|
||
assert_eq!(e[0].embedding[0], 9);
|
||
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_QUALITY).unwrap(), 0);
|
||
|
||
// The marker says one face at the native edge, and is fresh — which
|
||
// is what makes the sync export it again.
|
||
let (found, edge, at): (i64, i64, i64) = c
|
||
.query_row(
|
||
"SELECT faces_found, source_edge, indexed_at FROM face_index",
|
||
[],
|
||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||
)
|
||
.unwrap();
|
||
assert_eq!((found, edge), (1, 6000));
|
||
assert!(at >= marked_at, "the marker was not refreshed");
|
||
}
|
||
|
||
/// The marker a per-face pass leaves names the detector that drew the
|
||
/// boxes, whatever pipeline the pass itself ran as. A marker under the
|
||
/// pass's id over faces spelled another way is one the export finds no
|
||
/// faces under — and it sent every other device "nothing here".
|
||
#[test]
|
||
fn an_update_keeps_the_marker_under_the_detector_that_found_the_faces() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
1024,
|
||
&[DetectedFace {
|
||
quality: None,
|
||
..face(1)
|
||
}],
|
||
)
|
||
.unwrap();
|
||
// The state V14 leaves: the faces, and no marker at all.
|
||
c.execute("DELETE FROM face_index", []).unwrap();
|
||
|
||
record_updates(
|
||
&c,
|
||
img,
|
||
"scrfd_10g+w600k_mbf",
|
||
6000,
|
||
&[FaceUpdate {
|
||
embedding: Some((vec![9; 1024], 21.5)),
|
||
..FaceUpdate::for_face(ids[0])
|
||
}],
|
||
&[],
|
||
)
|
||
.unwrap();
|
||
|
||
let markers: Vec<(String, i64)> = c
|
||
.prepare("SELECT model_id, faces_found FROM face_index")
|
||
.unwrap()
|
||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
||
.unwrap()
|
||
.map(Result::unwrap)
|
||
.collect();
|
||
assert_eq!(markers, vec![("w600k_mbf".to_string(), 1)]);
|
||
|
||
// A marker already there under the pass's own id is replaced, not
|
||
// kept beside the right one.
|
||
c.execute(
|
||
"INSERT INTO face_index(image_id, model_id, indexed_at, faces_found, source_edge)
|
||
VALUES (1, 'scrfd_10g+w600k_mbf', 0, 0, 6000)",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
record_updates(
|
||
&c,
|
||
img,
|
||
"scrfd_10g+w600k_mbf",
|
||
6000,
|
||
&[FaceUpdate {
|
||
crop: Some(vec![1, 2, 3]),
|
||
..FaceUpdate::for_face(ids[0])
|
||
}],
|
||
&[],
|
||
)
|
||
.unwrap();
|
||
let n: i64 = c
|
||
.query_row("SELECT COUNT(*) FROM face_index", [], |r| r.get(0))
|
||
.unwrap();
|
||
assert_eq!(n, 1, "a second marker survived");
|
||
|
||
// With every face dropped there is no detector left to name, and
|
||
// the pass's own id records that it looked.
|
||
record_updates(&c, img, "scrfd_10g+w600k_mbf", 6000, &[], &ids).unwrap();
|
||
let marker: (String, i64) = c
|
||
.query_row("SELECT model_id, faces_found FROM face_index", [], |r| {
|
||
Ok((r.get(0)?, r.get(1)?))
|
||
})
|
||
.unwrap();
|
||
assert_eq!(marker, ("scrfd_10g+w600k_mbf".to_string(), 0));
|
||
}
|
||
|
||
/// Re-detection is coalesced per image, so it must replace rather than
|
||
/// append — otherwise every re-index doubles the library's face count.
|
||
#[test]
|
||
fn re_detection_replaces_rather_than_appending() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap();
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[face(3)]).unwrap();
|
||
assert_eq!(for_image(&c, img).unwrap().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn the_embedder_is_the_half_after_the_plus() {
|
||
assert_eq!(embedder_of("w600k_mbf"), "w600k_mbf");
|
||
assert_eq!(embedder_of("scrfd_10g+w600k_mbf"), "w600k_mbf");
|
||
assert_eq!(embedder_of("scrfd_2.5g+w600k_mbf"), "w600k_mbf");
|
||
assert_eq!(embedder_of("scrfd_10g+other"), "other");
|
||
}
|
||
|
||
/// A detector change must not split the library: the clustering pass,
|
||
/// the coverage figure and the "is this indexed" question all see every
|
||
/// generation that shares an embedder, and none of a different one.
|
||
#[test]
|
||
fn every_detector_in_front_of_one_embedder_is_one_population() {
|
||
let c = db();
|
||
let old = image(&c, 1);
|
||
let new = image(&c, 2);
|
||
let foreign = image(&c, 3);
|
||
record_detections(&c, old, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let mut f = face(2);
|
||
f.model_id = "scrfd_10g+w600k_mbf".into();
|
||
record_detections(&c, new, "scrfd_10g+w600k_mbf", 1024, &[f]).unwrap();
|
||
let mut g = face(3);
|
||
g.model_id = "scrfd_10g+other".into();
|
||
record_detections(&c, foreign, "scrfd_10g+other", 1024, &[g]).unwrap();
|
||
|
||
for id in ["w600k_mbf", "scrfd_10g+w600k_mbf", "scrfd_2.5g+w600k_mbf"] {
|
||
assert_eq!(embeddings(&c, id).unwrap().len(), 2, "{id}");
|
||
assert_eq!(unassigned(&c, id).unwrap().len(), 2, "{id}");
|
||
assert_eq!(coverage(&c, id).unwrap().indexed, 2, "{id}");
|
||
assert!(is_indexed(&c, old, id).unwrap(), "{id}");
|
||
assert!(is_indexed(&c, new, id).unwrap(), "{id}");
|
||
assert!(!is_indexed(&c, foreign, id).unwrap(), "{id}");
|
||
}
|
||
assert_eq!(embeddings(&c, "scrfd_10g+other").unwrap().len(), 1);
|
||
assert_eq!(coverage(&c, "scrfd_10g+other").unwrap().indexed, 1);
|
||
}
|
||
|
||
/// The fit is over the embedder's similarity space, so it is the same fit
|
||
/// whichever detector is chosen — and a detector change must not discard
|
||
/// it.
|
||
#[test]
|
||
fn a_calibration_outlives_a_detector_change() {
|
||
let c = db();
|
||
let cal = Calibration {
|
||
a: 1.5,
|
||
b: -0.5,
|
||
w_size: 0.1,
|
||
valid: true,
|
||
positive_pairs: 40,
|
||
negative_pairs: 400,
|
||
};
|
||
put_calibration(&c, "w600k_mbf", &cal, "abc").unwrap();
|
||
let (got, hash) = calibration(&c, "scrfd_10g+w600k_mbf").unwrap().unwrap();
|
||
assert_eq!(got, cal);
|
||
assert_eq!(hash, "abc");
|
||
assert!(calibration(&c, "scrfd_10g+other").unwrap().is_none());
|
||
}
|
||
|
||
/// The invariant FR-CULL-10 turns on: a re-index with a better model must
|
||
/// not throw away the user's own labelling.
|
||
#[test]
|
||
fn re_detection_carries_a_confirmation_across() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
confirm(&c, ids[0], anna).unwrap();
|
||
|
||
// Same face, box nudged the way a better detector would nudge it.
|
||
let mut moved = face(9);
|
||
moved.x = 0.11;
|
||
moved.y = 0.105;
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[moved]).unwrap();
|
||
|
||
let got = for_image(&c, img).unwrap();
|
||
assert_eq!(got.len(), 1);
|
||
assert_eq!(
|
||
got[0].person,
|
||
Some(anna),
|
||
"confirmation was lost on re-index"
|
||
);
|
||
assert!(got[0].confirmed);
|
||
}
|
||
|
||
/// A stored vector pointing along one axis of the embedding space, so a
|
||
/// test can say "these two faces are the same" or "orthogonal" exactly.
|
||
fn along(axis: usize) -> Vec<u8> {
|
||
let mut v = Box::new([0.0_f32; dr_face::EMBEDDING_DIM]);
|
||
v[axis] = 1.0;
|
||
dr_face::Embedding {
|
||
model: dr_face::ModelId::new("w600k_mbf"),
|
||
v,
|
||
}
|
||
.to_f16_bytes()
|
||
}
|
||
|
||
fn face_at(x: f32, y: f32, w: f32, h: f32, axis: usize) -> DetectedFace {
|
||
DetectedFace {
|
||
x,
|
||
y,
|
||
w,
|
||
h,
|
||
embedding: along(axis),
|
||
..face(1)
|
||
}
|
||
}
|
||
|
||
/// The other half of FR-CULL-10's promise: the suggestions and the
|
||
/// rejections are the state of the People screen, and a re-index that
|
||
/// dropped them would hand back a library of strangers.
|
||
#[test]
|
||
fn re_detection_carries_a_suggestion_and_a_rejection_across() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
let bob = create_person(&c, "Bob").unwrap();
|
||
suggest(&c, ids[0], anna, 0.83).unwrap();
|
||
reject(&c, ids[0], bob).unwrap();
|
||
|
||
let mut moved = face(9);
|
||
moved.x = 0.11;
|
||
record_detections(&c, img, "scrfd_10g+w600k_mbf", 4000, &[moved]).unwrap();
|
||
|
||
let got = for_image(&c, img).unwrap();
|
||
assert_eq!(got.len(), 1);
|
||
assert_eq!(got[0].person, Some(anna), "suggestion was lost on re-index");
|
||
assert!(
|
||
!got[0].confirmed,
|
||
"a suggestion came back as a confirmation"
|
||
);
|
||
assert!((got[0].probability - 0.83).abs() < 1e-6);
|
||
assert!(
|
||
!suggest(&c, got[0].id, bob, 0.99).unwrap(),
|
||
"the rejection of Bob was lost on re-index"
|
||
);
|
||
}
|
||
|
||
/// A box the low-resolution pass drew badly enough that overlap alone
|
||
/// would not claim it: the vector does.
|
||
#[test]
|
||
fn a_moved_box_is_claimed_by_its_embedding() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
1024,
|
||
&[face_at(0.10, 0.10, 0.20, 0.20, 3)],
|
||
)
|
||
.unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
confirm(&c, ids[0], anna).unwrap();
|
||
|
||
// Shifted by two thirds of its width: IoU ≈ 0.2, well under the 0.5
|
||
// the box route needs, but the boxes still touch.
|
||
let again = face_at(0.23, 0.10, 0.20, 0.20, 3);
|
||
record_detections(&c, img, "scrfd_10g+w600k_mbf", 4000, &[again]).unwrap();
|
||
let got = for_image(&c, img).unwrap();
|
||
assert_eq!(got[0].person, Some(anna));
|
||
assert!(got[0].confirmed);
|
||
|
||
// The same shift with a stranger's vector is a different face, and
|
||
// the confirmation is not handed to it.
|
||
let other = image(&c, 2);
|
||
let ids = record_detections(
|
||
&c,
|
||
other,
|
||
"w600k_mbf",
|
||
1024,
|
||
&[face_at(0.10, 0.10, 0.20, 0.20, 3)],
|
||
)
|
||
.unwrap();
|
||
confirm(&c, ids[0], anna).unwrap();
|
||
let stranger = face_at(0.23, 0.10, 0.20, 0.20, 4);
|
||
record_detections(&c, other, "scrfd_10g+w600k_mbf", 4000, &[stranger]).unwrap();
|
||
assert_eq!(for_image(&c, other).unwrap()[0].person, None);
|
||
}
|
||
|
||
/// Two people side by side, boxes overlapping both ways, and the new
|
||
/// boxes shifted so that overlap alone would swap them.
|
||
#[test]
|
||
fn the_embedding_breaks_a_tie_between_neighbouring_faces() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
1024,
|
||
&[
|
||
face_at(0.10, 0.10, 0.20, 0.20, 1),
|
||
face_at(0.25, 0.10, 0.20, 0.20, 2),
|
||
],
|
||
)
|
||
.unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
let bob = create_person(&c, "Bob").unwrap();
|
||
confirm(&c, ids[0], anna).unwrap();
|
||
confirm(&c, ids[1], bob).unwrap();
|
||
|
||
// Anna's new box overlaps Bob's old one more than her own.
|
||
let anna_again = face_at(0.20, 0.10, 0.20, 0.20, 1);
|
||
let bob_again = face_at(0.35, 0.10, 0.20, 0.20, 2);
|
||
let new = record_detections(
|
||
&c,
|
||
img,
|
||
"scrfd_10g+w600k_mbf",
|
||
4000,
|
||
&[anna_again, bob_again],
|
||
)
|
||
.unwrap();
|
||
let got = for_image(&c, img).unwrap();
|
||
let person_of = |id: FaceId| got.iter().find(|f| f.id == id).unwrap().person;
|
||
assert_eq!(person_of(new[0]), Some(anna));
|
||
assert_eq!(person_of(new[1]), Some(bob));
|
||
}
|
||
|
||
/// The same vector somewhere else in the frame — a mirror, a print on
|
||
/// the wall — is not the same face, and overlap is what says so.
|
||
#[test]
|
||
fn the_same_person_elsewhere_in_the_frame_is_not_claimed() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
1024,
|
||
&[face_at(0.10, 0.10, 0.20, 0.20, 5)],
|
||
)
|
||
.unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
confirm(&c, ids[0], anna).unwrap();
|
||
|
||
record_detections(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
1024,
|
||
&[face_at(0.60, 0.60, 0.20, 0.20, 5)],
|
||
)
|
||
.unwrap();
|
||
assert_eq!(for_image(&c, img).unwrap()[0].person, None);
|
||
}
|
||
|
||
/// Each old face is carried onto at most one new one: a second box over
|
||
/// the same face — a detector that fires twice — does not become a
|
||
/// second Anna.
|
||
#[test]
|
||
fn an_identity_is_carried_onto_one_face_only() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
confirm(&c, ids[0], anna).unwrap();
|
||
|
||
let mut twin = face(1);
|
||
twin.x += 0.02;
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[face(1), twin]).unwrap();
|
||
let named = for_image(&c, img)
|
||
.unwrap()
|
||
.iter()
|
||
.filter(|f| f.person == Some(anna))
|
||
.count();
|
||
assert_eq!(named, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn a_suggestion_never_overwrites_a_confirmation() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
let bob = create_person(&c, "Bob").unwrap();
|
||
|
||
confirm(&c, ids[0], anna).unwrap();
|
||
let changed = suggest(&c, ids[0], bob, 0.99).unwrap();
|
||
assert!(!changed, "a later pass overwrote a user confirmation");
|
||
assert_eq!(for_image(&c, img).unwrap()[0].person, Some(anna));
|
||
}
|
||
|
||
#[test]
|
||
fn a_rejection_stops_the_face_being_suggested_again() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
|
||
suggest(&c, ids[0], anna, 0.8).unwrap();
|
||
reject(&c, ids[0], anna).unwrap();
|
||
assert_eq!(for_image(&c, img).unwrap()[0].person, None);
|
||
|
||
let again = suggest(&c, ids[0], anna, 0.95).unwrap();
|
||
assert!(
|
||
!again,
|
||
"clustering re-suggested a face the user pushed away"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn confirming_overrides_an_earlier_rejection() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
reject(&c, ids[0], anna).unwrap();
|
||
confirm(&c, ids[0], anna).unwrap();
|
||
assert_eq!(for_image(&c, img).unwrap()[0].person, Some(anna));
|
||
}
|
||
|
||
#[test]
|
||
fn merging_moves_the_faces_and_leaves_a_redirect() {
|
||
let c = db();
|
||
let i1 = image(&c, 1);
|
||
let i2 = image(&c, 2);
|
||
let a = record_detections(&c, i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let b = record_detections(&c, i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
let annie = create_person(&c, "Annie").unwrap();
|
||
confirm(&c, a[0], anna).unwrap();
|
||
confirm(&c, b[0], annie).unwrap();
|
||
|
||
assert_eq!(merge_people(&c, anna, annie).unwrap(), 1);
|
||
assert_eq!(for_person(&c, anna, false).unwrap().len(), 2);
|
||
assert_eq!(resolve_person(&c, annie).unwrap(), anna);
|
||
// The redirect survives so a sync cannot resurrect the merged person.
|
||
assert_eq!(people(&c).unwrap().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn merging_does_not_duplicate_a_face_both_people_hold() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let a = create_person(&c, "A").unwrap();
|
||
let b = create_person(&c, "B").unwrap();
|
||
confirm(&c, ids[0], a).unwrap();
|
||
// `face_person` is keyed by face, so B can only hold it after A lets go.
|
||
unassign(&c, ids[0]).unwrap();
|
||
confirm(&c, ids[0], b).unwrap();
|
||
confirm(&c, ids[0], a).unwrap();
|
||
merge_people(&c, a, b).unwrap();
|
||
assert_eq!(for_person(&c, a, false).unwrap().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn images_for_person_counts_a_photograph_once() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
// Two faces of the same person in one frame — a mirror, or a collage.
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
confirm(&c, ids[0], anna).unwrap();
|
||
confirm(&c, ids[1], anna).unwrap();
|
||
assert_eq!(images_for_person(&c, anna, false).unwrap(), vec![img]);
|
||
}
|
||
|
||
#[test]
|
||
fn suggested_faces_are_excluded_from_a_person_by_default() {
|
||
let c = db();
|
||
let i1 = image(&c, 1);
|
||
let i2 = image(&c, 2);
|
||
let a = record_detections(&c, i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let b = record_detections(&c, i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
confirm(&c, a[0], anna).unwrap();
|
||
suggest(&c, b[0], anna, 0.8).unwrap();
|
||
|
||
assert_eq!(for_person(&c, anna, false).unwrap().len(), 1);
|
||
assert_eq!(for_person(&c, anna, true).unwrap().len(), 2);
|
||
assert_eq!(images_for_person(&c, anna, false).unwrap().len(), 1);
|
||
assert_eq!(images_for_person(&c, anna, true).unwrap().len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn people_reports_confirmed_and_suggested_separately() {
|
||
let c = db();
|
||
let i1 = image(&c, 1);
|
||
let i2 = image(&c, 2);
|
||
let a = record_detections(&c, i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let b = record_detections(&c, i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
confirm(&c, a[0], anna).unwrap();
|
||
suggest(&c, b[0], anna, 0.7).unwrap();
|
||
|
||
let p = people(&c).unwrap();
|
||
assert_eq!(p.len(), 1);
|
||
assert_eq!(p[0].confirmed_faces, 1);
|
||
assert_eq!(p[0].suggested_faces, 1);
|
||
assert_eq!(p[0].name, "Anna");
|
||
}
|
||
|
||
#[test]
|
||
fn renaming_keeps_the_merge_identity() {
|
||
let c = db();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
let before = people(&c).unwrap()[0].uuid.clone();
|
||
rename_person(&c, anna, "Anna Smith").unwrap();
|
||
let after = &people(&c).unwrap()[0];
|
||
assert_eq!(after.name, "Anna Smith");
|
||
assert_eq!(after.uuid, before, "a rename must not change identity");
|
||
}
|
||
|
||
#[test]
|
||
fn deleting_face_data_leaves_the_photographs_alone() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
confirm(&c, ids[0], anna).unwrap();
|
||
reject(&c, ids[1], anna).unwrap();
|
||
put_calibration(
|
||
&c,
|
||
"w600k_mbf",
|
||
&Calibration {
|
||
a: 16.0,
|
||
b: -4.3,
|
||
w_size: 0.0,
|
||
valid: true,
|
||
positive_pairs: 900,
|
||
negative_pairs: 90_000,
|
||
},
|
||
"hash",
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(delete_all_face_data(&c).unwrap(), 2);
|
||
assert!(for_image(&c, img).unwrap().is_empty());
|
||
assert!(people(&c).unwrap().is_empty());
|
||
assert!(calibration(&c, "w600k_mbf").unwrap().is_none());
|
||
let images: i64 = c
|
||
.query_row("SELECT COUNT(*) FROM images", [], |r| r.get(0))
|
||
.unwrap();
|
||
assert_eq!(images, 1, "deleting face data deleted a photograph");
|
||
}
|
||
|
||
#[test]
|
||
fn calibration_round_trips_and_its_boundary_inverts_its_probability() {
|
||
let c = db();
|
||
let cal = Calibration {
|
||
a: 16.2,
|
||
b: -4.33,
|
||
w_size: 0.0,
|
||
valid: true,
|
||
positive_pairs: 500,
|
||
negative_pairs: 50_000,
|
||
};
|
||
put_calibration(&c, "w600k_mbf", &cal, "abc").unwrap();
|
||
let (got, hash) = calibration(&c, "w600k_mbf").unwrap().unwrap();
|
||
assert_eq!(hash, "abc");
|
||
assert!((got.a - 16.2).abs() < 1e-4);
|
||
assert!(got.valid);
|
||
|
||
for &p in &[0.5_f32, 0.9, 0.99] {
|
||
let cos = got.boundary_at(p, 112.0, 0.0);
|
||
let back = got.probability(cos, 112.0, 0.0);
|
||
assert!((back - p).abs() < 1e-3, "p={p} round-tripped to {back}");
|
||
}
|
||
}
|
||
|
||
/// The reference implementation's fitted MBF curve puts the P=0.5 boundary
|
||
/// at cosine 0.267 (docs/dev/faces.md §1). Our own first end-to-end run scored
|
||
/// 0.596 between distinct photographs of one person and 0.05 between
|
||
/// different people, so those two must land either side.
|
||
#[test]
|
||
fn the_reference_calibration_separates_the_measured_cosines() {
|
||
let cal = Calibration {
|
||
a: 16.2,
|
||
b: -16.2 * 0.267,
|
||
w_size: 0.0,
|
||
valid: true,
|
||
positive_pairs: 0,
|
||
negative_pairs: 0,
|
||
};
|
||
assert!(cal.probability(0.596, 200.0, 0.0) > 0.99);
|
||
assert!(cal.probability(0.050, 200.0, 0.0) < 0.05);
|
||
assert!((cal.boundary_at(0.5, 200.0, 0.0) - 0.267).abs() < 1e-3);
|
||
}
|
||
|
||
#[test]
|
||
fn unassigned_lists_only_faces_with_no_identity_and_the_right_model() {
|
||
let c = db();
|
||
let i1 = image(&c, 1);
|
||
let i2 = image(&c, 2);
|
||
let a = record_detections(&c, i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
record_detections(&c, i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
|
||
let anna = create_person(&c, "Anna").unwrap();
|
||
confirm(&c, a[0], anna).unwrap();
|
||
|
||
assert_eq!(unassigned(&c, "w600k_mbf").unwrap().len(), 1);
|
||
assert!(unassigned(&c, "some_other_model").unwrap().is_empty());
|
||
}
|
||
|
||
/// The bug this table exists for: a photograph with no faces must not look
|
||
/// like one that has never been indexed, or every landscape in the library
|
||
/// is re-examined on every pass, for ever.
|
||
#[test]
|
||
fn an_image_with_no_faces_still_counts_as_indexed() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[]).unwrap();
|
||
|
||
assert!(is_indexed(&c, img, "w600k_mbf").unwrap());
|
||
assert!(for_image(&c, img).unwrap().is_empty());
|
||
|
||
let cov = coverage(&c, "w600k_mbf").unwrap();
|
||
assert_eq!(cov.indexed, 1);
|
||
assert_eq!(cov.without_faces, 1);
|
||
assert_eq!(cov.faces, 0);
|
||
assert_eq!(cov.outstanding(), 0);
|
||
assert!(cov.is_complete());
|
||
}
|
||
|
||
#[test]
|
||
fn a_model_change_puts_every_image_back_in_the_queue() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
|
||
assert!(is_indexed(&c, img, "w600k_mbf").unwrap());
|
||
assert!(
|
||
!is_indexed(&c, img, "some_better_model").unwrap(),
|
||
"a new model must not inherit the old model's coverage"
|
||
);
|
||
assert_eq!(coverage(&c, "some_better_model").unwrap().outstanding(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn coverage_counts_images_not_faces() {
|
||
let c = db();
|
||
let i1 = image(&c, 1);
|
||
let i2 = image(&c, 2);
|
||
image(&c, 3);
|
||
|
||
// Three faces across two images; the third image is untouched.
|
||
record_detections(&c, i1, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap();
|
||
record_detections(&c, i2, "w600k_mbf", 1024, &[face(3)]).unwrap();
|
||
|
||
let cov = coverage(&c, "w600k_mbf").unwrap();
|
||
assert_eq!(cov.images, 3);
|
||
assert_eq!(cov.indexed, 2);
|
||
assert_eq!(cov.faces, 3);
|
||
assert_eq!(cov.outstanding(), 1);
|
||
assert!(!cov.is_complete());
|
||
assert!((cov.fraction() - 2.0 / 3.0).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn coverage_leaves_out_the_shadowed_half_of_a_raw_jpeg_pair() {
|
||
let c = db();
|
||
let raw = image(&c, 1);
|
||
let jpeg = image(&c, 2);
|
||
// The JPEG beside a RAW is not a separate photograph, and no sweep
|
||
// will ever index one.
|
||
c.execute(
|
||
"UPDATE images SET shadowed_by = ?1 WHERE id = ?2",
|
||
rusqlite::params![raw.0 as i64, jpeg.0 as i64],
|
||
)
|
||
.unwrap();
|
||
|
||
record_detections(&c, raw, "w600k_mbf", 2560, &[face(1)]).unwrap();
|
||
|
||
let cov = coverage(&c, "w600k_mbf").unwrap();
|
||
// Counting the shadowed one would leave a permanent remainder that no
|
||
// amount of indexing could bring down -- 4,424 of them on the
|
||
// reference library, which read as a stuck job.
|
||
assert_eq!(cov.images, 1);
|
||
assert_eq!(cov.outstanding(), 0);
|
||
assert!(cov.is_complete());
|
||
}
|
||
|
||
#[test]
|
||
fn re_indexing_one_image_updates_its_marker_rather_than_adding_a_second() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap();
|
||
record_detections(&c, img, "w600k_mbf", 2048, &[face(3)]).unwrap();
|
||
|
||
let cov = coverage(&c, "w600k_mbf").unwrap();
|
||
assert_eq!(cov.indexed, 1, "a second marker row was written");
|
||
assert_eq!(cov.faces, 1);
|
||
|
||
let edge: i64 = c
|
||
.query_row(
|
||
"SELECT source_edge FROM face_index WHERE image_id = ?1",
|
||
[img.0 as i64],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(edge, 2048, "the marker should record the newer proxy");
|
||
}
|
||
|
||
/// An image holds one pipeline's faces at a time, so the first pipeline's
|
||
/// marker goes with its faces — but the image stays indexed for every
|
||
/// pipeline sharing the embedder, because the faces behind the marker
|
||
/// that remains are the same population. Switching detector and back
|
||
/// therefore neither re-queues the image nor leaves a marker with nothing
|
||
/// behind it.
|
||
#[test]
|
||
fn re_indexing_under_another_model_drops_the_first_models_marker() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
record_detections(&c, img, "w600k_mbf", 2048, &[face(1)]).unwrap();
|
||
record_detections(&c, img, "scrfd_2.5g+w600k_mbf", 2048, &[face(2), face(3)]).unwrap();
|
||
|
||
let markers: Vec<String> = {
|
||
let mut q = c
|
||
.prepare("SELECT model_id FROM face_index WHERE image_id = ?1")
|
||
.unwrap();
|
||
q.query_map([img.0 as i64], |r| r.get(0))
|
||
.unwrap()
|
||
.collect::<Result<_, _>>()
|
||
.unwrap()
|
||
};
|
||
assert_eq!(
|
||
markers,
|
||
vec!["scrfd_2.5g+w600k_mbf".to_string()],
|
||
"the first pipeline's marker outlived its faces"
|
||
);
|
||
assert!(is_indexed(&c, img, "scrfd_2.5g+w600k_mbf").unwrap());
|
||
assert!(is_indexed(&c, img, "w600k_mbf").unwrap());
|
||
assert_eq!(for_image(&c, img).unwrap().len(), 2);
|
||
assert_eq!(coverage(&c, "w600k_mbf").unwrap().outstanding(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn clearing_a_marker_puts_that_image_back_in_the_queue() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
clear_index_marker(&c, img, "w600k_mbf").unwrap();
|
||
|
||
assert!(!is_indexed(&c, img, "w600k_mbf").unwrap());
|
||
assert_eq!(coverage(&c, "w600k_mbf").unwrap().outstanding(), 1);
|
||
// The faces themselves are untouched: clearing a marker asks for a
|
||
// re-detection, not a deletion.
|
||
assert_eq!(for_image(&c, img).unwrap().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn an_empty_library_is_complete_rather_than_zero_percent() {
|
||
let c = db();
|
||
let cov = coverage(&c, "w600k_mbf").unwrap();
|
||
assert!(cov.is_complete());
|
||
assert!((cov.fraction() - 1.0).abs() < 1e-6);
|
||
}
|
||
|
||
/// Deleting face data must clear the run markers too, or the library
|
||
/// reports itself fully indexed while holding no faces at all — and the
|
||
/// sweep then refuses to rebuild what the user just asked to remove.
|
||
#[test]
|
||
fn deleting_face_data_clears_the_run_markers() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
delete_all_face_data(&c).unwrap();
|
||
|
||
assert!(!is_indexed(&c, img, "w600k_mbf").unwrap());
|
||
let cov = coverage(&c, "w600k_mbf").unwrap();
|
||
assert_eq!(cov.indexed, 0);
|
||
assert_eq!(cov.outstanding(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn embeddings_come_back_as_stored() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[face(7)]).unwrap();
|
||
let e = embeddings(&c, "w600k_mbf").unwrap();
|
||
assert_eq!(e.len(), 1);
|
||
assert_eq!(e[0].image, img);
|
||
assert_eq!(e[0].embedding.len(), 1024);
|
||
assert_eq!(e[0].embedding[0], 7);
|
||
assert!((e[0].crop_px - 180.0).abs() < 1e-3);
|
||
assert_eq!(e[0].quality, Some(17.0));
|
||
}
|
||
|
||
// ── stored crops ──────────────────────────────────────────────────────
|
||
|
||
fn face_with_crop(seed: u8, crop: Vec<u8>) -> DetectedFace {
|
||
DetectedFace { crop, ..face(seed) }
|
||
}
|
||
|
||
#[test]
|
||
fn a_crop_stored_with_a_face_comes_back_as_stored() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
1024,
|
||
&[face_with_crop(3, vec![9; 128])],
|
||
)
|
||
.unwrap();
|
||
assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![9; 128]));
|
||
}
|
||
|
||
/// A face indexed before crops existed has none, and that has to read back
|
||
/// as an honest absence rather than an empty image.
|
||
#[test]
|
||
fn a_face_with_no_crop_reads_back_as_none() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(3)]).unwrap();
|
||
assert_eq!(crop(&c, ids[0]).unwrap(), None);
|
||
assert_eq!(faces_without_crop(&c, "w600k_mbf").unwrap(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn crops_come_back_per_person_and_skip_the_ones_without() {
|
||
let c = db();
|
||
let a = image(&c, 1);
|
||
let b = image(&c, 2);
|
||
let with = record_detections(&c, a, "w600k_mbf", 1024, &[face_with_crop(1, vec![4; 16])])
|
||
.unwrap()[0];
|
||
let without = record_detections(&c, b, "w600k_mbf", 1024, &[face(2)]).unwrap()[0];
|
||
|
||
let p = create_person(&c, "Anna").unwrap();
|
||
confirm(&c, with, p).unwrap();
|
||
confirm(&c, without, p).unwrap();
|
||
|
||
let crops = crops_for_person(&c, p, true).unwrap();
|
||
assert_eq!(crops.len(), 1, "a face with no crop should not appear");
|
||
assert_eq!(crops.get(&with), Some(&vec![4; 16]));
|
||
}
|
||
|
||
/// Re-indexing replaces the crop along with everything else, so a better
|
||
/// pass over the same photograph updates what the screen draws.
|
||
#[test]
|
||
fn re_indexing_replaces_the_stored_crop() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[face_with_crop(1, vec![1; 8])]).unwrap();
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face_with_crop(1, vec![2; 8])])
|
||
.unwrap();
|
||
assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![2; 8]));
|
||
}
|
||
|
||
// ── setting a person aside ────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn a_person_can_be_ignored_and_un_ignored() {
|
||
let c = db();
|
||
let p = create_person(&c, "").unwrap();
|
||
assert!(!is_ignored(&c, p).unwrap());
|
||
|
||
set_ignored(&c, p, true).unwrap();
|
||
assert!(is_ignored(&c, p).unwrap());
|
||
assert!(
|
||
people(&c)
|
||
.unwrap()
|
||
.iter()
|
||
.find(|q| q.id == p)
|
||
.unwrap()
|
||
.ignored
|
||
);
|
||
|
||
set_ignored(&c, p, false).unwrap();
|
||
assert!(!is_ignored(&c, p).unwrap());
|
||
}
|
||
|
||
/// Ignoring is a judgement, so it bumps the revision the same way a rename
|
||
/// does — a device that syncs has to see that something changed.
|
||
#[test]
|
||
fn ignoring_a_person_bumps_their_revision() {
|
||
let c = db();
|
||
let p = create_person(&c, "").unwrap();
|
||
let before: i64 = c
|
||
.query_row(
|
||
"SELECT revision FROM people WHERE id = ?1",
|
||
[p.0 as i64],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap();
|
||
set_ignored(&c, p, true).unwrap();
|
||
let after: i64 = c
|
||
.query_row(
|
||
"SELECT revision FROM people WHERE id = ?1",
|
||
[p.0 as i64],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap();
|
||
assert!(after > before);
|
||
}
|
||
|
||
// ── pruning what clustering left behind ───────────────────────────────
|
||
|
||
#[test]
|
||
fn an_empty_unnamed_person_is_pruned() {
|
||
let c = db();
|
||
create_person(&c, "").unwrap();
|
||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 1);
|
||
assert!(people(&c).unwrap().is_empty());
|
||
}
|
||
|
||
/// The rule that matters: a name is user data and survives whatever else
|
||
/// happens to the group.
|
||
#[test]
|
||
fn an_empty_named_person_is_kept() {
|
||
let c = db();
|
||
create_person(&c, "Anna").unwrap();
|
||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
|
||
assert_eq!(people(&c).unwrap().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unnamed_person_with_faces_is_kept() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let f = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap()[0];
|
||
let p = create_person(&c, "").unwrap();
|
||
suggest(&c, f, p, 0.95).unwrap();
|
||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
|
||
}
|
||
|
||
/// An ignored group is empty of nothing — the user ruled on it, and
|
||
/// deleting it would bring it straight back on the next Regroup.
|
||
#[test]
|
||
fn an_ignored_person_is_never_pruned() {
|
||
let c = db();
|
||
let p = create_person(&c, "").unwrap();
|
||
set_ignored(&c, p, true).unwrap();
|
||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
|
||
}
|
||
|
||
/// A merge tombstone has to outlive its faces or the device on the other
|
||
/// side of the sync resurrects the person it redirects.
|
||
#[test]
|
||
fn a_merge_target_is_never_pruned() {
|
||
let c = db();
|
||
let target = create_person(&c, "").unwrap();
|
||
let source = create_person(&c, "").unwrap();
|
||
c.execute(
|
||
"UPDATE people SET merged_into = ?2 WHERE id = ?1",
|
||
rusqlite::params![source.0 as i64, target.0 as i64],
|
||
)
|
||
.unwrap();
|
||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
|
||
}
|
||
}
|