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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user