A confirm or a reject changes one row and redraws the whole grid, and the redraw re-read every crop blob of the selected person (4 MB for the largest) and decoded every one — 316 ms per click on the reference library's 754-face person, to arrive at the pixels already on screen. `load_faces` now takes the crops the previous load decoded, keyed by face, and moves each into its new cell; the blob read is skipped when every face is already in hand. `refresh` drains the old cells into it rather than cloning them. The redraw is 2.6 ms.
1308 lines
49 KiB
Rust
1308 lines
49 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,
|
||
/// The model's own reading of how recognisable the crop was — the length
|
||
/// of its raw embedding (`dr_face::MIN_GALLERY_QUALITY`). `None` for a
|
||
/// face indexed before it was kept.
|
||
pub quality: Option<f32>,
|
||
/// TRACES: FR-CULL-8a | FR-CULL-13
|
||
/// What the eyes are doing, where they were read.
|
||
pub eyes: Option<dr_face::EyeReading>,
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// The quality as text — "Quality 17.3", or "Quality —" where it was never
|
||
/// measured.
|
||
///
|
||
/// Called *quality* and not *norm* on the screen, because that is what the
|
||
/// number is used as and what the user can act on: a low one is the model
|
||
/// saying it could not make the face out, and the fix is a better
|
||
/// photograph. One decimal, because the gate sits at a whole number and
|
||
/// the faces worth a second look are the ones just either side of it.
|
||
pub fn quality_label(&self) -> String {
|
||
match self.quality {
|
||
Some(q) => format!("Quality {q:.1}"),
|
||
None => "Quality —".into(),
|
||
}
|
||
}
|
||
|
||
/// Whether this face is good enough to be compared *against*.
|
||
///
|
||
/// What the screen dims the quality for: a face below the floor is still
|
||
/// somebody and still placed, but it vouches for no one else, and a user
|
||
/// wondering why a person's group did not gather the rest of them should
|
||
/// be able to see that none of its members can.
|
||
pub fn in_gallery(&self) -> bool {
|
||
dr_face::in_gallery(self.quality)
|
||
}
|
||
|
||
/// The eye state as a badge — "Eyes closed", "Sunglasses" or "Eyes
|
||
/// unclear" — or nothing.
|
||
///
|
||
/// Nothing for open eyes and nothing for a face never read, on the rule
|
||
/// the confirmed marker follows: the common case carries no mark, so the
|
||
/// marks that appear mean something. The three that do appear are the
|
||
/// states the eyes-open filter treats differently from open — one it
|
||
/// drops, two it lets through — and a user asking why a frame is or is
|
||
/// not in the grid can read the answer off the face.
|
||
pub fn eyes_label(&self) -> &'static str {
|
||
match self.eyes.map(|e| e.state()) {
|
||
Some(dr_face::EyeState::Open) | None => "",
|
||
Some(state) => state.label(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Put a grouping preview into words.
|
||
///
|
||
/// **The group count leads, not the grouped-face count.** They move in opposite
|
||
/// directions on either side of the right setting, and only one of them says
|
||
/// which side you are on: loosening gathers fragments into people, so the group
|
||
/// count climbs — until it starts welding separate people together, at which
|
||
/// point it *falls* while the grouped faces keep rising. `dr_face::cluster`
|
||
/// records that measurement at length. A line that led with "1,340 of 1,813
|
||
/// faces grouped" would make the over-merged setting look like the best one.
|
||
///
|
||
/// The largest group is here for the same reason: it is where over-merging
|
||
/// shows up first and most legibly, because a user who knows their own library
|
||
/// knows whether anyone in it has been photographed six hundred times.
|
||
pub fn preview_label(p: &crate::faces::GroupingPreview) -> String {
|
||
if p.faces == 0 {
|
||
return "No faces indexed yet, so there is nothing to group.".into();
|
||
}
|
||
format!(
|
||
"{} group(s), holding {} of {} faces. Largest: {}.",
|
||
p.groups, p.grouped, p.faces, p.largest
|
||
)
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// **A group holding no faces is not a group, and does not appear.** Only the
|
||
/// unnamed ones, which is exactly the set `faces::prune_empty_unnamed` treats
|
||
/// as debris: a pass creates a person per cluster, the next pass regroups those
|
||
/// faces elsewhere, and the person it emptied survives until something prunes
|
||
/// it. Nothing prunes on this path, so they accumulate — the reference library
|
||
/// reached 11,739 of them against 2,525 real people, and the rail was 85 per
|
||
/// cent rows that named nobody and could do nothing.
|
||
///
|
||
/// Filtered rather than deleted, because this is a screen being drawn and not a
|
||
/// catalog being repaired. A row is withheld; nothing is lost, a sync cannot
|
||
/// resurrect what was never removed, and the prune stays the one place that
|
||
/// decides these are disposable.
|
||
///
|
||
/// An empty group with a *name* still shows. That one is not debris, it is the
|
||
/// symptom of a real failure — a named person whose faces were regrouped out
|
||
/// from under them (see `Population::read` on why naming has to anchor) — and
|
||
/// the user cannot merge it back into the group that took them if the rail has
|
||
/// hidden it.
|
||
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()
|
||
.filter(|p| {
|
||
p.confirmed_faces + p.suggested_faces > 0 || !p.name.trim().is_empty() || p.ignored
|
||
})
|
||
.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.
|
||
///
|
||
/// `cut` is whatever the previous load of this grid had already decoded,
|
||
/// keyed by face, and is consumed: a crop found there is moved into the new
|
||
/// cell and neither read from the catalog nor decoded again. A confirm or a
|
||
/// reject changes one face's row and redraws the whole grid, and without this
|
||
/// the redraw re-read four megabytes of JPEG and decoded seven hundred of
|
||
/// them — 300 ms on the reference library's largest person, per click, to
|
||
/// arrive at pixels the screen was already showing. Face ids are global, so
|
||
/// a map from another person's grid is merely useless, never wrong.
|
||
pub fn load_faces(
|
||
catalog: &Catalog,
|
||
store: &ThumbStore,
|
||
person: PersonId,
|
||
mut cut: std::collections::HashMap<FaceId, FaceCrop>,
|
||
) -> 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 — but only when a face
|
||
// is not already in hand. The common redraw has every face cached and
|
||
// skips the blob read entirely; a face the cache lacks (a regroup, a
|
||
// fresh sweep) costs the read for the whole person once, and it is
|
||
// cached from then on.
|
||
//
|
||
// Where a face has a stored crop 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 = if rows.iter().all(|f| cut.contains_key(&f.id)) {
|
||
Default::default()
|
||
} else {
|
||
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 cut
|
||
.remove(&f.id)
|
||
.or_else(|| 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,
|
||
quality: f.quality,
|
||
eyes: f.eyes,
|
||
});
|
||
}
|
||
|
||
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, Default::default())?;
|
||
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(|e| (e.face, e))
|
||
.collect();
|
||
|
||
let mut candidates = Vec::with_capacity(cells.len());
|
||
let mut order = Vec::with_capacity(cells.len());
|
||
for c in &cells {
|
||
let Some(e) = stored.get(&c.face) else {
|
||
continue;
|
||
};
|
||
let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &e.embedding) else {
|
||
continue;
|
||
};
|
||
candidates.push(dr_face::Candidate {
|
||
face: c.face.0,
|
||
image: e.image.0,
|
||
embedding: emb.v.to_vec(),
|
||
crop_px: e.crop_px,
|
||
quality: e.quality,
|
||
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 crate::faces::GroupingPreview;
|
||
use dr_catalog::faces::DetectedFace;
|
||
|
||
#[test]
|
||
fn a_preview_leads_with_the_group_count() {
|
||
let line = preview_label(&GroupingPreview {
|
||
faces: 1813,
|
||
groups: 328,
|
||
grouped: 1341,
|
||
largest: 69,
|
||
});
|
||
// The group count is the number that says which side of the right
|
||
// setting you are on, so it is the number the sentence starts with.
|
||
assert!(line.starts_with("328 group"), "{line}");
|
||
assert!(line.contains("1341 of 1813"), "{line}");
|
||
assert!(line.contains("69"), "{line}");
|
||
}
|
||
|
||
/// The ordinary state of a library nobody has run the indexer over — and
|
||
/// "0 group(s), holding 0 of 0 faces" would read as a failure of the dials
|
||
/// rather than as an absence of input.
|
||
#[test]
|
||
fn a_preview_of_nothing_says_there_is_nothing() {
|
||
let line = preview_label(&GroupingPreview::default());
|
||
assert!(line.contains("No faces indexed"), "{line}");
|
||
}
|
||
|
||
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,
|
||
quality: Some(f32::from(seed) + 10.0),
|
||
eyes: None,
|
||
landmarks_dense: Vec::new(),
|
||
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,
|
||
quality: Some(17.26),
|
||
eyes: None,
|
||
};
|
||
assert_eq!(cell.confidence_label(), "87% likely");
|
||
|
||
let confirmed = FaceCell {
|
||
confirmed: true,
|
||
..cell
|
||
};
|
||
assert_eq!(confirmed.confidence_label(), "Confirmed");
|
||
}
|
||
|
||
/// The number is the embedding's length, and the screen calls it what it
|
||
/// is used as. A face from before it was kept says so rather than showing
|
||
/// a zero that would read as the worst face in the library.
|
||
#[test]
|
||
fn the_quality_is_labelled_as_such_and_dimmed_below_the_floor() {
|
||
let cell = FaceCell {
|
||
face: FaceId(1),
|
||
image: ImageId(1),
|
||
crop: None,
|
||
confirmed: false,
|
||
probability: 0.5,
|
||
crop_px: 120.0,
|
||
quality: Some(17.26),
|
||
eyes: None,
|
||
};
|
||
assert_eq!(cell.quality_label(), "Quality 17.3");
|
||
assert!(cell.in_gallery());
|
||
|
||
let poor = FaceCell {
|
||
quality: Some(9.4),
|
||
..cell.clone()
|
||
};
|
||
assert_eq!(poor.quality_label(), "Quality 9.4");
|
||
assert!(!poor.in_gallery());
|
||
|
||
let unmeasured = FaceCell {
|
||
quality: None,
|
||
..cell
|
||
};
|
||
assert_eq!(unmeasured.quality_label(), "Quality —");
|
||
assert!(
|
||
unmeasured.in_gallery(),
|
||
"an unmeasured face is not a poor one"
|
||
);
|
||
}
|
||
|
||
/// Only the two states the filter treats differently from open are
|
||
/// badged; open and unread carry no mark.
|
||
#[test]
|
||
fn the_eye_badge_names_a_blink_or_sunglasses_and_nothing_else() {
|
||
let eye = |open| dr_face::Eye {
|
||
open,
|
||
px: 40.0,
|
||
sharpness: 0.2,
|
||
};
|
||
let reading = |right, left, sunglasses| {
|
||
Some(dr_face::EyeReading {
|
||
right: eye(right),
|
||
left: eye(left),
|
||
sunglasses,
|
||
})
|
||
};
|
||
let cell = FaceCell {
|
||
face: FaceId(1),
|
||
image: ImageId(1),
|
||
crop: None,
|
||
confirmed: false,
|
||
probability: 0.5,
|
||
crop_px: 120.0,
|
||
quality: Some(17.26),
|
||
eyes: None,
|
||
};
|
||
assert_eq!(cell.eyes_label(), "");
|
||
let open = FaceCell {
|
||
eyes: reading(0.9, 0.9, 0.0),
|
||
..cell.clone()
|
||
};
|
||
assert_eq!(open.eyes_label(), "");
|
||
let blink = FaceCell {
|
||
eyes: reading(0.9, 0.2, 0.0),
|
||
..cell.clone()
|
||
};
|
||
assert_eq!(blink.eyes_label(), "Eyes closed");
|
||
let shades = FaceCell {
|
||
eyes: reading(0.9, 0.2, 0.9),
|
||
..cell.clone()
|
||
};
|
||
assert_eq!(shades.eyes_label(), "Sunglasses");
|
||
let mut soft = reading(0.1, 0.1, 0.0).unwrap();
|
||
soft.right.sharpness = 0.0;
|
||
soft.left.sharpness = 0.0;
|
||
let unclear = FaceCell {
|
||
eyes: Some(soft),
|
||
..cell
|
||
};
|
||
assert_eq!(unclear.eyes_label(), "Eyes unclear");
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
|
||
/// The rail withholds the debris a regrouping pass leaves behind, and only
|
||
/// that: the same rows `prune_empty_unnamed` treats as disposable.
|
||
///
|
||
/// Not a nicety. Every pass creates a person per cluster and the next pass
|
||
/// may empty it, nothing on this path prunes, and the reference library
|
||
/// reached 11,739 of them against 2,525 real people — a rail that was 85
|
||
/// per cent rows naming nobody, each one costing a query and a built row.
|
||
#[test]
|
||
fn the_rail_withholds_empty_unnamed_groups_and_nothing_else() {
|
||
let c = catalog();
|
||
let i1 = image(&c, 1);
|
||
let held =
|
||
faces::record_detections(c.connection(), i1, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||
|
||
let with_faces = faces::create_person(c.connection(), "Anna").unwrap();
|
||
faces::confirm(c.connection(), held[0], with_faces).unwrap();
|
||
|
||
// The three that hold nothing. Only the first is debris.
|
||
faces::create_person(c.connection(), "").unwrap();
|
||
let named = faces::create_person(c.connection(), "Bob").unwrap();
|
||
let set_aside = faces::create_person(c.connection(), "").unwrap();
|
||
faces::set_ignored(c.connection(), set_aside, true).unwrap();
|
||
|
||
let shown: Vec<_> = load_people(&c, "w600k_mbf")
|
||
.unwrap()
|
||
.people
|
||
.into_iter()
|
||
.map(|p| p.id)
|
||
.collect();
|
||
|
||
assert!(shown.contains(&with_faces), "a group with faces is a group");
|
||
assert!(
|
||
shown.contains(&named),
|
||
"an empty group with a name is the visible symptom of a regroup that \
|
||
emptied it, and hiding it takes away the only way to merge it back"
|
||
);
|
||
assert!(
|
||
shown.contains(&set_aside),
|
||
"set aside is a judgement the user made, and the prune spares it too"
|
||
);
|
||
assert_eq!(
|
||
shown.len(),
|
||
3,
|
||
"the empty unnamed group is the one that goes"
|
||
);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|