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:
@@ -175,7 +175,7 @@ pub fn spawn_sync(
|
||||
}
|
||||
|
||||
// Eight, because a sync touches eight distinct things — the same reason
|
||||
// `spawn_face_sweep` carries the allow: bundling them into a struct would name
|
||||
// `repairs::spawn` carries the allow: bundling them into a struct would name
|
||||
// nothing that exists.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run(
|
||||
|
||||
+66
-45
@@ -132,7 +132,7 @@ pub fn faces_outstanding(
|
||||
// proxy exists, the job requests one at background priority". An
|
||||
// earlier comment here read it the other way round, and the result was
|
||||
// a whole-library button that could only reach photographs the user had
|
||||
// personally zoomed into. `library::spawn_face_sweep` is that
|
||||
// personally zoomed into. `repairs::spawn` is that
|
||||
// requirement implemented; this one is the local-only variant.
|
||||
.filter(|req| store.contains(req.file_id, FACE_TIER))
|
||||
.collect();
|
||||
@@ -144,7 +144,7 @@ pub fn faces_outstanding(
|
||||
/// The catalog can say how many images have been through the model; only this
|
||||
/// layer can say *why* the rest have not, because the reason usually lives in
|
||||
/// the thumbnail store rather than the catalog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct IndexAudit {
|
||||
pub coverage: faces::Coverage,
|
||||
/// Outstanding, with a proxy already on disk.
|
||||
@@ -158,13 +158,17 @@ pub struct IndexAudit {
|
||||
/// ones above now do.
|
||||
pub awaiting_proxy: u64,
|
||||
/// TRACES: FR-CULL-8a
|
||||
/// Faces this model found that the measuring pass still has to read:
|
||||
/// stored without their quality, or — on a device with the eye models —
|
||||
/// without an eye reading. Work that is not visible in the coverage
|
||||
/// figure, since every one of these images carries its run marker, and
|
||||
/// that has to be counted here or the screen calls the library finished
|
||||
/// and takes the button away that would finish it.
|
||||
pub unmeasured: u64,
|
||||
/// What each repair in the registry still lists, by its label
|
||||
/// (`crate::repairs::counts`) — the faces stored without their quality,
|
||||
/// without an eye reading on a device with the eye models, without a
|
||||
/// crop; the images a weaker detector indexed. Work that is not visible
|
||||
/// in the coverage figure, since every one of these images carries its
|
||||
/// run marker, and that has to be counted here or the screen calls the
|
||||
/// library finished and takes the button away that would finish it.
|
||||
///
|
||||
/// Detection's own entry is left out: it is the outstanding figure
|
||||
/// above, split by proxy.
|
||||
pub owed: Vec<(&'static str, u64)>,
|
||||
}
|
||||
|
||||
impl IndexAudit {
|
||||
@@ -199,21 +203,27 @@ impl IndexAudit {
|
||||
if outstanding > 0 {
|
||||
s.push_str(&format!("; {outstanding} to index"));
|
||||
}
|
||||
if self.unmeasured > 0 {
|
||||
s.push_str(&format!(
|
||||
"; {} face(s) to read for quality or eye state",
|
||||
self.unmeasured
|
||||
));
|
||||
for (label, n) in &self.owed {
|
||||
if *n > 0 {
|
||||
s.push_str(&format!("; {n} {label}"));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Whether anything but detection is left: the state an already-indexed
|
||||
/// library is in the day the eye models arrive, where the button reads
|
||||
/// "Read eye state" rather than promising to index.
|
||||
pub fn has_repairs(&self) -> bool {
|
||||
self.owed.iter().any(|(_, n)| *n > 0)
|
||||
}
|
||||
|
||||
/// Whether the sweep has nothing left to do — nothing to index *and*
|
||||
/// nothing to measure. The screen hides the button on this, so it has to
|
||||
/// be false while the measuring pass has work, or the eye readings of an
|
||||
/// already-indexed library could never be filled in.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.coverage.is_complete() && self.unmeasured == 0
|
||||
self.coverage.is_complete() && !self.has_repairs()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,18 +233,23 @@ impl IndexAudit {
|
||||
/// and the one to run before deciding whether to start a sweep. Cheap: two
|
||||
/// counts and one indexed scan, no decoding and no inference.
|
||||
///
|
||||
/// `eyes` is whether this device has the eye models, and it decides whether
|
||||
/// a face without an eye reading counts as work — see
|
||||
/// `dr_catalog::faces::faces_unmeasured`.
|
||||
/// `repairs` is the registry this device would run (`crate::repairs::
|
||||
/// registry`), which decides what counts as work: a device without the eye
|
||||
/// models has no eye repair and so lists no faces to read.
|
||||
pub fn audit(
|
||||
catalog: &Catalog,
|
||||
store: &ThumbStore,
|
||||
model_id: &str,
|
||||
eyes: bool,
|
||||
repairs: &[crate::repairs::Repair],
|
||||
) -> Result<IndexAudit, dr_catalog::CatalogError> {
|
||||
let conn = catalog.connection();
|
||||
let coverage = faces::coverage(conn, model_id)?;
|
||||
let unmeasured = faces::faces_unmeasured(conn, model_id, eyes)?;
|
||||
let owed = crate::repairs::counts(catalog, store, repairs)?
|
||||
.into_iter()
|
||||
.zip(repairs.iter())
|
||||
.filter(|(_, r)| r.name != "face-detection")
|
||||
.map(|(c, _)| c)
|
||||
.collect();
|
||||
|
||||
// Split the outstanding set by whether a proxy exists. This is the query
|
||||
// `faces_outstanding` runs without the store filter, so the two cannot
|
||||
@@ -271,7 +286,7 @@ pub fn audit(
|
||||
coverage,
|
||||
ready,
|
||||
awaiting_proxy: awaiting,
|
||||
unmeasured,
|
||||
owed,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -545,17 +560,17 @@ pub fn index_native(
|
||||
/// What re-embedding the faces already on one image produced.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Measured {
|
||||
pub measured: Vec<faces::Measurement>,
|
||||
pub measured: Vec<faces::FaceUpdate>,
|
||||
/// Faces whose stored landmarks no longer make a warp. See
|
||||
/// `dr_catalog::faces::record_measurements` for what becomes of them.
|
||||
/// `dr_catalog::faces::record_updates` for what becomes of them.
|
||||
pub dropped: Vec<faces::FaceId>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-8 | FR-CULL-9
|
||||
/// Embed the faces already found on one image again, from its native render.
|
||||
///
|
||||
/// The measuring half of the sweep, for faces stored before their quality was
|
||||
/// kept (schema V14). No detector: the boxes and landmarks in the catalog are
|
||||
/// The `face-quality` repair (`crate::repairs`), for faces stored before their
|
||||
/// quality was kept (schema V14). No detector: the boxes and landmarks in the catalog are
|
||||
/// taken as read, scaled back from the long edge they were normalised to, and
|
||||
/// each face is warped out of the native frame and embedded exactly as
|
||||
/// [`index_native`] would have done on the day. What comes back is the raw
|
||||
@@ -616,12 +631,10 @@ pub fn measure_native(
|
||||
long_edge,
|
||||
)
|
||||
};
|
||||
out.measured.push(faces::Measurement {
|
||||
face: f.id,
|
||||
embedding: embedded.to_f16_bytes(),
|
||||
quality: embedded.quality,
|
||||
eyes,
|
||||
landmarks_dense,
|
||||
out.measured.push(faces::FaceUpdate {
|
||||
embedding: Some((embedded.to_f16_bytes(), embedded.quality)),
|
||||
eyes: eyes.map(|e| (e, landmarks_dense)),
|
||||
..faces::FaceUpdate::for_face(f.id)
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -714,7 +727,7 @@ fn reduce_to(rgba: &[u8], width: usize, height: usize, target: usize) -> (usize,
|
||||
/// than a generalisation of it: this one takes a box already in native
|
||||
/// coordinates, and blurring that distinction is how a crop ends up sampled
|
||||
/// from the wrong scale.
|
||||
fn cut_crop_native(
|
||||
pub(crate) fn cut_crop_native(
|
||||
px: dr_face::Pixels<'_>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
@@ -766,7 +779,7 @@ fn normalise_landmarks(lm: &[(f32, f32); 5], long_edge: f32) -> [(f32, f32); 5]
|
||||
|
||||
/// Index every image whose proxy is **already on this disk**, in the background.
|
||||
///
|
||||
/// Not the whole-library pass — that is `library::spawn_face_sweep`, which
|
||||
/// Not the whole-library pass — that is `repairs::spawn`, which
|
||||
/// fetches what it has not got. This one never touches the network, which makes
|
||||
/// it the right shape for a tool run against a local store (see
|
||||
/// `examples/face_index.rs`) and the wrong shape for a user pressing "index my
|
||||
@@ -846,7 +859,7 @@ pub fn spawn_store_face_sweep(
|
||||
// single image as failed: twenty thousand refusals that all say the
|
||||
// same thing. The pass is not repairable here either, because there is
|
||||
// no larger tier for it to read; the pixels it needs have to come off
|
||||
// the server, which is `library::spawn_face_sweep`'s job.
|
||||
// the server, which is `repairs::spawn`'s job.
|
||||
if FACE_TIER.edge() < dr_face::MIN_CROP_EDGE {
|
||||
log::warn!(
|
||||
"face sweep: the local store's largest tier is {}px, below the {}px \
|
||||
@@ -1640,12 +1653,12 @@ mod tests {
|
||||
assert_eq!(out.measured.len(), 1);
|
||||
let m = &out.measured[0];
|
||||
assert_eq!(m.face, faces::FaceId(7));
|
||||
assert!(m.quality > 0.0);
|
||||
let (_, length) = dr_face::read_f16_bytes(model, &m.embedding).expect("decode");
|
||||
let (embedding, quality) = m.embedding.as_ref().expect("a vector");
|
||||
assert!(*quality > 0.0);
|
||||
let (_, length) = dr_face::read_f16_bytes(model, embedding).expect("decode");
|
||||
assert!(
|
||||
(length - m.quality).abs() < 0.05 * m.quality,
|
||||
"stored length {length} against reported quality {}",
|
||||
m.quality
|
||||
(length - quality).abs() < 0.05 * quality,
|
||||
"stored length {length} against reported quality {quality}"
|
||||
);
|
||||
|
||||
// Degenerate landmarks -- five points on one spot, which no
|
||||
@@ -1681,7 +1694,7 @@ mod tests {
|
||||
},
|
||||
ready: 30,
|
||||
awaiting_proxy: 10,
|
||||
unmeasured: 0,
|
||||
owed: Vec::new(),
|
||||
};
|
||||
let s = a.summary();
|
||||
assert!(s.contains("60/100"), "{s}");
|
||||
@@ -1707,7 +1720,7 @@ mod tests {
|
||||
},
|
||||
ready: 0,
|
||||
awaiting_proxy: 0,
|
||||
unmeasured: 0,
|
||||
owed: Vec::new(),
|
||||
};
|
||||
let s = a.summary();
|
||||
assert!(!s.contains("ready"), "{s}");
|
||||
@@ -1732,11 +1745,19 @@ mod tests {
|
||||
},
|
||||
ready: 0,
|
||||
awaiting_proxy: 0,
|
||||
unmeasured: 4,
|
||||
owed: vec![
|
||||
("images with faces to read for quality", 4),
|
||||
("images with faces to read for eye state", 0),
|
||||
],
|
||||
};
|
||||
assert!(a.coverage.is_complete());
|
||||
assert!(!a.is_complete());
|
||||
assert!(a.summary().contains("4 face(s) to read"), "{}", a.summary());
|
||||
assert!(
|
||||
a.summary()
|
||||
.ends_with("; 4 images with faces to read for quality"),
|
||||
"{}",
|
||||
a.summary()
|
||||
);
|
||||
}
|
||||
|
||||
/// The figure the real library actually produced: 110 of 23,528 rounds to
|
||||
@@ -1752,7 +1773,7 @@ mod tests {
|
||||
},
|
||||
ready: 69,
|
||||
awaiting_proxy: 23_349,
|
||||
unmeasured: 0,
|
||||
owed: Vec::new(),
|
||||
};
|
||||
let s = a.summary();
|
||||
assert!(s.contains("0.5%"), "{s}");
|
||||
@@ -1770,7 +1791,7 @@ mod tests {
|
||||
},
|
||||
ready: 99_999,
|
||||
awaiting_proxy: 0,
|
||||
unmeasured: 0,
|
||||
owed: Vec::new(),
|
||||
};
|
||||
assert!(a.summary().contains("<0.1%"), "{}", a.summary());
|
||||
}
|
||||
|
||||
+58
-21
@@ -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));
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -56,6 +56,7 @@ mod preset_store;
|
||||
mod presets;
|
||||
mod recovery_ui;
|
||||
mod remote;
|
||||
pub mod repairs;
|
||||
mod segmentation;
|
||||
mod settings_store;
|
||||
mod settings_ui;
|
||||
|
||||
+59
-1209
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user