mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user