Put the grouping dials where the regrouping is

The merge probability was `dr_face`'s constant and the smallest group was
a bare `< 2` in the clustering pass. Both were tuned on one library —
1,813 faces of one photographer's family — and the quantity they optimise
is a property of the population, not of the model. A household at close
family resemblance and two thousand strangers at a wedding want different
answers, and neither of them is the reference library. The doc comment
already conceded the point and pointed at `face_index --tune`; a
photographer does not have a terminal.

So they are `FaceSettings` now, saved per device beside the cache budgets
and edited from the People screen — beside the Regroup button that
applies them and the rail that shows what they did, because a value
changed three screens away from its effect is one nobody can tune.

Moving them is safe by construction, which is why nothing asks for
confirmation: a regroup writes only the suggested half, and
confirmations, names and ignores enter as anchors and come back
unchanged. The smallest-group rule is applied only to groups the system
invented — a group the user named or set aside survives it whatever its
size, because a display preference does not overrule a judgement.

**Withdrawal, without which the setting does nothing visible.** Raising
the smallest group stops the pass creating small groups; it does not
remove the ones a previous pass made, because those still hold their
suggestions, so they are not empty, so the prune leaves them. The pass
now releases every unanchored face it did not place before pruning.

And a dial you cannot see the effect of is not a dial. "What would this
do?" runs the same population through the clusterer without opening a
transaction and reports groups, faces grouped and largest group — one row
of `--tune`'s table, on the user's own library, on a worker thread. The
line leads with the group count because that is the number that says
which side of the right setting you are on: it climbs as fragments are
gathered into people and falls as separate people start being welded,
while the grouped-face count rises straight through both.

