Keep one person to one face per photograph in the catalog merge
The grouping pass never puts two faces of one photograph in the same group (the cannot-link in dr_face::cluster). The merge did not check this. When the two devices disagree about which face in a frame is a person, merge_people_within applied the remote's confirmation, or the anchor of a set-aside group, to face X. This device already held the same person on face Y of the same photograph, so the person ended up on both faces. The reference library has 80 such person/photograph pairs on the desktop and 89 on the tablet: 79/88 unnamed set-aside groups and one named person confirmed on two faces. There are no duplicate faces (no pair of faces in one image and embedder with IoU >= 0.5). An incoming assignment is now refused when another local face of the same photograph already holds that person. The one exception is an incoming confirmation against a local suggestion: the suggestion is withdrawn and the confirmation is applied. Two confirmations stay as this device has them, the same rule as a local confirmation outranking a remote one. A face that already holds the person is not a rival to itself, so a steady-state pass is unaffected. On the reference pair this refuses 0 assignments and writes the same 8,954 as before; it only changes what a future disagreement does. The refusals are counted in MergeReport::faces_one_per_photograph. Existing pairs are left alone. They are two different faces (cosine 0.31 for the named one), not one face twice, so there is nothing to fuse, and which face is the wrong one is not the merge's to guess.
This commit is contained in:
@@ -116,6 +116,9 @@ pub struct MergeReport {
|
|||||||
/// Remote faces placed on a local one by their embedding, where the
|
/// Remote faces placed on a local one by their embedding, where the
|
||||||
/// boxes disagreed or were ambiguous (see `match_faces`).
|
/// boxes disagreed or were ambiguous (see `match_faces`).
|
||||||
pub faces_matched_by_embedding: usize,
|
pub faces_matched_by_embedding: usize,
|
||||||
|
/// Assignments from the remote refused because this device already has
|
||||||
|
/// that person on another face of the same photograph.
|
||||||
|
pub faces_one_per_photograph: usize,
|
||||||
|
|
||||||
/// Redundant identities for one word, retired by
|
/// Redundant identities for one word, retired by
|
||||||
/// [`crate::keywords::fuse_duplicates`].
|
/// [`crate::keywords::fuse_duplicates`].
|
||||||
@@ -1083,7 +1086,12 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
|
|||||||
// ---- match the remote's faces onto this device's ----------------------
|
// ---- match the remote's faces onto this device's ----------------------
|
||||||
let matched = match_faces(tx)?;
|
let matched = match_faces(tx)?;
|
||||||
report.faces_matched_by_embedding += matched.by_embedding;
|
report.faces_matched_by_embedding += matched.by_embedding;
|
||||||
let face_map = matched.map;
|
let FaceMatch {
|
||||||
|
map: face_map,
|
||||||
|
on_file,
|
||||||
|
file_of,
|
||||||
|
..
|
||||||
|
} = matched;
|
||||||
if face_map.is_empty() {
|
if face_map.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -1129,6 +1137,8 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
|
|||||||
probability = excluded.probability,
|
probability = excluded.probability,
|
||||||
confirmed = excluded.confirmed",
|
confirmed = excluded.confirmed",
|
||||||
)?;
|
)?;
|
||||||
|
let mut withdraw =
|
||||||
|
tx.prepare_cached("DELETE FROM face_person WHERE face_id = ?1 AND confirmed = 0")?;
|
||||||
|
|
||||||
for (remote_face, person, probability, confirmed) in incoming {
|
for (remote_face, person, probability, confirmed) in incoming {
|
||||||
let Some(&local_face) = face_map.get(&remote_face) else {
|
let Some(&local_face) = face_map.get(&remote_face) else {
|
||||||
@@ -1155,6 +1165,37 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(),
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One person, one face per photograph -- the cannot-link the
|
||||||
|
// grouping pass already keeps (`dr_face::cluster`), which the
|
||||||
|
// merge did not. When the two devices disagree about *which*
|
||||||
|
// face in a frame is somebody, taking the remote's answer
|
||||||
|
// beside this device's own puts the person on both. The
|
||||||
|
// reference library holds 80 such pairs (79 set-aside
|
||||||
|
// strangers, one named person), the same on both devices. The
|
||||||
|
// face this device already gave the person keeps them, unless
|
||||||
|
// the remote's is a confirmation and this device's only a
|
||||||
|
// suggestion.
|
||||||
|
//
|
||||||
|
// A face that already holds the person adds nothing beside it,
|
||||||
|
// whatever else the photograph holds.
|
||||||
|
let adds_person = current.is_none_or(|(held_person, ..)| held_person != person);
|
||||||
|
let rival = file_of
|
||||||
|
.get(&local_face)
|
||||||
|
.filter(|_| adds_person)
|
||||||
|
.and_then(|file| {
|
||||||
|
on_file[file].iter().copied().find(|&other| {
|
||||||
|
other != local_face && held.get(&other).is_some_and(|h| h.0 == person)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if let Some(rival) = rival {
|
||||||
|
if !confirmed || held[&rival].2 == 1 {
|
||||||
|
report.faces_one_per_photograph += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
withdraw.execute([rival])?;
|
||||||
|
held.remove(&rival);
|
||||||
|
}
|
||||||
|
|
||||||
// Written only when it differs. Rewriting a row with the values it
|
// Written only when it differs. Rewriting a row with the values it
|
||||||
// already holds dirtied a page per face, every pass, for nothing;
|
// already holds dirtied a page per face, every pass, for nothing;
|
||||||
// the report still counts it, as it always has.
|
// the report still counts it, as it always has.
|
||||||
@@ -2917,4 +2958,71 @@ mod tests {
|
|||||||
assert_eq!(person_of(&c, placed), None);
|
assert_eq!(person_of(&c, placed), None);
|
||||||
assert_eq!(person_of(&c, free), None);
|
assert_eq!(person_of(&c, free), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── one person, one face per photograph ───────────────────────────────
|
||||||
|
|
||||||
|
/// Two faces far apart in one photograph, one each side's remote
|
||||||
|
/// counterpart can be matched to by box: (local 7, local 8, remote 42
|
||||||
|
/// over 8).
|
||||||
|
fn two_faces_one_photograph(c: &Connection) -> (i64, i64, i64) {
|
||||||
|
for db in ["main", "remote_cat"] {
|
||||||
|
add_synced_image(c, db, 1, 5000);
|
||||||
|
}
|
||||||
|
let here = add_face(c, "main", 7, 1, 0.10);
|
||||||
|
let there = add_face(c, "main", 8, 1, 0.60);
|
||||||
|
let remote = add_face(c, "remote_cat", 42, 1, 0.60);
|
||||||
|
(here, there, remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The devices disagree about which stranger in a crowd a set-aside
|
||||||
|
/// group holds. Taking the remote's anchor beside this device's own put
|
||||||
|
/// one person on two faces of one frame.
|
||||||
|
#[test]
|
||||||
|
fn a_set_aside_anchor_does_not_land_beside_this_devices_own() {
|
||||||
|
let c = two_catalogs();
|
||||||
|
let (here, there, remote) = two_faces_one_photograph(&c);
|
||||||
|
add_person(&c, "main", 1, "u-stranger", "", true);
|
||||||
|
add_person(&c, "remote_cat", 3, "u-stranger", "", true);
|
||||||
|
assign(&c, "main", here, 1, false);
|
||||||
|
assign(&c, "remote_cat", remote, 3, false);
|
||||||
|
|
||||||
|
let report = merge_all(&c).unwrap();
|
||||||
|
assert_eq!(report.faces_one_per_photograph, 1);
|
||||||
|
assert_eq!(person_of(&c, here), Some((String::new(), false)));
|
||||||
|
assert_eq!(person_of(&c, there), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A confirmation from the other device outranks a suggestion here for
|
||||||
|
/// the same person on another face, which gives the person up.
|
||||||
|
#[test]
|
||||||
|
fn a_remote_confirmation_moves_a_local_suggestion_off_the_other_face() {
|
||||||
|
let c = two_catalogs();
|
||||||
|
let (here, there, remote) = two_faces_one_photograph(&c);
|
||||||
|
add_person(&c, "main", 1, "u-anna", "Anna", false);
|
||||||
|
add_person(&c, "remote_cat", 3, "u-anna", "Anna", false);
|
||||||
|
assign(&c, "main", here, 1, false);
|
||||||
|
assign(&c, "remote_cat", remote, 3, true);
|
||||||
|
|
||||||
|
merge_all(&c).unwrap();
|
||||||
|
assert_eq!(person_of(&c, there), Some(("Anna".to_string(), true)));
|
||||||
|
assert_eq!(person_of(&c, here), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two confirmations of one person on two faces of one photograph is a
|
||||||
|
/// disagreement no merge can settle; this device's stands, and the
|
||||||
|
/// second is not added beside it.
|
||||||
|
#[test]
|
||||||
|
fn a_remote_confirmation_does_not_double_a_local_one() {
|
||||||
|
let c = two_catalogs();
|
||||||
|
let (here, there, remote) = two_faces_one_photograph(&c);
|
||||||
|
add_person(&c, "main", 1, "u-anna", "Anna", false);
|
||||||
|
add_person(&c, "remote_cat", 3, "u-anna", "Anna", false);
|
||||||
|
assign(&c, "main", here, 1, true);
|
||||||
|
assign(&c, "remote_cat", remote, 3, true);
|
||||||
|
|
||||||
|
let report = merge_all(&c).unwrap();
|
||||||
|
assert_eq!(report.faces_one_per_photograph, 1);
|
||||||
|
assert_eq!(person_of(&c, here), Some(("Anna".to_string(), true)));
|
||||||
|
assert_eq!(person_of(&c, there), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user