Skip to main content

smriti/services/face_processor/
clustering.rs

1use super::*;
2
3/// Upper bound on unresolved faces fed into Stage B when the legacy
4/// complete-link path is in use. With `hnsw_clustering` (default-on)
5/// FaceClusterer::cluster runs in O(n log n) so the cap is effectively
6/// infinite; we keep the constant only to bound the legacy fallback
7/// when someone disables the feature for A/B testing.
8#[cfg(not(feature = "hnsw_clustering"))]
9const MAX_STAGE_B_INPUT: usize = 800;
10const MAX_POST_MERGE_CLUSTER_PAIRS: usize = 50_000;
11
12impl FaceProcessor {
13    /// Run incremental clustering in two stages:
14    /// 1) Gallery k-NN retrieval + confidence bands:
15    ///    HIGH      -> auto-assign to top cluster
16    ///    AMBIGUOUS -> queue for user review
17    ///    LOW       -> leave for Stage 2
18    /// 2) Complete-link agglomerative clustering on still-unresolved faces
19    ///
20    /// Public so integration tests (and re-cluster-only flows that skip
21    /// detection) can call it without going through the full
22    /// `process_photos` pipeline.
23    pub fn run_clustering(
24        face_repo: &FaceRepo,
25        clustering_threshold: f32,
26        resolver_weights: crate::ml::ResolverWeights,
27    ) -> Result<usize, String> {
28        let mut assigned_to_existing = 0usize;
29        let mut queued_for_review = 0usize;
30        let strict_max_distance = clustering_threshold.clamp(0.15, 0.6);
31
32        // Stage A: gallery-based retrieval with confidence bands.
33        let galleries = face_repo
34            .get_gallery_embeddings()
35            .map_err(|e| format!("Failed to load person galleries: {}", e))?;
36        let cluster_photo_rows = face_repo
37            .get_cluster_photo_ids()
38            .map_err(|e| format!("Failed to load cluster-photo map: {}", e))?;
39        let cannot_merge = face_repo
40            .get_cannot_merge_map()
41            .map_err(|e| format!("Failed to load cannot-merge constraints: {}", e))?;
42
43        let mut cluster_photo_ids: std::collections::HashMap<i64, std::collections::HashSet<i64>> =
44            std::collections::HashMap::new();
45        for (cluster_id, photo_id) in cluster_photo_rows {
46            cluster_photo_ids
47                .entry(cluster_id)
48                .or_default()
49                .insert(photo_id);
50        }
51
52        // Build the gallery slice form retrieve_candidates expects.
53        let mut gallery_by_cluster: std::collections::HashMap<
54            i64,
55            Vec<(i64, crate::ml::FaceEmbedding)>,
56        > = std::collections::HashMap::new();
57        for g in galleries {
58            gallery_by_cluster
59                .entry(g.cluster_id)
60                .or_default()
61                .push((g.face_id, g.embedding));
62        }
63        let gallery_vec: Vec<(i64, Vec<(i64, crate::ml::FaceEmbedding)>)> =
64            gallery_by_cluster.into_iter().collect();
65
66        let banding = crate::ml::BandingConfig::default();
67
68        let unclustered = face_repo
69            .get_unclustered_faces_with_photo_embeddings()
70            .map_err(|e| format!("Failed to get unclustered faces: {}", e))?;
71
72        for (face_id, photo_id, embedding) in &unclustered {
73            // Build exclusion set: clusters already present in this photo
74            // (same-photo conflict) and anything cannot-merge with those.
75            let mut exclude: std::collections::HashSet<i64> = std::collections::HashSet::new();
76            let clusters_in_photo: Vec<i64> = cluster_photo_ids
77                .iter()
78                .filter(|(_, set)| set.contains(photo_id))
79                .map(|(cid, _)| *cid)
80                .collect();
81            for cid in &clusters_in_photo {
82                exclude.insert(*cid);
83                if let Some(forbidden) = cannot_merge.get(cid) {
84                    for f in forbidden {
85                        exclude.insert(*f);
86                    }
87                }
88            }
89
90            let hits = crate::ml::retrieve_candidates(
91                embedding,
92                &gallery_vec,
93                5,
94                1.0 - strict_max_distance, // treat distance threshold as similarity lower bound
95                &exclude,
96            );
97
98            // Context re-rank using co-occurrence and temporal-neighbor signals.
99            let resolver_ctx = Self::build_resolver_context(face_repo, *photo_id, *face_id, &hits);
100            let reranked = crate::ml::rerank(&hits, &resolver_ctx, resolver_weights);
101            let band = crate::ml::retrieval::classify(&reranked, &banding);
102
103            match band {
104                crate::ml::ConfidenceBand::High { hit } => {
105                    face_repo
106                        .assign_face_to_cluster(*face_id, hit.cluster_id)
107                        .map_err(|e| format!("Failed to assign face to cluster: {}", e))?;
108                    cluster_photo_ids
109                        .entry(hit.cluster_id)
110                        .or_default()
111                        .insert(*photo_id);
112                    assigned_to_existing += 1;
113                }
114                crate::ml::ConfidenceBand::Ambiguous { top, runner_up } => {
115                    let ambiguity = runner_up.as_ref().map(|r| top.score - r.score);
116                    if let Err(e) =
117                        face_repo.enqueue_review(*face_id, top.cluster_id, top.score, ambiguity)
118                    {
119                        tracing::warn!("Failed to enqueue review for face {}: {}", face_id, e);
120                    }
121                    queued_for_review += 1;
122                }
123                crate::ml::ConfidenceBand::Low => {
124                    // Leave unassigned; Stage B (agglomerative) will handle it.
125                }
126            }
127        }
128
129        if queued_for_review > 0 {
130            tracing::info!(
131                "Queued {} ambiguous faces for user review",
132                queued_for_review
133            );
134        }
135
136        // Stage B: complete-link agglomerative clustering on unresolved faces.
137        let unresolved = face_repo
138            .get_unclustered_faces_with_photo_embeddings()
139            .map_err(|e| format!("Failed to reload unresolved faces: {}", e))?;
140
141        if unresolved.is_empty() {
142            face_repo
143                .refresh_all_galleries()
144                .map_err(|e| format!("Failed to refresh galleries: {}", e))?;
145            tracing::info!(
146                "Agglomerative clustering: assigned {} faces to existing galleries; no unresolved faces left",
147                assigned_to_existing
148            );
149            return Ok(0);
150        }
151
152        #[cfg(not(feature = "hnsw_clustering"))]
153        if unresolved.len() > MAX_STAGE_B_INPUT {
154            // Legacy path only: skip Stage B; route unresolved faces
155            // through the rescue pipeline so they remain visible
156            // (auto-matched against galleries or queued for ambiguous
157            // review) instead of paying the quadratic cost.
158            tracing::warn!(
159                "Skipping Stage B complete-link clustering: {} unresolved faces exceeds cap of {}. Enable `hnsw_clustering` to remove this cap.",
160                unresolved.len(),
161                MAX_STAGE_B_INPUT
162            );
163            face_repo
164                .refresh_all_galleries()
165                .map_err(|e| format!("Failed to refresh galleries: {}", e))?;
166            let (rescued, queued) = Self::rescue_orphan_faces(face_repo)?;
167            tracing::info!(
168                "Clustering (Stage B skipped): {} to-existing, {} rescued, {} queued, from {} unresolved",
169                assigned_to_existing,
170                rescued,
171                queued,
172                unresolved.len()
173            );
174            return Ok(0);
175        }
176
177        let inputs: Vec<ClusterInput> = unresolved
178            .iter()
179            .map(|(face_id, photo_id, emb)| ClusterInput {
180                face_id: *face_id,
181                photo_id: *photo_id,
182                current_cluster_id: None,
183                embedding: emb.clone(),
184            })
185            .collect();
186
187        // Load face_negatives so the clusterer can honour user rejections.
188        let negatives: std::collections::HashSet<(i64, i64)> = {
189            let mut stmt = face_repo
190                .conn
191                .prepare("SELECT face_id, not_cluster_id FROM face_negatives")
192                .map_err(|e| format!("Failed to load negatives: {}", e))?;
193            let rows = stmt
194                .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))
195                .map_err(|e| format!("Failed to query negatives: {}", e))?;
196            let mut set = std::collections::HashSet::new();
197            for row in rows {
198                let pair = row.map_err(|e| format!("Failed to read negative row: {}", e))?;
199                set.insert(pair);
200            }
201            set
202        };
203
204        let clusterer = FaceClusterer::new().with_max_distance(strict_max_distance);
205        let assignments = clusterer.cluster(&inputs, Some(&negatives));
206
207        let mut cluster_groups: HashMap<i32, Vec<i64>> = HashMap::new();
208        for (face_id, cluster_id) in assignments {
209            if cluster_id >= 0 {
210                cluster_groups.entry(cluster_id).or_default().push(face_id);
211            }
212        }
213
214        let mut clusters_created = 0usize;
215        for face_ids in cluster_groups.values() {
216            if face_ids.len() >= 2 {
217                face_repo
218                    .create_cluster(face_ids)
219                    .map_err(|e| format!("Failed to create cluster: {}", e))?;
220                clusters_created += 1;
221            }
222        }
223
224        face_repo
225            .refresh_all_galleries()
226            .map_err(|e| format!("Failed to refresh galleries: {}", e))?;
227
228        // Post-pass: unify clusters that are actually the same person but
229        // got split by complete-link's strictness under lighting variance.
230        // Uses single-link (best pair) across gallery members with a stricter
231        // "multiple good pairs" requirement to avoid false positives.
232        let merged = Self::merge_similar_clusters(face_repo)?;
233
234        // Rescue pass: faces that ended up as singletons (not placed into
235        // any cluster by Stage A or Stage B) need a second chance. Otherwise
236        // they're invisible to the user: they have cluster_id = NULL so they
237        // don't show in People, and nothing queues them for review.
238        let (rescued, queued) = Self::rescue_orphan_faces(face_repo)?;
239
240        // Final fallback: any face still without a cluster becomes its own
241        // singleton cluster. Catches the "small library, every face unique"
242        // case where Stage A had no galleries to match against, Stage B's
243        // complete-link clusterer correctly produced no merges, and the
244        // rescue pass had nothing to compare to either. Without this, those
245        // faces stay cluster_id = NULL forever — invisible in the People
246        // view, even though detection successfully found them.
247        let singletons_promoted = Self::promote_orphans_to_singletons(face_repo)?;
248
249        tracing::info!(
250            "Clustering: {} to-existing, {} new, merged {}, rescued {}, queued {}, singletons {}, from {} unresolved",
251            assigned_to_existing,
252            clusters_created,
253            merged,
254            rescued,
255            queued,
256            singletons_promoted,
257            unresolved.len()
258        );
259
260        Ok(clusters_created.saturating_sub(merged) + singletons_promoted)
261    }
262
263    /// Rescue pass for orphan faces (cluster_id IS NULL after all earlier
264    /// stages). For each orphan, k-NN retrieve against existing cluster
265    /// galleries with a looser threshold than the first-pass retrieval.
266    /// Classify into three bands:
267    ///   - HIGH       (mean top-k sim >= 0.60, 0.08 margin over runner-up):
268    ///     auto-assign to best cluster
269    ///   - AMBIGUOUS  (0.45 <= sim < 0.60):
270    ///     enqueue for user review — the review badge will prompt
271    ///     them to resolve without any explicit UI "check now" button
272    ///   - LOW        (< 0.45):
273    ///     leave as orphan; a future scan or better gallery may
274    ///     eventually pick it up
275    ///
276    /// Returns (auto_rescued, queued_for_review).
277    fn rescue_orphan_faces(face_repo: &FaceRepo) -> Result<(usize, usize), String> {
278        // Deliberately looser than first-pass retrieval:
279        // first-pass min_similarity = 1.0 - strict_max_distance ~= 0.58.
280        // We accept weaker gallery members here because the orphan either
281        // didn't fit Stage B's strict complete-link or matched nothing in
282        // Stage A — it may still be a valid match under some gallery member.
283        const RESCUE_MIN_SIM: f32 = 0.45;
284
285        let rescue_banding = crate::ml::BandingConfig {
286            low_threshold: 0.45,
287            high_threshold: 0.60,
288            margin: 0.08,
289        };
290
291        let galleries = face_repo
292            .get_gallery_embeddings()
293            .map_err(|e| format!("Failed to load galleries for rescue: {}", e))?;
294
295        if galleries.is_empty() {
296            // No clusters exist -> nothing to match against.
297            return Ok((0, 0));
298        }
299
300        let mut gallery_by_cluster: HashMap<i64, Vec<(i64, crate::ml::FaceEmbedding)>> =
301            HashMap::new();
302        for g in galleries {
303            gallery_by_cluster
304                .entry(g.cluster_id)
305                .or_default()
306                .push((g.face_id, g.embedding));
307        }
308        let gallery_vec: Vec<(i64, Vec<(i64, crate::ml::FaceEmbedding)>)> =
309            gallery_by_cluster.into_iter().collect();
310
311        let cannot_merge = face_repo
312            .get_cannot_merge_map()
313            .map_err(|e| format!("Failed to load cannot-merge: {}", e))?;
314
315        let cluster_photo_rows = face_repo
316            .get_cluster_photo_ids()
317            .map_err(|e| format!("Failed to load cluster-photo map: {}", e))?;
318        let mut cluster_photo_ids: HashMap<i64, std::collections::HashSet<i64>> = HashMap::new();
319        for (cid, pid) in cluster_photo_rows {
320            cluster_photo_ids.entry(cid).or_default().insert(pid);
321        }
322
323        let orphans = face_repo
324            .get_unclustered_faces_with_photo_embeddings()
325            .map_err(|e| format!("Failed to load orphan faces: {}", e))?;
326
327        let mut rescued = 0usize;
328        let mut queued = 0usize;
329
330        for (face_id, photo_id, embedding) in &orphans {
331            // Same-photo conflict + cannot-merge-transitively exclusions.
332            let mut exclude: std::collections::HashSet<i64> = std::collections::HashSet::new();
333            let clusters_in_photo: Vec<i64> = cluster_photo_ids
334                .iter()
335                .filter(|(_, set)| set.contains(photo_id))
336                .map(|(cid, _)| *cid)
337                .collect();
338            for cid in &clusters_in_photo {
339                exclude.insert(*cid);
340                if let Some(forbidden) = cannot_merge.get(cid) {
341                    for f in forbidden {
342                        exclude.insert(*f);
343                    }
344                }
345            }
346
347            let hits = crate::ml::retrieve_candidates(
348                embedding,
349                &gallery_vec,
350                5,
351                RESCUE_MIN_SIM,
352                &exclude,
353            );
354            let band = crate::ml::retrieval::classify(&hits, &rescue_banding);
355
356            match band {
357                crate::ml::ConfidenceBand::High { hit } => {
358                    if let Err(e) = face_repo.assign_face_to_cluster(*face_id, hit.cluster_id) {
359                        tracing::warn!("rescue: assign_face_to_cluster failed: {}", e);
360                        continue;
361                    }
362                    cluster_photo_ids
363                        .entry(hit.cluster_id)
364                        .or_default()
365                        .insert(*photo_id);
366                    rescued += 1;
367                }
368                crate::ml::ConfidenceBand::Ambiguous { top, runner_up } => {
369                    let ambiguity = runner_up.as_ref().map(|r| top.score - r.score);
370                    if let Err(e) =
371                        face_repo.enqueue_review(*face_id, top.cluster_id, top.score, ambiguity)
372                    {
373                        tracing::warn!("rescue: enqueue_review failed: {}", e);
374                        continue;
375                    }
376                    queued += 1;
377                }
378                crate::ml::ConfidenceBand::Low => {
379                    // Stays an orphan for now.
380                }
381            }
382        }
383
384        Ok((rescued, queued))
385    }
386
387    /// Promote any face still lacking a cluster assignment into its own
388    /// singleton cluster. Runs after every other pass — by the time we
389    /// get here, the face has failed both stages of clustering AND the
390    /// rescue pass. Rather than leave it invisible, we give it a row in
391    /// `face_clusters` so the People view shows it. The UI's
392    /// "Faces seen only once" section is the destination.
393    ///
394    /// Honours `user_confirmed >= 0` so user-hidden faces (set to -1)
395    /// don't get resurrected as singleton clusters.
396    fn promote_orphans_to_singletons(face_repo: &FaceRepo) -> Result<usize, String> {
397        let orphan_ids: Vec<i64> = {
398            let mut stmt = face_repo
399                .conn
400                .prepare(
401                    "SELECT id FROM faces \
402                     WHERE cluster_id IS NULL AND user_confirmed >= 0",
403                )
404                .map_err(|e| format!("Failed to query orphan faces: {}", e))?;
405            let rows = stmt
406                .query_map([], |r| r.get::<_, i64>(0))
407                .map_err(|e| format!("Failed to read orphan faces: {}", e))?;
408            let mut out = Vec::new();
409            for r in rows {
410                out.push(r.map_err(|e| format!("Failed to read orphan row: {}", e))?);
411            }
412            out
413        };
414
415        let mut made = 0usize;
416        for face_id in orphan_ids {
417            match face_repo.create_cluster(&[face_id]) {
418                Ok(_) => made += 1,
419                Err(e) => tracing::warn!(
420                    "Could not promote orphan face {} to singleton cluster: {}",
421                    face_id,
422                    e
423                ),
424            }
425        }
426        Ok(made)
427    }
428
429    /// Unify clusters that likely represent the same person but got split by
430    /// complete-link's strict pairwise requirement.
431    ///
432    /// For each pair (A, B) of existing clusters:
433    ///   - Skip if cannot-merge is set.
434    ///   - Skip if they share any photo (same-photo-different-faces means
435    ///     they must be different people).
436    ///   - Compute cross-cluster similarity using single-link on the gallery:
437    ///     mean of the top-3 cosine similarities across all member pairs.
438    ///   - If the mean top-3 exceeds `merge_threshold`, queue the merge.
439    ///
440    /// Merges are applied in descending score order with a union-find over
441    /// live cluster IDs, so a chain of fragments can all collapse into one.
442    fn merge_similar_clusters(face_repo: &FaceRepo) -> Result<usize, String> {
443        // Tunable: how aggressively to unify fragments. Higher = safer.
444        // Mean of top-3 cross-cluster similarities above this -> merge.
445        const MERGE_THRESHOLD: f32 = 0.55;
446        const TOP_K_PAIRS: usize = 3;
447
448        let galleries = face_repo
449            .get_gallery_embeddings()
450            .map_err(|e| format!("Failed to load galleries: {}", e))?;
451
452        let mut gallery_by_cluster: HashMap<i64, Vec<crate::ml::FaceEmbedding>> = HashMap::new();
453        for g in galleries {
454            gallery_by_cluster
455                .entry(g.cluster_id)
456                .or_default()
457                .push(g.embedding);
458        }
459
460        let cannot_merge = face_repo
461            .get_cannot_merge_map()
462            .map_err(|e| format!("Failed to load cannot-merge: {}", e))?;
463
464        let cluster_photo_rows = face_repo
465            .get_cluster_photo_ids()
466            .map_err(|e| format!("Failed to load cluster-photo map: {}", e))?;
467        let mut cluster_photos: HashMap<i64, std::collections::HashSet<i64>> = HashMap::new();
468        for (cid, pid) in cluster_photo_rows {
469            cluster_photos.entry(cid).or_default().insert(pid);
470        }
471
472        let cluster_ids: Vec<i64> = gallery_by_cluster.keys().copied().collect();
473        let pair_count = post_merge_pair_count(cluster_ids.len());
474        if pair_count > MAX_POST_MERGE_CLUSTER_PAIRS {
475            tracing::info!(
476                "Skipping post-pass face-cluster merge: {} cluster pairs exceeds cap {}",
477                pair_count,
478                MAX_POST_MERGE_CLUSTER_PAIRS
479            );
480            return Ok(0);
481        }
482
483        let mut candidates: Vec<(f32, i64, i64)> = Vec::new();
484
485        for i in 0..cluster_ids.len() {
486            for j in (i + 1)..cluster_ids.len() {
487                let a = cluster_ids[i];
488                let b = cluster_ids[j];
489
490                if cannot_merge.get(&a).is_some_and(|set| set.contains(&b)) {
491                    continue;
492                }
493
494                let photos_a = cluster_photos.get(&a);
495                let photos_b = cluster_photos.get(&b);
496                let shares_photo = match (photos_a, photos_b) {
497                    (Some(pa), Some(pb)) => pa.iter().any(|p| pb.contains(p)),
498                    _ => false,
499                };
500                if shares_photo {
501                    continue;
502                }
503
504                let ga = gallery_by_cluster
505                    .get(&a)
506                    .map(|v| v.as_slice())
507                    .unwrap_or(&[]);
508                let gb = gallery_by_cluster
509                    .get(&b)
510                    .map(|v| v.as_slice())
511                    .unwrap_or(&[]);
512                if ga.is_empty() || gb.is_empty() {
513                    continue;
514                }
515
516                let mut sims: Vec<f32> = Vec::with_capacity(ga.len() * gb.len());
517                for ea in ga {
518                    for eb in gb {
519                        let s = ea.cosine_similarity(eb);
520                        if s.is_nan() {
521                            continue;
522                        }
523                        sims.push(s);
524                    }
525                }
526                if sims.is_empty() {
527                    continue;
528                }
529                sims.sort_by(|x, y| y.total_cmp(x));
530                let k = TOP_K_PAIRS.min(sims.len());
531                let mean_top_k: f32 = sims.iter().take(k).sum::<f32>() / k as f32;
532
533                if mean_top_k >= MERGE_THRESHOLD {
534                    candidates.push((mean_top_k, a, b));
535                }
536            }
537        }
538
539        candidates.sort_by(|x, y| y.0.total_cmp(&x.0));
540
541        // Union-find: each cluster starts pointing to itself; merges redirect.
542        let mut parent: HashMap<i64, i64> = cluster_ids.iter().map(|&c| (c, c)).collect();
543        fn find(parent: &mut HashMap<i64, i64>, mut x: i64) -> i64 {
544            loop {
545                let p = *parent.get(&x).unwrap_or(&x);
546                if p == x {
547                    return x;
548                }
549                // Path compression.
550                let pp = *parent.get(&p).unwrap_or(&p);
551                parent.insert(x, pp);
552                x = pp;
553            }
554        }
555
556        let mut merged_count = 0usize;
557        for (score, a, b) in candidates {
558            let ra = find(&mut parent, a);
559            let rb = find(&mut parent, b);
560            if ra == rb {
561                continue;
562            }
563            // Keep the smaller cluster id as the survivor (arbitrary but stable).
564            let (survivor, casualty) = if ra < rb { (ra, rb) } else { (rb, ra) };
565            match face_repo.merge_clusters(casualty, survivor) {
566                Ok(_) => {
567                    parent.insert(casualty, survivor);
568                    merged_count += 1;
569                    tracing::info!(
570                        "Post-pass merge: cluster {} -> {} (score {:.3})",
571                        casualty,
572                        survivor,
573                        score
574                    );
575                }
576                Err(e) => {
577                    tracing::warn!("merge_similar_clusters: merge failed {}: {}", casualty, e);
578                }
579            }
580        }
581
582        Ok(merged_count)
583    }
584
585    /// Save a face crop image to disk as JPEG.
586    pub(crate) fn save_face_crop(
587        aligned_face: &image::RgbImage,
588        path: &Path,
589    ) -> Result<(), image::ImageError> {
590        let dynamic = image::DynamicImage::ImageRgb8(aligned_face.clone());
591        let resized = dynamic.resize_exact(80, 80, image::imageops::FilterType::Lanczos3);
592        resized.save(path)
593    }
594
595    /// Regenerate missing face crop files from stored bounding box data.
596    pub fn regenerate_missing_crops(drive_path: &Path) -> Result<usize, String> {
597        let db = Database::open_for_drive(drive_path)
598            .map_err(|e| format!("Failed to open database: {}", e))?;
599        let face_repo = FaceRepo::new(&db.conn);
600
601        let faces_dir = Self::faces_dir(drive_path);
602        if let Err(e) = std::fs::create_dir_all(&faces_dir) {
603            return Err(format!("Failed to create faces directory: {}", e));
604        }
605
606        let all_faces = face_repo
607            .get_all_faces_with_paths()
608            .map_err(|e| format!("Failed to get faces: {}", e))?;
609
610        let mut regenerated = 0usize;
611        for (face_id, file_path, orientation, bbox_x, bbox_y, bbox_w, bbox_h) in &all_faces {
612            let crop_path = faces_dir.join(format!("{}.jpg", face_id));
613            if crop_path.exists() {
614                continue;
615            }
616
617            let full_path =
618                match crate::services::path_util::safe_join_relative(drive_path, file_path) {
619                    Ok(path) => path,
620                    Err(e) => {
621                        tracing::trace!(
622                            "regenerate face crop skipped invalid photo path {}: {}",
623                            file_path,
624                            e
625                        );
626                        continue;
627                    }
628                };
629            let img = match image::open(&full_path) {
630                Ok(img) => apply_exif_orientation(img, *orientation),
631                Err(_) => continue,
632            };
633
634            let (img_w, img_h) = (img.width() as f32, img.height() as f32);
635
636            let px = (bbox_x * img_w) as u32;
637            let py = (bbox_y * img_h) as u32;
638            let pw = (bbox_w * img_w) as u32;
639            let ph = (bbox_h * img_h) as u32;
640
641            let pad_x = (pw as f32 * 0.2) as u32;
642            let pad_y = (ph as f32 * 0.2) as u32;
643            let crop_x = px.saturating_sub(pad_x);
644            let crop_y = py.saturating_sub(pad_y);
645            let crop_w = (pw + 2 * pad_x).min(img.width() - crop_x);
646            let crop_h = (ph + 2 * pad_y).min(img.height() - crop_y);
647
648            if crop_w == 0 || crop_h == 0 {
649                continue;
650            }
651
652            let cropped = img.crop_imm(crop_x, crop_y, crop_w, crop_h);
653            let resized = cropped.resize_exact(80, 80, image::imageops::FilterType::Lanczos3);
654            if resized.save(&crop_path).is_ok() {
655                regenerated += 1;
656            }
657        }
658
659        if regenerated > 0 {
660            tracing::info!("Regenerated {} missing face crop thumbnails", regenerated);
661        }
662
663        Ok(regenerated)
664    }
665
666    /// Get the face crops directory for a drive.
667    pub fn faces_dir(drive_path: &Path) -> PathBuf {
668        drive_path.join(".photovault").join("faces")
669    }
670
671    /// Streaming Stage A — run *only* the gallery-match leg of the
672    /// clustering pipeline so faces appear in the People view as the
673    /// writer thread commits chunks. Stage B (complete-link / new
674    /// cluster creation) and the rescue pass stay end-of-pipeline,
675    /// because both need the full unresolved set to make sane
676    /// decisions.
677    ///
678    /// HIGH-band hits are auto-assigned. AMBIGUOUS hits go onto the
679    /// review queue (so the badge bumps). LOW hits stay unassigned
680    /// for end-of-run clustering to handle.
681    pub(crate) fn stream_assign_existing_clusters(
682        face_repo: &FaceRepo,
683        clustering_threshold: f32,
684        resolver_weights: crate::ml::ResolverWeights,
685    ) -> Result<usize, String> {
686        let strict_max_distance = clustering_threshold.clamp(0.15, 0.6);
687
688        let galleries = face_repo
689            .get_gallery_embeddings()
690            .map_err(|e| format!("stream Stage A: load galleries: {}", e))?;
691        if galleries.is_empty() {
692            // No existing clusters yet; nothing to match against. Wait
693            // for end-of-run Stage B to seed the first set of people.
694            return Ok(0);
695        }
696
697        let cluster_photo_rows = face_repo
698            .get_cluster_photo_ids()
699            .map_err(|e| format!("stream Stage A: cluster-photo map: {}", e))?;
700        let cannot_merge = face_repo
701            .get_cannot_merge_map()
702            .map_err(|e| format!("stream Stage A: cannot-merge: {}", e))?;
703
704        let mut cluster_photo_ids: HashMap<i64, std::collections::HashSet<i64>> = HashMap::new();
705        for (cid, pid) in cluster_photo_rows {
706            cluster_photo_ids.entry(cid).or_default().insert(pid);
707        }
708
709        let mut gallery_by_cluster: HashMap<i64, Vec<(i64, crate::ml::FaceEmbedding)>> =
710            HashMap::new();
711        for g in galleries {
712            gallery_by_cluster
713                .entry(g.cluster_id)
714                .or_default()
715                .push((g.face_id, g.embedding));
716        }
717        let gallery_vec: Vec<(i64, Vec<(i64, crate::ml::FaceEmbedding)>)> =
718            gallery_by_cluster.into_iter().collect();
719
720        let banding = crate::ml::BandingConfig::default();
721        let unclustered = face_repo
722            .get_unclustered_faces_with_photo_embeddings()
723            .map_err(|e| format!("stream Stage A: get unclustered: {}", e))?;
724
725        let mut assigned = 0usize;
726        for (face_id, photo_id, embedding) in &unclustered {
727            let mut exclude: std::collections::HashSet<i64> = std::collections::HashSet::new();
728            let clusters_in_photo: Vec<i64> = cluster_photo_ids
729                .iter()
730                .filter(|(_, set)| set.contains(photo_id))
731                .map(|(cid, _)| *cid)
732                .collect();
733            for cid in &clusters_in_photo {
734                exclude.insert(*cid);
735                if let Some(forbidden) = cannot_merge.get(cid) {
736                    for f in forbidden {
737                        exclude.insert(*f);
738                    }
739                }
740            }
741
742            let hits = crate::ml::retrieve_candidates(
743                embedding,
744                &gallery_vec,
745                5,
746                1.0 - strict_max_distance,
747                &exclude,
748            );
749            let resolver_ctx = Self::build_resolver_context(face_repo, *photo_id, *face_id, &hits);
750            let reranked = crate::ml::rerank(&hits, &resolver_ctx, resolver_weights);
751            let band = crate::ml::retrieval::classify(&reranked, &banding);
752
753            match band {
754                crate::ml::ConfidenceBand::High { hit }
755                    if face_repo
756                        .assign_face_to_cluster(*face_id, hit.cluster_id)
757                        .is_ok() =>
758                {
759                    cluster_photo_ids
760                        .entry(hit.cluster_id)
761                        .or_default()
762                        .insert(*photo_id);
763                    assigned += 1;
764                }
765                _ => {
766                    // AMBIGUOUS / LOW: leave for end-of-run pipeline.
767                }
768            }
769        }
770
771        Ok(assigned)
772    }
773}
774
775fn post_merge_pair_count(cluster_count: usize) -> usize {
776    cluster_count.saturating_mul(cluster_count.saturating_sub(1)) / 2
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782
783    #[test]
784    fn post_merge_pair_count_bounds_quadratic_pass() {
785        assert_eq!(post_merge_pair_count(0), 0);
786        assert_eq!(post_merge_pair_count(1), 0);
787        assert_eq!(post_merge_pair_count(316), 49_770);
788        assert_eq!(post_merge_pair_count(317), 50_086);
789    }
790}