Cluster faces into people, and calibrate what a similarity means

FR-CULL-9 forbids thresholding a bare cosine anywhere in the subsystem,
so calibrate fits P(same person) per library and reports whether the fit
is trustworthy. Two details carry most of the weight.

The fit runs against a 200-bin histogram rather than a pair list: a
25,000-face library has ~3e8 pairs and no gradient descent is running
over that. And a fresh library has no valid calibration, because the
positives have to come from user confirmations or burst siblings --
bootstrapping them from high cosine would fit the calibration to the
belief it was supposed to test.

Clustering defends against the over-merging FR-CULL-10 warns about with
constraints rather than a better threshold: two faces in one photograph
never merge, and two groups confirmed as different people never merge.
Average link rather than single link, so one strong edge cannot weld two
families together.

Calibration is defined once, in dr-face, and dr-catalog re-exports it.
Two implementations of one probability model is exactly how a number
comes to mean the wrong thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 20:10:33 +02:00
co-authored by Claude Opus 5
parent aac3136407
commit 00e78dc2ac
9 changed files with 929 additions and 52 deletions
+15 -50
View File
@@ -103,55 +103,14 @@ pub struct Person {
pub suggested_faces: u64,
}
/// The FR-CULL-9 calibration for one model.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Calibration {
pub a: f32,
pub b: f32,
pub w_size: f32,
/// Whether the fit is usable. When false the UI says confidence is
/// unavailable rather than presenting an untuned default as a measurement.
pub valid: bool,
pub positive_pairs: u64,
pub negative_pairs: u64,
}
impl Calibration {
/// P(same person), optionally shifted by a base-rate prior.
///
/// `log_prior_odds` is applied at evaluation rather than baked into the
/// fit, so one stored calibration serves every context: the odds that two
/// faces in a 40-image album match are not the odds in a 40,000-image
/// archive. Baking a prior in would need a refit per context and would make
/// the stored parameters mean different things depending on where they came
/// from.
pub fn probability(&self, cosine: f32, min_crop_px: f32, log_prior_odds: f32) -> f32 {
let z = self.a * cosine
+ self.b
+ self.w_size * min_crop_px.max(1.0).log2()
+ log_prior_odds;
// Branch on the sign so neither tail overflows: exp(-z) for large
// positive z, exp(z) for large negative.
if z >= 0.0 {
1.0 / (1.0 + (-z).exp())
} else {
let e = z.exp();
e / (1.0 + e)
}
}
/// The cosine at which [`Calibration::probability`] crosses `p`.
///
/// What turns "merge above 0.9" into an actual comparison against a stored
/// similarity, without evaluating the sigmoid per candidate edge.
pub fn boundary_at(&self, p: f32, min_crop_px: f32, log_prior_odds: f32) -> f32 {
((p / (1.0 - p)).ln()
- self.b
- self.w_size * min_crop_px.max(1.0).log2()
- log_prior_odds)
/ self.a
}
}
/// The FR-CULL-9 calibration, re-exported from where it is fitted.
///
/// Deliberately *not* redefined here. The catalog stores five numbers; what
/// those numbers mean — the sigmoid, the size term, the prior — is
/// [`dr_face::calibrate`]'s business, and two implementations of one
/// probability model is precisely the kind of divergence that yields a
/// plausible number meaning the wrong thing.
pub use dr_face::Calibration;
// ── detection ─────────────────────────────────────────────────────────────
@@ -278,6 +237,12 @@ pub fn unassigned(conn: &Connection, model_id: &str) -> Result<Vec<FaceId>, Cata
rows.collect::<Result<_, _>>().map_err(Into::into)
}
/// One face's stored embedding, as the clustering pass consumes it.
///
/// A named type rather than a tuple because it crosses a crate boundary and
/// "the third element" is not a thing anyone should have to remember.
pub type StoredEmbedding = (FaceId, ImageId, Vec<u8>, f32);
/// Embeddings for clustering, oldest first so the pass is deterministic.
///
/// Returned as raw f16 blobs rather than decoded vectors: the caller is
@@ -286,7 +251,7 @@ pub fn unassigned(conn: &Connection, model_id: &str) -> Result<Vec<FaceId>, Cata
pub fn embeddings(
conn: &Connection,
model_id: &str,
) -> Result<Vec<(FaceId, ImageId, Vec<u8>, f32)>, CatalogError> {
) -> Result<Vec<StoredEmbedding>, CatalogError> {
let mut q = conn.prepare(
"SELECT id, image_id, embedding, crop_px FROM faces
WHERE model_id = ?1 ORDER BY id",