One completeness job over a registry of repairs, and a re-index button

A library's records are never all complete at once. A face found before
its quality was kept has no quality; one found before the eye models
existed has no reading; one adopted from a peer's shard has no crop; an
image the fast detector examined on a 1024 px proxy has boxes the current
detector would not have drawn; an image the scan stat'ed has no capture
date. On the reference library that is 17,762 faces under the bare
w600k_mbf id with no quality, no reading and no dense landmarks, 4,144 of
them without a crop, beside 12,217 images the fast detector examined and
found nothing in. Every one of those gaps was its own pass — V14's
measuring pass, §17.5's eye pass, the sweep's proxy repair, the sweep's
detector upgrade — with its own work list, its own count and its own idea
of done, and adding a per-face field meant adding a pass. There was no
pass at all for the case the library is actually in: boxes and landmarks
drawn by a weaker detector on a proxy, which every later per-face pass
would have read from.

dr_ui::repairs replaces them with one job over a registry. A Repair names
one thing a record can lack — the predicate that says which images still
owe it, the input its handler needs (a header, the original, or a native
render), the handler, and what to record for an image that can never be
done. The job unions the predicates into one work list, fetches each
image once at the most any claimant asks for, renders it at most once,
and runs every handler whose predicate that image still matches, checked
again before each because a detection writes every field a per-face
handler would fill. The registry today: face-proxy, face-quality,
face-eyes, face-crop, face-detection, face-upgrade, metadata — the last
there to say that this is not a face job. Adding a field is one entry.

A repair's predicate is the only definition of its work: the count the
settings page shows, the list the job fetches and the check before its
handler run are one predicate, so the job converges. That is why the
registry is cut to what the device can do rather than listing what it
skips — an entry is a count and a set of originals to fetch — and why an
eye reading that cannot be cut is not a criterion.

The catalog side is generic to match: record_updates writes whichever
fields a FaceUpdate carries and re-marks the image so the shards export
it; faces_needing and count_needing answer a predicate the caller
supplies, replacing the measuring pass's three special cases.

Two buttons on the settings page run the job and differ in one
predicate. "Index faces" converges on coverage: has anything examined
this image. "Re-index every face" converges on provenance: face-detection
claims every image with no marker under the chosen detector, in either
of its forms (FaceDetector::model_ids, so a desktop in f32 and a tablet
on the Hexagon do not re-index each other's work), and a marker saying a
weaker one looked is not that. An original over the fetch budget is left
exactly as it was under the re-index, where the sweep marks it examined:
a re-detection with nothing found would delete the faces, and "cannot
fetch" is not "no faces".
This commit is contained in:
2026-09-19 18:52:13 +02:00
parent 2a4ac0ed3d
commit 5c00942b84
15 changed files with 2201 additions and 1450 deletions
+58 -21
View File
@@ -382,7 +382,8 @@ fn fill_covers(
/// Cheap enough to call on every open and after every sweep: two counts and one
/// indexed scan, no decoding and no inference.
/// `eyes` is whether this device has the eye models: with them, faces with
/// no eye reading are work the sweep has left (`crate::faces::audit`).
/// no eye reading are work the job has left (`crate::faces::audit`), and
/// the registry the count is taken from is the one the job would run.
pub fn refresh_coverage(
window: &AppWindow,
catalog: &Rc<RefCell<Option<Catalog>>>,
@@ -395,7 +396,18 @@ pub fn refresh_coverage(
window.set_identity_coverage(Default::default());
return;
};
match crate::faces::audit(cat, store, model_id, eyes) {
let detector = dr_types::FaceDetector::for_model_id(model_id).unwrap_or_default();
let repairs = crate::repairs::registry(
crate::repairs::Scope::Outstanding,
model_id,
detector,
crate::repairs::Capabilities {
gpu: true,
face_models: true,
eye_models: eyes,
},
);
match crate::faces::audit(cat, store, model_id, &repairs) {
Ok(a) => {
window.set_identity_coverage(a.summary().into());
// Complete means nothing left to index or measure, not "every
@@ -404,7 +416,7 @@ pub fn refresh_coverage(
window.set_identity_coverage_complete(a.is_complete());
// Detection done, readings outstanding: the button names the
// pass it will run rather than promising to index.
window.set_identity_coverage_read_only(a.coverage.is_complete() && a.unmeasured > 0);
window.set_identity_coverage_read_only(a.coverage.is_complete() && a.has_repairs());
}
Err(e) => {
log::warn!("identity: coverage check: {e}");
@@ -518,7 +530,7 @@ fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
///
/// A connection and not just paths, because the pass fetches its own pixels: an
/// image with no proxy is the ordinary case, not one to skip (see
/// `library::spawn_face_sweep`).
/// `repairs::spawn`).
pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathBuf);
/// The detector and embedder files, when both are present — and the eye
@@ -1087,7 +1099,12 @@ pub fn wire<S, M, P>(
});
}
{
// One launcher behind two buttons. "Index faces" and "Re-index every
// face" differ only in which images the pass visits (`FaceSweepScope`);
// the models, the progress, the activity row and the Stop button are the
// same, and a second copy of this closure would be a second place for
// them to disagree.
let launch: Rc<dyn Fn(crate::repairs::Scope)> = {
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
@@ -1097,7 +1114,8 @@ pub fn wire<S, M, P>(
let gpu = gpu.clone();
let settings_for_sweep = settings.clone();
let eyes_available = eyes_available.clone();
window.on_identity_index(move || {
let activity = activity.clone();
Rc::new(move |scope: crate::repairs::Scope| {
let Some(w) = weak.upgrade() else { return };
if ctl.sweep.borrow().is_some() {
return;
@@ -1116,30 +1134,38 @@ pub fn wire<S, M, P>(
return;
};
let reindex = scope == crate::repairs::Scope::Reindex;
ctl.progress.set((0, 0));
ctl.faces_found.set(0);
ctl.sweep_failed.set(0);
*ctl.activity.borrow_mut() =
Some(activity.begin(crate::activity::Kind::Index, "Indexing faces"));
*ctl.sweep.borrow_mut() = Some(crate::library::spawn_face_sweep(
*ctl.activity.borrow_mut() = Some(activity.begin(
crate::activity::Kind::Index,
if reindex {
"Re-indexing faces"
} else {
"Indexing faces"
},
));
*ctl.sweep.borrow_mut() = Some(crate::repairs::spawn(
conn,
catalog_path,
store_dir,
models,
Some(models),
model_id(&settings_for_sweep),
settings_for_sweep
.snapshot()
.faces
.detector
.supersedes()
.iter()
.map(|m| m.to_string())
.collect(),
settings_for_sweep.snapshot().faces.detector,
scope,
dr_face::DetectOptions::default(),
gpu,
Some(gpu),
));
w.set_identity_indexing(true);
w.set_identity_indexing_status("looking for images to index…".into());
w.set_identity_indexing_status(
if reindex {
"looking for images to detect again…"
} else {
"looking for images to index…"
}
.into(),
);
// Polled rather than pushed: the worker is a plain thread with an
// mpsc channel, and a timer on the UI thread keeps every Slint
@@ -1249,7 +1275,18 @@ pub fn wire<S, M, P>(
// A Slint timer stops when it drops, so it has to outlive this
// callback.
park_timer(timer);
});
})
};
{
let launch = launch.clone();
window.on_identity_index(move || launch(crate::repairs::Scope::Outstanding));
}
{
// TRACES: FR-CULL-8 | FR-CULL-10
let launch = launch.clone();
window.on_identity_reindex(move || launch(crate::repairs::Scope::Reindex));
}
{