An image with no faces in it was indistinguishable from one that had never been looked at, so every landscape, still life and document scan in the library was re-detected on every pass, for ever. In a real library that is most of it: on the 23,527-image test library, 64 of the first 110 images indexed contain no face at all. Schema v9 adds face_index, a run marker per (image, model) carrying the face count and the proxy edge it read. Keyed on the model, so a model change puts every image back in the queue by itself. That makes a coverage figure possible, which is the thing a user actually wants to see. The audit also splits the outstanding set by whether a proxy exists, because 23,417 awaiting a proxy and 110 ready to index are different problems, and telling the user to run indexing again would not fix the first. The Identity screen gains Index faces, Stop, and the coverage line. examples/face_index.rs is the same check and sweep without a window, which is the right shape for an overnight pass. Measured on the real library in release: 3.5 images/second, 110 images and 125 faces in 30 seconds, and a second run correctly finds nothing left to do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
575 lines
20 KiB
Rust
575 lines
20 KiB
Rust
//! TRACES: FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5
|
|
//! The Identity screen's model: who the library knows, and how the user fixes it.
|
|
//!
|
|
//! A third top-level screen beside the library and develop, because it is a
|
|
//! *place you go to work*, not a panel you glance at: naming a cluster, pulling
|
|
//! a stranger out of it, and merging two halves of the same person are tasks
|
|
//! with their own rhythm, and they need the whole window.
|
|
//!
|
|
//! # The screen exists because the clustering is wrong
|
|
//!
|
|
//! That is not pessimism, it is FR-CULL-10: clustering will over-merge on
|
|
//! siblings, on parents and children, and on the same person a decade apart. A
|
|
//! tool that could only merge would make its own errors permanent, so
|
|
//! **splitting is as prominent as merging here**, and every suggestion is
|
|
//! visibly a suggestion until the user says otherwise.
|
|
//!
|
|
//! # What this module is, and is not
|
|
//!
|
|
//! It builds a view model from the catalog and applies the user's decisions
|
|
//! back to it. It holds no Slint types, so the whole of it is testable against
|
|
//! an in-memory catalog with no window open — which matters because the
|
|
//! operations it performs are the ones that touch user data.
|
|
|
|
use dr_catalog::faces::{self, FaceId, PersonId};
|
|
use dr_catalog::Catalog;
|
|
use dr_thumbs::ThumbStore;
|
|
use dr_types::ImageId;
|
|
|
|
use crate::faces::{crop_face, FaceCrop, FACE_TIER};
|
|
|
|
/// Edge of a face crop in the grid, in pixels.
|
|
///
|
|
/// Generous for a thumbnail because the judgement being asked for — *is this
|
|
/// the same person* — is one the user cannot make from a postage stamp.
|
|
pub const FACE_CROP_EDGE: u32 = 128;
|
|
|
|
/// A row in the people rail.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct PersonRow {
|
|
pub id: PersonId,
|
|
/// Empty for a cluster the system found and the user has not named.
|
|
pub name: String,
|
|
pub confirmed_faces: u64,
|
|
pub suggested_faces: u64,
|
|
/// The face to show as this person's portrait, if any is loaded.
|
|
pub cover: Option<FaceId>,
|
|
}
|
|
|
|
impl PersonRow {
|
|
/// What to draw when the person has no name yet.
|
|
///
|
|
/// Deliberately not a guessed name. FR-CULL-10 has the *user* name a group,
|
|
/// and a system-chosen name would be an inference wearing a fact's clothes
|
|
/// — the exact conflation the confirmed/suggested split exists to prevent.
|
|
pub fn display_name(&self) -> String {
|
|
if self.name.is_empty() {
|
|
format!("Unnamed ({} faces)", self.confirmed_faces + self.suggested_faces)
|
|
} else {
|
|
self.name.clone()
|
|
}
|
|
}
|
|
|
|
/// Whether this group is still entirely the system's opinion.
|
|
pub fn is_unconfirmed(&self) -> bool {
|
|
self.confirmed_faces == 0
|
|
}
|
|
}
|
|
|
|
/// One face in the grid.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct FaceCell {
|
|
pub face: FaceId,
|
|
pub image: ImageId,
|
|
/// `None` while the crop is still being cut.
|
|
pub crop: Option<FaceCrop>,
|
|
/// The user asserted this, as against the system guessing it.
|
|
pub confirmed: bool,
|
|
/// Calibrated P(this face is this person).
|
|
pub probability: f32,
|
|
/// Whether that probability means anything — false when the library's
|
|
/// calibration has not been fitted (FR-CULL-9).
|
|
pub probability_known: bool,
|
|
/// Source pixels across the aligned crop. Small faces embed worse, and the
|
|
/// user deserves to know which of a bad suggestion's causes is in play.
|
|
pub crop_px: f32,
|
|
}
|
|
|
|
impl FaceCell {
|
|
/// Confidence as text, or why there is none.
|
|
///
|
|
/// The FR-CULL-9 rule made concrete: where the calibration is not fitted,
|
|
/// this says so rather than printing an untuned number that looks measured.
|
|
pub fn confidence_label(&self) -> String {
|
|
if self.confirmed {
|
|
"Confirmed".into()
|
|
} else if !self.probability_known {
|
|
"Confidence unavailable".into()
|
|
} else {
|
|
format!("{:.0}% likely", self.probability * 100.0)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Everything the Identity screen draws.
|
|
#[derive(Debug, Clone, Default, PartialEq)]
|
|
pub struct IdentityView {
|
|
pub people: Vec<PersonRow>,
|
|
pub selected: Option<PersonId>,
|
|
pub faces: Vec<FaceCell>,
|
|
/// Faces belonging to nobody — the pool the user can pull a new person out
|
|
/// of, and the honest answer to "why is this photo not under anyone".
|
|
pub unassigned: usize,
|
|
/// Whether the library's similarity calibration has been fitted.
|
|
pub calibrated: bool,
|
|
}
|
|
|
|
/// Read the people rail.
|
|
pub fn load_people(catalog: &Catalog, model_id: &str) -> Result<IdentityView, dr_catalog::CatalogError> {
|
|
let conn = catalog.connection();
|
|
let people = faces::people(conn)?
|
|
.into_iter()
|
|
.map(|p| PersonRow {
|
|
id: p.id,
|
|
name: p.name,
|
|
confirmed_faces: p.confirmed_faces,
|
|
suggested_faces: p.suggested_faces,
|
|
cover: None,
|
|
})
|
|
.collect();
|
|
|
|
Ok(IdentityView {
|
|
people,
|
|
selected: None,
|
|
faces: Vec::new(),
|
|
unassigned: faces::unassigned(conn, model_id)?.len(),
|
|
calibrated: faces::calibration(conn, model_id)?.is_some_and(|(c, _)| c.valid),
|
|
})
|
|
}
|
|
|
|
/// Read one person's faces, cutting a crop for each.
|
|
///
|
|
/// Suggestions are included and marked, never hidden: the whole purpose of this
|
|
/// screen is ruling on them, and a screen that showed only confirmed faces
|
|
/// would give the user nothing to do.
|
|
///
|
|
/// Crops come from the proxy the grid already built. An image whose proxy has
|
|
/// been evicted yields a cell with no crop rather than being dropped — the face
|
|
/// is still real, still counted, and still confirmable from its filename.
|
|
pub fn load_faces(
|
|
catalog: &Catalog,
|
|
store: &ThumbStore,
|
|
person: PersonId,
|
|
calibrated: bool,
|
|
) -> Result<Vec<FaceCell>, dr_catalog::CatalogError> {
|
|
let conn = catalog.connection();
|
|
let rows = faces::for_person(conn, person, true)?;
|
|
|
|
// One decode per *image*, not per face: a group photograph holding six
|
|
// faces of one family is one JPEG, and decoding it six times is the kind of
|
|
// waste that only shows up on a slow machine.
|
|
let mut decoded: std::collections::HashMap<ImageId, Option<(u32, u32, Vec<u8>)>> =
|
|
std::collections::HashMap::new();
|
|
|
|
let mut out = Vec::with_capacity(rows.len());
|
|
for f in rows {
|
|
let entry = decoded
|
|
.entry(f.image_id)
|
|
.or_insert_with(|| decode_proxy(catalog, store, f.image_id));
|
|
|
|
let crop = entry
|
|
.as_ref()
|
|
.and_then(|(w, h, rgba)| crop_face(rgba, *w, *h, &f, FACE_CROP_EDGE));
|
|
|
|
out.push(FaceCell {
|
|
face: f.id,
|
|
image: f.image_id,
|
|
crop,
|
|
confirmed: f.confirmed,
|
|
probability: f.probability,
|
|
probability_known: calibrated,
|
|
crop_px: f.crop_px,
|
|
});
|
|
}
|
|
|
|
Ok(out)
|
|
}
|
|
|
|
fn decode_proxy(
|
|
catalog: &Catalog,
|
|
store: &ThumbStore,
|
|
image: ImageId,
|
|
) -> Option<(u32, u32, Vec<u8>)> {
|
|
let file_id: i64 = catalog
|
|
.connection()
|
|
.query_row(
|
|
"SELECT file_id FROM remote WHERE image_id = ?1",
|
|
[image.0 as i64],
|
|
|r| r.get(0),
|
|
)
|
|
.ok()?;
|
|
let thumb = store.get(file_id as u64, FACE_TIER).ok()??;
|
|
dr_thumbs::codec::decode_rgba(&thumb.bytes).ok()
|
|
}
|
|
|
|
// ── the user's decisions ──────────────────────────────────────────────────
|
|
|
|
/// Name a group, or rename a person.
|
|
///
|
|
/// The identity is untouched: FR-CULL-12 makes a *name* the user data that
|
|
/// travels to the sidecar, while the person's UUID is what a cross-device merge
|
|
/// keys on, and renaming must not create a second person on the other device.
|
|
pub fn rename(
|
|
catalog: &Catalog,
|
|
person: PersonId,
|
|
name: &str,
|
|
) -> Result<(), dr_catalog::CatalogError> {
|
|
faces::rename_person(catalog.connection(), person, name.trim())
|
|
}
|
|
|
|
/// Confirm every suggestion in a group at once.
|
|
///
|
|
/// The operation the screen exists to make cheap. A cluster that is simply
|
|
/// right — the common case for a well-photographed person — should cost one
|
|
/// click, not forty.
|
|
pub fn confirm_all(
|
|
catalog: &Catalog,
|
|
person: PersonId,
|
|
) -> Result<usize, dr_catalog::CatalogError> {
|
|
let conn = catalog.connection();
|
|
let mut n = 0;
|
|
for f in faces::for_person(conn, person, true)? {
|
|
if !f.confirmed {
|
|
faces::confirm(conn, f.id, person)?;
|
|
n += 1;
|
|
}
|
|
}
|
|
Ok(n)
|
|
}
|
|
|
|
/// The user says this face is this person.
|
|
pub fn confirm(
|
|
catalog: &Catalog,
|
|
face: FaceId,
|
|
person: PersonId,
|
|
) -> Result<(), dr_catalog::CatalogError> {
|
|
faces::confirm(catalog.connection(), face, person)
|
|
}
|
|
|
|
/// The user says this face is not this person.
|
|
///
|
|
/// Remembered, so the next clustering pass does not put it straight back —
|
|
/// which is the difference between a correction and a recurring argument.
|
|
pub fn reject(
|
|
catalog: &Catalog,
|
|
face: FaceId,
|
|
person: PersonId,
|
|
) -> Result<(), dr_catalog::CatalogError> {
|
|
faces::reject(catalog.connection(), face, person)
|
|
}
|
|
|
|
/// Fold one person into another.
|
|
pub fn merge(
|
|
catalog: &Catalog,
|
|
target: PersonId,
|
|
source: PersonId,
|
|
) -> Result<u64, dr_catalog::CatalogError> {
|
|
faces::merge_people(catalog.connection(), target, source)
|
|
}
|
|
|
|
/// What a split would produce, without performing it.
|
|
///
|
|
/// Offered as a preview because a split is the destructive-feeling half of
|
|
/// FR-CULL-10 and the user should see the groups before agreeing to them. It
|
|
/// re-agglomerates this person's faces at a stricter threshold; a person who is
|
|
/// genuinely one person comes back as one group and the UI can say so instead
|
|
/// of splitting nothing.
|
|
pub fn preview_split(
|
|
catalog: &Catalog,
|
|
store: &ThumbStore,
|
|
person: PersonId,
|
|
model_id: &str,
|
|
strictness: f32,
|
|
) -> Result<Vec<Vec<FaceCell>>, dr_catalog::CatalogError> {
|
|
let conn = catalog.connection();
|
|
let cal = faces::calibration(conn, model_id)?
|
|
.map(|(c, _)| c)
|
|
.unwrap_or_default();
|
|
let calibrated = cal.valid;
|
|
|
|
let cells = load_faces(catalog, store, person, calibrated)?;
|
|
if cells.len() < 2 {
|
|
return Ok(vec![cells]);
|
|
}
|
|
|
|
let model = dr_face::ModelId::new(model_id.to_string());
|
|
let stored: std::collections::HashMap<_, _> = faces::embeddings(conn, model_id)?
|
|
.into_iter()
|
|
.map(|(id, image, blob, crop_px)| (id, (image, blob, crop_px)))
|
|
.collect();
|
|
|
|
let mut candidates = Vec::with_capacity(cells.len());
|
|
let mut order = Vec::with_capacity(cells.len());
|
|
for c in &cells {
|
|
let Some((image, blob, crop_px)) = stored.get(&c.face) else {
|
|
continue;
|
|
};
|
|
let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), blob) else {
|
|
continue;
|
|
};
|
|
candidates.push(dr_face::Candidate {
|
|
face: c.face.0,
|
|
image: image.0,
|
|
embedding: emb.v.to_vec(),
|
|
crop_px: *crop_px,
|
|
confirmed_person: None,
|
|
});
|
|
order.push(c.clone());
|
|
}
|
|
|
|
let groups = dr_face::split(&candidates, &cal, strictness);
|
|
Ok(groups
|
|
.into_iter()
|
|
.map(|g| g.members.into_iter().map(|m| order[m].clone()).collect())
|
|
.collect())
|
|
}
|
|
|
|
/// Pull a set of faces out of a person and onto a new one.
|
|
///
|
|
/// The commit half of [`preview_split`], and also what "these three are
|
|
/// actually someone else" does. The faces are **confirmed** onto the new
|
|
/// person, because the user has just asserted they belong together — leaving
|
|
/// them as suggestions would invite the next clustering pass to undo the
|
|
/// correction.
|
|
pub fn split_off(
|
|
catalog: &Catalog,
|
|
from: PersonId,
|
|
members: &[FaceId],
|
|
name: &str,
|
|
) -> Result<PersonId, dr_catalog::CatalogError> {
|
|
let conn = catalog.connection();
|
|
let new_person = faces::create_person(conn, name.trim())?;
|
|
for &face in members {
|
|
// Rejecting first is what stops the split being undone: without it the
|
|
// next pass sees a face that looks like `from` and suggests it straight
|
|
// back, and the user's correction becomes an argument they keep having.
|
|
faces::reject(conn, face, from)?;
|
|
faces::confirm(conn, face, new_person)?;
|
|
}
|
|
Ok(new_person)
|
|
}
|
|
|
|
/// Detach a face from everyone, asserting nothing.
|
|
///
|
|
/// Distinct from [`reject`]: this is "I do not know", and the face returns to
|
|
/// the pool for the next clustering pass to place. Needed for the case where
|
|
/// the user can see a crop is a lamp, not a person — rejecting it from *this*
|
|
/// person would leave it free to be suggested for the next one.
|
|
pub fn unassign(catalog: &Catalog, face: FaceId) -> Result<(), dr_catalog::CatalogError> {
|
|
faces::unassign(catalog.connection(), face)
|
|
}
|
|
|
|
/// Delete every face, person and embedding in the library (NFR-SEC-5).
|
|
///
|
|
/// Surfaced from this screen because this is where the user's face data
|
|
/// visibly lives, and a control to remove it that is hidden in a settings page
|
|
/// three levels down is a control that does not really exist.
|
|
pub fn delete_all(catalog: &Catalog) -> Result<u64, dr_catalog::CatalogError> {
|
|
faces::delete_all_face_data(catalog.connection())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use dr_catalog::faces::DetectedFace;
|
|
|
|
fn catalog() -> Catalog {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"dr-identity-{}-{:?}",
|
|
std::process::id(),
|
|
std::thread::current().id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("catalog.db");
|
|
let _ = std::fs::remove_file(&path);
|
|
Catalog::open(&path).unwrap()
|
|
}
|
|
|
|
fn image(c: &Catalog, n: i64) -> ImageId {
|
|
let conn = c.connection();
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.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); 5],
|
|
confidence: 0.9,
|
|
embedding: vec![seed; 1024],
|
|
crop_px: 180.0,
|
|
model_id: "w600k_mbf".into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_unnamed_group_says_how_many_faces_it_holds_rather_than_guessing_a_name() {
|
|
let row = PersonRow {
|
|
id: PersonId(1),
|
|
name: String::new(),
|
|
confirmed_faces: 0,
|
|
suggested_faces: 12,
|
|
cover: None,
|
|
};
|
|
assert_eq!(row.display_name(), "Unnamed (12 faces)");
|
|
assert!(row.is_unconfirmed());
|
|
}
|
|
|
|
#[test]
|
|
fn a_named_person_shows_their_name() {
|
|
let row = PersonRow {
|
|
id: PersonId(1),
|
|
name: "Anna".into(),
|
|
confirmed_faces: 4,
|
|
suggested_faces: 2,
|
|
cover: None,
|
|
};
|
|
assert_eq!(row.display_name(), "Anna");
|
|
assert!(!row.is_unconfirmed());
|
|
}
|
|
|
|
/// FR-CULL-9's rule at the point it becomes visible: an unfitted
|
|
/// calibration must not print a number that looks measured.
|
|
#[test]
|
|
fn an_uncalibrated_library_says_so_instead_of_showing_a_percentage() {
|
|
let cell = FaceCell {
|
|
face: FaceId(1),
|
|
image: ImageId(1),
|
|
crop: None,
|
|
confirmed: false,
|
|
probability: 0.87,
|
|
probability_known: false,
|
|
crop_px: 120.0,
|
|
};
|
|
assert_eq!(cell.confidence_label(), "Confidence unavailable");
|
|
|
|
let calibrated = FaceCell {
|
|
probability_known: true,
|
|
..cell.clone()
|
|
};
|
|
assert_eq!(calibrated.confidence_label(), "87% likely");
|
|
|
|
let confirmed = FaceCell {
|
|
confirmed: true,
|
|
probability_known: false,
|
|
..cell
|
|
};
|
|
assert_eq!(confirmed.confidence_label(), "Confirmed");
|
|
}
|
|
|
|
#[test]
|
|
fn the_people_rail_reports_the_unassigned_pool() {
|
|
let c = catalog();
|
|
let i1 = image(&c, 1);
|
|
let i2 = image(&c, 2);
|
|
let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
|
faces::record_detections(c.connection(), i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
|
|
|
|
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
|
faces::confirm(c.connection(), a[0], anna).unwrap();
|
|
|
|
let view = load_people(&c, "w600k_mbf").unwrap();
|
|
assert_eq!(view.people.len(), 1);
|
|
assert_eq!(view.people[0].name, "Anna");
|
|
assert_eq!(view.unassigned, 1);
|
|
assert!(!view.calibrated, "a fresh library has no fitted calibration");
|
|
}
|
|
|
|
#[test]
|
|
fn confirm_all_promotes_every_suggestion_and_nothing_else() {
|
|
let c = catalog();
|
|
let i1 = image(&c, 1);
|
|
let i2 = image(&c, 2);
|
|
let i3 = image(&c, 3);
|
|
let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
|
let b = faces::record_detections(c.connection(), i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
|
|
let d = faces::record_detections(c.connection(), i3, "w600k_mbf", 1024, &[face(3)]).unwrap();
|
|
|
|
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
|
faces::confirm(c.connection(), a[0], anna).unwrap();
|
|
faces::suggest(c.connection(), b[0], anna, 0.8).unwrap();
|
|
faces::suggest(c.connection(), d[0], anna, 0.7).unwrap();
|
|
|
|
assert_eq!(confirm_all(&c, anna).unwrap(), 2);
|
|
assert_eq!(confirm_all(&c, anna).unwrap(), 0, "should be idempotent");
|
|
assert_eq!(faces::for_person(c.connection(), anna, false).unwrap().len(), 3);
|
|
}
|
|
|
|
/// The correction must stick. Splitting a face off and then re-running
|
|
/// clustering must not put it back where the user took it from.
|
|
#[test]
|
|
fn a_split_face_is_not_suggested_back_to_the_person_it_left() {
|
|
let c = catalog();
|
|
let i1 = image(&c, 1);
|
|
let i2 = image(&c, 2);
|
|
let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
|
let b = faces::record_detections(c.connection(), i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
|
|
|
|
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
|
faces::confirm(c.connection(), a[0], anna).unwrap();
|
|
faces::suggest(c.connection(), b[0], anna, 0.95).unwrap();
|
|
|
|
let bob = split_off(&c, anna, &[b[0]], "Bob").unwrap();
|
|
assert_eq!(faces::for_person(c.connection(), bob, false).unwrap().len(), 1);
|
|
assert_eq!(faces::for_person(c.connection(), anna, true).unwrap().len(), 1);
|
|
|
|
// The next clustering pass tries again and is refused.
|
|
assert!(
|
|
!faces::suggest(c.connection(), b[0], anna, 0.99).unwrap(),
|
|
"the split was undone by the next inference pass"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn renaming_trims_and_keeps_the_person() {
|
|
let c = catalog();
|
|
let anna = faces::create_person(c.connection(), "").unwrap();
|
|
rename(&c, anna, " Anna Smith ").unwrap();
|
|
let people = faces::people(c.connection()).unwrap();
|
|
assert_eq!(people[0].name, "Anna Smith");
|
|
assert_eq!(people[0].id, anna);
|
|
}
|
|
|
|
#[test]
|
|
fn merging_folds_one_person_into_the_other() {
|
|
let c = catalog();
|
|
let i1 = image(&c, 1);
|
|
let i2 = image(&c, 2);
|
|
let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
|
let b = faces::record_detections(c.connection(), i2, "w600k_mbf", 1024, &[face(2)]).unwrap();
|
|
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
|
let annie = faces::create_person(c.connection(), "Annie").unwrap();
|
|
faces::confirm(c.connection(), a[0], anna).unwrap();
|
|
faces::confirm(c.connection(), b[0], annie).unwrap();
|
|
|
|
assert_eq!(merge(&c, anna, annie).unwrap(), 1);
|
|
assert_eq!(load_people(&c, "w600k_mbf").unwrap().people.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn deleting_everything_empties_the_screen() {
|
|
let c = catalog();
|
|
let i1 = image(&c, 1);
|
|
let a = faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
|
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
|
faces::confirm(c.connection(), a[0], anna).unwrap();
|
|
|
|
assert_eq!(delete_all(&c).unwrap(), 1);
|
|
let view = load_people(&c, "w600k_mbf").unwrap();
|
|
assert!(view.people.is_empty());
|
|
assert_eq!(view.unassigned, 0);
|
|
}
|
|
}
|