Record that face detection has run, not just what it found

An image with no faces in it was indistinguishable from one that had
never been looked at, so every landscape, still life and document scan in
the library was re-detected on every pass, for ever. In a real library
that is most of it: on the 23,527-image test library, 64 of the first 110
images indexed contain no face at all.

Schema v9 adds face_index, a run marker per (image, model) carrying the
face count and the proxy edge it read. Keyed on the model, so a model
change puts every image back in the queue by itself.

That makes a coverage figure possible, which is the thing a user actually
wants to see. The audit also splits the outstanding set by whether a
proxy exists, because 23,417 awaiting a proxy and 110 ready to index are
different problems, and telling the user to run indexing again would not
fix the first.

The Identity screen gains Index faces, Stop, and the coverage line.
examples/face_index.rs is the same check and sweep without a window,
which is the right shape for an overnight pass.

Measured on the real library in release: 3.5 images/second, 110 images
and 125 faces in 30 seconds, and a second run correctly finds nothing
left to do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 22:13:41 +02:00
co-authored by Claude Opus 5
parent 3e607222c6
commit 26a1eb7e28
14 changed files with 971 additions and 84 deletions
+147 -6
View File
@@ -59,11 +59,17 @@ pub struct FaceRequest {
pub file_id: u64,
}
/// Images that have a usable proxy and no faces recorded for this model.
/// Images that have a usable proxy and have not been through this model.
///
/// Asks `face_index` — the *run* marker — rather than asking whether the image
/// has any faces. Those are different questions, and confusing them is the
/// difference between a pass that converges and one that does not: a
/// photograph with no face in it would otherwise look identical to one never
/// examined, so every landscape in the library would be re-detected on every
/// run, for ever. See the V9 migration.
///
/// Keyed on the model, so a model upgrade re-indexes rather than leaving the
/// library half-described by weights that are no longer comparable — the
/// failure `faces.model_id` exists to make detectable.
/// library half-described by weights that are no longer comparable.
pub fn faces_outstanding(
catalog: &Catalog,
store: &ThumbStore,
@@ -76,8 +82,8 @@ pub fn faces_outstanding(
WHERE r.file_id IS NOT NULL
AND i.trashed_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM faces f
WHERE f.image_id = i.id AND f.model_id = ?1
SELECT 1 FROM face_index fi
WHERE fi.image_id = i.id AND fi.model_id = ?1
)
ORDER BY i.id",
)?;
@@ -98,6 +104,93 @@ pub fn faces_outstanding(
Ok(rows)
}
/// What a coverage check found.
///
/// 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)]
pub struct IndexAudit {
pub coverage: faces::Coverage,
/// Outstanding and ready: the proxy exists, so a sweep would do these now.
pub ready: u64,
/// Outstanding and blocked: no proxy yet, so the thumbnail sweep has to run
/// first. Reported separately because it is not a face-indexing problem and
/// telling the user to "run indexing again" would not fix it.
pub awaiting_proxy: u64,
}
impl IndexAudit {
/// One line, for a log or a status strip.
pub fn summary(&self) -> String {
let c = &self.coverage;
if c.images == 0 {
return "no images in the library".into();
}
let mut s = format!(
"{}/{} images indexed ({:.0}%), {} face(s), {} image(s) with none",
c.indexed,
c.images,
c.fraction() * 100.0,
c.faces,
c.without_faces,
);
if self.ready > 0 {
s.push_str(&format!("; {} ready to index", self.ready));
}
if self.awaiting_proxy > 0 {
s.push_str(&format!("; {} awaiting a proxy", self.awaiting_proxy));
}
s
}
}
/// Check every library image for a face-detection run marker.
///
/// The batch pass that answers "has face recognition been over all of this",
/// and the one to run before deciding whether to start a sweep. Cheap: two
/// counts and one indexed scan, no decoding and no inference.
pub fn audit(
catalog: &Catalog,
store: &ThumbStore,
model_id: &str,
) -> Result<IndexAudit, dr_catalog::CatalogError> {
let conn = catalog.connection();
let coverage = faces::coverage(conn, model_id)?;
// Split the outstanding set by whether a proxy exists. This is the query
// `faces_outstanding` runs without the store filter, so the two cannot
// disagree about what is outstanding.
let mut stmt = conn.prepare(
"SELECT r.file_id
FROM images i
JOIN remote r ON r.image_id = i.id
WHERE r.file_id IS NOT NULL
AND i.trashed_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM face_index fi
WHERE fi.image_id = i.id AND fi.model_id = ?1
)",
)?;
let (mut ready, mut awaiting) = (0u64, 0u64);
for file_id in stmt
.query_map([model_id], |r| r.get::<_, i64>(0))?
.filter_map(Result::ok)
{
if store.contains(file_id as u64, FACE_TIER) {
ready += 1;
} else {
awaiting += 1;
}
}
Ok(IndexAudit {
coverage,
ready,
awaiting_proxy: awaiting,
})
}
/// Detect and embed every face in one decoded proxy.
///
/// Coordinates come back **normalised to the long edge**, which is what the
@@ -284,7 +377,13 @@ pub fn spawn_face_sweep(
}
};
if let Err(e) = faces::record_detections(catalog.connection(), req.image_id, &faces) {
if let Err(e) = faces::record_detections(
catalog.connection(),
req.image_id,
&model_id,
w.max(h),
&faces,
) {
log::warn!("face sweep: storing faces for {:?}: {e}", req.image_id);
failed += 1;
continue;
@@ -537,6 +636,48 @@ fn rgba_to_rgb_f32(rgba: &[u8]) -> Vec<f32> {
mod tests {
use super::*;
#[test]
fn an_audit_summary_names_both_kinds_of_outstanding() {
let a = IndexAudit {
coverage: faces::Coverage {
images: 100,
indexed: 60,
without_faces: 40,
faces: 35,
},
ready: 30,
awaiting_proxy: 10,
};
let s = a.summary();
assert!(s.contains("60/100"), "{s}");
assert!(s.contains("60%"), "{s}");
assert!(s.contains("30 ready"), "{s}");
assert!(s.contains("10 awaiting"), "{s}");
}
#[test]
fn a_complete_audit_mentions_neither() {
let a = IndexAudit {
coverage: faces::Coverage {
images: 10,
indexed: 10,
without_faces: 7,
faces: 4,
},
ready: 0,
awaiting_proxy: 0,
};
let s = a.summary();
assert!(!s.contains("ready"), "{s}");
assert!(!s.contains("awaiting"), "{s}");
assert!(s.contains("10/10"), "{s}");
}
#[test]
fn an_empty_library_says_so_rather_than_reporting_zero_of_zero() {
assert_eq!(IndexAudit::default().summary(), "no images in the library");
}
#[test]
fn landmarks_normalise_against_the_long_edge() {
let lm = [(512.0, 256.0), (0.0, 0.0), (1024.0, 512.0), (10.0, 20.0), (5.0, 5.0)];