Merge PR #3286: FACES: a person's page finds its look-alikes in seconds, not minutes

docs/whats-new.md, docs/parity-checklist.md: both sides kept; header keeps the newer date.
This commit is contained in:
aiongg
2026-10-31 02:10:50 +00:00
3 changed files with 87 additions and 4 deletions
+6 -3
View File
@@ -280,6 +280,7 @@ mod imp {
use lightcraft_faces::runtime::Embedder;
use lightcraft_geom::Rect;
use lightcraft_meta::{Region, RegionKind};
use rayon::prelude::*;
use serde_json::{Value, json};
use super::super::bad;
@@ -731,11 +732,13 @@ mod imp {
return ready;
}
let bar = embedder.manifest().thresholds.match_cosine.unwrap_or(DEFAULT_THRESHOLD) * 0.8;
let refs: Vec<(&str, &[f32])> = gallery.iter().map(|e| ("person", &e[..])).collect();
let refs: Vec<&[f32]> = gallery.iter().map(|e| &e[..]).collect();
// every unnamed face against every face of theirs: some 400 million comparisons for a person with 4 000 faces
// in a library of 90 000, so on every core (minutes on one)
let mut scored: Vec<(f32, u64, usize, Rect)> = candidates
.into_iter()
.into_par_iter()
.filter_map(|(photo, index, rect, e)| {
let score = matching::rank(&e, &refs).first()?.score;
let score = matching::person_score(&e, &refs, matching::TOP_FACES)?;
(score >= bar).then_some((score, photo, index, rect))
})
.collect();
+71 -1
View File
@@ -32,10 +32,24 @@ pub fn cosine(a: &[f32], b: &[f32]) -> Option<f32> {
if a.len() != b.len() || a.is_empty() {
return None;
}
let d: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let d = dot(a, b);
d.is_finite().then_some(d)
}
/// The dot product of two vectors of the same length, summed in eight lanes so the compiler can use the processor's
/// vector instructions (one running sum can't be: each addition waits for the one before). Some four times faster on
/// 512-long embeddings; the result differs from a plain running sum only in the last bits.
fn dot(a: &[f32], b: &[f32]) -> f32 {
let mut lanes = [0f32; 8];
let ((ca, ra), (cb, rb)) = (a.as_chunks::<8>(), b.as_chunks::<8>());
for (x, y) in ca.iter().zip(cb) {
for ((l, x), y) in lanes.iter_mut().zip(x).zip(y) {
*l += x * y;
}
}
lanes.iter().sum::<f32>() + ra.iter().zip(rb).map(|(x, y)| x * y).sum::<f32>()
}
/// Rank the people in `gallery` (a name and one face's embedding per entry; a person appears once per face),
/// best first. Names are compared case-insensitively and shown as first seen.
pub fn rank(query: &[f32], gallery: &[(&str, &[f32])]) -> Vec<Ranked> {
@@ -60,6 +74,31 @@ pub fn rank(query: &[f32], gallery: &[(&str, &[f32])]) -> Vec<Ranked> {
out
}
/// One person's score for `query`, as [`rank`] gives it, when all of `gallery` is that person: the mean of the `top`
/// (1 to 8) faces most like it, or of all of them when they have fewer. `None` when no face compares. Made for scoring
/// many faces against one person: no names to group by and nothing allocated, so it can run on every core.
pub fn person_score(query: &[f32], gallery: &[&[f32]], top: usize) -> Option<f32> {
const MOST: usize = 8;
let top = top.clamp(1, MOST);
// the best similarities so far, highest first
let mut best = [f32::NEG_INFINITY; MOST];
let mut seen = 0usize;
for face in gallery {
let Some(c) = cosine(query, face) else { continue };
seen += 1;
if c > best[top - 1] {
let mut i = top - 1;
while i > 0 && best[i - 1] < c {
best[i] = best[i - 1];
i -= 1;
}
best[i] = c;
}
}
let n = seen.min(top);
(n > 0).then(|| best[..n].iter().sum::<f32>() / n as f32)
}
/// The best person if they pass both bars: a score of at least `threshold`, and at least `margin` ahead of the
/// next person (when there is one).
pub fn suggest(ranked: &[Ranked], threshold: f32, margin: f32) -> Option<Suggestion> {
@@ -95,6 +134,37 @@ mod tests {
assert_eq!(r2[0].name, "Di", "{r2:?}");
}
#[test]
fn a_persons_score_is_the_one_ranking_gives_them() {
// pseudo-random unit vectors, the same on every run
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 40) as f32 / (1u64 << 24) as f32 - 0.5
};
let mut face = || unit(&(0..16).map(|_| next()).collect::<Vec<_>>());
let gallery: Vec<Vec<f32>> = (0..40).map(|_| face()).collect();
let refs: Vec<&[f32]> = gallery.iter().map(Vec::as_slice).collect();
let named: Vec<(&str, &[f32])> = refs.iter().map(|e| ("Ann", *e)).collect();
for _ in 0..50 {
let q = face();
let ranked = rank(&q, &named);
let score = person_score(&q, &refs, TOP_FACES).unwrap();
assert!((score - ranked[0].score).abs() < 1e-6, "{score} against {}", ranked[0].score);
}
// fewer faces than asked, bad embeddings skipped, nothing to compare, and any count
let q = face();
assert!((person_score(&q, &refs[..1], 4).unwrap() - cosine(&q, refs[0]).unwrap()).abs() < 1e-6);
let nan = [f32::NAN; 16];
assert_eq!(person_score(&q, &[&nan[..], refs[0]], 2), person_score(&q, &refs[..1], 2));
assert_eq!(person_score(&q, &[], 2), None);
assert_eq!(person_score(&q, &[&nan[..]], 2), None);
assert_eq!(person_score(&q, &refs, 0), person_score(&q, &refs, 1));
assert_eq!(person_score(&q, &refs, 1000), person_score(&q, &refs, 8));
}
#[test]
fn a_suggestion_needs_a_strong_match_clearly_ahead() {
let r = |name: &str, score: f32| Ranked { name: name.into(), score, faces: 1 };
+10
View File
@@ -9,6 +9,16 @@
the selected photos as one undo step, only the active photo in Compare and Survey, and moves on with Auto Advance,
as the other label keys do.
## 2026-10-29
### A person's page opens in seconds in a large library
- Finding the unnamed faces that look like a person (a person's page, and again after each face confirmed there) now
runs on every processor core, and each comparison of two faces uses the processor's vector instructions. In a
library of 92 000 unnamed faces, a person with 4 100 named ones is estimated to have taken about 2 minutes 40 seconds
on one core of an Apple M1 Pro, with the window frozen, and now about 3 seconds (both measured on 3 000 of those
faces, 5.3 s and 0.1 s, and scaled up). Name suggestions and the order of the unnamed
faces get the faster comparison too.
## 2026-10-28
### The empty photo grid names Import Photos' shortcut as it is bound