🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 1h21m32s
Build and test / Layer separation (push) Successful in 37s
Traceability / Requirement traces (push) Successful in 25s
Build and test / Android (aarch64) (push) Failing after 33m58s
The merge of the SCRFD/MobileFaceNet work brought 69 rustfmt diffs across
dr-catalog, dr-face and dr-ui with it, so `cargo fmt --all -- --check` fails
on master and the Desktop job stops at its Format step — before clippy, the
tests or the release build have run at all. That makes the whole desktop
half of CI blind: a real compile error behind this would look exactly the
same from the outside. There was nothing behind it, as it turns out — with
the formatting fixed, clippy, the test suite and the release build all pass.
Every .rs hunk is `cargo fmt --all` on the pinned 1.92.0 toolchain, not a
hand edit, but it is worth being precise about what that moved, because it
is more than whitespace. Besides reflowing signatures and call chains,
rustfmt reordered the `pub mod` and `pub use` items in dr-face/src/lib.rs so
the `#[cfg(feature = "inference")]` entries sort in place, added the trailing
semicolon inside `let ... else { return }` bodies in identity_ui.rs, wrapped
a bare closure body in braces in cluster.rs, adjusted trailing commas, and
dropped a stray blank line at the end of identity_ui.rs. All of it is
semantically inert; none of it changes behaviour.
docs/traceability.md rides along because it has to. The matrix records each
TRACES tag by line number, and reflowing develop.rs, lib.rs, faces.rs,
identity.rs and identity_ui.rs moved them — FR-CAT-8, FR-CAT-9, FR-CULL-10,
FR-DEV-3, FR-DEV-3a and FR-DEV-3c all shift by a line or two. The matrix was
verified up to date on d777f7f before this commit, so this is drift these
formatting changes introduced, not pre-existing staleness being swept up.
Leaving it for a follow-up commit would hand traceability-check.yml a
failure caused entirely by a whitespace change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1286 lines
47 KiB
Rust
1286 lines
47 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/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.
|
||
//!
|
||
//! # 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;
|
||
|
||
/// 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, L2-normalised — `dr_face::Embedding::to_f16_bytes`.
|
||
pub embedding: Vec<u8>,
|
||
/// Source pixels across the aligned crop (docs/faces.md §7).
|
||
pub crop_px: f32,
|
||
/// Which model produced the embedding. Comparing across models is the one
|
||
/// mistake that yields plausible garbage rather than an error.
|
||
pub model_id: String,
|
||
}
|
||
|
||
/// 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,
|
||
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 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 ─────────────────────────────────────────────────────────────
|
||
|
||
/// 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.
|
||
///
|
||
/// **Confirmed assignments survive** where the new detection covers the same
|
||
/// part of the frame: a re-index with a better model must not discard the
|
||
/// user's labelling (FR-CULL-10). Matching is by box overlap, since the face is
|
||
/// in the same place even when the box moves a little.
|
||
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()?;
|
||
|
||
// What the user had asserted, so it can be carried across the replacement.
|
||
let mut prior: Vec<(f32, f32, f32, f32, i64, f64)> = Vec::new();
|
||
{
|
||
let mut q = tx.prepare(
|
||
"SELECT f.x, f.y, f.w, f.h, fp.person_id, fp.probability
|
||
FROM faces f
|
||
JOIN face_person fp ON fp.face_id = f.id
|
||
WHERE f.image_id = ?1 AND fp.confirmed = 1",
|
||
)?;
|
||
let rows = q.query_map([image_id.0 as i64], |r| {
|
||
Ok((
|
||
r.get::<_, f64>(0)? as f32,
|
||
r.get::<_, f64>(1)? as f32,
|
||
r.get::<_, f64>(2)? as f32,
|
||
r.get::<_, f64>(3)? as f32,
|
||
r.get::<_, i64>(4)?,
|
||
r.get::<_, f64>(5)?,
|
||
))
|
||
})?;
|
||
for row in rows {
|
||
prior.push(row?);
|
||
}
|
||
}
|
||
|
||
tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?;
|
||
|
||
let now = now_secs();
|
||
let mut ids = Vec::with_capacity(faces.len());
|
||
for f in faces {
|
||
tx.execute(
|
||
"INSERT INTO faces
|
||
(image_id, x, y, w, h, landmarks, detector_confidence,
|
||
embedding, crop_px, model_id, detected_at)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||
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,
|
||
],
|
||
)?;
|
||
let id = FaceId(tx.last_insert_rowid() as u64);
|
||
|
||
// Re-attach a confirmation whose box this detection clearly replaces.
|
||
// 0.5 IoU is loose on purpose: the question is "is this the same face
|
||
// in the frame", not "is this the same box", and a better detector is
|
||
// entitled to move the box.
|
||
if let Some((_, _, _, _, person, prob)) = prior
|
||
.iter()
|
||
.filter(|p| iou((p.0, p.1, p.2, p.3), (f.x, f.y, f.w, f.h)) > 0.5)
|
||
.max_by(|a, b| {
|
||
iou((a.0, a.1, a.2, a.3), (f.x, f.y, f.w, f.h))
|
||
.total_cmp(&iou((b.0, b.1, b.2, b.3), (f.x, f.y, f.w, f.h)))
|
||
})
|
||
{
|
||
tx.execute(
|
||
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
|
||
VALUES (?1, ?2, ?3, 1)",
|
||
rusqlite::params![id.0 as i64, person, prob],
|
||
)?;
|
||
}
|
||
|
||
ids.push(id);
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
/// 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.
|
||
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.
|
||
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",
|
||
[],
|
||
|r| r.get(0),
|
||
)?;
|
||
let (indexed, without, faces): (i64, i64, i64) = conn.query_row(
|
||
"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 fi.model_id = ?1 AND i.trashed_at IS NULL",
|
||
[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.
|
||
pub fn is_indexed(
|
||
conn: &Connection,
|
||
image_id: ImageId,
|
||
model_id: &str,
|
||
) -> Result<bool, CatalogError> {
|
||
conn.query_row(
|
||
"SELECT EXISTS(SELECT 1 FROM face_index WHERE image_id = ?1 AND model_id = ?2)",
|
||
rusqlite::params![image_id.0 as i64, 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(
|
||
"DELETE FROM face_index WHERE image_id = ?1 AND model_id = ?2",
|
||
rusqlite::params![image_id.0 as i64, 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
|
||
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(
|
||
"SELECT f.id FROM faces f
|
||
LEFT JOIN face_person fp ON fp.face_id = f.id
|
||
WHERE fp.face_id IS NULL AND f.model_id = ?1",
|
||
)?;
|
||
let rows = q.query_map([model_id], |r| Ok(FaceId(r.get::<_, i64>(0)? as u64)))?;
|
||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||
}
|
||
|
||
/// One face's stored embedding, as the clustering pass consumes it.
|
||
///
|
||
/// A named type rather than a tuple because it crosses a crate boundary and
|
||
/// "the third element" is not a thing anyone should have to remember.
|
||
pub type StoredEmbedding = (FaceId, ImageId, Vec<u8>, f32);
|
||
|
||
/// Embeddings for clustering, oldest first so the pass is deterministic.
|
||
///
|
||
/// 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(
|
||
"SELECT id, image_id, embedding, crop_px FROM faces
|
||
WHERE model_id = ?1 ORDER BY id",
|
||
)?;
|
||
let rows = q.query_map([model_id], |r| {
|
||
Ok((
|
||
FaceId(r.get::<_, i64>(0)? as u64),
|
||
ImageId(r.get::<_, i64>(1)? as u64),
|
||
r.get::<_, Vec<u8>>(2)?,
|
||
r.get::<_, f64>(3)? 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> {
|
||
let mut q = conn.prepare(
|
||
"SELECT p.id, p.uuid, p.name,
|
||
COALESCE(SUM(fp.confirmed = 1), 0),
|
||
COALESCE(SUM(fp.confirmed = 0), 0)
|
||
FROM people p
|
||
LEFT JOIN face_person fp ON fp.person_id = p.id
|
||
WHERE p.merged_into IS NULL
|
||
GROUP BY p.id
|
||
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,
|
||
})
|
||
})?;
|
||
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 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
|
||
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.
|
||
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![
|
||
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",
|
||
[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)
|
||
}
|
||
|
||
// ── 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,
|
||
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),
|
||
})
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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,
|
||
model_id: "w600k_mbf".into(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn migration_reaches_version_nine() {
|
||
let c = db();
|
||
let v: i64 = c
|
||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||
.unwrap();
|
||
assert_eq!(v, 9);
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
|
||
#[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/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 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");
|
||
}
|
||
|
||
#[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].1, img);
|
||
assert_eq!(e[0].2.len(), 1024);
|
||
assert_eq!(e[0].2[0], 7);
|
||
assert!((e[0].3 - 180.0).abs() < 1e-3);
|
||
}
|
||
}
|