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
+175 -130
View File
@@ -502,58 +502,77 @@ fn match_priors(prior: &[Prior], faces: &[DetectedFace]) -> Vec<Option<usize>> {
carried
}
/// A face embedded again from its stored landmarks: the new vector and its
/// length. What the measuring pass hands back per face.
/// What a per-face pass wants written over one stored face.
///
/// Each field is `Some` where the pass produced it and `None` where it is
/// to be left exactly as it was: a device without the eye models writing a
/// fresh vector must not blank a reading a peer had already made. One
/// struct for every pass rather than one writer per column, so a new
/// per-face field is a field here and a handler in `dr_ui::faces::repairs`,
/// and nothing else.
#[derive(Debug, Clone, PartialEq)]
pub struct Measurement {
pub struct FaceUpdate {
pub face: FaceId,
/// 512 × f16, raw — `dr_face::Embedded::to_f16_bytes`.
pub embedding: Vec<u8>,
pub quality: f32,
/// See [`DetectedFace::eyes`]. `None` where the measuring device has no
/// eye models, in which case the stored reading is left as it was.
pub eyes: Option<EyeReading>,
/// See [`DetectedFace::landmarks_dense`]; written with `eyes`.
pub landmarks_dense: Vec<u8>,
/// The raw vector (`dr_face::Embedded::to_f16_bytes`) and its length.
pub embedding: Option<(Vec<u8>, f32)>,
/// See [`DetectedFace::eyes`], with the dense landmarks it was read from
/// (see [`DetectedFace::landmarks_dense`]; empty stores NULL).
pub eyes: Option<(EyeReading, Vec<u8>)>,
/// See [`DetectedFace::crop`]. An empty crop is not written.
pub crop: Option<Vec<u8>>,
}
/// Write fresh embeddings over faces that were found before their quality was
/// kept, and re-mark the image as indexed.
impl FaceUpdate {
/// An update that changes nothing yet, for a handler to fill one field of.
pub fn for_face(face: FaceId) -> Self {
Self {
face,
embedding: None,
eyes: None,
crop: None,
}
}
}
/// Write what a per-face pass produced over the faces it read, and re-mark
/// the image as indexed.
///
/// The cheaper half of what `record_detections` does, for the case schema V14
/// created: the boxes and landmarks are right, the identities are the user's
/// work, and only the vector needs doing again. Updating in place is what
/// keeps `face_person` and the face ids exactly as they were -- a
/// re-detection carries identities across by matching old faces to new, and
/// a match is a judgement where an update in place is a fact.
/// The cheaper half of what `record_detections` does, for a face whose box
/// and landmarks are right and whose identity is the user's work, and which
/// lacks something a later pass can fill from the native render: its
/// quality (schema V14), its eye reading and dense landmarks (V16, V18), its
/// crop. Updating in place is what keeps `face_person` and the face ids
/// exactly as they were -- a re-detection carries identities across by
/// matching old faces to new, and a match is a judgement where an update
/// in place is a fact.
///
/// `dropped` are faces whose landmarks turned out to be degenerate -- the warp
/// could not be built from them. Deleted here, as detection would have refused
/// to store them (`dr_ui::faces::index_proxy`), and because a face left with
/// no reading would put its image back on the measuring pass's list on every
/// sweep, at the cost of an original each time.
/// `dropped` are faces whose landmarks turned out to be degenerate -- the
/// warp could not be built from them. Deleted here, as detection would have
/// refused to store them (`dr_ui::faces::index_native`), and because a face
/// that can never be filled would put its image back on the pass's list on
/// every sweep, at the cost of an original each time.
///
/// The run marker is re-written with a fresh time, and that is not
/// bookkeeping: `face_shard::export_to_shards` re-exports an image whose
/// marker is newer than the store's copy, which is how the measured vectors
/// reach the other devices.
pub fn record_measurements(
/// marker is newer than the store's copy, which is how what was written
/// here reaches the other devices.
pub fn record_updates(
conn: &Connection,
image_id: ImageId,
model_id: &str,
source_edge: u32,
measured: &[Measurement],
updates: &[FaceUpdate],
dropped: &[FaceId],
) -> Result<(), CatalogError> {
let tx = conn.unchecked_transaction()?;
for m in measured {
tx.execute(
"UPDATE faces SET embedding = ?2, quality = ?3 WHERE id = ?1",
rusqlite::params![m.face.0 as i64, m.embedding, f64::from(m.quality)],
)?;
// Written only when read: a device without the eye models measuring
// a face a peer had already read must not blank the reading.
if let Some(e) = m.eyes {
for u in updates {
if let Some((embedding, quality)) = &u.embedding {
tx.execute(
"UPDATE faces SET embedding = ?2, quality = ?3 WHERE id = ?1",
rusqlite::params![u.face.0 as i64, embedding, f64::from(*quality)],
)?;
}
if let Some((e, dense)) = &u.eyes {
tx.execute(
"UPDATE faces
SET eye_right = ?2, eye_right_px = ?3, eye_right_sharp = ?4,
@@ -561,7 +580,7 @@ pub fn record_measurements(
sunglasses = ?8, landmarks_dense = ?9
WHERE id = ?1",
rusqlite::params![
m.face.0 as i64,
u.face.0 as i64,
f64::from(e.right.open),
f64::from(e.right.px),
f64::from(e.right.sharpness),
@@ -569,10 +588,16 @@ pub fn record_measurements(
f64::from(e.left.px),
f64::from(e.left.sharpness),
f64::from(e.sunglasses),
(!m.landmarks_dense.is_empty()).then_some(m.landmarks_dense.as_slice()),
(!dense.is_empty()).then_some(dense.as_slice()),
],
)?;
}
if let Some(crop) = u.crop.as_ref().filter(|c| !c.is_empty()) {
tx.execute(
"UPDATE faces SET crop = ?2 WHERE id = ?1",
rusqlite::params![u.face.0 as i64, crop],
)?;
}
}
for f in dropped {
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
@@ -604,45 +629,48 @@ pub fn record_measurements(
Ok(())
}
/// The faces on one image that have no quality reading yet — or, when the
/// device can read eyes, no eye reading either.
/// The faces on one image that a per-face pass still owes something to.
///
/// The measuring pass's per-image work: every face this model found whose
/// vector was stored as a unit one (schema V14), with the landmarks the
/// warp is rebuilt from; and, with `eyes`, every face never shown to the eye
/// models (schema V16). `eyes` is whether this device *has* those models —
/// a device without them must not list faces it cannot measure, or the pass
/// would fetch every original in the library to do nothing to it.
pub fn unmeasured_on_image(
/// `needs` is SQL over `faces` aliased as `f` -- `f.quality IS NULL`, say --
/// and it comes from the pass, not from here: which columns a face can lack
/// is the business of the handlers that fill them (`dr_ui::faces::repairs`),
/// and the catalog's part is to answer the question exactly as asked, so
/// that the count a screen shows, the list a sweep fetches and the faces a
/// handler is given are one predicate and the pass converges.
///
/// Keyed on the embedder half of `model_id`, like every other reader: a
/// face found by another detector in front of the same embedder is one of
/// this pipeline's faces.
pub fn faces_needing(
conn: &Connection,
image_id: ImageId,
model_id: &str,
eyes: bool,
needs: &str,
) -> Result<Vec<Face>, CatalogError> {
let mut q = conn.prepare(&format!(
"SELECT f.id FROM faces f
WHERE f.image_id = ?1 AND {} = ?2 AND ({needs})",
embedder_sql("f.model_id")
))?;
let owed: std::collections::HashSet<i64> = q
.query_map(
rusqlite::params![image_id.0 as i64, embedder_of(model_id)],
|r| r.get::<_, i64>(0),
)?
.collect::<Result<_, _>>()?;
Ok(for_image(conn, image_id)?
.into_iter()
.filter(|f| {
embedder_of(&f.model_id) == embedder_of(model_id)
&& (f.quality.is_none() || (eyes && f.eyes.is_none()))
})
.filter(|f| owed.contains(&(f.id.0 as i64)))
.collect())
}
/// How many of a model's faces have no quality reading, or — with `eyes` —
/// no eye reading.
///
/// What the measuring pass has left to do, for a screen that wants to say so.
/// `eyes` means what it means in [`unmeasured_on_image`].
pub fn faces_unmeasured(
conn: &Connection,
model_id: &str,
eyes: bool,
) -> Result<u64, CatalogError> {
/// How many of a model's faces a per-face pass still owes something to.
/// `needs` is what it is in [`faces_needing`].
pub fn count_needing(conn: &Connection, model_id: &str, needs: &str) -> Result<u64, CatalogError> {
conn.query_row(
&format!(
"SELECT COUNT(*) FROM faces WHERE {} = ?1 AND {}",
embedder_sql("model_id"),
unmeasured_sql("", eyes)
"SELECT COUNT(*) FROM faces f WHERE {} = ?1 AND ({needs})",
embedder_sql("f.model_id")
),
[embedder_of(model_id)],
|r| r.get::<_, i64>(0),
@@ -1381,22 +1409,6 @@ pub(crate) fn read_eyes(
}))
}
/// The predicate "this face still needs measuring", over `faces` aliased as
/// `prefix` (`"f."` or `""`).
///
/// One place for it because two queries ask — the count above and the
/// sweep's work list in `dr_ui::library` — and the two agreeing is what
/// makes the pass converge: a face the count reports is a face the list
/// fetches, and a face the list fetches is one whose reading the write
/// fills, so it leaves both.
pub fn unmeasured_sql(prefix: &str, eyes: bool) -> String {
if eyes {
format!("({prefix}quality IS NULL OR {prefix}eye_right IS NULL)")
} else {
format!("{prefix}quality IS NULL")
}
}
fn landmarks_to_blob(lm: &[(f32, f32); 5]) -> Vec<u8> {
let mut out = Vec::with_capacity(40);
for &(x, y) in lm {
@@ -1575,10 +1587,13 @@ mod tests {
);
}
const NEEDS_QUALITY: &str = "f.quality IS NULL";
const NEEDS_EYES: &str = "f.eye_right IS NULL";
/// The seven eye columns are one fact: a face with none of them reads as
/// unread, and the measuring pass is what fills them.
/// unread, and a per-face pass asking by predicate is what fills them.
#[test]
fn eyes_are_measured_only_where_the_device_can_read_them() {
fn eyes_are_filled_only_where_a_pass_produced_a_reading() {
let c = db();
let img = image(&c, 1);
let unread = DetectedFace {
@@ -1588,67 +1603,64 @@ mod tests {
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[unread, face(2)]).unwrap();
assert_eq!(for_image(&c, img).unwrap().len(), 2);
// Quality is present on both, so a device without the eye models has
// nothing to do here; one with them has one face to read.
assert_eq!(faces_unmeasured(&c, "w600k_mbf", false).unwrap(), 0);
assert_eq!(faces_unmeasured(&c, "w600k_mbf", true).unwrap(), 1);
// Quality is present on both, so a pass that only fills quality has
// nothing to do here; one that reads eyes has one face.
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_QUALITY).unwrap(), 0);
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_EYES).unwrap(), 1);
assert_eq!(
unmeasured_on_image(&c, img, "w600k_mbf", false)
faces_needing(&c, img, "w600k_mbf", NEEDS_QUALITY)
.unwrap()
.len(),
0
);
let todo = unmeasured_on_image(&c, img, "w600k_mbf", true).unwrap();
let todo = faces_needing(&c, img, "w600k_mbf", NEEDS_EYES).unwrap();
assert_eq!(todo.len(), 1);
assert_eq!(todo[0].id, ids[0]);
// A measurement with no reading leaves the columns alone …
record_measurements(
// An update with no reading leaves the columns alone …
record_updates(
&c,
img,
"w600k_mbf",
6000,
&[Measurement {
face: ids[0],
embedding: vec![9; 1024],
quality: 21.5,
eyes: None,
landmarks_dense: Vec::new(),
&[FaceUpdate {
embedding: Some((vec![9; 1024], 21.5)),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
assert_eq!(faces_unmeasured(&c, "w600k_mbf", true).unwrap(), 1);
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_EYES).unwrap(), 1);
// … and one with a reading fills them.
record_measurements(
record_updates(
&c,
img,
"w600k_mbf",
6000,
&[Measurement {
face: ids[0],
embedding: vec![9; 1024],
quality: 21.5,
eyes: Some(EyeReading {
right: Eye {
open: 0.2,
px: 40.0,
sharpness: 0.2,
&[FaceUpdate {
eyes: Some((
EyeReading {
right: Eye {
open: 0.2,
px: 40.0,
sharpness: 0.2,
},
left: Eye {
open: 0.9,
px: 40.0,
sharpness: 0.2,
},
sunglasses: 0.0,
},
left: Eye {
open: 0.9,
px: 40.0,
sharpness: 0.2,
},
sunglasses: 0.0,
}),
landmarks_dense: vec![9; 424],
vec![9; 424],
)),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
assert_eq!(faces_unmeasured(&c, "w600k_mbf", true).unwrap(), 0);
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_EYES).unwrap(), 0);
assert_eq!(
for_image(&c, img).unwrap()[0].landmarks_dense.len(),
424,
@@ -1662,10 +1674,46 @@ mod tests {
);
}
/// The measuring pass writes over the vector and nothing else: the face
/// keeps its id, its box and whoever the user said it was.
/// A crop is filled the same way, and an empty one is not written.
#[test]
fn measuring_replaces_the_vector_and_keeps_the_identity() {
fn a_crop_is_filled_in_place() {
let c = db();
let img = image(&c, 1);
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
assert_eq!(count_needing(&c, "w600k_mbf", "f.crop IS NULL").unwrap(), 1);
record_updates(
&c,
img,
"w600k_mbf",
6000,
&[FaceUpdate {
crop: Some(Vec::new()),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
assert_eq!(count_needing(&c, "w600k_mbf", "f.crop IS NULL").unwrap(), 1);
record_updates(
&c,
img,
"w600k_mbf",
6000,
&[FaceUpdate {
crop: Some(vec![1, 2, 3]),
..FaceUpdate::for_face(ids[0])
}],
&[],
)
.unwrap();
assert_eq!(count_needing(&c, "w600k_mbf", "f.crop IS NULL").unwrap(), 0);
assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![1, 2, 3]));
}
/// An update writes over the vector and nothing else: the face keeps its
/// id, its box and whoever the user said it was.
#[test]
fn an_update_replaces_the_vector_and_keeps_the_identity() {
let c = db();
let img = image(&c, 1);
let unmeasured = DetectedFace {
@@ -1682,9 +1730,9 @@ mod tests {
.unwrap();
let person = create_person(&c, "Anna").unwrap();
confirm(&c, ids[0], person).unwrap();
assert_eq!(faces_unmeasured(&c, "w600k_mbf", false).unwrap(), 2);
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_QUALITY).unwrap(), 2);
assert_eq!(
unmeasured_on_image(&c, img, "w600k_mbf", false)
faces_needing(&c, img, "w600k_mbf", NEEDS_QUALITY)
.unwrap()
.len(),
2
@@ -1695,17 +1743,14 @@ mod tests {
c.execute("UPDATE face_index SET indexed_at = indexed_at - 100", [])
.unwrap();
record_measurements(
record_updates(
&c,
img,
"w600k_mbf",
6000,
&[Measurement {
face: ids[0],
embedding: vec![9; 1024],
quality: 21.5,
eyes: None,
landmarks_dense: Vec::new(),
&[FaceUpdate {
embedding: Some((vec![9; 1024], 21.5)),
..FaceUpdate::for_face(ids[0])
}],
&[ids[1]],
)
@@ -1719,7 +1764,7 @@ mod tests {
assert!(got[0].confirmed);
let e = embeddings(&c, "w600k_mbf").unwrap();
assert_eq!(e[0].embedding[0], 9);
assert_eq!(faces_unmeasured(&c, "w600k_mbf", false).unwrap(), 0);
assert_eq!(count_needing(&c, "w600k_mbf", NEEDS_QUALITY).unwrap(), 0);
// The marker says one face at the native edge, and is fresh — which
// is what makes the sync export it again.
+1 -1
View File
@@ -61,7 +61,7 @@ pub use collections::{Collection, CollectionKind, TreeRow};
pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash};
pub use error::CatalogError;
pub use face_shard::{FaceShardStore, SharedFace};
pub use faces::{Calibration, DetectedFace, Face, FaceId, Measurement, Person, PersonId};
pub use faces::{Calibration, DetectedFace, Face, FaceId, FaceUpdate, Person, PersonId};
pub use jobs::{Job, JobKind, Priority};
pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword};
pub use merge::MergeReport;
+10 -10
View File
@@ -701,20 +701,20 @@ const V14: &str = r#"
-- face this rule is not yet protecting anyone from, and the only way to
-- measure it is to embed it again.
--
-- The sweep's measuring pass is what does that: `dr_ui::library::
-- faces_unmeasured` lists every image holding a face with no reading, and
-- each face is embedded again from the native render with the landmarks it
-- already has, the raw vector written over the old one (`record_measurements`)
-- and nothing else touched -- not the id, not the box, not who the user said
-- it was. The faces keep drawing the People screen throughout.
-- The `face-quality` repair is what does that (`dr_ui::repairs`, once the
-- sweep's measuring pass): it lists every face with no reading, and each is
-- embedded again from the native render with the landmarks it already has,
-- the raw vector written over the old one (`record_updates`) and nothing
-- else touched -- not the id, not the box, not who the user said it was.
-- The faces keep drawing the People screen throughout.
--
-- The run markers of those images are forgotten too, exactly as V12 forgot
-- the runs made against too small a proxy. The build this shipped in had no
-- measuring pass yet, and a marker is the one thing that stops a face ever
-- being looked at again; with the pass in place `faces_unindexed` leaves
-- these images to it rather than detecting them from scratch, so the
-- deletion costs nothing -- and an image that was examined and found empty
-- keeps its marker, since there is nothing on it to measure.
-- being looked at again; with the repair in place, detection leaves an
-- image holding this embedder's faces to it rather than detecting from
-- scratch, so the deletion costs nothing -- and an image that was examined
-- and found empty keeps its marker, since there is nothing on it to measure.
--
-- The cost is a re-fetch of every image with a face on it, on the next pass
-- the user starts. That is a whole-library transfer (FR-NC-6), and it starts
+25 -4
View File
@@ -275,15 +275,24 @@ impl FaceDetector {
}
}
/// Both ids this detector writes under — the f32 form and the int8 one —
/// for a question that is about the detector and not about which form
/// of it a device happened to run: "has the chosen detector been over
/// this image", asked by a re-index that must not ping-pong between a
/// desktop that runs it in f32 and a tablet that runs it on the Hexagon.
pub fn model_ids(self) -> [&'static str; 2] {
[self.model_id(), self.model_id_int8()]
}
/// The detector that writes under a pipeline id, if it is one of these.
///
/// The inverse of [`Self::model_id`]. `None` for an id from another
/// embedder or a build this one does not know, which a caller treats as
/// "cannot rank" rather than as weaker than anything.
/// The inverse of [`Self::model_ids`] -- either spelling. `None` for an
/// id from another embedder or a build this one does not know, which a
/// caller treats as "cannot rank" rather than as weaker than anything.
pub fn for_model_id(model_id: &str) -> Option<FaceDetector> {
FaceDetector::ALL
.into_iter()
.find(|d| d.model_id() == model_id)
.find(|d| d.model_ids().contains(&model_id))
}
/// Whether this detector finds more than `other` does — the measured
@@ -1738,6 +1747,17 @@ mod tests {
assert_eq!(s.faces, FaceSettings::default());
}
#[test]
fn a_detectors_two_spellings_share_its_embedder_and_nothing_else() {
for d in FaceDetector::ALL {
let [f32_id, int8_id] = d.model_ids();
assert_eq!(f32_id, d.model_id());
assert_eq!(int8_id, d.model_id_int8());
assert_ne!(f32_id, int8_id);
assert_eq!(f32_id.rsplit('+').next(), int8_id.rsplit('+').next());
}
}
/// The detector every existing library was indexed with must keep the id
/// those libraries were written under, or an upgrade would report every
/// one of them un-indexed.
@@ -1757,6 +1777,7 @@ mod tests {
);
}
assert_eq!(FaceDetector::for_model_id(d.model_id()), Some(d));
assert_eq!(FaceDetector::for_model_id(d.model_id_int8()), Some(d));
}
assert_eq!(FaceDetector::for_model_id("scrfd_10g+other"), None);
}
+111 -14
View File
@@ -816,10 +816,10 @@ here, and it is the phone and tablet story that should decide whether it gets bu
anything downstream sees them. A probe's confidence (§9.1) is computed from the references it
matched; a reference's confidence hears nothing from a probe. A face whose quality was never
recorded is admitted to the gallery — a rule that cannot be checked admits rather than excludes —
and the next indexing pass **measures** it: `faces_unmeasured` lists every image holding one, and
each such face is embedded again from the native render with the landmarks it already has, the raw
vector written over the old one and its id, box and identity untouched
(`faces::record_measurements`). No detector runs and no suggestion is lost — the cost is the
and the next indexing pass **measures** it: the `face-quality` repair (§18.1) lists every image
holding one, and each such face is embedded again from the native render with the landmarks it
already has, the raw vector written over the old one and its id, box and identity untouched
(`faces::record_updates`). No detector runs and no suggestion is lost — the cost is the
original fetched once more, since the length exists only at the moment of embedding.
**The algorithm.** Constrained average-link agglomeration over the probability graph, merging while
@@ -1119,8 +1119,8 @@ variant; its vectors are one space, and the detector only decides where the boxe
So every reader now keys on the embedder half of the id (`faces::embedder_of`, and
`embedder_sql` for the queries): all three detectors are one population, and changing between
them empties nothing. `record_detections` is unchanged — an image holds one pipeline's faces at a
time, and a re-detection carries confirmations across by box overlap — and it is where the
generations meet. The merge's `match_faces` matches within an embedder for the same reason. The
time, and a re-detection carries identities across by box overlap and embedding (§18) — and it is
where the generations meet. The merge's `match_faces` matches within an embedder for the same reason. The
shards travel every generation, each under its own id, and a peer adopts whichever it is sent.
What a stronger choice still does is queue the images a weaker detector indexed for re-detection
(`FaceDetector::supersedes`), after the ones nothing has indexed and never downwards, so a tablet
@@ -1257,9 +1257,9 @@ frames to family snapshots as the real unknowns.
| ID | How this document addresses it |
|---|---|
| FR-CULL-8 | §4 detection, §6 embedding, §7 the proxy tier and its consequences, §10 the `DetectFaces` job |
| FR-CULL-8 | §4 detection, §6 embedding, §7 the proxy tier and its consequences, §10 the `DetectFaces` job, §18 the re-index |
| FR-CULL-9 | §8 — pairs, fit, validity, and the reliability-diagram acceptance test |
| FR-CULL-10 | §9 constrained agglomeration, confirmations as anchors, split by re-agglomeration |
| FR-CULL-10 | §9 constrained agglomeration, confirmations as anchors, split by re-agglomeration; §18 what a re-index carries across |
| FR-CULL-11 | §10 the `Person` selector term, confirmed-only by default |
| FR-CULL-12 | §10 schema unchanged from catalog.md §10.1: embeddings derived, names to the sidecar |
| NFR-SEC-5 | §11 — the obligations restated as structural properties, one of them CI-checkable |
@@ -1447,12 +1447,12 @@ been the same size and worse: three figures near 1.0 is six pixels at that scale
### 17.5 The measuring pass, and shards
A face indexed before the models existed, or on a device without them, has no reading. The sweep's
measuring pass — the one V14 built to re-embed faces stored as unit vectors — lists those faces
too, on a device that has the models, and reads their eyes from the same native render with the
box and landmarks already stored (`dr_ui::faces::measure_native`). No detector runs and no identity
moves. A device *without* the models does not list them, or it would fetch every original in the
library to do nothing to it; `dr_catalog::faces::unmeasured_sql` is the one predicate both the
A face indexed before the models existed, or on a device without them, has no reading. The
`face-eyes` repair (§18.1; on the day this was written, the sweep's measuring pass — the one V14
built to re-embed faces stored as unit vectors) lists those faces, on a device that has the models,
and reads their eyes from the same native render with the box and landmarks already stored. No
detector runs and no identity moves. A device *without* the models has no such repair, or it would
fetch every original in the library to do nothing to it; the repair's predicate is the one both the
count and the work list use, so the pass converges. The People screen's coverage line counts
these faces as work to read and keeps the button while any remain — reading **Read eye state**
once detection is complete and only readings are left, which is the state an already-indexed
@@ -1470,3 +1470,100 @@ the only device that has done the detection at all.
| **M11** | Open-eye recall and blink precision from the **native** pass with the shipped configuration, on a labelled sample that contains real blinks — a burst with one in it is enough | §17.3's figures are proxy figures from 25 faces, with no precision beside them; this is the number FR-CULL-13's acceptance clause asks for, and where the two readability floors get set on more than 25 faces |
| **M12** | Whether the remaining open-eye failure — a lens reflection over an open eye — moves with the sharpness floor, or needs the eye classifier told about spectacles | If the latter, the fix is a classifier trained closer to this domain, and that is a different decision |
| **M13** | Sunglasses recall on more than twelve faces, and the false-positive rate on caps and clear glasses | The two sunglasses the head classifier missed on the sample became false blinks; a library of skiers would say whether that is two faces or a class |
---
## 18. The completeness job, and what a re-index carries across · 2026-09-19
The reference library on the day this was written: 17,762 faces under the bare `w600k_mbf` id —
found by the fast detector on 1024 px proxies, stored as unit vectors, no quality, no crop on 4,144
of them, no eye reading, no dense landmarks — beside 1,177 under `scrfd_10g+w600k_mbf` from the
native pass, and 12,217 images the fast detector examined and found nothing in. Over those faces:
3,778 confirmations, 13,011 suggestions, 77 rejections, and 17,276 people rows. Every one of those
gaps was, until now, its own pass: V14's measuring pass for the quality, §17.5's for the eyes, the
sweep's proxy repair, the sweep's detector upgrade, and a re-index that did not exist. Adding a
per-face field meant adding a pass, with its own work list, its own count and its own idea of done.
### 18.1 One job over a registry
`dr_ui::repairs` replaces them with one job over a **registry**. A `Repair` names one thing a catalog
record can lack — the predicate that says which images still owe it, the input its handler needs
(the file's header, the whole original, or a native render), the handler that fills it, and, where
there is one, 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 one handler's write satisfies the next's (a detection writes every field a per-face handler
would fill). The registry today:
| Repair | Owed by | Input | Handler |
|---|---|---|---|
| `face-proxy` (sweep only) | images holding faces whose 1024 px proxy is not in the store | native render | detect again, write the proxy |
| `face-quality` | faces with `quality IS NULL` | native render | warp from the stored landmarks, embed, write the raw vector and its length (reads eyes on the same warp where it can) |
| `face-eyes` | faces with no eye reading or no dense landmarks, on a device with the eye models | native render | read the eyes and dense landmarks from the stored box and landmarks |
| `face-crop` | faces with `crop IS NULL` | native render | cut the crop from the frame |
| `face-detection` | sweep: images with no marker under the embedder and no faces; re-index: images with no marker under the **chosen detector**, either spelling | native render | detect, embed, replace the faces, carry identities across (§18.2) |
| `face-upgrade` (sweep only) | images whose marker is a weaker detector's | native render | as `face-detection` |
| `metadata` | `images.metadata_state < 2` | header | EXIF to the catalog, the dateless marked examined |
A repair's predicate is the *only* definition of its work. The count the settings page shows
(`faces::audit`, per repair), the list the job fetches and the check before each handler are one
predicate, so a record the count reports is one the job fetches and one the handler fills, and the
job ends. This is why an eye reading that cannot be cut is not a criterion — such a face stays
unread however often it is detected, and listing it would fetch its original on every press — and
why a degenerate face is dropped rather than left. It is also why the registry is cut to what the
device can do (`Capabilities`): a device without the eye models has no `face-eyes` entry, rather
than an entry it skips, because an entry is a count and a set of originals to fetch.
The registry is ordered, and the order is the work list's: an image only the last repair claims
comes after one the first does, which is what puts a few hundred proxy repairs ahead of twenty
thousand un-indexed images. Within one image the same order runs the handlers, detection before the
per-face repairs, since detection fills what they would.
Adding a field is one entry: a predicate over `faces f` or `images i`, and a handler that fills it
from `Fetched`. `metadata` is in the table to say that this is not a face job — the same machinery
carries a capture date, and could carry a thumbnail, a perceptual hash or a head pose.
### 18.1a The two scopes
Both buttons on the settings page run the job; they differ in one predicate. **Index faces**
(`Scope::Outstanding`) converges on *coverage* — has anything examined this image — and treats a
face a weaker detector found on a proxy as found, which is the right question for a pass that must
not fetch the library twice. **Re-index every face** (`Scope::Reindex`) 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 running it in f32 and a tablet on the Hexagon in int8
do not re-index each other's work), and a marker saying a weaker one looked is not that. This is
the one place in the subsystem keyed on the exact detector rather than the embedder. Convergent all
the same: an image the job has been through leaves the list, a kill costs the images in flight, and
a second press resumes.
An original over the fetch budget is skipped without being fetched. Under the sweep, detection
records an examination that found nothing — the honest record for an image that cannot be
examined, and what stops the half gigabyte being spent once per sweep. Under the re-index, and
under every repair over records that already exist, it is left exactly as it was: a re-detection
with nothing found would delete the faces, and "cannot fetch" is not "no faces".
### 18.2 What is carried across
`dr_catalog::faces::record_detections` replaces an image's faces and carries identities onto the
new ones. Before this section it carried confirmations only, by box overlap above 0.5 IoU, and a
re-detection of the library above would have left 13,011 suggestions and 77 rejections on the
floor — correct by FR-CULL-12's letter, since suggestions are derived data, and a People screen
emptied to strangers by the user's own button.
Now every old face is read before the delete — box, vector, assignment, rejections — and matched to
the new faces one-to-one, best pair first. A pair qualifies when the boxes **overlap at all** and
either the overlap alone says so (IoU above 0.5, the old rule) or the embeddings do (cosine above
`SAME_FACE_COSINE` = 0.45, the reference library's P≈0.95 line from §9's table). The embedding route
is for the box a low-resolution pass drew badly enough that overlap alone would not claim it; the
vector is also what breaks the tie in a group photograph, where two neighbouring faces overlap both
new boxes. Overlap is required on both routes because the same vector elsewhere in the frame — a
mirror, a print on the wall — is not the same face and must not take its name. Onto the matched
face go the assignment as it was, confirmed or suggested with its probability, and every
rejection.
It is a match, not an update in place, and that is why the per-face repairs exist beside
detection: where nothing about a face but one field needs doing, `record_updates` keeps the id and
there is nothing to judge.
The merge's `match_faces` still matches by overlap alone across devices. It is the same question,
and the same answer would serve it; it is not changed here.
+13 -13
View File
File diff suppressed because one or more lines are too long
+14 -2
View File
@@ -60,7 +60,19 @@ fn main() {
}
};
let audit = match faces::audit(&catalog, &store, MODEL_ID, false) {
// The registry a device without the eye models would run: what this
// example counts as work is what the app would.
let repairs = dr_ui::repairs::registry(
dr_ui::repairs::Scope::Outstanding,
MODEL_ID,
dr_types::FaceDetector::Scrfd500m,
dr_ui::repairs::Capabilities {
gpu: true,
face_models: true,
eye_models: false,
},
);
let audit = match faces::audit(&catalog, &store, MODEL_ID, &repairs) {
Ok(a) => a,
Err(e) => {
eprintln!("coverage check failed: {e}");
@@ -227,7 +239,7 @@ fn main() {
}
}
if let Ok(a) = faces::audit(&catalog, &store, MODEL_ID, false) {
if let Ok(a) = faces::audit(&catalog, &store, MODEL_ID, &repairs) {
println!("{}", a.summary());
}
println!("\nrun again with --cluster to group these faces into people.");
+1 -1
View File
@@ -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
View File
@@ -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
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));
}
{
+1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -448,6 +448,7 @@ export component AppWindow inherits Window {
callback identity-ignore-person(int, bool);
callback identity-show-photos(int, bool);
callback identity-index();
callback identity-reindex();
callback identity-stop-indexing();
callback identity-check-coverage();
callback identity-delete-all();
@@ -1440,6 +1441,7 @@ in property <bool> panel-visible: true;
library-open: root.library-open;
thumbnail-library() => { root.library-thumbnail-all(); }
index-faces() => { root.identity-index(); }
reindex-faces() => { root.identity-reindex(); }
// TRACES: NFR-OPS-1
diagnostics-preview: root.diagnostics-preview;
+49
View File
@@ -91,6 +91,12 @@ export component SettingsPage inherits Rectangle {
/// No model on disk, so the pass cannot run at all.
in property <bool> face-model-missing: false;
callback index-faces();
/// TRACES: FR-CULL-8 | FR-CULL-10
/// The re-index: every image the chosen detector has not been over at
/// native resolution, detected again with names carried across. The
/// same running state as the pass above — one job, two ways to ask for
/// it — so both buttons go quiet together.
callback reindex-faces();
/// TRACES: FR-CULL-8
/// Which detector the pass finds faces with — docs/faces.md §12.3 for
/// what each costs and finds. The choice is a model change: coverage is
@@ -520,6 +526,49 @@ export component SettingsPage inherits Rectangle {
}
}
// TRACES: FR-CULL-8 | FR-CULL-10
// The re-index, under the pass it is the heavier
// form of. Both run the completeness job
// (dr_ui::repairs) and differ in one predicate:
// indexing converges on coverage and leaves a face a
// weaker detector found on a small proxy as found;
// this one detects every such image again so every
// box, landmark, crop and vector is the current
// detector's from the native render, with names,
// suggestions and rejections carried onto the new
// faces. It fetches every original it visits, which
// is why it says so and never starts on its own.
if root.library-open: Rectangle {
height: Theme.gap-sm;
}
if root.library-open: Caption {
text: "Re-indexing detects every face again with "
+ "the chosen detector at full resolution, "
+ "on every photograph it has not yet been "
+ "over — including those an earlier, "
+ "faster pass looked at — and fills in "
+ "whatever else a record is missing on the "
+ "way. Names, suggestions and rejections "
+ "are carried onto the new faces. It "
+ "fetches every original it visits, and "
+ "it can be stopped and resumed.";
wrap: word-wrap;
}
if root.library-open: Rectangle {
height: Theme.control-height;
Button {
x: 0;
text: root.face-indexing
? "Indexing faces…"
: "Re-index every face";
enabled: !root.face-indexing && !root.face-model-missing;
clicked => { root.reindex-faces(); }
}
}
if root.activity-kept > 0: Rectangle {
height: Theme.control-height;