The preview parks its poll timer in a slot of its own. A preview and a
regroup are allowed to be in flight together, and sharing the sweep's
single slot would have the second to start drop the first's timer —
visible as a Regroup that finished on its worker and never said so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:32:26 +02:00
co-authored by Claude Opus 5
parent a4b9deaf96
commit 23c74d7063
10 changed files with 884 additions and 88 deletions
+155
View File
@@ -54,6 +54,7 @@ pub struct Settings {
pub develop: DevelopSettings, pub develop: DevelopSettings,
pub import: ImportSettings, pub import: ImportSettings,
pub library: LibrarySettings, pub library: LibrarySettings,
pub faces: FaceSettings,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -109,6 +110,95 @@ impl Default for LibrarySettings {
} }
} }
// ---------------------------------------------------------------------------
// Faces
// ---------------------------------------------------------------------------
/// TRACES: FR-CULL-9 | FR-CULL-10
/// How hard the grouping pass tries to put two faces together, and how small a
/// group it will still call a person.
///
/// # Why these are settings at all
///
/// FR-CULL-10 is built on the clustering being wrong, and the two ways it is
/// wrong pull in opposite directions. Too loose and it welds siblings into one
/// person — the error the user cannot undo by hand. Too tight and a real person
/// arrives as nine fragments to be merged one at a time. The balance point is a
/// property of *the library*: how many people are in it, how closely related
/// they are, how far apart in years the photographs run. `dr_face`'s default was
/// measured on one 1,813-face reference library, and its own documentation says
/// so.
///
/// So the numbers the tuning harness prints are put on the screen instead of
/// staying in a doc comment. Regrouping is re-runnable by construction —
/// suggestions are the pass's own output and confirmations are never touched —
/// which is what makes a value the user can move safe to offer.
///
/// **Per device, not per library, like everything else in this file.** These
/// only decide what a *local* regrouping pass does; the people it produces are
/// catalog data and sync normally.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct FaceSettings {
/// Probability above which two groups are judged to be one person.
///
/// A calibrated probability and never a bare similarity, which is
/// FR-CULL-9's standing rule for this subsystem — so the control the user
/// moves is in the same units as the confidence printed under every face.
pub merge_probability: f32,
/// The smallest group the pass will make a person out of.
///
/// A group of one is a stray, and naming every stray fills the People rail
/// with noise that has to be dismissed one entry at a time before the real
/// clusters are visible. Raising it is how a user with a crowded library
/// says "only show me people I have actually photographed more than once".
///
/// Groups that already carry a confirmation, a name, or an ignore are never
/// dropped by this, whatever their size: those are the user's judgements and
/// a display preference does not overrule them (FR-CULL-12).
pub min_group_size: u32,
}
impl FaceSettings {
/// What `dr_face` was tuned to, restated here because `dr-types` sits below
/// the face engine and must not depend on it.
///
/// `dr_ui::faces` holds the test that keeps the two numbers equal; a
/// default that drifted from the engine's would silently mean the settings
/// page's "default" marker pointed at a value the engine had abandoned.
pub const DEFAULT_MERGE_PROBABILITY: f32 = 0.80;
/// The range the settings control offers.
///
/// Not 0..1. Below about a half the pass stops building people and starts
/// melting them together — `dr_face::cluster`'s own measurements show the
/// group count *falling* while the grouped-face count rises, which is the
/// shape of over-merging — and above 0.95 almost nothing merges at all. A
/// slider whose ends are both useless spends most of its travel on answers
/// no one wants.
pub const PROBABILITY_RANGE: (f32, f32) = (0.50, 0.95);
/// The range the smallest-group control offers.
///
/// One means "show me every stray", which is a real thing to want while
/// hunting for a face the grouping missed. The top end is a judgement about
/// crowded libraries rather than a limit of the algorithm.
pub const GROUP_SIZE_RANGE: (u32, u32) = (1, 12);
}
impl Default for FaceSettings {
fn default() -> Self {
Self {
merge_probability: Self::DEFAULT_MERGE_PROBABILITY,
// Two, because a group of one is not evidence of anything. This is
// the number the clustering pass carried as a literal before it was
// a setting, so an existing library regroups identically until the
// user moves it.
min_group_size: 2,
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Import // Import
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -884,6 +974,21 @@ impl Settings {
pub fn sanitise(&mut self) { pub fn sanitise(&mut self) {
self.export.quality = self.export.quality.clamp(1, 100); self.export.quality = self.export.quality.clamp(1, 100);
// Clamped to the range the slider offers rather than to 0..1. A
// probability of 0.02 is not a looser setting, it is a pass that welds
// the whole library into one person, and the file is hand-editable.
// NaN reaches here as a `f32` from JSON and survives every comparison,
// so it is answered explicitly instead of by `clamp`, which panics on
// it.
let (lo, hi) = FaceSettings::PROBABILITY_RANGE;
if !self.faces.merge_probability.is_finite() {
self.faces.merge_probability = FaceSettings::default().merge_probability;
}
self.faces.merge_probability = self.faces.merge_probability.clamp(lo, hi);
let (lo, hi) = FaceSettings::GROUP_SIZE_RANGE;
self.faces.min_group_size = self.faces.min_group_size.clamp(lo, hi);
// Snapped to an offered count rather than clamped to a range. The // Snapped to an offered count rather than clamped to a range. The
// settings page lights the chip whose value matches, so a // settings page lights the chip whose value matches, so a
// hand-edited 40 would leave every chip dark and the page unable to // hand-edited 40 would leave every chip dark and the page unable to
@@ -1269,6 +1374,56 @@ mod tests {
assert!(s.export.destination.is_empty()); assert!(s.export.destination.is_empty());
} }
#[test]
fn sanitise_pulls_a_hand_edited_merge_probability_into_range() {
let mut s = Settings::default();
// The file is plain JSON in a config directory and a user is entitled
// to edit it. 0.02 is not a looser grouping, it is one person.
s.faces.merge_probability = 0.02;
s.sanitise();
assert_eq!(s.faces.merge_probability, FaceSettings::PROBABILITY_RANGE.0);
s.faces.merge_probability = 4.0;
s.sanitise();
assert_eq!(s.faces.merge_probability, FaceSettings::PROBABILITY_RANGE.1);
}
/// `f32::clamp` panics on a NaN bound and returns NaN for a NaN input, and
/// a NaN threshold silently groups nothing at all — every comparison
/// against it is false. JSON can carry one in.
#[test]
fn sanitise_answers_a_merge_probability_that_is_not_a_number() {
let mut s = Settings::default();
s.faces.merge_probability = f32::NAN;
s.sanitise();
assert_eq!(
s.faces.merge_probability,
FaceSettings::default().merge_probability
);
}
#[test]
fn sanitise_keeps_the_smallest_group_at_one_or_more() {
let mut s = Settings::default();
// Zero would be a pass that made a person out of nothing.
s.faces.min_group_size = 0;
s.sanitise();
assert_eq!(s.faces.min_group_size, FaceSettings::GROUP_SIZE_RANGE.0);
s.faces.min_group_size = 9_000;
s.sanitise();
assert_eq!(s.faces.min_group_size, FaceSettings::GROUP_SIZE_RANGE.1);
}
/// A settings file written before the dials existed is missing the whole
/// section, and has to load as the defaults rather than as a refusal.
#[test]
fn a_file_from_before_the_grouping_dials_still_loads() {
let older = r#"{"export":{"quality":90}}"#;
let s: Settings = serde_json::from_str(older).expect("older file should parse");
assert_eq!(s.faces, FaceSettings::default());
}
#[test] #[test]
fn sanitise_leaves_a_real_destination_alone() { fn sanitise_leaves_a_real_destination_alone() {
let mut s = Settings::default(); let mut s = Settings::default();
+34
View File
@@ -788,6 +788,40 @@ It is **not** a merge threshold and must not become one. Uniqueness is relative,
one named person would hand every stray face a 1. "Is this the same person at all" stays §8's one named person would hand every stray face a 1. "Is this the same person at all" stays §8's
question, and coherence is the half of the product that carries it. question, and coherence is the half of the product that carries it.
### 9.2 The two numbers the user is allowed to move · 2026-08-29
The merge probability and the smallest group the pass will call a person are `FaceSettings` in
`dr-types`, edited from the People screen and saved per device beside the cache budgets. They were
constants: `dr_face::DEFAULT_MERGE_PROBABILITY` and a bare `< 2` in `dr_ui::faces::recluster`.
**Why they had to become settings.** The default was tuned on one library — the table in
`dr_face::cluster`'s doc comment is 1,813 faces of one photographer's family — and the quantity it
optimises is a property of the population, not of the model. A library of one household at close
family resemblance and a library of two thousand strangers at a wedding want different answers, and
neither of them is the reference library. The doc comment already conceded the point ("this is a
*default*, not a constant of nature") and pointed at `face_index --tune` as the way to find a better
one; a photographer does not have a terminal.
**Why moving them is safe, and why that is the reason there is no confirmation on it.** A regroup
writes only the *suggested* half. Confirmations, names and ignores enter as anchors and come back
unchanged (FR-CULL-10), so the pass is re-runnable by construction and a dial the user can move is
just that property being used. The smallest-group rule is applied only to groups the system invented:
a group carrying a person — confirmed, named or set aside — survives it whatever its size, because a
display preference does not overrule a judgement (FR-CULL-12).
**Withdrawal, which the setting does not work without.** Raising the smallest group stops the pass
*creating* small groups; it does not by itself remove the ones a previous pass made, because those
still hold their suggestions, so they are not empty, so `prune_empty_unnamed` leaves them. The pass
therefore now releases every unanchored face it did not place — `faces::unassign` — before pruning.
Without that step the control appears to do nothing until the library is reindexed.
**The preview.** `dr_ui::faces::preview_grouping` runs the same population through
`dr_face::cluster` and reports groups, faces grouped and largest group without opening a
transaction. It is `face_index --tune`'s row for one setting, on the user's own library, on a worker
thread. The line leads with the **group count** because that is the number that says which side of
the right setting you are on: it climbs as fragments are gathered into people and falls as separate
people start being welded together, while the grouped-face count rises straight through both.
--- ---
## 10. Catalog and jobs ## 10. Catalog and jobs
+5 -1
View File
@@ -88,7 +88,11 @@ fn main() {
// whole-library operation over the embeddings detection produced, and it is // whole-library operation over the embeddings detection produced, and it is
// worth running *after* a sweep rather than during one (catalog.md §10.2). // worth running *after* a sweep rather than during one (catalog.md §10.2).
if args.iter().any(|a| a == "--cluster") { if args.iter().any(|a| a == "--cluster") {
match dr_ui::faces::recluster(&catalog, MODEL_ID, dr_face::DEFAULT_MERGE_PROBABILITY) { // The engine's own defaults, not this device's settings file: a batch
// job run over a library on a server has no business inheriting the
// dials somebody moved on their laptop.
let grouping = dr_types::settings::FaceSettings::default();
match dr_ui::faces::recluster(&catalog, MODEL_ID, &grouping) {
Ok((suggested, created)) => { Ok((suggested, created)) => {
println!("\nclustering: {suggested} suggestion(s), {created} new group(s)"); println!("\nclustering: {suggested} suggestion(s), {created} new group(s)");
report_people(&catalog); report_people(&catalog);
+347 -75
View File
@@ -30,6 +30,7 @@ use dr_catalog::faces::{self, DetectedFace};
use dr_catalog::Catalog; use dr_catalog::Catalog;
use dr_face::{align, Calibration, DetectOptions, Detection, Detector, Embedder, ModelId}; use dr_face::{align, Calibration, DetectOptions, Detection, Detector, Embedder, ModelId};
use dr_thumbs::{ThumbSize, ThumbStore}; use dr_thumbs::{ThumbSize, ThumbStore};
use dr_types::settings::FaceSettings;
use dr_types::ImageId; use dr_types::ImageId;
/// The tier faces are found on. See the module note. /// The tier faces are found on. See the module note.
@@ -476,6 +477,148 @@ pub fn spawn_store_face_sweep(
rx rx
} }
/// Everything a grouping pass reads before it decides anything.
///
/// Split out because two callers need exactly this and must agree on it: the
/// pass that writes, and the preview that reports what the pass *would* do. A
/// preview built from a second, subtly different reading — anchors omitted,
/// say — would answer a question about a library nobody has.
struct Population {
cal: Calibration,
candidates: Vec<dr_face::Candidate>,
/// Catalog ids, parallel to `candidates`.
ids: Vec<faces::FaceId>,
/// Faces the user has ruled on, and who they are. See [`Population::read`].
anchors: std::collections::HashMap<faces::FaceId, faces::PersonId>,
}
impl Population {
/// Takes the catalog and not its connection: `rusqlite` is `dr-catalog`'s
/// dependency and not this crate's, and reaching for the connection type by
/// name here would drag it across a layer that has kept clear of it.
fn read(catalog: &Catalog, model_id: &str) -> Result<Self, dr_catalog::CatalogError> {
let conn = catalog.connection();
// No valid calibration is not a reason to refuse to cluster — it is a
// reason not to *display* a confidence (FR-CULL-9). `Calibration::
// default` is the reference implementation's fitted curve with `valid`
// false, which is a documented operating point rather than an invented
// one.
let cal = faces::calibration(conn, model_id)?
.map(|(c, _)| c)
.unwrap_or_else(Calibration::default);
// Which faces the user has already ruled on, so they enter as anchors.
//
// Anything the user has ruled on anchors, and there are three ways of
// ruling — only the first of which is obvious.
//
// A **confirmation** is the plain case. **Setting a group aside** is one
// too, and the faces it covers are only ever suggestions, so anchoring
// confirmations alone let every ignored group scatter into fresh unnamed
// groups that were not ignored, and the strangers came straight back.
//
// And so is **giving a group a name**. That was the omission that did
// the most damage, because it is silent. Naming a cluster does not
// confirm its faces — they stay suggestions — so the next Regroup cut
// them loose, regrouped them into a brand new person, and left the named
// one holding nothing. `prune_empty_unnamed` will not remove it, because
// it has a name. Name the new group the same thing and it happens again.
// That is how one library came to hold sixteen people called Catherine,
// fourteen of them empty, with her faces split across the two that were
// not.
//
// A name is a judgement about *this group* (FR-CULL-12), exactly as an
// ignore is. Anchoring them all also does one better: a newly indexed
// face that matches a named person now merges *into* them rather than
// arriving as a stranger.
let mut anchors = std::collections::HashMap::new();
for p in faces::people(conn)? {
let ruled_on = p.ignored || !p.name.trim().is_empty();
for f in faces::for_person(conn, p.id, ruled_on)? {
anchors.insert(f.id, p.id);
}
}
let stored = faces::embeddings(conn, model_id)?;
let model = ModelId::new(model_id.to_string());
let mut candidates = Vec::with_capacity(stored.len());
let mut ids = Vec::with_capacity(stored.len());
for (face_id, image_id, blob, crop_px) in stored {
let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &blob) else {
log::warn!("face {face_id:?} has a malformed embedding, skipped");
continue;
};
candidates.push(dr_face::Candidate {
face: face_id.0,
image: image_id.0,
embedding: emb.v.to_vec(),
crop_px,
confirmed_person: anchors.get(&face_id).map(|p| p.0),
});
ids.push(face_id);
}
Ok(Self {
cal,
candidates,
ids,
anchors,
})
}
}
/// What a grouping pass would produce, without producing it.
///
/// The numbers `dr_face::cluster`'s own tuning table is built from, for one
/// setting rather than ten — because the question a photographer is actually
/// asking of the dials is "what does *my* library look like at this value", and
/// the doc-comment table answers it for a library that is not theirs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct GroupingPreview {
/// Faces that went in.
pub faces: usize,
/// Groups that would survive the smallest-group rule.
pub groups: usize,
/// Faces those groups would hold.
pub grouped: usize,
/// The biggest group. The tell for over-merging: it is the number that runs
/// away when the confidence is set too low.
pub largest: usize,
}
/// Report what a grouping pass would do, writing nothing.
///
/// Read-only by construction — it never opens a transaction — which is what
/// makes it safe to run repeatedly while the user moves a slider. Comparing two
/// settings by *applying* both would leave the second one's answer polluted by
/// the first one's suggestions.
pub fn preview_grouping(
catalog: &Catalog,
model_id: &str,
grouping: &FaceSettings,
) -> Result<GroupingPreview, dr_catalog::CatalogError> {
let min_group = grouping.min_group_size.max(1) as usize;
let pop = Population::read(catalog, model_id)?;
if pop.candidates.is_empty() {
return Ok(GroupingPreview::default());
}
let clusters = dr_face::cluster(&pop.candidates, &pop.cal, grouping.merge_probability);
// The same rule the writing pass applies, so the preview and the result
// cannot disagree about what counts as a person.
let kept: Vec<_> = clusters
.iter()
.filter(|c| c.members.len() >= min_group || c.person.is_some())
.collect();
Ok(GroupingPreview {
faces: pop.candidates.len(),
groups: kept.len(),
grouped: kept.iter().map(|c| c.members.len()).sum(),
largest: kept.iter().map(|c| c.members.len()).max().unwrap_or(0),
})
}
/// Group the library's faces into people, writing suggestions. /// Group the library's faces into people, writing suggestions.
/// ///
/// Confirmations are never touched: they enter the clusterer as anchors and /// Confirmations are never touched: they enter the clusterer as anchors and
@@ -487,72 +630,22 @@ pub fn spawn_store_face_sweep(
pub fn recluster( pub fn recluster(
catalog: &Catalog, catalog: &Catalog,
model_id: &str, model_id: &str,
min_probability: f32, grouping: &FaceSettings,
) -> Result<(usize, usize), dr_catalog::CatalogError> { ) -> Result<(usize, usize), dr_catalog::CatalogError> {
let min_probability = grouping.merge_probability;
let min_group = grouping.min_group_size.max(1) as usize;
let conn = catalog.connection(); let conn = catalog.connection();
// No valid calibration is not a reason to refuse to cluster — it is a let Population {
// reason not to *display* a confidence (FR-CULL-9). `Calibration::default` cal,
// is the reference implementation's fitted curve with `valid` false, which candidates,
// is a documented operating point rather than an invented one. ids,
let cal = faces::calibration(conn, model_id)? anchors: confirmed,
.map(|(c, _)| c) } = Population::read(catalog, model_id)?;
.unwrap_or_else(Calibration::default); if candidates.is_empty() {
let stored = faces::embeddings(conn, model_id)?;
if stored.is_empty() {
return Ok((0, 0)); return Ok((0, 0));
} }
// Which faces the user has already ruled on, so they enter as anchors.
//
// Anything the user has ruled on anchors, and there are three ways of
// ruling — only the first of which is obvious.
//
// A **confirmation** is the plain case. **Setting a group aside** is one
// too, and the faces it covers are only ever suggestions, so anchoring
// confirmations alone let every ignored group scatter into fresh unnamed
// groups that were not ignored, and the strangers came straight back.
//
// And so is **giving a group a name**. That was the omission that did the
// most damage, because it is silent. Naming a cluster does not confirm its
// faces — they stay suggestions — so the next Regroup cut them loose,
// regrouped them into a brand new person, and left the named one holding
// nothing. `prune_empty_unnamed` will not remove it, because it has a name.
// Name the new group the same thing and it happens again. That is how one
// library came to hold sixteen people called Catherine, fourteen of them
// empty, with her faces split across the two that were not.
//
// A name is a judgement about *this group* (FR-CULL-12), exactly as an
// ignore is. Anchoring them all also does one better: a newly indexed face
// that matches a named person now merges *into* them rather than arriving
// as a stranger.
let mut confirmed = std::collections::HashMap::new();
for p in faces::people(conn)? {
let ruled_on = p.ignored || !p.name.trim().is_empty();
for f in faces::for_person(conn, p.id, ruled_on)? {
confirmed.insert(f.id, p.id);
}
}
let model = ModelId::new(model_id.to_string());
let mut candidates = Vec::with_capacity(stored.len());
let mut ids = Vec::with_capacity(stored.len());
for (face_id, image_id, blob, crop_px) in stored {
let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &blob) else {
log::warn!("face {face_id:?} has a malformed embedding, skipped");
continue;
};
candidates.push(dr_face::Candidate {
face: face_id.0,
image: image_id.0,
embedding: emb.v.to_vec(),
crop_px,
confirmed_person: confirmed.get(&face_id).map(|p| p.0),
});
ids.push(face_id);
}
let dr_face::Grouping { let dr_face::Grouping {
clusters, clusters,
confidence, confidence,
@@ -560,10 +653,20 @@ pub fn recluster(
let mut suggested = 0usize; let mut suggested = 0usize;
let mut created = 0usize; let mut created = 0usize;
// Every face this pass actually placed. What is *not* in here at the end is
// a face the previous pass had an opinion about and this one does not, and
// it has to be let go — see below.
let mut placed = std::collections::HashSet::with_capacity(ids.len());
for c in &clusters { for c in &clusters {
// A group of one is not a person. Naming every stray face would fill // A group of one is not a person, and the user says how much bigger
// the People view with noise the user then has to dismiss. // than one it has to be (`FaceSettings::min_group_size`). Naming every
if c.members.len() < 2 && c.person.is_none() { // stray face would fill the People view with noise they then have to
// dismiss one entry at a time.
//
// Only ever applied to a group the system invented. A group with a
// `person` is one the user has already confirmed, named or set aside,
// and a display preference does not overrule a judgement (FR-CULL-12).
if c.members.len() < min_group && c.person.is_none() {
continue; continue;
} }
@@ -579,6 +682,7 @@ pub fn recluster(
for &m in &c.members { for &m in &c.members {
let face = ids[m]; let face = ids[m];
placed.insert(face);
if confirmed.contains_key(&face) { if confirmed.contains_key(&face) {
continue; continue;
} }
@@ -594,6 +698,31 @@ pub fn recluster(
} }
} }
// Faces the previous pass placed and this one did not.
//
// Without this the parameters above are only half connected to the screen.
// Raise the smallest group to three and the pass stops *creating* groups of
// two — but last pass's group of two still holds its two suggestions, so it
// is not empty, so the prune below leaves it, and the rail does not change.
// The setting would appear to do nothing until the library was reindexed.
//
// Suggestions are this pass's own output (FR-CULL-10), so withdrawing one it
// no longer stands behind is exactly what it is entitled to do. Anchors are
// skipped by construction: a confirmed, named or ignored face is in
// `confirmed`, enters as an anchor, and comes back out inside a group that
// is never dropped.
let mut released = 0usize;
for face in &ids {
if placed.contains(face) || confirmed.contains_key(face) {
continue;
}
faces::unassign(conn, *face)?;
released += 1;
}
if released > 0 {
log::info!("reclustering released {released} face(s) it no longer groups");
}
// Groups the *previous* pass created that this one left empty. Without // Groups the *previous* pass created that this one left empty. Without
// this, every press of Regroup adds a rail entry per group it no longer // this, every press of Regroup adds a rail entry per group it no longer
// believes in, and the screen fills with "Unnamed (0 faces)" — which is // believes in, and the screen fills with "Unnamed (0 faces)" — which is
@@ -612,6 +741,43 @@ pub fn recluster(
Ok((suggested, created)) Ok((suggested, created))
} }
/// The answer from a preview pass.
#[derive(Debug, Clone, PartialEq)]
pub enum PreviewMessage {
Ready(GroupingPreview),
Failed(String),
}
/// Report what a grouping pass would do, **on a worker thread**.
///
/// Same reasoning as [`spawn_recluster`], and the same cost: a preview is a
/// clustering pass that throws its answer away, so it is exactly as unbounded
/// as the pass it is previewing and exactly as unwelcome on the UI thread.
///
/// Cancellation is dropping the receiver — so moving the slider again while one
/// is in flight abandons it, which is the behaviour a dial with a preview
/// button needs.
pub fn spawn_grouping_preview(
catalog_path: PathBuf,
model_id: String,
grouping: FaceSettings,
) -> Receiver<PreviewMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let msg = match Catalog::open(&catalog_path) {
Ok(catalog) => match preview_grouping(&catalog, &model_id, &grouping) {
Ok(p) => PreviewMessage::Ready(p),
Err(e) => PreviewMessage::Failed(e.to_string()),
},
Err(e) => PreviewMessage::Failed(format!("cannot open catalog: {e}")),
};
let _ = tx.send(msg);
});
rx
}
/// Progress from a regrouping pass. /// Progress from a regrouping pass.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum ReclusterMessage { pub enum ReclusterMessage {
@@ -643,7 +809,7 @@ pub enum ReclusterMessage {
pub fn spawn_recluster( pub fn spawn_recluster(
catalog_path: PathBuf, catalog_path: PathBuf,
model_id: String, model_id: String,
min_probability: f32, grouping: FaceSettings,
) -> Receiver<ReclusterMessage> { ) -> Receiver<ReclusterMessage> {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
@@ -667,7 +833,7 @@ pub fn spawn_recluster(
return; return;
} }
let msg = match recluster(&catalog, &model_id, min_probability) { let msg = match recluster(&catalog, &model_id, &grouping) {
Ok((suggested, created)) => ReclusterMessage::Finished { suggested, created }, Ok((suggested, created)) => ReclusterMessage::Finished { suggested, created },
Err(e) => ReclusterMessage::Failed(e.to_string()), Err(e) => ReclusterMessage::Failed(e.to_string()),
}; };
@@ -1125,7 +1291,7 @@ mod tests {
put_face(&catalog, 1, 0, 1.0); put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99); put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let people = faces::people(catalog.connection()).unwrap(); let people = faces::people(catalog.connection()).unwrap();
assert_eq!(people.len(), 1, "the two faces should have grouped"); assert_eq!(people.len(), 1, "the two faces should have grouped");
let stranger = people[0].id; let stranger = people[0].id;
@@ -1133,7 +1299,7 @@ mod tests {
faces::set_ignored(catalog.connection(), stranger, true).unwrap(); faces::set_ignored(catalog.connection(), stranger, true).unwrap();
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap(); let after = faces::people(catalog.connection()).unwrap();
assert_eq!( assert_eq!(
@@ -1157,13 +1323,13 @@ mod tests {
put_face(&catalog, 1, 0, 1.0); put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99); put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let stranger = faces::people(catalog.connection()).unwrap()[0].id; let stranger = faces::people(catalog.connection()).unwrap()[0].id;
faces::set_ignored(catalog.connection(), stranger, true).unwrap(); faces::set_ignored(catalog.connection(), stranger, true).unwrap();
// The same person turns up in a third photograph. // The same person turns up in a third photograph.
put_face(&catalog, 3, 0, 0.98); put_face(&catalog, 3, 0, 0.98);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap(); let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1, "a new face made a second group: {after:?}"); assert_eq!(after.len(), 1, "a new face made a second group: {after:?}");
@@ -1177,13 +1343,13 @@ mod tests {
let catalog = catalog_with(3); let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0); put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99); put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let id = faces::people(catalog.connection()).unwrap()[0].id; let id = faces::people(catalog.connection()).unwrap()[0].id;
faces::set_ignored(catalog.connection(), id, true).unwrap(); faces::set_ignored(catalog.connection(), id, true).unwrap();
faces::set_ignored(catalog.connection(), id, false).unwrap(); faces::set_ignored(catalog.connection(), id, false).unwrap();
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap(); let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1); assert_eq!(after.len(), 1);
assert!(!after[0].ignored); assert!(!after[0].ignored);
@@ -1200,7 +1366,7 @@ mod tests {
put_face(&catalog, 1, 0, 1.0); put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99); put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let people = faces::people(catalog.connection()).unwrap(); let people = faces::people(catalog.connection()).unwrap();
assert_eq!(people.len(), 1); assert_eq!(people.len(), 1);
let her = people[0].id; let her = people[0].id;
@@ -1210,7 +1376,7 @@ mod tests {
// types a name and moves on has done. // types a name and moves on has done.
faces::rename_person(catalog.connection(), her, "Catherine").unwrap(); faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap(); let after = faces::people(catalog.connection()).unwrap();
assert_eq!( assert_eq!(
@@ -1233,15 +1399,121 @@ mod tests {
let catalog = catalog_with(3); let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0); put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99); put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let her = faces::people(catalog.connection()).unwrap()[0].id; let her = faces::people(catalog.connection()).unwrap()[0].id;
faces::rename_person(catalog.connection(), her, "Catherine").unwrap(); faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
put_face(&catalog, 3, 0, 0.98); put_face(&catalog, 3, 0, 0.98);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap(); let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1, "a second Catherine appeared: {after:?}"); assert_eq!(after.len(), 1, "a second Catherine appeared: {after:?}");
assert_eq!(after[0].suggested_faces, 3); assert_eq!(after[0].suggested_faces, 3);
} }
/// `dr-types` sits below the face engine and cannot name its constant, so
/// it restates the number. This is the thing that stops the two drifting:
/// a settings page marking 0.80 as "default" while the engine had moved to
/// 0.75 would put the reset dot on a value nothing else agreed with.
#[test]
fn the_settings_default_is_the_engines_own_tuned_value() {
assert_eq!(
FaceSettings::default().merge_probability,
dr_face::DEFAULT_MERGE_PROBABILITY
);
}
/// The smallest-group setting has to change what is on the rail, not just
/// what the *next* pass would build. Before the release step in
/// [`recluster`], raising it left the previous pass's small groups sitting
/// there full of suggestions — not empty, so not pruned — and the control
/// looked broken.
#[test]
fn raising_the_smallest_group_takes_the_small_groups_off_the_rail() {
let catalog = catalog_with(5);
// One pair, and one trio: two groups at the default of two.
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
put_face(&catalog, 3, 1, 1.0);
put_face(&catalog, 4, 1, 0.99);
put_face(&catalog, 5, 1, 0.98);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
assert_eq!(
faces::people(catalog.connection()).unwrap().len(),
2,
"the two groups should have formed at the default"
);
recluster(
&catalog,
TEST_MODEL,
&FaceSettings {
min_group_size: 3,
..FaceSettings::default()
},
)
.unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(
after.len(),
1,
"the pair survived a smallest-group of three: {after:?}"
);
assert_eq!(after[0].suggested_faces, 3, "the trio lost members");
}
/// And the setting does not overrule the user. A group they named is theirs
/// (FR-CULL-12), however few faces it holds.
#[test]
fn a_named_group_survives_a_smallest_group_it_is_under() {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let her = faces::people(catalog.connection()).unwrap()[0].id;
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
recluster(
&catalog,
TEST_MODEL,
&FaceSettings {
min_group_size: 6,
..FaceSettings::default()
},
)
.unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1, "Catherine was dropped: {after:?}");
assert_eq!(after[0].name, "Catherine");
assert_eq!(after[0].suggested_faces, 2);
}
/// Down at one, every stray becomes a group of its own — which is what a
/// user hunting for a face the grouping missed has asked for.
#[test]
fn a_smallest_group_of_one_shows_the_strays() {
let catalog = catalog_with(2);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 1, 1.0);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
assert!(
faces::people(catalog.connection()).unwrap().is_empty(),
"two unrelated faces made a person at the default"
);
recluster(
&catalog,
TEST_MODEL,
&FaceSettings {
min_group_size: 1,
..FaceSettings::default()
},
)
.unwrap();
assert_eq!(faces::people(catalog.connection()).unwrap().len(), 2);
}
} }
+48
View File
@@ -117,6 +117,29 @@ impl FaceCell {
} }
} }
/// 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. /// Everything the Identity screen draws.
#[derive(Debug, Clone, Default, PartialEq)] #[derive(Debug, Clone, Default, PartialEq)]
pub struct IdentityView { pub struct IdentityView {
@@ -646,8 +669,33 @@ pub fn delete_all(catalog: &Catalog) -> Result<u64, dr_catalog::CatalogError> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::faces::GroupingPreview;
use dr_catalog::faces::DetectedFace; 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 { fn catalog() -> Catalog {
let dir = std::env::temp_dir().join(format!( let dir = std::env::temp_dir().join(format!(
"dr-identity-{}-{:?}", "dr-identity-{}-{:?}",
+137 -1
View File
@@ -81,6 +81,12 @@ pub struct IdentityController {
/// the handle, and clustering a real library is not something a Slint /// the handle, and clustering a real library is not something a Slint
/// callback may do on the UI thread. /// callback may do on the UI thread.
regroup: RefCell<Option<Receiver<crate::faces::ReclusterMessage>>>, regroup: RefCell<Option<Receiver<crate::faces::ReclusterMessage>>>,
/// The running read-only preview of the grouping dials, if any.
///
/// Separate from `regroup` because the two are allowed to be about
/// different things at once and neither blocks the other: a preview writes
/// nothing, so there is nothing for a concurrent pass to corrupt.
preview: RefCell<Option<Receiver<crate::faces::PreviewMessage>>>,
/// Whether the rail is showing the people the user has set aside. /// Whether the rail is showing the people the user has set aside.
show_ignored: std::cell::Cell<bool>, show_ignored: std::cell::Cell<bool>,
/// Rail portraits, kept between refreshes. /// Rail portraits, kept between refreshes.
@@ -363,12 +369,33 @@ pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathB
/// The detector and embedder files, when both are present. /// The detector and embedder files, when both are present.
pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf); pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf);
/// Put the grouping dials on the screen from the settings record.
///
/// Read back out of the controller rather than echoed from the callback's
/// argument, because `Settings::sanitise` may have moved the number: a slider
/// showing 40% while the file held the clamped 50% would be a control that
/// silently disagreed with what the next Regroup was going to do.
fn push_grouping(window: &AppWindow, settings: &crate::settings_ui::SettingsController) {
let s = settings.snapshot();
window.set_identity_merge_probability(s.faces.merge_probability * 100.0);
window.set_identity_min_group_size(s.faces.min_group_size as i32);
}
/// Attach every Identity callback. /// Attach every Identity callback.
///
/// Eight arguments because the screen has eight distinct dependencies and no
/// two of them belong together: three ways of reaching the library, two
/// controllers, the window, the activity log and the settings record. Bundling
/// them into a parameter struct would name a thing that does not exist — the
/// same reason every other `wire` in this file's neighbourhood carries the
/// allow.
#[allow(clippy::too_many_arguments)]
pub fn wire<S, M, P>( pub fn wire<S, M, P>(
window: &AppWindow, window: &AppWindow,
ctl: Rc<IdentityController>, ctl: Rc<IdentityController>,
catalog: Rc<RefCell<Option<Catalog>>>, catalog: Rc<RefCell<Option<Catalog>>>,
activity: Rc<crate::activity::ActivityLog>, activity: Rc<crate::activity::ActivityLog>,
settings: Rc<crate::settings_ui::SettingsController>,
store: S, store: S,
models: M, models: M,
paths: P, paths: P,
@@ -381,6 +408,36 @@ pub fn wire<S, M, P>(
let models: Rc<dyn Fn() -> Option<ModelPaths>> = Rc::new(models); let models: Rc<dyn Fn() -> Option<ModelPaths>> = Rc::new(models);
let paths: Rc<dyn Fn() -> Option<SweepPaths>> = Rc::new(paths); let paths: Rc<dyn Fn() -> Option<SweepPaths>> = Rc::new(paths);
// The dials start where the settings file left them, once, rather than on
// every open: the screen writes them back through the two callbacks below,
// and re-pushing them mid-drag would fight the slider's own live value.
push_grouping(window, &settings);
{
let weak = window.as_weak();
let settings = settings.clone();
window.on_identity_merge_probability_changed(move |percent| {
let Some(w) = weak.upgrade() else { return };
settings.edit(|s| s.faces.merge_probability = percent / 100.0);
push_grouping(&w, &settings);
// The answer on screen was about the old value. Left there it would
// be read as a description of the new one, which is worse than
// having no preview at all.
w.set_identity_grouping_preview(Default::default());
});
}
{
let weak = window.as_weak();
let settings = settings.clone();
window.on_identity_min_group_size_changed(move |n| {
let Some(w) = weak.upgrade() else { return };
settings.edit(|s| s.faces.min_group_size = n.max(0) as u32);
push_grouping(&w, &settings);
w.set_identity_grouping_preview(Default::default());
});
}
// Re-read everything and redraw. Every mutating callback ends in this // Re-read everything and redraw. Every mutating callback ends in this
// rather than patching the model in place: the operations here have // rather than patching the model in place: the operations here have
// second-order effects — a merge empties a person, a split creates one, // second-order effects — a merge empties a person, a split creates one,
@@ -670,12 +727,76 @@ pub fn wire<S, M, P>(
}); });
} }
{
let weak = window.as_weak();
let ctl = ctl.clone();
let paths = paths.clone();
let settings = settings.clone();
window.on_identity_preview_grouping(move || {
let Some(w) = weak.upgrade() else { return };
// One at a time, like every other pass here. Two previews would
// race to write the same line and the loser's answer would win.
if ctl.preview.borrow().is_some() {
return;
}
let Some((_, catalog_path, _)) = paths() else {
return;
};
w.set_identity_previewing(true);
*ctl.preview.borrow_mut() = Some(crate::faces::spawn_grouping_preview(
catalog_path,
MODEL_ID.to_string(),
settings.snapshot().faces,
));
let timer = slint::Timer::default();
let weak_tick = w.as_weak();
let ctl_tick = ctl.clone();
timer.start(
slint::TimerMode::Repeated,
Duration::from_millis(100),
move || {
let Some(w) = weak_tick.upgrade() else { return };
let mut done = false;
{
let borrow = ctl_tick.preview.borrow();
let Some(rx) = borrow.as_ref() else { return };
while let Ok(msg) = rx.try_recv() {
match msg {
crate::faces::PreviewMessage::Ready(p) => {
w.set_identity_grouping_preview(
identity::preview_label(&p).into(),
);
done = true;
}
crate::faces::PreviewMessage::Failed(e) => {
log::warn!("identity: preview: {e}");
w.set_identity_grouping_preview(
format!("could not work it out: {e}").into(),
);
done = true;
}
}
}
}
if done {
*ctl_tick.preview.borrow_mut() = None;
w.set_identity_previewing(false);
}
},
);
park_preview_timer(timer);
});
}
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let ctl = ctl.clone(); let ctl = ctl.clone();
let catalog = catalog.clone(); let catalog = catalog.clone();
let store = store.clone(); let store = store.clone();
let paths = paths.clone(); let paths = paths.clone();
let settings_for_regroup = settings.clone();
window.on_identity_recluster(move || { window.on_identity_recluster(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
// One at a time. Two passes over the same faces would each create // One at a time. Two passes over the same faces would each create
@@ -693,7 +814,7 @@ pub fn wire<S, M, P>(
*ctl.regroup.borrow_mut() = Some(crate::faces::spawn_recluster( *ctl.regroup.borrow_mut() = Some(crate::faces::spawn_recluster(
catalog_path, catalog_path,
MODEL_ID.to_string(), MODEL_ID.to_string(),
dr_face::DEFAULT_MERGE_PROBABILITY, settings_for_regroup.snapshot().faces,
)); ));
// Polled from the UI thread, like the indexing sweep: the worker // Polled from the UI thread, like the indexing sweep: the worker
@@ -969,6 +1090,21 @@ fn park_timer(timer: slint::Timer) {
SWEEP_TIMER.with(|slot| *slot.borrow_mut() = Some(timer)); SWEEP_TIMER.with(|slot| *slot.borrow_mut() = Some(timer));
} }
/// Keep the grouping preview's poll timer alive.
///
/// **A slot of its own, and that is the whole point.** A preview and a
/// regrouping pass are allowed to be in flight together — the preview writes
/// nothing — so parking both in [`park_timer`]'s single slot would have the
/// second to start drop the first's timer. The visible symptom would be a
/// Regroup that finished on its worker and never told the screen: the button
/// stuck on "Regrouping…" for the life of the window.
fn park_preview_timer(timer: slint::Timer) {
thread_local! {
static PREVIEW_TIMER: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
}
PREVIEW_TIMER.with(|slot| *slot.borrow_mut() = Some(timer));
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+14 -10
View File
@@ -1003,6 +1003,19 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// faces are ticked for a split. // faces are ticked for a split.
let identity = std::rc::Rc::new(identity_ui::IdentityController::new()); let identity = std::rc::Rc::new(identity_ui::IdentityController::new());
// Settings: cache ceilings, export defaults and the face grouping dials, in
// their own config file.
//
// Wired independently of every view below. It reads no library and holds no
// session, so it has nothing to be sequenced against — which is the reason
// it is a page reachable from anywhere rather than a panel inside one view.
// Hoisted this far up because three separate things need the same record:
// the export action, the importer, and the People screen's grouping dials.
// A controller scoped to any one wiring block would be gone by the time the
// others were built, and two controllers each holding their own copy would
// each save over the other.
let settings = settings_ui::SettingsController::new();
// Launch screen: shown when there is nothing to display — no local paths // Launch screen: shown when there is nothing to display — no local paths
// and no configured library. A user who has already signed in and chosen // and no configured library. A user who has already signed in and chosen
// a folder goes straight to their images (FR-NC-1). // a folder goes straight to their images (FR-NC-1).
@@ -1090,6 +1103,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
identity.clone(), identity.clone(),
library.catalog(), library.catalog(),
activity.clone(), activity.clone(),
settings.clone(),
move || { move || {
let conn = lib_store.session()?; let conn = lib_store.session()?;
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account))
@@ -1162,16 +1176,6 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
} }
} }
// Settings: cache ceilings and export defaults, in their own config file.
//
// Wired independently of every view above. It reads no library and holds no
// session, so it has nothing to be sequenced against — which is the reason
// it is a page reachable from anywhere rather than a panel inside one view.
// Hoisted out of the block below: the export action needs the same record
// the settings page edits, and a controller scoped to the wiring block
// would be gone by the time that callback is built.
let settings = settings_ui::SettingsController::new();
// TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a | FR-NC-7b // TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a | FR-NC-7b
// Import: a card into the library, and on to the server. // Import: a card into the library, and on to the server.
// //
+7 -1
View File
@@ -111,7 +111,13 @@ impl SettingsController {
/// accidentally write back a stale copy of the fields it was not editing — /// accidentally write back a stale copy of the fields it was not editing —
/// with a save on every keystroke, two controls holding their own snapshots /// with a save on every keystroke, two controls holding their own snapshots
/// would overwrite each other. /// would overwrite each other.
fn edit(&self, f: impl FnOnce(&mut Settings)) { ///
/// Public because the settings page is no longer the only screen that edits
/// this record: the People screen carries the grouping dials, for the reason
/// identity.slint gives, and they are the same per-device preferences saved
/// to the same file. Everything that writes settings comes through here, so
/// the sanitise-and-save discipline holds wherever the control lives.
pub fn edit(&self, f: impl FnOnce(&mut Settings)) {
{ {
let mut settings = self.settings.borrow_mut(); let mut settings = self.settings.borrow_mut();
f(&mut settings); f(&mut settings);
+16
View File
@@ -401,6 +401,15 @@ export component AppWindow inherits Window {
callback identity-recluster(); callback identity-recluster();
in property <bool> identity-regrouping: false; in property <bool> identity-regrouping: false;
in property <string> identity-regroup-status; in property <string> identity-regroup-status;
// The grouping dials Regroup turns. Percent and a face count, both stored
// in the device settings file beside the cache budgets.
in property <float> identity-merge-probability: 80;
in property <int> identity-min-group-size: 2;
callback identity-merge-probability-changed(float);
callback identity-min-group-size-changed(int);
callback identity-preview-grouping();
in property <bool> identity-previewing: false;
in property <string> identity-grouping-preview;
in property <int> identity-ignored-count: 0; in property <int> identity-ignored-count: 0;
in property <bool> identity-show-ignored: false; in property <bool> identity-show-ignored: false;
in property <bool> identity-selected-ignored: false; in property <bool> identity-selected-ignored: false;
@@ -1263,6 +1272,13 @@ in property <bool> panel-visible: true;
photos-filtered: root.library-filter-people.length > 0; photos-filtered: root.library-filter-people.length > 0;
regrouping: root.identity-regrouping; regrouping: root.identity-regrouping;
regroup-status: root.identity-regroup-status; regroup-status: root.identity-regroup-status;
merge-probability: root.identity-merge-probability;
min-group-size: root.identity-min-group-size;
merge-probability-changed(v) => { root.identity-merge-probability-changed(v); }
min-group-size-changed(n) => { root.identity-min-group-size-changed(n); }
previewing: root.identity-previewing;
grouping-preview: root.identity-grouping-preview;
preview-grouping() => { root.identity-preview-grouping(); }
ignored-count: root.identity-ignored-count; ignored-count: root.identity-ignored-count;
show-ignored: root.identity-show-ignored; show-ignored: root.identity-show-ignored;
selected-ignored: root.identity-selected-ignored; selected-ignored: root.identity-selected-ignored;
+121
View File
@@ -16,6 +16,7 @@
import { Theme } from "theme.slint"; import { Theme } from "theme.slint";
import { Panel, Button, IconButton, Field } from "widgets.slint"; import { Panel, Button, IconButton, Field } from "widgets.slint";
import { SliderRow } from "controls.slint";
import { Icon } from "icons.slint"; import { Icon } from "icons.slint";
export struct IdentityPerson { export struct IdentityPerson {
@@ -235,6 +236,11 @@ export component IdentityScreen inherits Rectangle {
/// live and has to say what it is doing rather than simply stopping. /// live and has to say what it is doing rather than simply stopping.
in property <bool> regrouping: false; in property <bool> regrouping: false;
in property <string> regroup-status; in property <string> regroup-status;
/// Whether the grouping dials are open.
///
/// Private to the screen: nothing in Rust needs to know, and a disclosure
/// state that round-tripped through a callback would flicker.
property <bool> grouping-open: false;
/// People the user has set aside, and whether the rail is showing them. /// People the user has set aside, and whether the rail is showing them.
in property <int> ignored-count: 0; in property <int> ignored-count: 0;
in property <bool> show-ignored: false; in property <bool> show-ignored: false;
@@ -251,6 +257,21 @@ export component IdentityScreen inherits Rectangle {
/// "and also" button only appears when there is something to add to. /// "and also" button only appears when there is something to add to.
in property <bool> photos-filtered: false; in property <bool> photos-filtered: false;
callback recluster(); callback recluster();
/// TRACES: FR-CULL-9
/// How sure the pass has to be before it calls two groups one person, as a
/// percentage — the same units every confidence on this screen is printed
/// in, because a control in *cosines* would be the one place in the
/// subsystem that thresholds a bare similarity.
in property <float> merge-probability: 80;
/// The smallest group the pass will make a person out of.
in property <int> min-group-size: 2;
callback merge-probability-changed(float);
callback min-group-size-changed(int);
/// Ask what the dials would do, without doing it.
callback preview-grouping();
in property <bool> previewing: false;
/// The answer, in words. Empty until one has been asked for.
in property <string> grouping-preview;
callback index-faces(); callback index-faces();
callback stop-indexing(); callback stop-indexing();
callback check-coverage(); callback check-coverage();
@@ -530,6 +551,106 @@ export component IdentityScreen inherits Rectangle {
enabled: !root.regrouping; enabled: !root.regrouping;
clicked => { root.recluster(); } clicked => { root.recluster(); }
} }
// The dials Regroup turns, immediately beside it. They belong
// here and not on the settings page: they are only meaningful
// next to the button that applies them and the rail that shows
// what they did, and a value changed three screens away from
// its effect is a value nobody can tune.
Button {
text: "Grouping…";
active: root.grouping-open;
clicked => { root.grouping-open = !root.grouping-open; }
}
}
}
// --- the grouping dials ---------------------------------------
//
// Opened inline rather than in a popup, the way the develop
// column's film picker is: this screen already scrolls as one, and
// a second overlay to dismiss is a second gesture to lose.
if root.grouping-open: Rectangle {
border-radius: Theme.radius;
background: Theme.surface-raised;
// Stated, because the layout above it is a VerticalLayout that
// would otherwise stretch this block over the faces grid.
height: dials.preferred-height + 2 * Theme.gap;
dials := VerticalLayout {
x: Theme.gap;
y: Theme.gap;
width: parent.width - 2 * Theme.gap;
spacing: Theme.gap-sm;
// **The hints are two words each, and that is deliberate.**
// `FieldRow` draws a hint as a non-wrapping elided
// `Caption`, whose *minimum* width is still its whole text
// — and a layout cannot be narrower than its children's
// minimums, so a sentence here would set the minimum width
// of this pane and, on a phone, of the screen. The
// sentences go in the wrapping paragraph below instead,
// where they cost nothing.
SliderRow {
label: "Match confidence";
hint: "%";
value: root.merge-probability;
default-value: 80;
minimum: 50;
maximum: 95;
precision: 0;
enabled: !root.regrouping;
changed(v) => { root.merge-probability-changed(v); }
reset => { root.merge-probability-changed(80); }
}
SliderRow {
label: "Smallest group";
hint: "faces";
value: root.min-group-size;
default-value: 2;
minimum: 1;
maximum: 12;
precision: 0;
enabled: !root.regrouping;
changed(v) => { root.min-group-size-changed(v); }
reset => { root.min-group-size-changed(2); }
}
// The dial is otherwise blind: nothing on the screen says
// what 78% means for *this* library until the user commits
// to a pass and reads the rail. `dr_face`'s tuning table is
// the right answer to that question and it is measured on
// somebody else's photographs, so this runs it here — one
// row of it, read-only, for the value actually set.
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: start;
Button {
text: root.previewing ? "Working…" : "What would this do?";
enabled: !root.previewing && !root.regrouping;
clicked => { root.preview-grouping(); }
}
if root.grouping-preview != "": Text {
text: root.grouping-preview;
color: Theme.ink-dim;
font-size: Theme.text-sm;
vertical-alignment: center;
wrap: word-wrap;
horizontal-stretch: 1;
}
}
// What the two dials mean, and — said plainly, because
// they look destructive and are not — what they cannot
// touch: they move the *suggested* half, which this pass
// owns and may revise as often as it likes.
Text {
text: "Lower confidence gathers more of each person together; too low and different people are welded into one. A bigger smallest group keeps one-off strangers off the rail.\n\nPress Regroup to apply. Names, confirmations and the groups you have set aside are kept whatever the dials say.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
}
} }
} }