Measure the faces already found rather than finding them again
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m13s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 54s
Build and test / Layer separation (push) Failing after 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 52s
Build and test / Android (aarch64) (push) Successful in 30m6s

Every face stored before its quality was kept holds a unit vector, and
V14 forgot the run marker of each image holding one so that the next
sweep would look again. Looking again meant detecting again: a whole
re-detection per image, with every suggestion on it thrown away and the
confirmations carried across by box overlap, to recover one number.

The sweep now has a measuring pass between the proxy repair and the
un-indexed images. It lists every image holding an unmeasured face,
fetches the original once, warps each stored face from the landmarks it
already has, embeds it, and writes the raw vector and its length over
the old row. Ids, boxes and identities are untouched; the marker is
re-written fresh so the sync exports the measured vectors. A face whose
landmarks no longer make a warp is dropped, as detection would have
refused to store it. `faces_unindexed` leaves those images to the
measuring pass, so the V14 deletion no longer costs a second detection.
This commit is contained in:
2026-09-11 21:50:12 +02:00
parent 8b3abdb787
commit 16f3fb41a3
7 changed files with 578 additions and 48 deletions
+166
View File
@@ -267,6 +267,107 @@ pub fn record_detections(
Ok(ids)
}
/// A face embedded again from its stored landmarks: the new vector and its
/// length. What the measuring pass hands back per face.
#[derive(Debug, Clone, PartialEq)]
pub struct Measurement {
pub face: FaceId,
/// 512 × f16, raw — `dr_face::Embedded::to_f16_bytes`.
pub embedding: Vec<u8>,
pub quality: f32,
}
/// Write fresh embeddings over faces that were found before their quality was
/// kept, 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 would carry confirmations across by box overlap and lose
/// every suggestion, for no gain.
///
/// `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.
///
/// 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(
conn: &Connection,
image_id: ImageId,
model_id: &str,
source_edge: u32,
measured: &[Measurement],
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)],
)?;
}
for f in dropped {
tx.execute("DELETE FROM faces WHERE id = ?1", [f.0 as i64])?;
}
let remaining: i64 = tx.query_row(
"SELECT COUNT(*) FROM faces WHERE image_id = ?1 AND model_id = ?2",
rusqlite::params![image_id.0 as i64, model_id],
|r| r.get(0),
)?;
tx.execute(
"INSERT INTO face_index (image_id, model_id, indexed_at, faces_found, source_edge)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(image_id, model_id) DO UPDATE SET
indexed_at = excluded.indexed_at,
faces_found = excluded.faces_found,
source_edge = excluded.source_edge",
rusqlite::params![
image_id.0 as i64,
model_id,
now_secs(),
remaining,
source_edge as i64,
],
)?;
tx.commit()?;
Ok(())
}
/// The faces on one image that have no quality reading yet.
///
/// 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.
pub fn unmeasured_on_image(
conn: &Connection,
image_id: ImageId,
model_id: &str,
) -> Result<Vec<Face>, CatalogError> {
Ok(for_image(conn, image_id)?
.into_iter()
.filter(|f| f.model_id == model_id && f.quality.is_none())
.collect())
}
/// How many of a model's faces have no quality reading.
///
/// What the measuring pass has left to do, for a screen that wants to say so.
pub fn faces_unmeasured(conn: &Connection, model_id: &str) -> Result<u64, CatalogError> {
conn.query_row(
"SELECT COUNT(*) FROM faces WHERE model_id = ?1 AND quality IS NULL",
[model_id],
|r| r.get::<_, i64>(0),
)
.map(|n| n as u64)
.map_err(Into::into)
}
/// How much of the library has been through face detection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Coverage {
@@ -1080,6 +1181,71 @@ mod tests {
assert_eq!(stored[1].quality, Some(12.0));
}
/// The measuring pass writes over the vector and nothing else: the face
/// keeps its id, its box and whoever the user said it was.
#[test]
fn measuring_replaces_the_vector_and_keeps_the_identity() {
let c = db();
let img = image(&c, 1);
let unmeasured = DetectedFace {
quality: None,
..face(1)
};
let ids = record_detections(
&c,
img,
"w600k_mbf",
1024,
&[unmeasured.clone(), unmeasured],
)
.unwrap();
let person = create_person(&c, "Anna").unwrap();
confirm(&c, ids[0], person).unwrap();
assert_eq!(faces_unmeasured(&c, "w600k_mbf").unwrap(), 2);
assert_eq!(unmeasured_on_image(&c, img, "w600k_mbf").unwrap().len(), 2);
let marked_at: i64 = c
.query_row("SELECT indexed_at FROM face_index", [], |r| r.get(0))
.unwrap();
c.execute("UPDATE face_index SET indexed_at = indexed_at - 100", [])
.unwrap();
record_measurements(
&c,
img,
"w600k_mbf",
6000,
&[Measurement {
face: ids[0],
embedding: vec![9; 1024],
quality: 21.5,
}],
&[ids[1]],
)
.unwrap();
let got = for_image(&c, img).unwrap();
assert_eq!(got.len(), 1, "the degenerate face was kept");
assert_eq!(got[0].id, ids[0]);
assert_eq!(got[0].quality, Some(21.5));
assert_eq!(got[0].person, Some(person));
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").unwrap(), 0);
// The marker says one face at the native edge, and is fresh — which
// is what makes the sync export it again.
let (found, edge, at): (i64, i64, i64) = c
.query_row(
"SELECT faces_found, source_edge, indexed_at FROM face_index",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!((found, edge), (1, 6000));
assert!(at >= marked_at, "the marker was not refreshed");
}
/// Re-detection is coalesced per image, so it must replace rather than
/// append — otherwise every re-index doubles the library's face count.
#[test]
+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, Person, PersonId};
pub use faces::{Calibration, DetectedFace, Face, FaceId, Measurement, Person, PersonId};
pub use jobs::{Job, JobKind, Priority};
pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword};
pub use merge::MergeReport;
+14 -8
View File
@@ -607,14 +607,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.
--
-- So the run markers of every image that holds a face are forgotten, exactly
-- as V12 forgot the runs made against too small a proxy, and for the same
-- reason: the work list is "images with no marker", and an image examined
-- without this number is otherwise indistinguishable from one examined with
-- it. The faces stay where they are and keep drawing the People screen until
-- the next pass replaces them, `record_detections` carries the user's
-- confirmations across by box overlap, and an image that was examined and
-- found empty keeps its marker -- there is nothing on it to re-measure.
-- 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 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.
--
-- 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