diff --git a/core/dr-catalog/src/merge.rs b/core/dr-catalog/src/merge.rs index efa4876..315576e 100644 --- a/core/dr-catalog/src/merge.rs +++ b/core/dr-catalog/src/merge.rs @@ -116,6 +116,9 @@ pub struct MergeReport { /// Remote faces placed on a local one by their embedding, where the /// boxes disagreed or were ambiguous (see `match_faces`). 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 /// [`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 ---------------------- let matched = match_faces(tx)?; 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() { return Ok(()); } @@ -1129,6 +1137,8 @@ fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(), probability = excluded.probability, 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 { 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; } + // 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 // already holds dirtied a page per face, every pass, for nothing; // 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, 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); + } }