The Identity screen said 4,593 images were left to index and stayed there for hours across repeated runs, which is what a stuck job looks like. It was not stuck. 4,424 of those 4,593 are shadowed -- the JPEG half of a RAW+JPEG pair -- and no sweep will ever index one, because every work list is built on VISIBLE, which excludes them. They are not separate photographs and the grid does not show them either. But faces::coverage counted them: its denominator was "images WHERE trashed_at IS NULL", with no shadowed_by clause. So the outstanding figure had a floor of 4,424 that no amount of work could bring down, and Coverage::is_complete could never once return true no matter how completely the library had been indexed. A progress number that cannot reach its own target is worse than no progress number. The fix is to count the population the sweeps actually draw from, in all three places that were describing it differently: coverage's denominator and its indexed join, and audit's split of the outstanding set, which had the same gap and fed the same status line. On the reference library the denominator goes from 23,531 to 19,107 and outstanding from 4,593 to 169 -- the second of which is a number the user can watch go down, and which turns out to be a real and separate fetch failure worth chasing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1623 lines
60 KiB
Rust
1623 lines
60 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,
|
||
/// 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,
|
||
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 ─────────────────────────────────────────────────────────────
|
||
|
||
/// 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, crop)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||
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()),
|
||
],
|
||
)?;
|
||
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, 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),
|
||
)?;
|
||
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 AND i.shadowed_by 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),
|
||
p.ignored
|
||
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,
|
||
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 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)
|
||
}
|
||
|
||
/// 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(
|
||
"SELECT COUNT(*) FROM faces WHERE model_id = ?1 AND crop IS NULL",
|
||
[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,
|
||
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(),
|
||
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());
|
||
}
|
||
|
||
/// 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 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");
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
// ── 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);
|
||
}
|
||
}
|