Carry every identity across a re-detection, by box and by embedding
record_detections replaces an image's faces and carried only the user's confirmations onto the new ones, by box overlap above 0.5 IoU. Everything else on the old faces was dropped: the suggestions the last grouping pass made, and the people the user had said a face was not. On the reference library that is 13,011 suggestions and 77 rejections beside 3,778 confirmations — a re-detection of it would have been correct by FR-CULL-12's letter, since suggestions are derived data, and would have handed back a People screen of strangers. 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). The embedding route claims the box a low-resolution pass drew badly enough that overlap alone would not; 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. The merge's match_faces still matches by overlap alone across devices; it is the same question and is not changed here.
This commit is contained in:
@@ -762,8 +762,8 @@ pub fn export_to_shards_reporting(
|
|||||||
/// computed is the one it can vouch for. A peer's *stronger* pass is another
|
/// computed is the one it can vouch for. A peer's *stronger* pass is another
|
||||||
/// matter: it is the re-detection this device's own sweep would queue
|
/// matter: it is the re-detection this device's own sweep would queue
|
||||||
/// (`FaceDetector::supersedes`), already done, and taking it is what spares
|
/// (`FaceDetector::supersedes`), already done, and taking it is what spares
|
||||||
/// a tablet the fetch. Names survive the replacement by box overlap, as they
|
/// a tablet the fetch. Names survive the replacement by box overlap and
|
||||||
/// do a local re-detection.
|
/// embedding, as they do a local re-detection (`faces::record_detections`).
|
||||||
///
|
///
|
||||||
/// A peer's faces are taken under whichever compatible detector found them:
|
/// A peer's faces are taken under whichever compatible detector found them:
|
||||||
/// a tablet set to the fast detector adopts the desktop's thorough pass
|
/// a tablet set to the fast detector adopts the desktop's thorough pass
|
||||||
|
|||||||
+383
-47
@@ -207,16 +207,60 @@ pub use dr_face::Calibration;
|
|||||||
|
|
||||||
// ── detection ─────────────────────────────────────────────────────────────
|
// ── detection ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The cosine above which two vectors from one embedder are taken to be
|
||||||
|
/// the same face in the same photograph, for carrying an identity across a
|
||||||
|
/// re-detection.
|
||||||
|
///
|
||||||
|
/// Set at the reference library's P≈0.95 line (docs/faces.md §9's table:
|
||||||
|
/// 0.449), which is far above anything two different people in one frame
|
||||||
|
/// reach and below what one face re-embedded from a better crop of itself
|
||||||
|
/// does. The number is only ever asked about *overlapping* boxes on *one*
|
||||||
|
/// image, which is what makes such a loose figure safe: the question is not
|
||||||
|
/// "is this the same person" but "is this the same face, now that the box
|
||||||
|
/// has moved".
|
||||||
|
pub const SAME_FACE_COSINE: f32 = 0.45;
|
||||||
|
|
||||||
|
/// What one stored face knew about itself, read before a re-detection
|
||||||
|
/// replaces it.
|
||||||
|
struct Prior {
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
w: f32,
|
||||||
|
h: f32,
|
||||||
|
/// Decoded and unit length, under the *embedder* half of its id so a
|
||||||
|
/// cosine against a new face from the same embedder is defined.
|
||||||
|
embedding: Option<dr_face::Embedding>,
|
||||||
|
/// `(person, probability, confirmed)`, where the face had one.
|
||||||
|
assignment: Option<(i64, f64, bool)>,
|
||||||
|
rejected: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Replace every face on an image with a fresh detection pass.
|
/// Replace every face on an image with a fresh detection pass.
|
||||||
///
|
///
|
||||||
/// Replace rather than append, because `DetectFaces` is coalesced per image
|
/// Replace rather than append, because `DetectFaces` is coalesced per image
|
||||||
/// (FR-CAT-3) and re-running it must be idempotent — appending would double the
|
/// (FR-CAT-3) and re-running it must be idempotent — appending would double the
|
||||||
/// faces on every re-index and silently inflate every cluster.
|
/// faces on every re-index and silently inflate every cluster.
|
||||||
///
|
///
|
||||||
/// **Confirmed assignments survive** where the new detection covers the same
|
/// # What survives the replacement
|
||||||
/// part of the frame: a re-index with a better model must not discard the
|
///
|
||||||
/// user's labelling (FR-CULL-10). Matching is by box overlap, since the face is
|
/// **The identity of every face the new detection finds again** — the user's
|
||||||
/// in the same place even when the box moves a little.
|
/// confirmations first of all (FR-CULL-10), and with them the suggestions
|
||||||
|
/// the last grouping pass made and the people the user has said a face is
|
||||||
|
/// *not*. A re-index with a better model must not discard the user's
|
||||||
|
/// labelling, and it should not empty the People screen either: a library
|
||||||
|
/// of thirteen thousand suggestions re-detected into thirteen thousand
|
||||||
|
/// unassigned faces is correct by the letter of FR-CULL-12 and looks, to
|
||||||
|
/// the person who spent an afternoon on it, like the work was thrown away.
|
||||||
|
///
|
||||||
|
/// A new face is the same face as an old one when the two **overlap in the
|
||||||
|
/// frame and either the boxes agree or the embeddings do**: an IoU above 0.5,
|
||||||
|
/// which is loose on purpose because a better detector is entitled to move
|
||||||
|
/// the box; or a cosine above [`SAME_FACE_COSINE`] between the vectors, for
|
||||||
|
/// the box a low-resolution pass drew badly enough that overlap alone would
|
||||||
|
/// not claim it. The embedding is also what breaks a tie — two faces side by
|
||||||
|
/// side in a group photograph both overlap both new boxes, and the vector
|
||||||
|
/// says which is which where the rectangles cannot. Each old face is
|
||||||
|
/// carried onto at most one new one, best pair first.
|
||||||
///
|
///
|
||||||
/// **Every model's faces are replaced, and every other model's marker goes
|
/// **Every model's faces are replaced, and every other model's marker goes
|
||||||
/// with them.** An image holds the faces of whichever pipeline looked at it
|
/// with them.** An image holds the faces of whichever pipeline looked at it
|
||||||
@@ -236,29 +280,15 @@ pub fn record_detections(
|
|||||||
) -> Result<Vec<FaceId>, CatalogError> {
|
) -> Result<Vec<FaceId>, CatalogError> {
|
||||||
let tx = conn.unchecked_transaction()?;
|
let tx = conn.unchecked_transaction()?;
|
||||||
|
|
||||||
// What the user had asserted, so it can be carried across the replacement.
|
// Everything the old faces knew, so it can be carried across the
|
||||||
let mut prior: Vec<(f32, f32, f32, f32, i64, f64)> = Vec::new();
|
// replacement. Read only when there is something to carry it onto: a
|
||||||
{
|
// pass that found nothing has nothing to match, and decoding a vector
|
||||||
let mut q = tx.prepare(
|
// per face for no reader would be the wasted work.
|
||||||
"SELECT f.x, f.y, f.w, f.h, fp.person_id, fp.probability
|
let prior = if faces.is_empty() {
|
||||||
FROM faces f
|
Vec::new()
|
||||||
JOIN face_person fp ON fp.face_id = f.id
|
} else {
|
||||||
WHERE f.image_id = ?1 AND fp.confirmed = 1",
|
read_priors(&tx, image_id)?
|
||||||
)?;
|
};
|
||||||
let rows = q.query_map([image_id.0 as i64], |r| {
|
|
||||||
Ok((
|
|
||||||
r.get::<_, f64>(0)? as f32,
|
|
||||||
r.get::<_, f64>(1)? as f32,
|
|
||||||
r.get::<_, f64>(2)? as f32,
|
|
||||||
r.get::<_, f64>(3)? as f32,
|
|
||||||
r.get::<_, i64>(4)?,
|
|
||||||
r.get::<_, f64>(5)?,
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
for row in rows {
|
|
||||||
prior.push(row?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?;
|
tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
@@ -266,9 +296,11 @@ pub fn record_detections(
|
|||||||
rusqlite::params![image_id.0 as i64, model_id],
|
rusqlite::params![image_id.0 as i64, model_id],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let carried = match_priors(&prior, faces);
|
||||||
|
|
||||||
let now = now_secs();
|
let now = now_secs();
|
||||||
let mut ids = Vec::with_capacity(faces.len());
|
let mut ids = Vec::with_capacity(faces.len());
|
||||||
for f in faces {
|
for (i, f) in faces.iter().enumerate() {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO faces
|
"INSERT INTO faces
|
||||||
(image_id, x, y, w, h, landmarks, detector_confidence,
|
(image_id, x, y, w, h, landmarks, detector_confidence,
|
||||||
@@ -306,28 +338,38 @@ pub fn record_detections(
|
|||||||
)?;
|
)?;
|
||||||
let id = FaceId(tx.last_insert_rowid() as u64);
|
let id = FaceId(tx.last_insert_rowid() as u64);
|
||||||
|
|
||||||
// Re-attach a confirmation whose box this detection clearly replaces.
|
if let Some(p) = carried[i].map(|at| &prior[at]) {
|
||||||
// 0.5 IoU is loose on purpose: the question is "is this the same face
|
if let Some((person, prob, confirmed)) = p.assignment {
|
||||||
// in the frame", not "is this the same box", and a better detector is
|
tx.execute(
|
||||||
// entitled to move the box.
|
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
|
||||||
if let Some((_, _, _, _, person, prob)) = prior
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
.iter()
|
rusqlite::params![id.0 as i64, person, prob, confirmed],
|
||||||
.filter(|p| iou((p.0, p.1, p.2, p.3), (f.x, f.y, f.w, f.h)) > 0.5)
|
)?;
|
||||||
.max_by(|a, b| {
|
}
|
||||||
iou((a.0, a.1, a.2, a.3), (f.x, f.y, f.w, f.h))
|
for person in &p.rejected {
|
||||||
.total_cmp(&iou((b.0, b.1, b.2, b.3), (f.x, f.y, f.w, f.h)))
|
tx.execute(
|
||||||
})
|
"INSERT OR IGNORE INTO face_person_rejected (face_id, person_id)
|
||||||
{
|
VALUES (?1, ?2)",
|
||||||
tx.execute(
|
rusqlite::params![id.0 as i64, person],
|
||||||
"INSERT INTO face_person (face_id, person_id, probability, confirmed)
|
)?;
|
||||||
VALUES (?1, ?2, ?3, 1)",
|
}
|
||||||
rusqlite::params![id.0 as i64, person, prob],
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ids.push(id);
|
ids.push(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lost = prior
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(at, p)| p.assignment.is_some() && !carried.contains(&Some(*at)))
|
||||||
|
.count();
|
||||||
|
if lost > 0 {
|
||||||
|
log::debug!(
|
||||||
|
"image {}: {lost} assigned face(s) not found again by the re-detection",
|
||||||
|
image_id.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The run marker, written whether or not anything was found. Zero faces is
|
// The run marker, written whether or not anything was found. Zero faces is
|
||||||
// a real answer and recording it is what stops the next pass looking at
|
// a real answer and recording it is what stops the next pass looking at
|
||||||
// this photograph again -- see the V9 migration.
|
// this photograph again -- see the V9 migration.
|
||||||
@@ -351,6 +393,115 @@ pub fn record_detections(
|
|||||||
Ok(ids)
|
Ok(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The faces on an image as they stand, with everything a re-detection
|
||||||
|
/// carries forward.
|
||||||
|
fn read_priors(tx: &Connection, image_id: ImageId) -> Result<Vec<Prior>, CatalogError> {
|
||||||
|
let mut out: Vec<(i64, Prior)> = Vec::new();
|
||||||
|
{
|
||||||
|
let mut q = tx.prepare(
|
||||||
|
"SELECT f.id, f.x, f.y, f.w, f.h, f.model_id, f.embedding,
|
||||||
|
fp.person_id, fp.probability, fp.confirmed
|
||||||
|
FROM faces f
|
||||||
|
LEFT JOIN face_person fp ON fp.face_id = f.id
|
||||||
|
WHERE f.image_id = ?1
|
||||||
|
ORDER BY f.id",
|
||||||
|
)?;
|
||||||
|
let rows = q.query_map([image_id.0 as i64], |r| {
|
||||||
|
let model: String = r.get(5)?;
|
||||||
|
let blob: Vec<u8> = r.get(6)?;
|
||||||
|
let person: Option<i64> = r.get(7)?;
|
||||||
|
Ok((
|
||||||
|
r.get::<_, i64>(0)?,
|
||||||
|
Prior {
|
||||||
|
x: r.get::<_, f64>(1)? as f32,
|
||||||
|
y: r.get::<_, f64>(2)? as f32,
|
||||||
|
w: r.get::<_, f64>(3)? as f32,
|
||||||
|
h: r.get::<_, f64>(4)? as f32,
|
||||||
|
embedding: dr_face::Embedding::from_f16_bytes(
|
||||||
|
dr_face::ModelId::new(embedder_of(&model).to_string()),
|
||||||
|
&blob,
|
||||||
|
),
|
||||||
|
assignment: match person {
|
||||||
|
Some(p) => Some((p, r.get::<_, f64>(8)?, r.get::<_, i64>(9)? != 0)),
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
rejected: Vec::new(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
for row in rows {
|
||||||
|
out.push(row?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut q = tx.prepare(
|
||||||
|
"SELECT fr.face_id, fr.person_id
|
||||||
|
FROM face_person_rejected fr
|
||||||
|
JOIN faces f ON f.id = fr.face_id
|
||||||
|
WHERE f.image_id = ?1",
|
||||||
|
)?;
|
||||||
|
let rows = q.query_map([image_id.0 as i64], |r| {
|
||||||
|
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
|
||||||
|
})?;
|
||||||
|
for row in rows {
|
||||||
|
let (face, person) = row?;
|
||||||
|
if let Some((_, p)) = out.iter_mut().find(|(id, _)| *id == face) {
|
||||||
|
p.rejected.push(person);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out.into_iter().map(|(_, p)| p).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which old face, if any, each new detection replaces — indexed like
|
||||||
|
/// `faces`, holding an index into `prior`.
|
||||||
|
///
|
||||||
|
/// One-to-one, best pair first: every qualifying pair is scored by how much
|
||||||
|
/// the boxes and the vectors agree, and pairs are taken in that order until
|
||||||
|
/// each side is spoken for. A pair qualifies when the boxes overlap at all
|
||||||
|
/// and either the overlap alone says so (IoU above 0.5) or the embeddings do
|
||||||
|
/// ([`SAME_FACE_COSINE`]). Overlap is required on both routes, because two
|
||||||
|
/// faces of one person in one frame — a mirror, a photograph within the
|
||||||
|
/// photograph — have the same vector and must not swap names.
|
||||||
|
fn match_priors(prior: &[Prior], faces: &[DetectedFace]) -> Vec<Option<usize>> {
|
||||||
|
let mut pairs: Vec<(f32, usize, usize)> = Vec::new();
|
||||||
|
for (pi, p) in prior.iter().enumerate() {
|
||||||
|
let old = (p.x, p.y, p.w, p.h);
|
||||||
|
for (fi, f) in faces.iter().enumerate() {
|
||||||
|
let overlap = iou(old, (f.x, f.y, f.w, f.h));
|
||||||
|
if overlap <= 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cosine = p
|
||||||
|
.embedding
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|old| {
|
||||||
|
let new = dr_face::Embedding::from_f16_bytes(
|
||||||
|
dr_face::ModelId::new(embedder_of(&f.model_id).to_string()),
|
||||||
|
&f.embedding,
|
||||||
|
)?;
|
||||||
|
old.cosine(&new)
|
||||||
|
})
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
if overlap > 0.5 || cosine >= SAME_FACE_COSINE {
|
||||||
|
pairs.push((overlap + cosine.max(0.0), pi, fi));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pairs.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||||
|
|
||||||
|
let mut carried = vec![None; faces.len()];
|
||||||
|
let mut taken = vec![false; prior.len()];
|
||||||
|
for (_, pi, fi) in pairs {
|
||||||
|
if taken[pi] || carried[fi].is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
taken[pi] = true;
|
||||||
|
carried[fi] = Some(pi);
|
||||||
|
}
|
||||||
|
carried
|
||||||
|
}
|
||||||
|
|
||||||
/// A face embedded again from its stored landmarks: the new vector and its
|
/// A face embedded again from its stored landmarks: the new vector and its
|
||||||
/// length. What the measuring pass hands back per face.
|
/// length. What the measuring pass hands back per face.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -373,8 +524,8 @@ pub struct Measurement {
|
|||||||
/// created: the boxes and landmarks are right, the identities are the user's
|
/// 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
|
/// 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
|
/// keeps `face_person` and the face ids exactly as they were -- a
|
||||||
/// re-detection would carry confirmations across by box overlap and lose
|
/// re-detection carries identities across by matching old faces to new, and
|
||||||
/// every suggestion, for no gain.
|
/// 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
|
/// `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
|
/// could not be built from them. Deleted here, as detection would have refused
|
||||||
@@ -1678,6 +1829,191 @@ mod tests {
|
|||||||
assert!(got[0].confirmed);
|
assert!(got[0].confirmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A stored vector pointing along one axis of the embedding space, so a
|
||||||
|
/// test can say "these two faces are the same" or "orthogonal" exactly.
|
||||||
|
fn along(axis: usize) -> Vec<u8> {
|
||||||
|
let mut v = Box::new([0.0_f32; dr_face::EMBEDDING_DIM]);
|
||||||
|
v[axis] = 1.0;
|
||||||
|
dr_face::Embedding {
|
||||||
|
model: dr_face::ModelId::new("w600k_mbf"),
|
||||||
|
v,
|
||||||
|
}
|
||||||
|
.to_f16_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn face_at(x: f32, y: f32, w: f32, h: f32, axis: usize) -> DetectedFace {
|
||||||
|
DetectedFace {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
embedding: along(axis),
|
||||||
|
..face(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other half of FR-CULL-10's promise: the suggestions and the
|
||||||
|
/// rejections are the state of the People screen, and a re-index that
|
||||||
|
/// dropped them would hand back a library of strangers.
|
||||||
|
#[test]
|
||||||
|
fn re_detection_carries_a_suggestion_and_a_rejection_across() {
|
||||||
|
let c = db();
|
||||||
|
let img = image(&c, 1);
|
||||||
|
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||||||
|
let anna = create_person(&c, "Anna").unwrap();
|
||||||
|
let bob = create_person(&c, "Bob").unwrap();
|
||||||
|
suggest(&c, ids[0], anna, 0.83).unwrap();
|
||||||
|
reject(&c, ids[0], bob).unwrap();
|
||||||
|
|
||||||
|
let mut moved = face(9);
|
||||||
|
moved.x = 0.11;
|
||||||
|
record_detections(&c, img, "scrfd_10g+w600k_mbf", 4000, &[moved]).unwrap();
|
||||||
|
|
||||||
|
let got = for_image(&c, img).unwrap();
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].person, Some(anna), "suggestion was lost on re-index");
|
||||||
|
assert!(
|
||||||
|
!got[0].confirmed,
|
||||||
|
"a suggestion came back as a confirmation"
|
||||||
|
);
|
||||||
|
assert!((got[0].probability - 0.83).abs() < 1e-6);
|
||||||
|
assert!(
|
||||||
|
!suggest(&c, got[0].id, bob, 0.99).unwrap(),
|
||||||
|
"the rejection of Bob was lost on re-index"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A box the low-resolution pass drew badly enough that overlap alone
|
||||||
|
/// would not claim it: the vector does.
|
||||||
|
#[test]
|
||||||
|
fn a_moved_box_is_claimed_by_its_embedding() {
|
||||||
|
let c = db();
|
||||||
|
let img = image(&c, 1);
|
||||||
|
let ids = record_detections(
|
||||||
|
&c,
|
||||||
|
img,
|
||||||
|
"w600k_mbf",
|
||||||
|
1024,
|
||||||
|
&[face_at(0.10, 0.10, 0.20, 0.20, 3)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let anna = create_person(&c, "Anna").unwrap();
|
||||||
|
confirm(&c, ids[0], anna).unwrap();
|
||||||
|
|
||||||
|
// Shifted by two thirds of its width: IoU ≈ 0.2, well under the 0.5
|
||||||
|
// the box route needs, but the boxes still touch.
|
||||||
|
let again = face_at(0.23, 0.10, 0.20, 0.20, 3);
|
||||||
|
record_detections(&c, img, "scrfd_10g+w600k_mbf", 4000, &[again]).unwrap();
|
||||||
|
let got = for_image(&c, img).unwrap();
|
||||||
|
assert_eq!(got[0].person, Some(anna));
|
||||||
|
assert!(got[0].confirmed);
|
||||||
|
|
||||||
|
// The same shift with a stranger's vector is a different face, and
|
||||||
|
// the confirmation is not handed to it.
|
||||||
|
let other = image(&c, 2);
|
||||||
|
let ids = record_detections(
|
||||||
|
&c,
|
||||||
|
other,
|
||||||
|
"w600k_mbf",
|
||||||
|
1024,
|
||||||
|
&[face_at(0.10, 0.10, 0.20, 0.20, 3)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
confirm(&c, ids[0], anna).unwrap();
|
||||||
|
let stranger = face_at(0.23, 0.10, 0.20, 0.20, 4);
|
||||||
|
record_detections(&c, other, "scrfd_10g+w600k_mbf", 4000, &[stranger]).unwrap();
|
||||||
|
assert_eq!(for_image(&c, other).unwrap()[0].person, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two people side by side, boxes overlapping both ways, and the new
|
||||||
|
/// boxes shifted so that overlap alone would swap them.
|
||||||
|
#[test]
|
||||||
|
fn the_embedding_breaks_a_tie_between_neighbouring_faces() {
|
||||||
|
let c = db();
|
||||||
|
let img = image(&c, 1);
|
||||||
|
let ids = record_detections(
|
||||||
|
&c,
|
||||||
|
img,
|
||||||
|
"w600k_mbf",
|
||||||
|
1024,
|
||||||
|
&[
|
||||||
|
face_at(0.10, 0.10, 0.20, 0.20, 1),
|
||||||
|
face_at(0.25, 0.10, 0.20, 0.20, 2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let anna = create_person(&c, "Anna").unwrap();
|
||||||
|
let bob = create_person(&c, "Bob").unwrap();
|
||||||
|
confirm(&c, ids[0], anna).unwrap();
|
||||||
|
confirm(&c, ids[1], bob).unwrap();
|
||||||
|
|
||||||
|
// Anna's new box overlaps Bob's old one more than her own.
|
||||||
|
let anna_again = face_at(0.20, 0.10, 0.20, 0.20, 1);
|
||||||
|
let bob_again = face_at(0.35, 0.10, 0.20, 0.20, 2);
|
||||||
|
let new = record_detections(
|
||||||
|
&c,
|
||||||
|
img,
|
||||||
|
"scrfd_10g+w600k_mbf",
|
||||||
|
4000,
|
||||||
|
&[anna_again, bob_again],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let got = for_image(&c, img).unwrap();
|
||||||
|
let person_of = |id: FaceId| got.iter().find(|f| f.id == id).unwrap().person;
|
||||||
|
assert_eq!(person_of(new[0]), Some(anna));
|
||||||
|
assert_eq!(person_of(new[1]), Some(bob));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same vector somewhere else in the frame — a mirror, a print on
|
||||||
|
/// the wall — is not the same face, and overlap is what says so.
|
||||||
|
#[test]
|
||||||
|
fn the_same_person_elsewhere_in_the_frame_is_not_claimed() {
|
||||||
|
let c = db();
|
||||||
|
let img = image(&c, 1);
|
||||||
|
let ids = record_detections(
|
||||||
|
&c,
|
||||||
|
img,
|
||||||
|
"w600k_mbf",
|
||||||
|
1024,
|
||||||
|
&[face_at(0.10, 0.10, 0.20, 0.20, 5)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let anna = create_person(&c, "Anna").unwrap();
|
||||||
|
confirm(&c, ids[0], anna).unwrap();
|
||||||
|
|
||||||
|
record_detections(
|
||||||
|
&c,
|
||||||
|
img,
|
||||||
|
"w600k_mbf",
|
||||||
|
1024,
|
||||||
|
&[face_at(0.60, 0.60, 0.20, 0.20, 5)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(for_image(&c, img).unwrap()[0].person, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each old face is carried onto at most one new one: a second box over
|
||||||
|
/// the same face — a detector that fires twice — does not become a
|
||||||
|
/// second Anna.
|
||||||
|
#[test]
|
||||||
|
fn an_identity_is_carried_onto_one_face_only() {
|
||||||
|
let c = db();
|
||||||
|
let img = image(&c, 1);
|
||||||
|
let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap();
|
||||||
|
let anna = create_person(&c, "Anna").unwrap();
|
||||||
|
confirm(&c, ids[0], anna).unwrap();
|
||||||
|
|
||||||
|
let mut twin = face(1);
|
||||||
|
twin.x += 0.02;
|
||||||
|
record_detections(&c, img, "w600k_mbf", 1024, &[face(1), twin]).unwrap();
|
||||||
|
let named = for_image(&c, img)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.person == Some(anna))
|
||||||
|
.count();
|
||||||
|
assert_eq!(named, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_suggestion_never_overwrites_a_confirmation() {
|
fn a_suggestion_never_overwrites_a_confirmation() {
|
||||||
let c = db();
|
let c = db();
|
||||||
|
|||||||
@@ -199,7 +199,8 @@ pub struct FaceSettings {
|
|||||||
/// the clustering and the sync all go on over every face already found.
|
/// the clustering and the sync all go on over every face already found.
|
||||||
/// What a stronger choice does is queue the images a weaker one indexed for
|
/// What a stronger choice does is queue the images a weaker one indexed for
|
||||||
/// re-detection, after the ones nothing has indexed ([`Self::supersedes`]),
|
/// re-detection, after the ones nothing has indexed ([`Self::supersedes`]),
|
||||||
/// with confirmed names carried across by `record_detections`' box overlap.
|
/// with names carried across by `record_detections`' box and embedding
|
||||||
|
/// matching.
|
||||||
///
|
///
|
||||||
/// The first version of this setting keyed everything on the full id, and
|
/// The first version of this setting keyed everything on the full id, and
|
||||||
/// choosing Thorough emptied both devices' People screens until a whole-
|
/// choosing Thorough emptied both devices' People screens until a whole-
|
||||||
|
|||||||
+11
-11
@@ -25,7 +25,7 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen
|
|||||||
|
|
||||||
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between.
|
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2040`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2042`</sub>
|
||||||
|
|
||||||
### Move a magnified photograph about
|
### Move a magnified photograph about
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
|
|||||||
|
|
||||||
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2131`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2133`</sub>
|
||||||
|
|
||||||
### Paint a mask by hand
|
### Paint a mask by hand
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ Only once there is something outside the viewport to reach, which is why the cur
|
|||||||
|
|
||||||
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2218`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2220`</sub>
|
||||||
|
|
||||||
### Take back the last change
|
### Take back the last change
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
|
|||||||
|
|
||||||
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2439`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2441`</sub>
|
||||||
|
|
||||||
### Do it again after taking it back
|
### Do it again after taking it back
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
- **Pointer** — Click it, or press Redo in the History header
|
- **Pointer** — Click it, or press Redo in the History header
|
||||||
- **Keyboard** — Ctrl+Shift+Z
|
- **Keyboard** — Ctrl+Shift+Z
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2452`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2454`</sub>
|
||||||
|
|
||||||
### Copy the settings from this photograph
|
### Copy the settings from this photograph
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
|
|
||||||
The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
The panel is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2485`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2487`</sub>
|
||||||
|
|
||||||
### Paste the settings onto this photograph
|
### Paste the settings onto this photograph
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ The panel is the copy that has to work: a tablet has no modifier key to hold and
|
|||||||
|
|
||||||
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2497`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2499`</sub>
|
||||||
|
|
||||||
### Change which group of adjustments is on screen
|
### Change which group of adjustments is on screen
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
|
|||||||
|
|
||||||
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2525`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2527`</sub>
|
||||||
|
|
||||||
### Look at the photograph at 1:1
|
### Look at the photograph at 1:1
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ The groups are whatever the operation set declares itself to be about, so there
|
|||||||
|
|
||||||
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans.
|
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2560`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2562`</sub>
|
||||||
|
|
||||||
### Move to the next or previous photograph
|
### Move to the next or previous photograph
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
|||||||
|
|
||||||
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing.
|
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2612`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2614`</sub>
|
||||||
|
|
||||||
### See the photograph before you edited it
|
### See the photograph before you edited it
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
|
|||||||
|
|
||||||
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2736`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2738`</sub>
|
||||||
|
|
||||||
### Put one control back to its default
|
### Put one control back to its default
|
||||||
|
|
||||||
|
|||||||
+70
-70
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user