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:
@@ -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);
|
||||
|
||||
+347
-75
@@ -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<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.
|
||||
///
|
||||
/// 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<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.
|
||||
#[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<ReclusterMessage> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64, dr_catalog::CatalogError> {
|
||||
#[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-{}-{:?}",
|
||||
|
||||
+137
-1
@@ -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<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.
|
||||
show_ignored: std::cell::Cell<bool>,
|
||||
/// 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<S, M, P>(
|
||||
window: &AppWindow,
|
||||
ctl: Rc<IdentityController>,
|
||||
catalog: Rc<RefCell<Option<Catalog>>>,
|
||||
activity: Rc<crate::activity::ActivityLog>,
|
||||
settings: Rc<crate::settings_ui::SettingsController>,
|
||||
store: S,
|
||||
models: M,
|
||||
paths: P,
|
||||
@@ -381,6 +408,36 @@ pub fn wire<S, M, P>(
|
||||
let models: Rc<dyn Fn() -> Option<ModelPaths>> = Rc::new(models);
|
||||
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
|
||||
// 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<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 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<S, M, P>(
|
||||
*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<Option<slint::Timer>> = const { RefCell::new(None) };
|
||||
}
|
||||
PREVIEW_TIMER.with(|slot| *slot.borrow_mut() = Some(timer));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+14
-10
@@ -1011,6 +1011,19 @@ pub fn run(paths: Vec<PathBuf>) -> 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).
|
||||
@@ -1098,6 +1111,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
identity.clone(),
|
||||
library.catalog(),
|
||||
activity.clone(),
|
||||
settings.clone(),
|
||||
move || {
|
||||
let conn = lib_store.session()?;
|
||||
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account))
|
||||
@@ -1170,16 +1184,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
|
||||
// Import: a card into the library, and on to the server.
|
||||
//
|
||||
|
||||
@@ -114,8 +114,12 @@ impl SettingsController {
|
||||
///
|
||||
/// `pub(crate)` rather than private because the settings page is no longer
|
||||
/// the only surface that writes a preference: the scope chips are drawn
|
||||
/// here *and* in the preset sheet, and both change one stored set. The
|
||||
/// closure is what makes that safe, which is why the shape stays.
|
||||
/// here *and* in the preset sheet, and the People screen carries the
|
||||
/// grouping dials, for the reason identity.slint gives. All of them change
|
||||
/// one stored set of per-device preferences saved to the same file, and all
|
||||
/// of them come through here, so the sanitise-and-save discipline holds
|
||||
/// wherever the control lives. The closure is what makes that safe, which
|
||||
/// is why the shape stays.
|
||||
pub(crate) fn edit(&self, f: impl FnOnce(&mut Settings)) {
|
||||
{
|
||||
let mut settings = self.settings.borrow_mut();
|
||||
|
||||
@@ -419,6 +419,15 @@ export component AppWindow inherits Window {
|
||||
callback identity-recluster();
|
||||
in property <bool> identity-regrouping: false;
|
||||
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 <bool> identity-show-ignored: false;
|
||||
in property <bool> identity-selected-ignored: false;
|
||||
@@ -1323,6 +1332,13 @@ in property <bool> 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;
|
||||
|
||||
@@ -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 <bool> regrouping: false;
|
||||
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.
|
||||
in property <int> ignored-count: 0;
|
||||
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.
|
||||
in property <bool> 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 <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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user