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:
@@ -286,15 +286,16 @@ fn tune_thresholds(catalog: &Catalog) {
|
||||
|
||||
let model = dr_face::ModelId::new(MODEL_ID.to_string());
|
||||
let mut candidates = Vec::with_capacity(stored.len());
|
||||
for (face_id, image_id, blob, crop_px) in stored {
|
||||
let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &blob) else {
|
||||
for f in stored {
|
||||
let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &f.embedding) else {
|
||||
continue;
|
||||
};
|
||||
candidates.push(dr_face::Candidate {
|
||||
face: face_id.0,
|
||||
image: image_id.0,
|
||||
face: f.face.0,
|
||||
image: f.image.0,
|
||||
embedding: emb.v.to_vec(),
|
||||
crop_px,
|
||||
crop_px: f.crop_px,
|
||||
quality: f.quality,
|
||||
confirmed_person: None,
|
||||
});
|
||||
}
|
||||
|
||||
+17
-12
@@ -306,7 +306,7 @@ pub fn index_proxy(
|
||||
continue;
|
||||
}
|
||||
|
||||
let embedding = embedder.embed(&aligned)?;
|
||||
let embedded = embedder.embed(&aligned)?;
|
||||
|
||||
out.push(DetectedFace {
|
||||
x: d.bbox.0 / long_edge,
|
||||
@@ -315,8 +315,9 @@ pub fn index_proxy(
|
||||
h: d.height() / long_edge,
|
||||
landmarks: normalise_landmarks(&d.landmarks, long_edge),
|
||||
confidence: d.confidence,
|
||||
embedding: embedding.to_f16_bytes(),
|
||||
embedding: embedded.to_f16_bytes(),
|
||||
crop_px: aligned.source_px(),
|
||||
quality: Some(embedded.quality),
|
||||
model_id: embedder.model().as_str().to_string(),
|
||||
// Cut here, while the buffer is still in hand. This is the only
|
||||
// moment in the whole pipeline where the pixels are free.
|
||||
@@ -419,7 +420,7 @@ pub fn index_native(
|
||||
continue;
|
||||
}
|
||||
|
||||
let embedding = embedder.embed(&aligned)?;
|
||||
let embedded = embedder.embed(&aligned)?;
|
||||
let (bx, by) = (d.bbox.0 * sx, d.bbox.1 * sy);
|
||||
let (bw, bh) = (d.width() * sx, d.height() * sy);
|
||||
|
||||
@@ -430,8 +431,9 @@ pub fn index_native(
|
||||
h: bh / long_edge,
|
||||
landmarks: normalise_landmarks(&landmarks, long_edge),
|
||||
confidence: d.confidence,
|
||||
embedding: embedding.to_f16_bytes(),
|
||||
embedding: embedded.to_f16_bytes(),
|
||||
crop_px: aligned.source_px(),
|
||||
quality: Some(embedded.quality),
|
||||
model_id: embedder.model().as_str().to_string(),
|
||||
crop: cut_crop_native(native, width, height, (bx, by, bw, bh)).unwrap_or_default(),
|
||||
});
|
||||
@@ -837,19 +839,20 @@ impl Population {
|
||||
let model = ModelId::new(model_id.to_string());
|
||||
let mut candidates = Vec::with_capacity(stored.len());
|
||||
let mut ids = Vec::with_capacity(stored.len());
|
||||
for (face_id, image_id, blob, crop_px) in stored {
|
||||
let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &blob) else {
|
||||
log::warn!("face {face_id:?} has a malformed embedding, skipped");
|
||||
for f in stored {
|
||||
let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &f.embedding) else {
|
||||
log::warn!("face {:?} has a malformed embedding, skipped", f.face);
|
||||
continue;
|
||||
};
|
||||
candidates.push(dr_face::Candidate {
|
||||
face: face_id.0,
|
||||
image: image_id.0,
|
||||
face: f.face.0,
|
||||
image: f.image.0,
|
||||
embedding: emb.v.to_vec(),
|
||||
crop_px,
|
||||
confirmed_person: anchors.get(&face_id).map(|p| p.0),
|
||||
crop_px: f.crop_px,
|
||||
quality: f.quality,
|
||||
confirmed_person: anchors.get(&f.face).map(|p| p.0),
|
||||
});
|
||||
ids.push(face_id);
|
||||
ids.push(f.face);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
@@ -1516,6 +1519,7 @@ mod tests {
|
||||
landmarks: [(0.0, 0.0); 5],
|
||||
confidence: 0.9,
|
||||
crop_px: 120.0,
|
||||
quality: None,
|
||||
model_id: "w600k_mbf".into(),
|
||||
person: None,
|
||||
probability: 0.0,
|
||||
@@ -1633,6 +1637,7 @@ mod tests {
|
||||
confidence: 0.9,
|
||||
embedding: embedding(identity, cosine),
|
||||
crop_px: 150.0,
|
||||
quality: None,
|
||||
model_id: TEST_MODEL.to_string(),
|
||||
crop: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -97,6 +97,10 @@ pub struct FaceCell {
|
||||
/// Source pixels across the aligned crop. Small faces embed worse, and the
|
||||
/// user deserves to know which of a bad suggestion's causes is in play.
|
||||
pub crop_px: f32,
|
||||
/// The model's own reading of how recognisable the crop was — the length
|
||||
/// of its raw embedding (`dr_face::MIN_GALLERY_QUALITY`). `None` for a
|
||||
/// face indexed before it was kept.
|
||||
pub quality: Option<f32>,
|
||||
}
|
||||
|
||||
impl FaceCell {
|
||||
@@ -115,6 +119,31 @@ impl FaceCell {
|
||||
format!("{:.0}% likely", self.probability * 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The quality as text — "Quality 17.3", or "Quality —" where it was never
|
||||
/// measured.
|
||||
///
|
||||
/// Called *quality* and not *norm* on the screen, because that is what the
|
||||
/// number is used as and what the user can act on: a low one is the model
|
||||
/// saying it could not make the face out, and the fix is a better
|
||||
/// photograph. One decimal, because the gate sits at a whole number and
|
||||
/// the faces worth a second look are the ones just either side of it.
|
||||
pub fn quality_label(&self) -> String {
|
||||
match self.quality {
|
||||
Some(q) => format!("Quality {q:.1}"),
|
||||
None => "Quality —".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this face is good enough to be compared *against*.
|
||||
///
|
||||
/// What the screen dims the quality for: a face below the floor is still
|
||||
/// somebody and still placed, but it vouches for no one else, and a user
|
||||
/// wondering why a person's group did not gather the rest of them should
|
||||
/// be able to see that none of its members can.
|
||||
pub fn in_gallery(&self) -> bool {
|
||||
dr_face::in_gallery(self.quality)
|
||||
}
|
||||
}
|
||||
|
||||
/// Put a grouping preview into words.
|
||||
@@ -254,6 +283,7 @@ pub fn load_faces(
|
||||
confirmed: f.confirmed,
|
||||
probability: f.probability,
|
||||
crop_px: f.crop_px,
|
||||
quality: f.quality,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -615,23 +645,24 @@ pub fn preview_split(
|
||||
let model = dr_face::ModelId::new(model_id.to_string());
|
||||
let stored: std::collections::HashMap<_, _> = faces::embeddings(conn, model_id)?
|
||||
.into_iter()
|
||||
.map(|(id, image, blob, crop_px)| (id, (image, blob, crop_px)))
|
||||
.map(|e| (e.face, e))
|
||||
.collect();
|
||||
|
||||
let mut candidates = Vec::with_capacity(cells.len());
|
||||
let mut order = Vec::with_capacity(cells.len());
|
||||
for c in &cells {
|
||||
let Some((image, blob, crop_px)) = stored.get(&c.face) else {
|
||||
let Some(e) = stored.get(&c.face) else {
|
||||
continue;
|
||||
};
|
||||
let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), blob) else {
|
||||
let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &e.embedding) else {
|
||||
continue;
|
||||
};
|
||||
candidates.push(dr_face::Candidate {
|
||||
face: c.face.0,
|
||||
image: image.0,
|
||||
image: e.image.0,
|
||||
embedding: emb.v.to_vec(),
|
||||
crop_px: *crop_px,
|
||||
crop_px: e.crop_px,
|
||||
quality: e.quality,
|
||||
confirmed_person: None,
|
||||
});
|
||||
order.push(c.clone());
|
||||
@@ -755,6 +786,7 @@ mod tests {
|
||||
confidence: 0.9,
|
||||
embedding: vec![seed; 1024],
|
||||
crop_px: 180.0,
|
||||
quality: Some(f32::from(seed) + 10.0),
|
||||
model_id: "w600k_mbf".into(),
|
||||
crop: Vec::new(),
|
||||
}
|
||||
@@ -802,6 +834,7 @@ mod tests {
|
||||
confirmed: false,
|
||||
probability: 0.87,
|
||||
crop_px: 120.0,
|
||||
quality: Some(17.26),
|
||||
};
|
||||
assert_eq!(cell.confidence_label(), "87% likely");
|
||||
|
||||
@@ -812,6 +845,41 @@ mod tests {
|
||||
assert_eq!(confirmed.confidence_label(), "Confirmed");
|
||||
}
|
||||
|
||||
/// The number is the embedding's length, and the screen calls it what it
|
||||
/// is used as. A face from before it was kept says so rather than showing
|
||||
/// a zero that would read as the worst face in the library.
|
||||
#[test]
|
||||
fn the_quality_is_labelled_as_such_and_dimmed_below_the_floor() {
|
||||
let cell = FaceCell {
|
||||
face: FaceId(1),
|
||||
image: ImageId(1),
|
||||
crop: None,
|
||||
confirmed: false,
|
||||
probability: 0.5,
|
||||
crop_px: 120.0,
|
||||
quality: Some(17.26),
|
||||
};
|
||||
assert_eq!(cell.quality_label(), "Quality 17.3");
|
||||
assert!(cell.in_gallery());
|
||||
|
||||
let poor = FaceCell {
|
||||
quality: Some(9.4),
|
||||
..cell.clone()
|
||||
};
|
||||
assert_eq!(poor.quality_label(), "Quality 9.4");
|
||||
assert!(!poor.in_gallery());
|
||||
|
||||
let unmeasured = FaceCell {
|
||||
quality: None,
|
||||
..cell
|
||||
};
|
||||
assert_eq!(unmeasured.quality_label(), "Quality —");
|
||||
assert!(
|
||||
unmeasured.in_gallery(),
|
||||
"an unmeasured face is not a poor one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_people_rail_reports_the_unassigned_pool() {
|
||||
let c = catalog();
|
||||
|
||||
@@ -478,6 +478,8 @@ fn push_faces(window: &AppWindow, ctl: &IdentityController, cells: &[FaceCell])
|
||||
confirmed: c.confirmed,
|
||||
confidence: c.confidence_label().into(),
|
||||
crop_px: c.crop_px as i32,
|
||||
quality: c.quality_label().into(),
|
||||
in_gallery: c.in_gallery(),
|
||||
picked: picked.contains(&c.face),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -5937,6 +5937,7 @@ mod tests {
|
||||
confidence: 0.9,
|
||||
embedding: vec![0u8; 1024],
|
||||
crop_px: 120.0,
|
||||
quality: None,
|
||||
crop: Vec::new(),
|
||||
model_id: "w600k_mbf".into(),
|
||||
};
|
||||
@@ -5986,6 +5987,7 @@ mod tests {
|
||||
confidence: 0.9,
|
||||
embedding: vec![0u8; 1024],
|
||||
crop_px: 120.0,
|
||||
quality: None,
|
||||
crop: Vec::new(),
|
||||
model_id: "w600k_mbf".into(),
|
||||
};
|
||||
@@ -7012,6 +7014,7 @@ mod tests {
|
||||
confidence: 0.9,
|
||||
embedding: vec![0u8; 1024],
|
||||
crop_px: 120.0,
|
||||
quality: None,
|
||||
crop: Vec::new(),
|
||||
model_id: "w600k_mbf".into(),
|
||||
};
|
||||
|
||||
@@ -51,6 +51,15 @@ export struct IdentityFace {
|
||||
// with has causes, and "the face was 41 pixels across" is one the user can
|
||||
// act on by finding a better photograph.
|
||||
crop-px: int,
|
||||
// "Quality 17.3" — the model's own reading of how recognisable the crop
|
||||
// was, composed in Rust like `confidence` and for the same reason. The
|
||||
// other cause a bad suggestion can have, and the one the grouping pass
|
||||
// acts on: a face below the floor is placed but never compared against.
|
||||
quality: string,
|
||||
// Whether the face clears that floor. Drawn, not just known, because a
|
||||
// group that has gathered none of a person's other photographs has an
|
||||
// explanation the user can only see if every member's label says it.
|
||||
in-gallery: bool,
|
||||
// Part of the current multi-select — what a split would carry.
|
||||
picked: bool,
|
||||
}
|
||||
@@ -110,6 +119,28 @@ component FaceCell inherits Rectangle {
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// The quality, over the foot of the crop. In the picture rather
|
||||
// than the caption because the caption is the verdict controls'
|
||||
// and at the compact width there is no room beside them; and
|
||||
// dimmed below the floor, which is the one state of it that
|
||||
// changes what the grouping does.
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: parent.height - self.height;
|
||||
width: parent.width;
|
||||
height: 16px;
|
||||
background: Theme.ground.with-alpha(0.55);
|
||||
Text {
|
||||
text: face.quality;
|
||||
color: face.in-gallery ? Theme.ink-dim : Theme.warn-ink;
|
||||
font-size: Theme.text-sm;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
width: parent.width;
|
||||
height: parent.height;
|
||||
}
|
||||
}
|
||||
|
||||
// A confirmed face carries a quiet marker rather than a badge: the
|
||||
// grid is mostly confirmed once the user has worked through it, and
|
||||
// a loud mark on the common case is just noise.
|
||||
|
||||
Reference in New Issue
Block a user