diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index 8bc950d..13ccafe 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -54,6 +54,7 @@ pub struct Settings { pub develop: DevelopSettings, pub import: ImportSettings, 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 // --------------------------------------------------------------------------- @@ -884,6 +974,21 @@ impl Settings { pub fn sanitise(&mut self) { 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 // settings page lights the chip whose value matches, so a // 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()); } + #[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] fn sanitise_leaves_a_real_destination_alone() { let mut s = Settings::default(); diff --git a/docs/faces.md b/docs/faces.md index 6abfcfd..5fef824 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -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 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 diff --git a/ui/dr-ui/examples/face_index.rs b/ui/dr-ui/examples/face_index.rs index 7c1f4a9..f8d09c3 100644 --- a/ui/dr-ui/examples/face_index.rs +++ b/ui/dr-ui/examples/face_index.rs @@ -88,7 +88,11 @@ fn main() { // whole-library operation over the embeddings detection produced, and it is // worth running *after* a sweep rather than during one (catalog.md §10.2). 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)) => { println!("\nclustering: {suggested} suggestion(s), {created} new group(s)"); report_people(&catalog); diff --git a/ui/dr-ui/src/faces.rs b/ui/dr-ui/src/faces.rs index 95e6f24..4e6675e 100644 --- a/ui/dr-ui/src/faces.rs +++ b/ui/dr-ui/src/faces.rs @@ -30,6 +30,7 @@ use dr_catalog::faces::{self, DetectedFace}; use dr_catalog::Catalog; use dr_face::{align, Calibration, DetectOptions, Detection, Detector, Embedder, ModelId}; use dr_thumbs::{ThumbSize, ThumbStore}; +use dr_types::settings::FaceSettings; use dr_types::ImageId; /// The tier faces are found on. See the module note. @@ -476,6 +477,148 @@ pub fn spawn_store_face_sweep( 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, + /// Catalog ids, parallel to `candidates`. + ids: Vec, + /// Faces the user has ruled on, and who they are. See [`Population::read`]. + anchors: std::collections::HashMap, +} + +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 { + 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 { + 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. /// /// Confirmations are never touched: they enter the clusterer as anchors and @@ -487,72 +630,22 @@ pub fn spawn_store_face_sweep( pub fn recluster( catalog: &Catalog, model_id: &str, - min_probability: f32, + grouping: &FaceSettings, ) -> 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(); - // 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); - - let stored = faces::embeddings(conn, model_id)?; - if stored.is_empty() { + let Population { + cal, + candidates, + ids, + anchors: confirmed, + } = Population::read(catalog, model_id)?; + if candidates.is_empty() { 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 { clusters, confidence, @@ -560,10 +653,20 @@ pub fn recluster( let mut suggested = 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 { - // A group of one is not a person. Naming every stray face would fill - // the People view with noise the user then has to dismiss. - if c.members.len() < 2 && c.person.is_none() { + // A group of one is not a person, and the user says how much bigger + // than one it has to be (`FaceSettings::min_group_size`). Naming every + // 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; } @@ -579,6 +682,7 @@ pub fn recluster( for &m in &c.members { let face = ids[m]; + placed.insert(face); if confirmed.contains_key(&face) { 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 // 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 @@ -612,6 +741,43 @@ pub fn recluster( 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 { + 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. #[derive(Debug, Clone, PartialEq)] pub enum ReclusterMessage { @@ -643,7 +809,7 @@ pub enum ReclusterMessage { pub fn spawn_recluster( catalog_path: PathBuf, model_id: String, - min_probability: f32, + grouping: FaceSettings, ) -> Receiver { let (tx, rx) = std::sync::mpsc::channel(); @@ -667,7 +833,7 @@ pub fn spawn_recluster( 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 }, Err(e) => ReclusterMessage::Failed(e.to_string()), }; @@ -1125,7 +1291,7 @@ mod tests { put_face(&catalog, 1, 0, 1.0); 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(); assert_eq!(people.len(), 1, "the two faces should have grouped"); let stranger = people[0].id; @@ -1133,7 +1299,7 @@ mod tests { 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(); assert_eq!( @@ -1157,13 +1323,13 @@ mod tests { put_face(&catalog, 1, 0, 1.0); 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; faces::set_ignored(catalog.connection(), stranger, true).unwrap(); // The same person turns up in a third photograph. 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(); assert_eq!(after.len(), 1, "a new face made a second group: {after:?}"); @@ -1177,13 +1343,13 @@ mod tests { let catalog = catalog_with(3); put_face(&catalog, 1, 0, 1.0); 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; faces::set_ignored(catalog.connection(), id, true).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(); assert_eq!(after.len(), 1); assert!(!after[0].ignored); @@ -1200,7 +1366,7 @@ mod tests { put_face(&catalog, 1, 0, 1.0); 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(); assert_eq!(people.len(), 1); let her = people[0].id; @@ -1210,7 +1376,7 @@ mod tests { // types a name and moves on has done. 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(); assert_eq!( @@ -1233,15 +1399,121 @@ mod tests { let catalog = catalog_with(3); put_face(&catalog, 1, 0, 1.0); 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; faces::rename_person(catalog.connection(), her, "Catherine").unwrap(); 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(); assert_eq!(after.len(), 1, "a second Catherine appeared: {after:?}"); 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); + } } diff --git a/ui/dr-ui/src/identity.rs b/ui/dr-ui/src/identity.rs index 1d21b98..29c4c25 100644 --- a/ui/dr-ui/src/identity.rs +++ b/ui/dr-ui/src/identity.rs @@ -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. #[derive(Debug, Clone, Default, PartialEq)] pub struct IdentityView { @@ -646,8 +669,33 @@ pub fn delete_all(catalog: &Catalog) -> Result { #[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-{}-{:?}", diff --git a/ui/dr-ui/src/identity_ui.rs b/ui/dr-ui/src/identity_ui.rs index 48f7cb6..5f8415f 100644 --- a/ui/dr-ui/src/identity_ui.rs +++ b/ui/dr-ui/src/identity_ui.rs @@ -81,6 +81,12 @@ pub struct IdentityController { /// the handle, and clustering a real library is not something a Slint /// callback may do on the UI thread. regroup: RefCell>>, + /// 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>>, /// Whether the rail is showing the people the user has set aside. show_ignored: std::cell::Cell, /// 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. 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. +/// +/// 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( window: &AppWindow, ctl: Rc, catalog: Rc>>, activity: Rc, + settings: Rc, store: S, models: M, paths: P, @@ -381,6 +408,36 @@ pub fn wire( let models: Rc Option> = Rc::new(models); let paths: Rc Option> = 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 // rather than patching the model in place: the operations here have // second-order effects — a merge empties a person, a split creates one, @@ -670,12 +727,76 @@ pub fn wire( }); } + { + 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 ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); let paths = paths.clone(); + let settings_for_regroup = settings.clone(); window.on_identity_recluster(move || { let Some(w) = weak.upgrade() else { return }; // One at a time. Two passes over the same faces would each create @@ -693,7 +814,7 @@ pub fn wire( *ctl.regroup.borrow_mut() = Some(crate::faces::spawn_recluster( catalog_path, 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 @@ -969,6 +1090,21 @@ fn park_timer(timer: slint::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> = const { RefCell::new(None) }; + } + PREVIEW_TIMER.with(|slot| *slot.borrow_mut() = Some(timer)); +} + #[cfg(test)] mod tests { use super::*; diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 70fd502..b16968b 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -1003,6 +1003,19 @@ pub fn run(paths: Vec) -> Result<()> { // faces are ticked for a split. 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 // and no configured library. A user who has already signed in and chosen // a folder goes straight to their images (FR-NC-1). @@ -1090,6 +1103,7 @@ pub fn run(paths: Vec) -> Result<()> { identity.clone(), library.catalog(), activity.clone(), + settings.clone(), move || { let conn = lib_store.session()?; dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) @@ -1162,16 +1176,6 @@ pub fn run(paths: Vec) -> 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 // Import: a card into the library, and on to the server. // diff --git a/ui/dr-ui/src/settings_ui.rs b/ui/dr-ui/src/settings_ui.rs index 1c689c9..b882f9c 100644 --- a/ui/dr-ui/src/settings_ui.rs +++ b/ui/dr-ui/src/settings_ui.rs @@ -111,7 +111,13 @@ impl SettingsController { /// 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 /// 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(); f(&mut settings); diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 97beb6f..ab474af 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -401,6 +401,15 @@ export component AppWindow inherits Window { callback identity-recluster(); in property identity-regrouping: false; in property 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 identity-merge-probability: 80; + in property identity-min-group-size: 2; + callback identity-merge-probability-changed(float); + callback identity-min-group-size-changed(int); + callback identity-preview-grouping(); + in property identity-previewing: false; + in property identity-grouping-preview; in property identity-ignored-count: 0; in property identity-show-ignored: false; in property identity-selected-ignored: false; @@ -1263,6 +1272,13 @@ in property panel-visible: true; photos-filtered: root.library-filter-people.length > 0; regrouping: root.identity-regrouping; 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; show-ignored: root.identity-show-ignored; selected-ignored: root.identity-selected-ignored; diff --git a/ui/dr-ui/ui/identity.slint b/ui/dr-ui/ui/identity.slint index c8d03a2..8fda5bb 100644 --- a/ui/dr-ui/ui/identity.slint +++ b/ui/dr-ui/ui/identity.slint @@ -16,6 +16,7 @@ import { Theme } from "theme.slint"; import { Panel, Button, IconButton, Field } from "widgets.slint"; +import { SliderRow } from "controls.slint"; import { Icon } from "icons.slint"; 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. in property regrouping: false; in property 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 grouping-open: false; /// People the user has set aside, and whether the rail is showing them. in property ignored-count: 0; in property show-ignored: false; @@ -251,6 +257,21 @@ export component IdentityScreen inherits Rectangle { /// "and also" button only appears when there is something to add to. in property photos-filtered: false; 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 merge-probability: 80; + /// The smallest group the pass will make a person out of. + in property 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 previewing: false; + /// The answer, in words. Empty until one has been asked for. + in property grouping-preview; callback index-faces(); callback stop-indexing(); callback check-coverage(); @@ -530,6 +551,106 @@ export component IdentityScreen inherits Rectangle { enabled: !root.regrouping; 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; + } } }