Benchmarks / CPU and I/O (per commit) (push) Successful in 3m13s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 54s
Build and test / Layer separation (push) Failing after 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 52s
Build and test / Android (aarch64) (push) Successful in 30m6s
Every face stored before its quality was kept holds a unit vector, and V14 forgot the run marker of each image holding one so that the next sweep would look again. Looking again meant detecting again: a whole re-detection per image, with every suggestion on it thrown away and the confirmations carried across by box overlap, to recover one number. The sweep now has a measuring pass between the proxy repair and the un-indexed images. It lists every image holding an unmeasured face, fetches the original once, warps each stored face from the landmarks it already has, embeds it, and writes the raw vector and its length over the old row. Ids, boxes and identities are untouched; the marker is re-written fresh so the sync exports the measured vectors. A face whose landmarks no longer make a warp is dropped, as detection would have refused to store it. `faces_unindexed` leaves those images to the measuring pass, so the V14 deletion no longer costs a second detection.
1827 lines
68 KiB
Rust
1827 lines
68 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, the raw model output — `dr_face::Embedded::to_f16_bytes`.
|
||
///
|
||
/// Raw rather than unit length, so the length ([`Self::quality`]) is in
|
||
/// the blob and not only beside it. Readers re-normalise on load.
|
||
pub embedding: Vec<u8>,
|
||
/// Source pixels across the aligned crop (docs/faces.md §7).
|
||
pub crop_px: f32,
|
||
/// Length of the raw embedding before normalisation — the model's own
|
||
/// reading of how recognisable the crop was, and the gate on whether
|
||
/// this face may be compared *against* (`dr_face::MIN_GALLERY_QUALITY`).
|
||
///
|
||
/// `None` where it was never measured: a face indexed, here or by a peer,
|
||
/// before raw vectors were stored. The unit vector those builds kept has
|
||
/// no length left to read, so the only way to measure one is to embed it
|
||
/// again (schema V14).
|
||
pub quality: Option<f32>,
|
||
/// Which model produced the embedding. Comparing across models is the one
|
||
/// mistake that yields plausible garbage rather than an error.
|
||
pub model_id: String,
|
||
/// The face itself, cut out and encoded, ready to draw.
|
||
///
|
||
/// Cut at detection time because that is the one moment the pixels are
|
||
/// already in memory. The alternative -- and what this replaced -- is
|
||
/// re-decoding the whole proxy and cutting the box out again every time
|
||
/// the People screen opens, which makes the screen a derivative of a cache
|
||
/// that is entitled to evict anything at any time.
|
||
///
|
||
/// Empty is allowed and means "not cut": a caller with only a box and an
|
||
/// embedding, such as a face adopted from a peer's shard that predates
|
||
/// crops, stores nothing here and the reader falls back to the proxy.
|
||
pub crop: Vec<u8>,
|
||
}
|
||
|
||
/// A stored face, with whatever identity it has acquired.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct Face {
|
||
pub id: FaceId,
|
||
pub image_id: ImageId,
|
||
pub x: f32,
|
||
pub y: f32,
|
||
pub w: f32,
|
||
pub h: f32,
|
||
pub landmarks: [(f32, f32); 5],
|
||
pub confidence: f32,
|
||
pub crop_px: f32,
|
||
/// See [`DetectedFace::quality`]. `None` for a face indexed before it was
|
||
/// recorded.
|
||
pub quality: Option<f32>,
|
||
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, quality)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||
rusqlite::params![
|
||
image_id.0 as i64,
|
||
f.x as f64,
|
||
f.y as f64,
|
||
f.w as f64,
|
||
f.h as f64,
|
||
landmarks_to_blob(&f.landmarks),
|
||
f.confidence as f64,
|
||
f.embedding,
|
||
f.crop_px as f64,
|
||
f.model_id,
|
||
now,
|
||
// NULL rather than an empty blob, so "no crop" is one state in
|
||
// the database rather than two the readers each have to know
|
||
// about.
|
||
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
|
||
f.quality.map(f64::from),
|
||
],
|
||
)?;
|
||
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)
|
||
}
|
||
|
||
/// A face embedded again from its stored landmarks: the new vector and its
|
||
/// length. What the measuring pass hands back per face.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct Measurement {
|
||
pub face: FaceId,
|
||
/// 512 × f16, raw — `dr_face::Embedded::to_f16_bytes`.
|
||
pub embedding: Vec<u8>,
|
||
pub quality: f32,
|
||
}
|
||
|
||
/// Write fresh embeddings over faces that were found before their quality was
|
||
/// kept, and re-mark the image as indexed.
|
||
///
|
||
/// The cheaper half of what `record_detections` does, for the case schema V14
|
||
/// created: the boxes and landmarks are right, the identities are the user's
|
||
/// work, and only the vector needs doing again. Updating in place is what
|
||
/// keeps `face_person` and the face ids exactly as they were -- a
|
||
/// re-detection would carry confirmations across by box overlap and lose
|
||
/// every suggestion, for no gain.
|
||
///
|
||
/// `dropped` are faces whose landmarks turned out to be degenerate -- the warp
|
||
/// could not be built from them. Deleted here, as detection would have refused
|
||
/// to store them (`dr_ui::faces::index_proxy`), and because a face left with
|
||
/// no reading would put its image back on the measuring pass's list on every
|
||
/// sweep, at the cost of an original each time.
|
||
///
|
||
/// The run marker is re-written with a fresh time, and that is not
|
||
/// bookkeeping: `face_shard::export_to_shards` re-exports an image whose
|
||
/// marker is newer than the store's copy, which is how the measured vectors
|
||
/// reach the other devices.
|
||
pub fn record_measurements(
|
||
conn: &Connection,
|
||
image_id: ImageId,
|
||
model_id: &str,
|
||
source_edge: u32,
|
||
measured: &[Measurement],
|
||
dropped: &[FaceId],
|
||
) -> Result<(), CatalogError> {
|
||
let tx = conn.unchecked_transaction()?;
|
||
for m in measured {
|
||
tx.execute(
|
||
"UPDATE faces SET embedding = ?2, quality = ?3 WHERE id = ?1",
|
||
rusqlite::params![m.face.0 as i64, m.embedding, f64::from(m.quality)],
|
||
)?;
|
||
}
|
||
for f in dropped {
|
||
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
|
||
}
|
||
let remaining: i64 = tx.query_row(
|
||
"SELECT COUNT(*) FROM faces WHERE image_id = ?1 AND model_id = ?2",
|
||
rusqlite::params![image_id.0 as i64, model_id],
|
||
|r| r.get(0),
|
||
)?;
|
||
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_secs(),
|
||
remaining,
|
||
source_edge as i64,
|
||
],
|
||
)?;
|
||
tx.commit()?;
|
||
Ok(())
|
||
}
|
||
|
||
/// The faces on one image that have no quality reading yet.
|
||
///
|
||
/// The measuring pass's per-image work: every face this model found whose
|
||
/// vector was stored as a unit one (schema V14), with the landmarks the
|
||
/// warp is rebuilt from.
|
||
pub fn unmeasured_on_image(
|
||
conn: &Connection,
|
||
image_id: ImageId,
|
||
model_id: &str,
|
||
) -> Result<Vec<Face>, CatalogError> {
|
||
Ok(for_image(conn, image_id)?
|
||
.into_iter()
|
||
.filter(|f| f.model_id == model_id && f.quality.is_none())
|
||
.collect())
|
||
}
|
||
|
||
/// How many of a model's faces have no quality reading.
|
||
///
|
||
/// What the measuring pass has left to do, for a screen that wants to say so.
|
||
pub fn faces_unmeasured(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
|
||
conn.query_row(
|
||
"SELECT COUNT(*) FROM faces WHERE model_id = ?1 AND quality IS NULL",
|
||
[model_id],
|
||
|r| r.get::<_, i64>(0),
|
||
)
|
||
.map(|n| n as u64)
|
||
.map_err(Into::into)
|
||
}
|
||
|
||
/// How much of the library has been through face detection.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub struct Coverage {
|
||
/// Images that are candidates at all — present, not trashed, and not the
|
||
/// shadowed half of a RAW+JPEG pair. The same population every sweep's
|
||
/// work list is drawn from; see [`coverage`] for why that matters.
|
||
pub images: u64,
|
||
/// Images this model has actually looked at.
|
||
pub indexed: u64,
|
||
/// Of those, how many had no face in them. Usually most of a library, and
|
||
/// worth showing so "0 faces" reads as a finding rather than a failure.
|
||
pub without_faces: u64,
|
||
/// Faces found across the whole library.
|
||
pub faces: u64,
|
||
}
|
||
|
||
impl Coverage {
|
||
/// Images still to look at.
|
||
pub fn outstanding(&self) -> u64 {
|
||
self.images.saturating_sub(self.indexed)
|
||
}
|
||
|
||
pub fn is_complete(&self) -> bool {
|
||
self.outstanding() == 0
|
||
}
|
||
|
||
/// Fraction indexed, `0.0..=1.0`. An empty library is complete, not zero:
|
||
/// there is nothing outstanding, and reporting 0% would read as a stall.
|
||
pub fn fraction(&self) -> f32 {
|
||
if self.images == 0 {
|
||
1.0
|
||
} else {
|
||
self.indexed as f32 / self.images as f32
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Count what has and has not been indexed, for one model.
|
||
///
|
||
/// The question the batch pass asks before it starts and the UI asks to draw a
|
||
/// progress figure. Answerable only because [`record_detections`] writes a run
|
||
/// marker: counting `faces` rows would report how many faces exist, which is a
|
||
/// different number and never reaches the image count.
|
||
///
|
||
/// # Shadowed images are not candidates, and the denominator has to agree
|
||
///
|
||
/// A shadowed image is the JPEG half of a RAW+JPEG pair. It is not a separate
|
||
/// photograph — the grid does not show it, and every sweep that builds a work
|
||
/// list excludes it. Counting it here anyway is not a rounding error: on the
|
||
/// reference library it put 4,424 images into the denominator that no pass is
|
||
/// permitted to touch, so "4,593 outstanding" had a floor of 4,424 that no
|
||
/// amount of indexing could ever bring down, and [`Coverage::is_complete`]
|
||
/// could never once return true. A progress figure that cannot reach its own
|
||
/// target reads exactly like a stuck job, which is what it was taken for.
|
||
pub fn coverage(conn: &Connection, model_id: &str) -> Result<Coverage, CatalogError> {
|
||
let images: i64 = conn.query_row(
|
||
"SELECT COUNT(*) FROM images
|
||
WHERE trashed_at IS NULL AND shadowed_by IS NULL",
|
||
[],
|
||
|r| r.get(0),
|
||
)?;
|
||
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, f.quality
|
||
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 struct rather than a tuple because it crosses a crate boundary and "the
|
||
/// fourth element" is not a thing anyone should have to remember.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct StoredEmbedding {
|
||
pub face: FaceId,
|
||
pub image: ImageId,
|
||
/// 512 × f16 — `dr_face::Embedding::from_f16_bytes` reads it.
|
||
pub embedding: Vec<u8>,
|
||
pub crop_px: f32,
|
||
/// See [`DetectedFace::quality`].
|
||
pub quality: Option<f32>,
|
||
}
|
||
|
||
/// Embeddings for clustering, oldest first so the pass is deterministic.
|
||
///
|
||
/// 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, quality FROM faces
|
||
WHERE model_id = ?1 ORDER BY id",
|
||
)?;
|
||
let rows = q.query_map([model_id], |r| {
|
||
Ok(StoredEmbedding {
|
||
face: FaceId(r.get::<_, i64>(0)? as u64),
|
||
image: ImageId(r.get::<_, i64>(1)? as u64),
|
||
embedding: r.get::<_, Vec<u8>>(2)?,
|
||
crop_px: r.get::<_, f64>(3)? as f32,
|
||
quality: r.get::<_, Option<f64>>(4)?.map(|q| q as f32),
|
||
})
|
||
})?;
|
||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||
}
|
||
|
||
// ── people ────────────────────────────────────────────────────────────────
|
||
|
||
/// Create a person.
|
||
///
|
||
/// A person is a UUID first and a name second (FR-CULL-10): the name is what
|
||
/// the user typed, the uuid is what a cross-device merge keys on, and renaming
|
||
/// changes only the former.
|
||
pub fn create_person(conn: &Connection, name: &str) -> Result<PersonId, CatalogError> {
|
||
let now = now_secs();
|
||
conn.execute(
|
||
"INSERT INTO people (uuid, name, created, revision, modified)
|
||
VALUES (?1, ?2, ?3, 1, ?3)",
|
||
rusqlite::params![new_uuid(), name, now],
|
||
)?;
|
||
Ok(PersonId(conn.last_insert_rowid() as u64))
|
||
}
|
||
|
||
/// Rename a person. The identity is untouched.
|
||
pub fn rename_person(conn: &Connection, person: PersonId, name: &str) -> Result<(), CatalogError> {
|
||
conn.execute(
|
||
"UPDATE people SET name = ?2, revision = revision + 1, modified = ?3
|
||
WHERE id = ?1",
|
||
rusqlite::params![person.0 as i64, name, now_secs()],
|
||
)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Everyone in the library, with their face counts, most-photographed first.
|
||
///
|
||
/// Merged-away people are excluded: they exist as redirects so a sync does not
|
||
/// resurrect them, not as entries in a list.
|
||
pub fn people(conn: &Connection) -> Result<Vec<Person>, CatalogError> {
|
||
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, f.quality
|
||
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,
|
||
quality: r.get::<_, Option<f64>>(13)?.map(|q| q 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,
|
||
quality: Some(10.0 + f32::from(seed)),
|
||
model_id: "w600k_mbf".into(),
|
||
crop: Vec::new(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn migration_reaches_the_current_version() {
|
||
let c = db();
|
||
let v: i64 = c
|
||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||
.unwrap();
|
||
assert_eq!(v, crate::schema::SCHEMA_VERSION);
|
||
}
|
||
|
||
#[test]
|
||
fn detections_round_trip_with_their_landmarks() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[face(1), face(2)]).unwrap();
|
||
let got = for_image(&c, img).unwrap();
|
||
assert_eq!(got.len(), 2);
|
||
assert_eq!(got[0].model_id, "w600k_mbf");
|
||
assert!((got[0].crop_px - 180.0).abs() < 1e-3);
|
||
assert!((got[0].landmarks[2].1 - 0.2).abs() < 1e-5);
|
||
assert!(got[0].person.is_none());
|
||
// Both readers carry the quality, and the one for the grouping pass
|
||
// carries it as the option it is.
|
||
let mut qualities: Vec<Option<f32>> = got.iter().map(|f| f.quality).collect();
|
||
qualities.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||
assert_eq!(qualities, vec![Some(11.0), Some(12.0)]);
|
||
let stored = embeddings(&c, "w600k_mbf").unwrap();
|
||
assert_eq!(stored.len(), 2);
|
||
assert_eq!(stored[0].quality, Some(11.0), "oldest first");
|
||
assert_eq!(stored[1].quality, Some(12.0));
|
||
}
|
||
|
||
/// The measuring pass writes over the vector and nothing else: the face
|
||
/// keeps its id, its box and whoever the user said it was.
|
||
#[test]
|
||
fn measuring_replaces_the_vector_and_keeps_the_identity() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let unmeasured = DetectedFace {
|
||
quality: None,
|
||
..face(1)
|
||
};
|
||
let ids = record_detections(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
1024,
|
||
&[unmeasured.clone(), unmeasured],
|
||
)
|
||
.unwrap();
|
||
let person = create_person(&c, "Anna").unwrap();
|
||
confirm(&c, ids[0], person).unwrap();
|
||
assert_eq!(faces_unmeasured(&c, "w600k_mbf").unwrap(), 2);
|
||
assert_eq!(unmeasured_on_image(&c, img, "w600k_mbf").unwrap().len(), 2);
|
||
let marked_at: i64 = c
|
||
.query_row("SELECT indexed_at FROM face_index", [], |r| r.get(0))
|
||
.unwrap();
|
||
c.execute("UPDATE face_index SET indexed_at = indexed_at - 100", [])
|
||
.unwrap();
|
||
|
||
record_measurements(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
6000,
|
||
&[Measurement {
|
||
face: ids[0],
|
||
embedding: vec![9; 1024],
|
||
quality: 21.5,
|
||
}],
|
||
&[ids[1]],
|
||
)
|
||
.unwrap();
|
||
|
||
let got = for_image(&c, img).unwrap();
|
||
assert_eq!(got.len(), 1, "the degenerate face was kept");
|
||
assert_eq!(got[0].id, ids[0]);
|
||
assert_eq!(got[0].quality, Some(21.5));
|
||
assert_eq!(got[0].person, Some(person));
|
||
assert!(got[0].confirmed);
|
||
let e = embeddings(&c, "w600k_mbf").unwrap();
|
||
assert_eq!(e[0].embedding[0], 9);
|
||
assert_eq!(faces_unmeasured(&c, "w600k_mbf").unwrap(), 0);
|
||
|
||
// The marker says one face at the native edge, and is fresh — which
|
||
// is what makes the sync export it again.
|
||
let (found, edge, at): (i64, i64, i64) = c
|
||
.query_row(
|
||
"SELECT faces_found, source_edge, indexed_at FROM face_index",
|
||
[],
|
||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||
)
|
||
.unwrap();
|
||
assert_eq!((found, edge), (1, 6000));
|
||
assert!(at >= marked_at, "the marker was not refreshed");
|
||
}
|
||
|
||
/// 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].image, img);
|
||
assert_eq!(e[0].embedding.len(), 1024);
|
||
assert_eq!(e[0].embedding[0], 7);
|
||
assert!((e[0].crop_px - 180.0).abs() < 1e-3);
|
||
assert_eq!(e[0].quality, Some(17.0));
|
||
}
|
||
|
||
// ── stored crops ──────────────────────────────────────────────────────
|
||
|
||
fn face_with_crop(seed: u8, crop: Vec<u8>) -> DetectedFace {
|
||
DetectedFace { crop, ..face(seed) }
|
||
}
|
||
|
||
#[test]
|
||
fn a_crop_stored_with_a_face_comes_back_as_stored() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(
|
||
&c,
|
||
img,
|
||
"w600k_mbf",
|
||
1024,
|
||
&[face_with_crop(3, vec![9; 128])],
|
||
)
|
||
.unwrap();
|
||
assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![9; 128]));
|
||
}
|
||
|
||
/// A face indexed before crops existed has none, and that has to read back
|
||
/// as an honest absence rather than an empty image.
|
||
#[test]
|
||
fn a_face_with_no_crop_reads_back_as_none() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(3)]).unwrap();
|
||
assert_eq!(crop(&c, ids[0]).unwrap(), None);
|
||
assert_eq!(faces_without_crop(&c, "w600k_mbf").unwrap(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn crops_come_back_per_person_and_skip_the_ones_without() {
|
||
let c = db();
|
||
let a = image(&c, 1);
|
||
let b = image(&c, 2);
|
||
let with = record_detections(&c, a, "w600k_mbf", 1024, &[face_with_crop(1, vec![4; 16])])
|
||
.unwrap()[0];
|
||
let without = record_detections(&c, b, "w600k_mbf", 1024, &[face(2)]).unwrap()[0];
|
||
|
||
let p = create_person(&c, "Anna").unwrap();
|
||
confirm(&c, with, p).unwrap();
|
||
confirm(&c, without, p).unwrap();
|
||
|
||
let crops = crops_for_person(&c, p, true).unwrap();
|
||
assert_eq!(crops.len(), 1, "a face with no crop should not appear");
|
||
assert_eq!(crops.get(&with), Some(&vec![4; 16]));
|
||
}
|
||
|
||
/// Re-indexing replaces the crop along with everything else, so a better
|
||
/// pass over the same photograph updates what the screen draws.
|
||
#[test]
|
||
fn re_indexing_replaces_the_stored_crop() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
record_detections(&c, img, "w600k_mbf", 1024, &[face_with_crop(1, vec![1; 8])]).unwrap();
|
||
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face_with_crop(1, vec![2; 8])])
|
||
.unwrap();
|
||
assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![2; 8]));
|
||
}
|
||
|
||
// ── setting a person aside ────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn a_person_can_be_ignored_and_un_ignored() {
|
||
let c = db();
|
||
let p = create_person(&c, "").unwrap();
|
||
assert!(!is_ignored(&c, p).unwrap());
|
||
|
||
set_ignored(&c, p, true).unwrap();
|
||
assert!(is_ignored(&c, p).unwrap());
|
||
assert!(
|
||
people(&c)
|
||
.unwrap()
|
||
.iter()
|
||
.find(|q| q.id == p)
|
||
.unwrap()
|
||
.ignored
|
||
);
|
||
|
||
set_ignored(&c, p, false).unwrap();
|
||
assert!(!is_ignored(&c, p).unwrap());
|
||
}
|
||
|
||
/// Ignoring is a judgement, so it bumps the revision the same way a rename
|
||
/// does — a device that syncs has to see that something changed.
|
||
#[test]
|
||
fn ignoring_a_person_bumps_their_revision() {
|
||
let c = db();
|
||
let p = create_person(&c, "").unwrap();
|
||
let before: i64 = c
|
||
.query_row(
|
||
"SELECT revision FROM people WHERE id = ?1",
|
||
[p.0 as i64],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap();
|
||
set_ignored(&c, p, true).unwrap();
|
||
let after: i64 = c
|
||
.query_row(
|
||
"SELECT revision FROM people WHERE id = ?1",
|
||
[p.0 as i64],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap();
|
||
assert!(after > before);
|
||
}
|
||
|
||
// ── pruning what clustering left behind ───────────────────────────────
|
||
|
||
#[test]
|
||
fn an_empty_unnamed_person_is_pruned() {
|
||
let c = db();
|
||
create_person(&c, "").unwrap();
|
||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 1);
|
||
assert!(people(&c).unwrap().is_empty());
|
||
}
|
||
|
||
/// The rule that matters: a name is user data and survives whatever else
|
||
/// happens to the group.
|
||
#[test]
|
||
fn an_empty_named_person_is_kept() {
|
||
let c = db();
|
||
create_person(&c, "Anna").unwrap();
|
||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
|
||
assert_eq!(people(&c).unwrap().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unnamed_person_with_faces_is_kept() {
|
||
let c = db();
|
||
let img = image(&c, 1);
|
||
let f = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap()[0];
|
||
let p = create_person(&c, "").unwrap();
|
||
suggest(&c, f, p, 0.95).unwrap();
|
||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
|
||
}
|
||
|
||
/// An ignored group is empty of nothing — the user ruled on it, and
|
||
/// deleting it would bring it straight back on the next Regroup.
|
||
#[test]
|
||
fn an_ignored_person_is_never_pruned() {
|
||
let c = db();
|
||
let p = create_person(&c, "").unwrap();
|
||
set_ignored(&c, p, true).unwrap();
|
||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
|
||
}
|
||
|
||
/// A merge tombstone has to outlive its faces or the device on the other
|
||
/// side of the sync resurrects the person it redirects.
|
||
#[test]
|
||
fn a_merge_target_is_never_pruned() {
|
||
let c = db();
|
||
let target = create_person(&c, "").unwrap();
|
||
let source = create_person(&c, "").unwrap();
|
||
c.execute(
|
||
"UPDATE people SET merged_into = ?2 WHERE id = ?1",
|
||
rusqlite::params![source.0 as i64, target.0 as i64],
|
||
)
|
||
.unwrap();
|
||
assert_eq!(prune_empty_unnamed(&c).unwrap(), 0);
|
||
}
|
||
}
|