Keep each face's quality, and never compare against a poor one
The embedder's raw output has a length, and the length is a reading of how recognisable the crop was: a blur, an occlusion or a hard profile comes out short. Normalising threw it away. A short vector sits near the middle of the sphere and matches a little of everyone, which is how one bad crop bridges two people in a grouping pass. So the length is kept — the store now holds the raw vector, re-normalised on load, with the length beside it as `faces.quality` — and a face under MIN_GALLERY_QUALITY (14) is a probe: measured against the gallery and placed where it fits, but never what another face is measured against. Two probes are never paired, and a probe is nobody's evidence for a confidence. The People screen shows the number as "Quality 17.3", dimmed below the floor. Faces indexed before this stored unit vectors and have no reading; they are admitted to the gallery, and schema V14 forgets the run marker of every image holding one so the next indexing pass measures them. A peer's unmeasured shard faces are not adopted, or a sync would write that marker back.
This commit is contained in:
@@ -61,10 +61,22 @@ pub struct DetectedFace {
|
||||
/// Five `(x, y)` pairs, normalised the same way.
|
||||
pub landmarks: [(f32, f32); 5],
|
||||
pub confidence: f32,
|
||||
/// 512 × f16, L2-normalised — `dr_face::Embedding::to_f16_bytes`.
|
||||
/// 512 × f16, the raw model output — `dr_face::Embedded::to_f16_bytes`.
|
||||
///
|
||||
/// Raw rather than unit length, so the length ([`Self::quality`]) is in
|
||||
/// the blob and not only beside it. Readers re-normalise on load.
|
||||
pub embedding: Vec<u8>,
|
||||
/// Source pixels across the aligned crop (docs/faces.md §7).
|
||||
pub crop_px: f32,
|
||||
/// Length of the raw embedding before normalisation — the model's own
|
||||
/// reading of how recognisable the crop was, and the gate on whether
|
||||
/// this face may be compared *against* (`dr_face::MIN_GALLERY_QUALITY`).
|
||||
///
|
||||
/// `None` where it was never measured: a face indexed, here or by a peer,
|
||||
/// before raw vectors were stored. The unit vector those builds kept has
|
||||
/// no length left to read, so the only way to measure one is to embed it
|
||||
/// again (schema V14).
|
||||
pub quality: Option<f32>,
|
||||
/// Which model produced the embedding. Comparing across models is the one
|
||||
/// mistake that yields plausible garbage rather than an error.
|
||||
pub model_id: String,
|
||||
@@ -94,6 +106,9 @@ pub struct Face {
|
||||
pub landmarks: [(f32, f32); 5],
|
||||
pub confidence: f32,
|
||||
pub crop_px: f32,
|
||||
/// See [`DetectedFace::quality`]. `None` for a face indexed before it was
|
||||
/// recorded.
|
||||
pub quality: Option<f32>,
|
||||
pub model_id: String,
|
||||
/// `None` when the face belongs to no one yet.
|
||||
pub person: Option<PersonId>,
|
||||
@@ -184,8 +199,8 @@ pub fn record_detections(
|
||||
tx.execute(
|
||||
"INSERT INTO faces
|
||||
(image_id, x, y, w, h, landmarks, detector_confidence,
|
||||
embedding, crop_px, model_id, detected_at, crop)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
embedding, crop_px, model_id, detected_at, crop, quality)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
rusqlite::params![
|
||||
image_id.0 as i64,
|
||||
f.x as f64,
|
||||
@@ -202,6 +217,7 @@ pub fn record_detections(
|
||||
// the database rather than two the readers each have to know
|
||||
// about.
|
||||
(!f.crop.is_empty()).then_some(f.crop.as_slice()),
|
||||
f.quality.map(f64::from),
|
||||
],
|
||||
)?;
|
||||
let id = FaceId(tx.last_insert_rowid() as u64);
|
||||
@@ -366,7 +382,7 @@ pub fn for_image(conn: &Connection, image_id: ImageId) -> Result<Vec<Face>, Cata
|
||||
let mut q = conn.prepare(
|
||||
"SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks,
|
||||
f.detector_confidence, f.crop_px, f.model_id,
|
||||
fp.person_id, fp.probability, fp.confirmed
|
||||
fp.person_id, fp.probability, fp.confirmed, f.quality
|
||||
FROM faces f
|
||||
LEFT JOIN face_person fp ON fp.face_id = f.id
|
||||
WHERE f.image_id = ?1
|
||||
@@ -393,9 +409,18 @@ pub fn unassigned(conn: &Connection, model_id: &str) -> Result<Vec<FaceId>, Cata
|
||||
|
||||
/// One face's stored embedding, as the clustering pass consumes it.
|
||||
///
|
||||
/// A named type rather than a tuple because it crosses a crate boundary and
|
||||
/// "the third element" is not a thing anyone should have to remember.
|
||||
pub type StoredEmbedding = (FaceId, ImageId, Vec<u8>, f32);
|
||||
/// A struct rather than a tuple because it crosses a crate boundary and "the
|
||||
/// fourth element" is not a thing anyone should have to remember.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct StoredEmbedding {
|
||||
pub face: FaceId,
|
||||
pub image: ImageId,
|
||||
/// 512 × f16 — `dr_face::Embedding::from_f16_bytes` reads it.
|
||||
pub embedding: Vec<u8>,
|
||||
pub crop_px: f32,
|
||||
/// See [`DetectedFace::quality`].
|
||||
pub quality: Option<f32>,
|
||||
}
|
||||
|
||||
/// Embeddings for clustering, oldest first so the pass is deterministic.
|
||||
///
|
||||
@@ -404,16 +429,17 @@ pub type StoredEmbedding = (FaceId, ImageId, Vec<u8>, f32);
|
||||
/// would double the memory of the one operation that holds them all at once.
|
||||
pub fn embeddings(conn: &Connection, model_id: &str) -> Result<Vec<StoredEmbedding>, CatalogError> {
|
||||
let mut q = conn.prepare(
|
||||
"SELECT id, image_id, embedding, crop_px FROM faces
|
||||
"SELECT id, image_id, embedding, crop_px, quality FROM faces
|
||||
WHERE model_id = ?1 ORDER BY id",
|
||||
)?;
|
||||
let rows = q.query_map([model_id], |r| {
|
||||
Ok((
|
||||
FaceId(r.get::<_, i64>(0)? as u64),
|
||||
ImageId(r.get::<_, i64>(1)? as u64),
|
||||
r.get::<_, Vec<u8>>(2)?,
|
||||
r.get::<_, f64>(3)? as f32,
|
||||
))
|
||||
Ok(StoredEmbedding {
|
||||
face: FaceId(r.get::<_, i64>(0)? as u64),
|
||||
image: ImageId(r.get::<_, i64>(1)? as u64),
|
||||
embedding: r.get::<_, Vec<u8>>(2)?,
|
||||
crop_px: r.get::<_, f64>(3)? as f32,
|
||||
quality: r.get::<_, Option<f64>>(4)?.map(|q| q as f32),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<Result<_, _>>().map_err(Into::into)
|
||||
}
|
||||
@@ -643,7 +669,7 @@ pub fn for_person(
|
||||
let mut q = conn.prepare(
|
||||
"SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks,
|
||||
f.detector_confidence, f.crop_px, f.model_id,
|
||||
fp.person_id, fp.probability, fp.confirmed
|
||||
fp.person_id, fp.probability, fp.confirmed, f.quality
|
||||
FROM faces f
|
||||
JOIN face_person fp ON fp.face_id = f.id
|
||||
WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1)
|
||||
@@ -894,6 +920,7 @@ fn read_face(r: &rusqlite::Row<'_>) -> rusqlite::Result<Face> {
|
||||
landmarks: blob_to_landmarks(&r.get::<_, Vec<u8>>(6)?),
|
||||
confidence: r.get::<_, f64>(7)? as f32,
|
||||
crop_px: r.get::<_, f64>(8)? as f32,
|
||||
quality: r.get::<_, Option<f64>>(13)?.map(|q| q as f32),
|
||||
model_id: r.get(9)?,
|
||||
person: person.map(|p| PersonId(p as u64)),
|
||||
probability: r.get::<_, Option<f64>>(11)?.unwrap_or(0.0) as f32,
|
||||
@@ -1016,6 +1043,7 @@ mod tests {
|
||||
confidence: 0.9,
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(10.0 + f32::from(seed)),
|
||||
model_id: "w600k_mbf".into(),
|
||||
crop: Vec::new(),
|
||||
}
|
||||
@@ -1041,6 +1069,15 @@ mod tests {
|
||||
assert!((got[0].crop_px - 180.0).abs() < 1e-3);
|
||||
assert!((got[0].landmarks[2].1 - 0.2).abs() < 1e-5);
|
||||
assert!(got[0].person.is_none());
|
||||
// Both readers carry the quality, and the one for the grouping pass
|
||||
// carries it as the option it is.
|
||||
let mut qualities: Vec<Option<f32>> = got.iter().map(|f| f.quality).collect();
|
||||
qualities.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
assert_eq!(qualities, vec![Some(11.0), Some(12.0)]);
|
||||
let stored = embeddings(&c, "w600k_mbf").unwrap();
|
||||
assert_eq!(stored.len(), 2);
|
||||
assert_eq!(stored[0].quality, Some(11.0), "oldest first");
|
||||
assert_eq!(stored[1].quality, Some(12.0));
|
||||
}
|
||||
|
||||
/// Re-detection is coalesced per image, so it must replace rather than
|
||||
@@ -1450,10 +1487,11 @@ mod tests {
|
||||
record_detections(&c, img, "w600k_mbf", 1024, &[face(7)]).unwrap();
|
||||
let e = embeddings(&c, "w600k_mbf").unwrap();
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].1, img);
|
||||
assert_eq!(e[0].2.len(), 1024);
|
||||
assert_eq!(e[0].2[0], 7);
|
||||
assert!((e[0].3 - 180.0).abs() < 1e-3);
|
||||
assert_eq!(e[0].image, img);
|
||||
assert_eq!(e[0].embedding.len(), 1024);
|
||||
assert_eq!(e[0].embedding[0], 7);
|
||||
assert!((e[0].crop_px - 180.0).abs() < 1e-3);
|
||||
assert_eq!(e[0].quality, Some(17.0));
|
||||
}
|
||||
|
||||
// ── stored crops ──────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user