FR-CULL-9 was read as "no fit, no number", so every library without 200 confirmed positive pairs showed "Confidence unavailable" on every suggestion — which is every library, until enough confirmations exist to fit one. The confirmations are made on this screen, ranked by the number it was withholding, so the degraded state was also the permanent one. There has always been a curve: Calibration::default is the reference implementation's fitted MBF sigmoid, which is what clustering already operates at. It is a published operating point, not an invention, and what the requirement forbids is presenting it *as though it were measured on this library*. So the percentage is shown, and the screen says once, above the grid, where the curve came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1024 lines
37 KiB
Rust
1024 lines
37 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;
|
||
|
||
/// Edge of the portrait beside a name in the people rail.
|
||
///
|
||
/// Small on purpose: the rail is for *recognising* a person you already know,
|
||
/// not for judging a likeness, and forty of them at grid size would be a
|
||
/// second grid competing with the real one.
|
||
pub const COVER_CROP_EDGE: u32 = 80;
|
||
|
||
/// 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>,
|
||
/// Set aside by the user — see `faces::set_ignored`.
|
||
pub ignored: bool,
|
||
}
|
||
|
||
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).
|
||
///
|
||
/// Always meaningful, and always shown. Where the library has no fit of
|
||
/// its own the number comes from the reference curve — a published
|
||
/// operating point, not an invention — and the screen says so once, at the
|
||
/// top, rather than blanking every face (FR-CULL-9).
|
||
pub probability: f32,
|
||
/// 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.
|
||
///
|
||
/// FR-CULL-9's rule is about *provenance*, not about silence: the number
|
||
/// must not be passed off as measured when it is not. Withholding it
|
||
/// entirely was the wrong reading — it left the user ranking forty
|
||
/// suggestions with nothing to rank them by, on every library that has not
|
||
/// yet earned a fit, which is most of them. The number is shown; where the
|
||
/// curve is the built-in one the screen says so above the grid.
|
||
pub fn confidence_label(&self) -> String {
|
||
if self.confirmed {
|
||
"Confirmed".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 from its
|
||
/// own faces, as against the built-in reference curve. Not a gate on
|
||
/// showing confidences — only on how the screen describes them.
|
||
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,
|
||
ignored: p.ignored,
|
||
})
|
||
.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,
|
||
) -> Result<Vec<FaceCell>, dr_catalog::CatalogError> {
|
||
let conn = catalog.connection();
|
||
let rows = faces::for_person(conn, person, true)?;
|
||
|
||
// The crops kept at detection time, in one query. Where a face has one this
|
||
// is the whole cost of drawing it — no proxy, no full-size JPEG decode, and
|
||
// no dependence on the thumbnail cache still holding the photograph.
|
||
let stored = faces::crops_for_person(conn, person, true)?;
|
||
|
||
// The fallback path, for faces indexed before crops were kept. 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 crop = match stored.get(&f.id).and_then(|b| decode_crop(b)) {
|
||
Some(c) => Some(c),
|
||
None => {
|
||
let entry = decoded
|
||
.entry(f.image_id)
|
||
.or_insert_with(|| decode_proxy(catalog, store, f.image_id));
|
||
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,
|
||
crop_px: f.crop_px,
|
||
});
|
||
}
|
||
|
||
Ok(out)
|
||
}
|
||
|
||
/// The face to show as a person's portrait.
|
||
///
|
||
/// Confirmed faces first, then the largest — `crop_px` is the number of source
|
||
/// pixels the face actually occupied, so the largest is the one the user has
|
||
/// the best chance of recognising. Falling back to a suggestion means a
|
||
/// freshly clustered group still has a face beside it, which is the moment the
|
||
/// portrait matters most: the rail is how the user decides which unnamed group
|
||
/// to open first.
|
||
pub fn load_cover(
|
||
catalog: &Catalog,
|
||
store: &ThumbStore,
|
||
person: PersonId,
|
||
) -> Result<Option<FaceCrop>, dr_catalog::CatalogError> {
|
||
let mut rows = faces::for_person(catalog.connection(), person, true)?;
|
||
if rows.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
// Confirmed first, then largest.
|
||
rows.sort_by(|a, b| {
|
||
b.confirmed
|
||
.cmp(&a.confirmed)
|
||
.then(b.crop_px.total_cmp(&a.crop_px))
|
||
});
|
||
|
||
// Cut the best few and take the first legible one.
|
||
//
|
||
// Size alone picked some very dark crops on the reference library: the
|
||
// largest face in a group is often the one nearest the camera in a badly
|
||
// lit frame, and a black square beside a name identifies nobody. Bounded
|
||
// at a handful because each candidate costs a JPEG decode, and the list is
|
||
// already in preference order — so this gives up size only when the
|
||
// preferred face is genuinely too dark to recognise.
|
||
let mut fallback: Option<FaceCrop> = None;
|
||
let conn = catalog.connection();
|
||
for face in rows.iter().take(COVER_CANDIDATES) {
|
||
// A stored crop costs a small JPEG decode; the fallback costs a full
|
||
// proxy decode, which is what made the rail expensive to build.
|
||
let crop = match faces::crop(conn, face.id)
|
||
.ok()
|
||
.flatten()
|
||
.and_then(|b| decode_crop(&b))
|
||
{
|
||
Some(c) => c,
|
||
None => {
|
||
let Some((w, h, rgba)) = decode_proxy(catalog, store, face.image_id) else {
|
||
continue;
|
||
};
|
||
let Some(c) = crop_face(&rgba, w, h, face, COVER_CROP_EDGE) else {
|
||
continue;
|
||
};
|
||
c
|
||
}
|
||
};
|
||
if mean_luma(&crop) >= MIN_COVER_LUMA {
|
||
return Ok(Some(crop));
|
||
}
|
||
fallback.get_or_insert(crop);
|
||
}
|
||
// Everything this person has is dark. Their largest face is still the best
|
||
// answer available, and showing it beats showing nothing.
|
||
Ok(fallback)
|
||
}
|
||
|
||
/// How many faces to cut before settling for the largest.
|
||
const COVER_CANDIDATES: usize = 4;
|
||
|
||
/// Mean luma a cover must reach to be preferred over a larger, darker one.
|
||
///
|
||
/// Low: this rejects the near-black, not the moody. A crop at 0.18 is a
|
||
/// legible face in a dim room; one at 0.05 is a silhouette.
|
||
const MIN_COVER_LUMA: f32 = 0.18;
|
||
|
||
/// Rec. 709 luma, averaged over the crop, ignoring transparent margin.
|
||
fn mean_luma(crop: &FaceCrop) -> f32 {
|
||
let mut sum = 0.0_f32;
|
||
let mut n = 0_u32;
|
||
for px in crop.rgba.chunks_exact(4) {
|
||
// Out-of-frame margin is transparent and black; counting it would make
|
||
// every edge-of-frame face look darker than it is.
|
||
if px[3] == 0 {
|
||
continue;
|
||
}
|
||
sum += (0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32) / 255.0;
|
||
n += 1;
|
||
}
|
||
if n == 0 {
|
||
0.0
|
||
} else {
|
||
sum / n as f32
|
||
}
|
||
}
|
||
|
||
/// Turn a stored crop back into pixels.
|
||
///
|
||
/// Returned at whatever size it was stored (`faces::STORED_CROP_EDGE`) rather
|
||
/// than resampled down to the cell: the grid and the rail scale it themselves,
|
||
/// and doing it here would mean two resamples where one will do — and a soft
|
||
/// portrait for the trouble.
|
||
fn decode_crop(bytes: &[u8]) -> Option<FaceCrop> {
|
||
let (w, h, rgba) = dr_thumbs::codec::decode_rgba(bytes).ok()?;
|
||
Some(FaceCrop {
|
||
width: w,
|
||
height: h,
|
||
rgba,
|
||
})
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
/// A named face box normalised to the image's long edge: `(x, y, w, h, name)`.
|
||
///
|
||
/// Named because it crosses three layers — catalog, develop session,
|
||
/// segmentation job — and "the fourth float" is not something anyone should
|
||
/// have to count out at each one.
|
||
pub type NormalisedNamedBox = (f32, f32, f32, f32, String);
|
||
|
||
/// The confirmed, named faces in one image, **normalised to the long edge**.
|
||
///
|
||
/// The form a develop session carries, because the proxy a segmentation run
|
||
/// settles on is not known when the image opens — so the scaling happens at
|
||
/// the far end, in [`crate::develop::SegmentationJob`].
|
||
///
|
||
/// **Confirmed names only.** A suggestion is the system's guess, and printing
|
||
/// a guessed name onto a mask region would launder it into a fact — the exact
|
||
/// conflation the confirmed/suggested split exists to prevent (FR-CULL-10). An
|
||
/// unrecognised or merely-suggested person leaves the region as "person",
|
||
/// which is honest.
|
||
pub fn named_boxes_normalised(
|
||
catalog: &Catalog,
|
||
image: ImageId,
|
||
) -> Result<Vec<NormalisedNamedBox>, dr_catalog::CatalogError> {
|
||
let conn = catalog.connection();
|
||
|
||
let mut names: std::collections::HashMap<PersonId, String> = std::collections::HashMap::new();
|
||
for p in faces::people(conn)? {
|
||
if !p.name.is_empty() {
|
||
names.insert(p.id, p.name);
|
||
}
|
||
}
|
||
if names.is_empty() {
|
||
return Ok(Vec::new());
|
||
}
|
||
|
||
Ok(faces::for_image(conn, image)?
|
||
.into_iter()
|
||
.filter(|f| f.confirmed)
|
||
.filter_map(|f| {
|
||
let name = f.person.and_then(|p| names.get(&p))?.clone();
|
||
Some((f.x, f.y, f.w, f.h, name))
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// A named face box for one image, in proxy pixels.
|
||
///
|
||
/// Owned rather than borrowed because it crosses from a catalog read into a
|
||
/// segmentation pass that outlives the query.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct NamedBox {
|
||
pub bbox: (f32, f32, f32, f32),
|
||
pub name: String,
|
||
}
|
||
|
||
/// The named faces in one image, ready to label its segmented regions.
|
||
///
|
||
/// **Confirmed names only.** A suggestion is the system's guess, and printing
|
||
/// a guessed name on a mask region would launder it into a fact — the exact
|
||
/// conflation the confirmed/suggested split exists to prevent (FR-CULL-10).
|
||
/// An unrecognised or merely-suggested person leaves the region as "person",
|
||
/// which is honest.
|
||
///
|
||
/// Boxes come back in the proxy pixel space the caller names, because that is
|
||
/// what `Segmentation` works in — the catalog stores them normalised to the
|
||
/// long edge precisely so this conversion is possible at any resolution.
|
||
pub fn named_boxes_for_image(
|
||
catalog: &Catalog,
|
||
image: ImageId,
|
||
proxy_w: usize,
|
||
proxy_h: usize,
|
||
) -> Result<Vec<NamedBox>, dr_catalog::CatalogError> {
|
||
let conn = catalog.connection();
|
||
let long_edge = proxy_w.max(proxy_h) as f32;
|
||
if long_edge <= 0.0 {
|
||
return Ok(Vec::new());
|
||
}
|
||
|
||
let mut names: std::collections::HashMap<PersonId, String> = std::collections::HashMap::new();
|
||
for p in faces::people(conn)? {
|
||
if !p.name.is_empty() {
|
||
names.insert(p.id, p.name);
|
||
}
|
||
}
|
||
|
||
Ok(faces::for_image(conn, image)?
|
||
.into_iter()
|
||
.filter(|f| f.confirmed)
|
||
.filter_map(|f| {
|
||
let name = f.person.and_then(|p| names.get(&p))?.clone();
|
||
Some(NamedBox {
|
||
bbox: (
|
||
f.x * long_edge,
|
||
f.y * long_edge,
|
||
(f.x + f.w) * long_edge,
|
||
(f.y + f.h) * long_edge,
|
||
),
|
||
name,
|
||
})
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
// ── 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)
|
||
}
|
||
|
||
/// Another person already carrying this name, if there is one.
|
||
///
|
||
/// A name is not an identity here — `people.uuid` is, which is what lets two
|
||
/// devices name the same cluster independently and still merge cleanly
|
||
/// (catalog.md, `people.uuid`). So this reports a *collision* for the screen to
|
||
/// offer a merge on and does nothing else: two people are allowed to share a
|
||
/// name, and a library with two Annas in it is a real library, not a mistake to
|
||
/// be corrected without being asked.
|
||
///
|
||
/// Compared case-insensitively and trimmed. "anna" typed on a phone keyboard
|
||
/// and "Anna" typed on a desktop are one intention, and an offer that appeared
|
||
/// only when the capitalisation happened to match would read as a bug.
|
||
///
|
||
/// Merged-away people cannot come back: `faces::people` already excludes them,
|
||
/// so a name freed by an earlier merge collides with nothing.
|
||
pub fn namesake(
|
||
catalog: &Catalog,
|
||
person: PersonId,
|
||
name: &str,
|
||
) -> Result<Option<PersonRow>, dr_catalog::CatalogError> {
|
||
let key = name.trim().to_lowercase();
|
||
// An unnamed cluster is not a namesake of every other unnamed cluster.
|
||
// They all render as "Unnamed (n faces)" and folding them together on that
|
||
// basis would merge the whole library into one person.
|
||
if key.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
Ok(dr_catalog::faces::people(catalog.connection())?
|
||
.into_iter()
|
||
.find(|p| p.id != person && p.name.trim().to_lowercase() == key)
|
||
.map(|p| PersonRow {
|
||
id: p.id,
|
||
name: p.name,
|
||
confirmed_faces: p.confirmed_faces,
|
||
suggested_faces: p.suggested_faces,
|
||
cover: None,
|
||
ignored: p.ignored,
|
||
}))
|
||
}
|
||
|
||
/// 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 cells = load_faces(catalog, store, person)?;
|
||
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(),
|
||
crop: Vec::new(),
|
||
}
|
||
}
|
||
|
||
#[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,
|
||
ignored: false,
|
||
};
|
||
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,
|
||
ignored: false,
|
||
};
|
||
assert_eq!(row.display_name(), "Anna");
|
||
assert!(!row.is_unconfirmed());
|
||
}
|
||
|
||
/// FR-CULL-9 at the point it becomes visible. The rule is that an unfitted
|
||
/// curve must not be *described* as measured — not that the number is
|
||
/// withheld, which left the user with forty unrankable suggestions on every
|
||
/// library too young to have earned a fit. The percentage is always shown;
|
||
/// the provenance is said once, at the screen level.
|
||
#[test]
|
||
fn every_suggestion_shows_a_percentage_whatever_the_curve_was_fitted_from() {
|
||
let cell = FaceCell {
|
||
face: FaceId(1),
|
||
image: ImageId(1),
|
||
crop: None,
|
||
confirmed: false,
|
||
probability: 0.87,
|
||
crop_px: 120.0,
|
||
};
|
||
assert_eq!(cell.confidence_label(), "87% likely");
|
||
|
||
let confirmed = FaceCell {
|
||
confirmed: true,
|
||
..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 fit of its own, and says so — it still shows confidences"
|
||
);
|
||
}
|
||
|
||
#[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 a_namesake_is_found_whatever_the_capitalisation() {
|
||
let c = catalog();
|
||
let anna = faces::create_person(c.connection(), "Anna Smith").unwrap();
|
||
let other = faces::create_person(c.connection(), "").unwrap();
|
||
|
||
rename(&c, other, " anna smith ").unwrap();
|
||
let found = namesake(&c, other, " anna smith ")
|
||
.unwrap()
|
||
.expect("a namesake");
|
||
assert_eq!(found.id, anna);
|
||
assert_eq!(
|
||
found.name, "Anna Smith",
|
||
"the existing spelling is reported"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_person_is_not_their_own_namesake() {
|
||
let c = catalog();
|
||
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
||
assert!(
|
||
namesake(&c, anna, "Anna").unwrap().is_none(),
|
||
"renaming someone to the name they already have offered a merge with themselves"
|
||
);
|
||
}
|
||
|
||
/// Every unnamed cluster renders as "Unnamed (n faces)". If an empty name
|
||
/// counted as a collision, the offer would appear on every cluster in a
|
||
/// freshly indexed library and accepting it would fold the library into one
|
||
/// person.
|
||
#[test]
|
||
fn an_empty_name_collides_with_nothing() {
|
||
let c = catalog();
|
||
faces::create_person(c.connection(), "").unwrap();
|
||
let second = faces::create_person(c.connection(), "").unwrap();
|
||
assert!(namesake(&c, second, "").unwrap().is_none());
|
||
assert!(namesake(&c, second, " ").unwrap().is_none());
|
||
}
|
||
|
||
/// A merge leaves a redirect behind rather than deleting the row, so the
|
||
/// name it carried must not keep colliding — otherwise the offer would
|
||
/// reappear immediately after being accepted, pointing at a person the
|
||
/// rail no longer shows.
|
||
#[test]
|
||
fn a_merged_away_person_is_not_a_namesake() {
|
||
let c = catalog();
|
||
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
||
let dup = faces::create_person(c.connection(), "Anna").unwrap();
|
||
merge(&c, anna, dup).unwrap();
|
||
|
||
let third = faces::create_person(c.connection(), "").unwrap();
|
||
rename(&c, third, "Anna").unwrap();
|
||
let found = namesake(&c, third, "Anna").unwrap().expect("a namesake");
|
||
assert_eq!(
|
||
found.id, anna,
|
||
"the offer pointed at the merged-away person"
|
||
);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
/// The segmentation link: a confirmed face inside a `person` region should
|
||
/// put that person's name on it.
|
||
#[test]
|
||
fn named_boxes_come_back_in_proxy_pixels() {
|
||
let c = catalog();
|
||
let img = image(&c, 1);
|
||
let ids =
|
||
faces::record_detections(c.connection(), img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
||
faces::confirm(c.connection(), ids[0], anna).unwrap();
|
||
|
||
// The fixture's face is x=0.1 y=0.1 w=0.2 h=0.3, normalised to the
|
||
// long edge — so at 1024×683 it spans 102.4..307.2 across.
|
||
let boxes = named_boxes_for_image(&c, img, 1024, 683).unwrap();
|
||
assert_eq!(boxes.len(), 1);
|
||
assert_eq!(boxes[0].name, "Anna");
|
||
assert!((boxes[0].bbox.0 - 102.4).abs() < 0.1, "{:?}", boxes[0].bbox);
|
||
assert!((boxes[0].bbox.2 - 307.2).abs() < 0.1, "{:?}", boxes[0].bbox);
|
||
|
||
// And the same face at another proxy size lands proportionally — the
|
||
// reason the catalog stores these normalised.
|
||
let bigger = named_boxes_for_image(&c, img, 2048, 1366).unwrap();
|
||
assert!((bigger[0].bbox.0 - 204.8).abs() < 0.1);
|
||
}
|
||
|
||
/// A suggestion is the system's guess. Printing a guessed name onto a mask
|
||
/// region would launder it into a fact.
|
||
#[test]
|
||
fn a_suggested_person_does_not_name_a_region() {
|
||
let c = catalog();
|
||
let img = image(&c, 1);
|
||
let ids =
|
||
faces::record_detections(c.connection(), img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let anna = faces::create_person(c.connection(), "Anna").unwrap();
|
||
faces::suggest(c.connection(), ids[0], anna, 0.95).unwrap();
|
||
|
||
assert!(named_boxes_for_image(&c, img, 1024, 683)
|
||
.unwrap()
|
||
.is_empty());
|
||
|
||
// Confirming it makes the name appear.
|
||
faces::confirm(c.connection(), ids[0], anna).unwrap();
|
||
assert_eq!(named_boxes_for_image(&c, img, 1024, 683).unwrap().len(), 1);
|
||
}
|
||
|
||
/// An unnamed cluster has nothing to say about a region.
|
||
#[test]
|
||
fn an_unnamed_group_does_not_name_a_region() {
|
||
let c = catalog();
|
||
let img = image(&c, 1);
|
||
let ids =
|
||
faces::record_detections(c.connection(), img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
let nameless = faces::create_person(c.connection(), "").unwrap();
|
||
faces::confirm(c.connection(), ids[0], nameless).unwrap();
|
||
|
||
assert!(named_boxes_for_image(&c, img, 1024, 683)
|
||
.unwrap()
|
||
.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn mean_luma_ignores_the_transparent_margin() {
|
||
// Half opaque white, half transparent black. Counting the margin would
|
||
// report 0.5; ignoring it reports 1.0, which is what the face is.
|
||
let mut rgba = vec![0u8; 4 * 4 * 4];
|
||
for (i, px) in rgba.chunks_exact_mut(4).enumerate() {
|
||
if i < 8 {
|
||
px.copy_from_slice(&[255, 255, 255, 255]);
|
||
}
|
||
}
|
||
let crop = FaceCrop {
|
||
width: 4,
|
||
height: 4,
|
||
rgba,
|
||
};
|
||
assert!(
|
||
(mean_luma(&crop) - 1.0).abs() < 1e-3,
|
||
"{}",
|
||
mean_luma(&crop)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_dark_crop_falls_below_the_cover_threshold_and_a_lit_one_clears_it() {
|
||
let solid = |v: u8| FaceCrop {
|
||
width: 2,
|
||
height: 2,
|
||
rgba: vec![v, v, v, 255, v, v, v, 255, v, v, v, 255, v, v, v, 255],
|
||
};
|
||
assert!(mean_luma(&solid(10)) < MIN_COVER_LUMA);
|
||
assert!(mean_luma(&solid(120)) >= MIN_COVER_LUMA);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|