Skip to main content

smriti/services/
face_processor.rs

1//! Face processing pipeline
2//!
3//! Orchestrates the face detection -> embedding -> clustering workflow.
4//! Uses rayon for parallel photo processing with thread-local ONNX sessions.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
9use std::sync::{mpsc, Arc, Mutex};
10use std::time::Duration;
11
12use rayon::prelude::*;
13
14use crate::db::face_repo::FaceRepo;
15use crate::db::Database;
16use crate::db::InferredIdentityRepo;
17use crate::ml::{
18    ClusterInput, FaceClusterer, FaceDetector, FaceEmbedder, FaceEmbedding, OnnxRuntime,
19};
20use crate::services::image_utils::apply_exif_orientation;
21use crate::services::path_util::safe_join_relative;
22
23/// Coarse progress phase. The UI uses this to keep the bar moving past
24/// the per-photo loop into clustering, so we never sit at "x/x" while
25/// the post-processing tail runs.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum FaceProcessingStage {
28    /// Per-photo detect + embed loop. `processed` counts photos.
29    Detecting,
30    /// Stage 3 propagation + Stage 4 clustering — runs once after the
31    /// per-photo loop finishes. `processed` is capped at 95% of `total`
32    /// so the UI shows a "wrapping up" state instead of staying at 100%.
33    Finishing,
34    /// Pipeline complete. `processed == total`.
35    Done,
36}
37
38/// Where face embeddings are being computed for this run.
39///
40/// Detection is always local — only the embedding (112×112 → 512-d
41/// step) can be offloaded. This is surfaced to the frontend so the
42/// user can confirm at a glance whether their cloud bridge is
43/// actually carrying load, vs silently falling back to local.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum EmbedderRoute {
46    /// Local ONNX session — the default path.
47    Local,
48    /// Configured remote bridge — embeddings POSTed as 112×112 crops.
49    Bridge,
50}
51
52impl EmbedderRoute {
53    pub fn as_str(self) -> &'static str {
54        match self {
55            EmbedderRoute::Local => "local",
56            EmbedderRoute::Bridge => "bridge",
57        }
58    }
59}
60
61/// Progress information for face processing
62#[derive(Debug, Clone)]
63pub struct FaceProcessingProgress {
64    pub processed: usize,
65    pub total: usize,
66    pub faces_found: usize,
67    /// Wall-clock seconds since processing started.
68    pub elapsed_secs: f64,
69    pub stage: FaceProcessingStage,
70    /// Number of streaming flushes the writer thread has committed so
71    /// far. The UI watches this and refreshes the People view when it
72    /// increments — that's how new clusters appear during a long run
73    /// instead of only at the end.
74    pub chunks_flushed: u32,
75    /// Where embeddings are running for this job. Set at job start
76    /// based on `face_gpu_bridge_enabled` + URL + a one-shot /health
77    /// probe; doesn't track runtime fallback (the 3-strikes circuit
78    /// breaker inside RemoteEmbedder will silently switch to local
79    /// without flipping this flag). Use the Test Connection button
80    /// in Settings if you need a live health check.
81    pub embedder_route: EmbedderRoute,
82}
83
84impl Default for FaceProcessingProgress {
85    fn default() -> Self {
86        Self {
87            processed: 0,
88            total: 0,
89            faces_found: 0,
90            elapsed_secs: 0.0,
91            stage: FaceProcessingStage::Detecting,
92            chunks_flushed: 0,
93            embedder_route: EmbedderRoute::Local,
94        }
95    }
96}
97
98/// Result of a face processing run
99#[derive(Debug, Clone)]
100pub struct FaceProcessingResult {
101    pub photos_processed: usize,
102    pub faces_detected: usize,
103    pub clusters_created: usize,
104    pub rejected_small: usize,
105    pub rejected_lowconf: usize,
106    pub rejected_blurry: usize,
107    pub rejected_yaw: usize,
108}
109
110/// Result of processing a single photo (collected from parallel workers)
111struct PhotoFaceResult {
112    photo_id: i64,
113    file_path: String,
114    faces: Vec<FaceInsert>,
115    taken_ts: Option<i64>,
116    brightness: f32,
117    had_error: bool,
118}
119
120/// Lightweight summary kept around after a result has been handed off
121/// to the writer thread. Stage 3 (contextual identity propagation)
122/// runs over these — it doesn't need the (large) face crops or
123/// embeddings, just enough to look up neighbors.
124struct ProcessedPhotoSummary {
125    photo_id: i64,
126    file_path: String,
127    taken_ts: Option<i64>,
128    brightness: f32,
129    had_error: bool,
130    has_faces: bool,
131}
132
133/// A face ready for DB insertion
134struct FaceInsert {
135    bbox_normalized: (f32, f32, f32, f32),
136    confidence: f32,
137    embedding: FaceEmbedding,
138    aligned_face: image::RgbImage,
139}
140
141struct ContextPropagationInput<'a> {
142    drive_path: &'a Path,
143    photo_id: i64,
144    file_path: &'a str,
145    target_ts: i64,
146    target_brightness: f32,
147    brightness_map: &'a HashMap<i64, f32>,
148}
149
150/// Face processing pipeline
151///
152/// Call `process_photos` to run the full detect -> embed -> cluster pipeline.
153pub struct FaceProcessor;
154
155impl FaceProcessor {
156    const CONTEXT_WINDOW_SECS: i64 = 60;
157    const CONTEXT_MIN_CONFIDENCE: f32 = 0.5;
158
159    /// Run the full face processing pipeline on unprocessed photos.
160    ///
161    /// Uses rayon for parallel detection/embedding with thread-local ONNX sessions,
162    /// then batches DB writes for efficiency.
163    pub fn process_photos(
164        drive_path: &Path,
165        model_dir: &Path,
166        detector_confidence: f32,
167        clustering_threshold: f32,
168        resolver_weights: crate::ml::ResolverWeights,
169        progress_tx: Option<async_channel::Sender<FaceProcessingProgress>>,
170        cancel_flag: Option<Arc<AtomicBool>>,
171    ) -> Result<FaceProcessingResult, String> {
172        // Open database
173        let db = Database::open_for_drive(drive_path)
174            .map_err(|e| format!("Failed to open database: {}", e))?;
175        let face_repo = FaceRepo::new(&db.conn);
176
177        // Reset processing flags if a prior run marked photos as processed
178        // but didn't actually detect any faces (e.g., model loading failed)
179        let _ = face_repo.reset_if_no_faces();
180
181        // Get unprocessed photos
182        let unprocessed = face_repo
183            .get_unprocessed_photos_with_context()
184            .map_err(|e| format!("Failed to get unprocessed photos: {}", e))?;
185
186        let inferred_repo = InferredIdentityRepo::new(&db.conn);
187
188        let total = unprocessed.len();
189        if total == 0 {
190            let clusters_created =
191                Self::run_clustering(&face_repo, clustering_threshold, resolver_weights)?;
192            return Ok(FaceProcessingResult {
193                photos_processed: 0,
194                faces_detected: 0,
195                clusters_created,
196                rejected_small: 0,
197                rejected_lowconf: 0,
198                rejected_blurry: 0,
199                rejected_yaw: 0,
200            });
201        }
202
203        let pipeline_start = std::time::Instant::now();
204
205        // Initialize ONNX Runtime. The bare error from `OnnxRuntime::init`
206        // is already explicit ("library not found, set ORT_DYLIB_PATH or
207        // place libonnxruntime.so..."); the previous wrapper duplicated
208        // those instructions and produced a wall-of-text toast. Keep the
209        // toast short and point users at the script that installs
210        // everything in one shot.
211        let runtime = OnnxRuntime::init().map_err(|e| {
212            format!(
213                "Face detection unavailable — {}. Run scripts/setup_assets.sh in the project root to install the ONNX runtime + face models.",
214                e
215            )
216        })?;
217
218        let detector_path = model_dir.join("scrfd_10g_bnkps.onnx");
219        let cfg = crate::config::AppConfig::load();
220        let embedder_path = model_dir.join(&cfg.face_embedder_model);
221        // Captured per-worker so the embedder can be built once per
222        // thread. Cloned cheaply via Arc<String>.
223        let gpu_bridge_url: Option<String> = if cfg.face_gpu_bridge_enabled {
224            cfg.face_gpu_bridge_url.clone()
225        } else {
226            None
227        };
228        let embedder_model_name: String = cfg.face_embedder_model.clone();
229        // Snapshot intended routing so the UI can show whether
230        // embedding is heading to the bridge or running locally for
231        // this run. We don't probe /health here — workers do that on
232        // their own when they construct the embedder, and per-thread
233        // results would be racy to aggregate. The chip on the UI
234        // reflects intent; live health is the Test Connection button.
235        let embedder_route = if gpu_bridge_url.is_some() {
236            EmbedderRoute::Bridge
237        } else {
238            EmbedderRoute::Local
239        };
240
241        if !detector_path.exists() {
242            return Err(
243                "Face detection model is missing. Run scripts/setup_assets.sh in the project root to download the SCRFD + ArcFace models."
244                    .to_string(),
245            );
246        }
247        if !embedder_path.exists() && gpu_bridge_url.is_none() {
248            return Err(
249                "Face embedding model is missing. Run scripts/setup_assets.sh in the project root to download the face models, or enable a healthy remote GPU bridge."
250                    .to_string(),
251            );
252        }
253
254        // Create faces directory
255        let faces_dir = drive_path.join(".photovault").join("faces");
256        if let Err(e) = std::fs::create_dir_all(&faces_dir) {
257            tracing::warn!("Failed to create faces directory: {}", e);
258        }
259
260        tracing::info!("Face processing: {} photos to process", total);
261
262        // Determine parallelism
263        let available_cpus = std::thread::available_parallelism()
264            .map(|n| n.get())
265            .unwrap_or(4);
266        // Bumped from 6 → 8 because the new two-stage pipeline does
267        // most of its work on tiny thumbnails; per-worker memory is a
268        // fraction of what it was when each worker held a 24 MP
269        // RGB buffer. 8 saturates an 8-core machine without OOM risk.
270        let num_workers = available_cpus.clamp(1, 8);
271        let intra_threads = (available_cpus / num_workers).max(1);
272
273        tracing::info!(
274            "Face pipeline: {} workers, {} intra-threads per session ({} CPUs)",
275            num_workers,
276            intra_threads,
277            available_cpus
278        );
279
280        // Build a custom rayon thread pool so we don't pollute the global pool
281        let pool = rayon::ThreadPoolBuilder::new()
282            .num_threads(num_workers)
283            .build()
284            .map_err(|e| format!("Failed to create thread pool: {}", e))?;
285
286        let processed_count = Arc::new(AtomicUsize::new(0));
287        let faces_count = Arc::new(AtomicUsize::new(0));
288        let rejected_small = Arc::new(AtomicUsize::new(0));
289        let rejected_lowconf = Arc::new(AtomicUsize::new(0));
290        let rejected_blurry = Arc::new(AtomicUsize::new(0));
291        let rejected_yaw = Arc::new(AtomicUsize::new(0));
292        let cancel = cancel_flag
293            .clone()
294            .unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
295        // Stage flag: 0=Detecting, 1=Finishing, 2=Done. The reporter
296        // thread reads this to decide whether to clamp `processed` to
297        // 95% of total — that's how the bar keeps moving during the
298        // clustering tail instead of stalling at 100%.
299        let stage_flag = Arc::new(AtomicU8::new(0));
300        // Bumped by the streaming writer after every chunk commit. The
301        // UI uses this signal to refresh the People view mid-run so
302        // newly-detected faces show up before the whole pipeline ends.
303        let chunks_flushed = Arc::new(AtomicUsize::new(0));
304
305        // Shared paths for thread-local session init
306        let detector_path = Arc::new(detector_path);
307        let embedder_path = Arc::new(embedder_path);
308        let drive_path_arc = Arc::new(drive_path.to_path_buf());
309
310        // Spawn a lightweight progress reporter
311        let progress_handle = {
312            let progress_tx = progress_tx.clone();
313            let processed_count = processed_count.clone();
314            let faces_count = faces_count.clone();
315            let cancel = cancel.clone();
316            let stage_flag = stage_flag.clone();
317            let chunks_flushed_atomic = chunks_flushed.clone();
318            let start_time = std::time::Instant::now();
319            std::thread::spawn(move || {
320                loop {
321                    let stage_raw = stage_flag.load(Ordering::Relaxed);
322                    let processed_raw = processed_count.load(Ordering::Relaxed);
323                    let stage = match stage_raw {
324                        0 => FaceProcessingStage::Detecting,
325                        1 => FaceProcessingStage::Finishing,
326                        _ => FaceProcessingStage::Done,
327                    };
328                    // Cap at 95% during Finishing so the UI shows a
329                    // moving bar with a "wrapping up" hint rather than
330                    // a frozen 100%.
331                    let processed = match stage {
332                        FaceProcessingStage::Finishing if total > 0 => {
333                            let cap = ((total as f64) * 0.95).floor() as usize;
334                            processed_raw.min(cap.max(1))
335                        }
336                        _ => processed_raw,
337                    };
338                    if let Some(ref tx) = progress_tx {
339                        let _ = tx.try_send(FaceProcessingProgress {
340                            processed,
341                            total,
342                            faces_found: faces_count.load(Ordering::Relaxed),
343                            elapsed_secs: start_time.elapsed().as_secs_f64(),
344                            stage,
345                            chunks_flushed: chunks_flushed_atomic.load(Ordering::Relaxed) as u32,
346                            embedder_route,
347                        });
348                    }
349                    // Exit only when the pipeline is fully done OR cancelled.
350                    if matches!(stage, FaceProcessingStage::Done) || cancel.load(Ordering::Relaxed)
351                    {
352                        break;
353                    }
354                    std::thread::sleep(Duration::from_millis(250));
355                }
356            })
357        };
358
359        // ---- Streaming writer thread ----
360        // Owns its own DB connection (rusqlite Connection isn't Send,
361        // so we can't share `db.conn` here). Drains chunks of
362        // PhotoFaceResult from the channel below, transactionally
363        // writes faces + flips faces_processed, and bumps the
364        // chunks_flushed atomic so the UI can refresh mid-run.
365        // Smaller chunks → more frequent UI refreshes during a run.
366        // The writer thread is fast (one transaction per flush), so the
367        // overhead of 5× more flushes is negligible compared to the
368        // visual win: faces appear in the People grid every ~5 photos
369        // instead of every ~25.
370        const FLUSH_CHUNK: usize = 5;
371        let (chunk_tx, chunk_rx) = mpsc::sync_channel::<Vec<PhotoFaceResult>>(4);
372        let writer_handle: std::thread::JoinHandle<Result<(usize, usize), String>> = {
373            let drive_path_buf = drive_path.to_path_buf();
374            let faces_dir_buf = faces_dir.clone();
375            let chunks_flushed = chunks_flushed.clone();
376            let cancel = cancel.clone();
377            let writer_resolver_weights = resolver_weights;
378            std::thread::spawn(move || -> Result<(usize, usize), String> {
379                let writer_db = Database::open_for_drive(&drive_path_buf)
380                    .map_err(|e| format!("Failed to open writer DB: {}", e))?;
381                let mut total_faces = 0usize;
382                let mut photos_processed = 0usize;
383                while let Ok(chunk) = chunk_rx.recv() {
384                    let was_cancelled = cancel.load(Ordering::Relaxed);
385                    let (faces_added, photos_added) = flush_result_chunk(
386                        &writer_db,
387                        &faces_dir_buf,
388                        &chunk,
389                        was_cancelled,
390                        clustering_threshold,
391                        writer_resolver_weights,
392                    )?;
393                    total_faces += faces_added;
394                    photos_processed += photos_added;
395                    chunks_flushed.fetch_add(1, Ordering::Relaxed);
396                }
397                Ok((total_faces, photos_processed))
398            })
399        };
400
401        // ---- Stage 1: Parallel Detection + Embedding (streaming) ----
402        let summaries: Arc<Mutex<Vec<ProcessedPhotoSummary>>> = Arc::new(Mutex::new(Vec::new()));
403        let buffer: Arc<Mutex<Vec<PhotoFaceResult>>> =
404            Arc::new(Mutex::new(Vec::with_capacity(FLUSH_CHUNK)));
405        let chunk_tx = Arc::new(chunk_tx);
406        pool.install(|| {
407            unprocessed
408                .par_iter()
409                .for_each(|(photo_id, file_path, orientation, taken_ts, file_hash)| {
410                    // Skip entirely on cancellation — don't mark this
411                    // photo processed, so a future run will retry it.
412                    if cancel.load(Ordering::Relaxed) {
413                        return;
414                    }
415                    let result: PhotoFaceResult = (|| -> PhotoFaceResult {
416
417                    // Thread-local ONNX sessions
418                    thread_local! {
419                        static DETECTOR: std::cell::RefCell<Option<FaceDetector>> = const { std::cell::RefCell::new(None) };
420                        static EMBEDDER: std::cell::RefCell<Option<FaceEmbedder>> = const { std::cell::RefCell::new(None) };
421                    }
422
423                    // Ensure sessions are initialized for this thread
424                    let det_path = detector_path.clone();
425                    let emb_path = embedder_path.clone();
426                    DETECTOR.with(|d| {
427                        if d.borrow().is_none() {
428                            match FaceDetector::new_with_threads(&runtime, det_path.as_ref(), intra_threads) {
429                                Ok(det) => {
430                                    *d.borrow_mut() = Some(det.with_confidence_threshold(detector_confidence));
431                                }
432                                Err(e) => {
433                                    tracing::error!("Failed to init detector in worker: {}", e);
434                                }
435                            }
436                        }
437                    });
438                    EMBEDDER.with(|e| {
439                        if e.borrow().is_none() {
440                            let embedder_cfg = crate::ml::EmbedderConfig {
441                                model_path: emb_path.as_ref().clone(),
442                                gpu_bridge_url: gpu_bridge_url.clone(),
443                                expected_model: embedder_model_name.clone(),
444                                intra_threads,
445                            };
446                            match FaceEmbedder::from_config(&runtime, &embedder_cfg) {
447                                Ok(emb) => {
448                                    *e.borrow_mut() = Some(emb);
449                                }
450                                Err(e_err) => {
451                                    tracing::error!("Failed to init embedder in worker: {}", e_err);
452                                }
453                            }
454                        }
455                    });
456
457                    // Two-stage decode: detect on the cheap cached
458                    // thumbnail, embed on the full image. SCRFD
459                    // downsamples to 640×640 internally so detection
460                    // accuracy doesn't care about the source size; it's
461                    // the ArcFace 112×112 alignment crop that needs
462                    // resolution. Decoding a 24 MP HEIC takes ~250 ms
463                    // vs ~5 ms for the 860 px thumb — for the ~80% of
464                    // photos that have zero faces, we skip the full
465                    // decode entirely.
466                    let full_path = match safe_join_relative(&drive_path_arc, file_path) {
467                        Ok(path) => path,
468                        Err(e) => {
469                            tracing::debug!("Invalid stored photo path {}: {}", file_path, e);
470                            processed_count.fetch_add(1, Ordering::Relaxed);
471                            return PhotoFaceResult {
472                                photo_id: *photo_id,
473                                file_path: file_path.clone(),
474                                faces: Vec::new(),
475                                taken_ts: *taken_ts,
476                                brightness: 0.0,
477                                had_error: true,
478                            };
479                        }
480                    };
481                    let large_thumb = drive_path_arc
482                        .join(".photovault")
483                        .join("thumbnails")
484                        .join("large")
485                        .join("v2")
486                        .join(&file_hash[..2.min(file_hash.len())])
487                        .join(format!("{}.jpg", file_hash));
488
489                    // ---- Stage 1: detect on the thumbnail when present ----
490                    let detect_image: image::DynamicImage = if large_thumb.exists() {
491                        match image::ImageReader::open(&large_thumb)
492                            .ok()
493                            .and_then(|r| r.with_guessed_format().ok())
494                            .and_then(|r| r.decode().ok())
495                        {
496                            Some(img) => img,
497                            None => match crate::services::image_io::open_image(&full_path) {
498                                Ok(img) => {
499                                    let img = apply_exif_orientation(img, *orientation);
500                                    let max_dim = img.width().max(img.height());
501                                    if max_dim > 2048 {
502                                        img.resize(2048, 2048, image::imageops::FilterType::Triangle)
503                                    } else {
504                                        img
505                                    }
506                                }
507                                Err(e) => {
508                                    tracing::debug!("Failed to open image {}: {}", file_path, e);
509                                    processed_count.fetch_add(1, Ordering::Relaxed);
510                                    return PhotoFaceResult {
511                                        photo_id: *photo_id,
512                                        file_path: file_path.clone(),
513                                        faces: Vec::new(),
514                                        taken_ts: *taken_ts,
515                                        brightness: 0.0,
516                                        had_error: true,
517                                    };
518                                }
519                            },
520                        }
521                    } else {
522                        // No thumbnail yet — decode full and use it for
523                        // both stages.
524                        match crate::services::image_io::open_image(&full_path) {
525                            Ok(img) => {
526                                let img = apply_exif_orientation(img, *orientation);
527                                let max_dim = img.width().max(img.height());
528                                if max_dim > 2048 {
529                                    img.resize(2048, 2048, image::imageops::FilterType::Triangle)
530                                } else {
531                                    img
532                                }
533                            }
534                            Err(e) => {
535                                tracing::debug!("Failed to open image {}: {}", file_path, e);
536                                processed_count.fetch_add(1, Ordering::Relaxed);
537                                return PhotoFaceResult {
538                                    photo_id: *photo_id,
539                                    file_path: file_path.clone(),
540                                    faces: Vec::new(),
541                                    taken_ts: *taken_ts,
542                                    brightness: 0.0,
543                                    had_error: true,
544                                };
545                            }
546                        }
547                    };
548
549                    // Brightness needs only thumbnail resolution.
550                    let brightness = Self::average_brightness(&detect_image);
551
552                    if cancel.load(Ordering::Relaxed) {
553                        return PhotoFaceResult {
554                            photo_id: *photo_id,
555                            file_path: file_path.clone(),
556                            faces: Vec::new(),
557                            taken_ts: *taken_ts,
558                            brightness,
559                            had_error: true,
560                        };
561                    }
562
563                    // Detect faces on the thumbnail.
564                    let mut detected = DETECTOR.with(|d| {
565                        let mut borrow = d.borrow_mut();
566                        match borrow.as_mut() {
567                            Some(det) => det.detect_adaptive(&detect_image),
568                            None => Vec::new(),
569                        }
570                    });
571
572                    // ---- Stage 2: re-align faces against the full
573                    // image for crisp embeddings. We only pay the
574                    // full-decode cost when faces actually exist.
575                    if !detected.is_empty() && large_thumb.exists() {
576                        if let Ok(full_img) =
577                            crate::services::image_io::open_image(&full_path)
578                        {
579                            let full_img = apply_exif_orientation(full_img, *orientation);
580                            let scale_x =
581                                full_img.width() as f32 / detect_image.width() as f32;
582                            let scale_y =
583                                full_img.height() as f32 / detect_image.height() as f32;
584                            for face in detected.iter_mut() {
585                                let mut scaled = face.landmarks;
586                                for lm in &mut scaled {
587                                    lm.0 *= scale_x;
588                                    lm.1 *= scale_y;
589                                }
590                                if let Some(aligned) =
591                                    crate::ml::alignment::align_face_112(&full_img, &scaled)
592                                {
593                                    face.aligned_face = Some(aligned);
594                                }
595                            }
596                        }
597                    }
598
599                    if !detected.is_empty() {
600                        tracing::info!(
601                            "Photo: {} faces detected in {}",
602                            detected.len(),
603                            file_path
604                        );
605                    }
606
607                    // Cancel check: post-detect, before per-face embeds.
608                    if cancel.load(Ordering::Relaxed) {
609                        return PhotoFaceResult {
610                            photo_id: *photo_id,
611                            file_path: file_path.clone(),
612                            faces: Vec::new(),
613                            taken_ts: *taken_ts,
614                            brightness,
615                            had_error: true,
616                        };
617                    }
618
619                    // Collect faces that pass quality gates, then embed as a single
620                    // batch. Group photos can have 8+ faces; batching eliminates
621                    // 3-5 separate ONNX sessions into one.
622                    let mut qualifying: Vec<(usize, &image::RgbImage)> = Vec::new();
623                    for (fi, face) in detected.iter().enumerate() {
624                        if cancel.load(Ordering::Relaxed) {
625                            break;
626                        }
627                        let (_bx, _by, bw, bh) = face.bbox;
628                        if bw * bh < Self::MIN_FACE_AREA_PX2 {
629                            rejected_small.fetch_add(1, Ordering::Relaxed);
630                            continue;
631                        }
632                        if face.confidence < Self::MIN_FACE_CONFIDENCE {
633                            rejected_lowconf.fetch_add(1, Ordering::Relaxed);
634                            continue;
635                        }
636                        let aligned = match face.aligned_face.as_ref() {
637                            Some(a) => a,
638                            None => continue,
639                        };
640                        let sharpness = Self::laplacian_variance(aligned);
641                        if sharpness < Self::MIN_LAPLACIAN_VAR {
642                            rejected_blurry.fetch_add(1, Ordering::Relaxed);
643                            continue;
644                        }
645                        let yaw = crate::ml::face_detector::estimate_yaw_from_landmarks(
646                            &face.landmarks,
647                        );
648                        if yaw > Self::MAX_FACE_YAW_DEG {
649                            rejected_yaw.fetch_add(1, Ordering::Relaxed);
650                            continue;
651                        }
652                        qualifying.push((fi, aligned));
653                    }
654
655                    let crops: Vec<image::RgbImage> = qualifying
656                        .iter()
657                        .map(|(_, a)| (*a).clone())
658                        .collect();
659                    let batch_embs = if crops.is_empty() {
660                        Vec::new()
661                    } else {
662                        EMBEDDER.with(|e| {
663                            let mut borrow = e.borrow_mut();
664                            borrow
665                                .as_mut()
666                                .map_or(Vec::new(), |emb| emb.embed_batch(&crops))
667                        })
668                    };
669
670                    let mut face_inserts = Vec::new();
671                    for (bi, &(fi, _aligned)) in qualifying.iter().enumerate() {
672                        if let Some(embedding) = batch_embs.get(bi).and_then(|e: &Option<FaceEmbedding>| e.clone()) {
673                            face_inserts.push(FaceInsert {
674                                bbox_normalized: detected[fi].bbox_normalized,
675                                confidence: detected[fi].confidence,
676                                embedding,
677                                aligned_face: detected[fi].aligned_face.clone().unwrap_or_default(),
678                            });
679                        }
680                    }
681
682                    faces_count.fetch_add(face_inserts.len(), Ordering::Relaxed);
683                    processed_count.fetch_add(1, Ordering::Relaxed);
684
685                    PhotoFaceResult {
686                        photo_id: *photo_id,
687                        file_path: file_path.clone(),
688                        faces: face_inserts,
689                        taken_ts: *taken_ts,
690                        brightness,
691                        had_error: false,
692                    }
693                    })();
694
695                    // Record summary BEFORE handing the result off — the
696                    // writer thread takes ownership of `result`, but
697                    // Stage 3 still needs the photo metadata.
698                    summaries
699                        .lock()
700                        .unwrap_or_else(|e| e.into_inner())
701                        .push(ProcessedPhotoSummary {
702                        photo_id: result.photo_id,
703                        file_path: result.file_path.clone(),
704                        taken_ts: result.taken_ts,
705                        brightness: result.brightness,
706                        had_error: result.had_error,
707                        has_faces: !result.faces.is_empty(),
708                    });
709
710                    // Push into the streaming buffer. When it hits
711                    // FLUSH_CHUNK, ship the chunk to the writer thread
712                    // so the user sees faces appear mid-run AND the
713                    // photos are persisted as faces_processed=TRUE.
714                    let mut buf = buffer.lock().unwrap_or_else(|e| e.into_inner());
715                    buf.push(result);
716                    if buf.len() >= FLUSH_CHUNK {
717                        let chunk = std::mem::replace(&mut *buf, Vec::with_capacity(FLUSH_CHUNK));
718                        drop(buf);
719                        // send blocks if writer is behind — that's fine,
720                        // it's natural backpressure that prevents memory
721                        // blow-up on a fast detector / slow disk.
722                        let _ = chunk_tx.send(chunk);
723                    }
724                });
725        });
726
727        // Final flush of any sub-FLUSH_CHUNK leftover photos.
728        {
729            let leftover = std::mem::take(&mut *buffer.lock().unwrap_or_else(|e| e.into_inner()));
730            if !leftover.is_empty() {
731                let _ = chunk_tx.send(leftover);
732            }
733        }
734        // Drop the only remaining sender — once the writer drains the
735        // last chunk, recv() returns Err and the thread exits.
736        drop(chunk_tx);
737
738        // Wait for the writer to finish committing.
739        let (mut total_faces, mut photos_processed) = match writer_handle.join() {
740            Ok(Ok(pair)) => pair,
741            Ok(Err(e)) => return Err(e),
742            Err(_) => return Err("Face writer thread panicked".to_string()),
743        };
744        // The writer used `mut` because Stage 3 below still adds via
745        // brightness lookups; but for now the only mutation here is to
746        // refresh `total_faces`/`photos_processed` for the toast text.
747        // Suppress the unused-mut lint if the compiler flags it.
748        let _ = (&mut total_faces, &mut photos_processed);
749
750        // Per-photo loop is done. Move into the post-processing tail
751        // (DB writes + propagation + clustering). The progress reporter
752        // keeps running and emits `Finishing` events with a 95% cap so
753        // the UI shows continued activity rather than stalling at x/x.
754        stage_flag.store(1, Ordering::Relaxed);
755
756        // Check if cancelled partway through
757        let was_cancelled = cancel.load(Ordering::Relaxed);
758
759        let rej_small = rejected_small.load(Ordering::Relaxed);
760        let rej_lowconf = rejected_lowconf.load(Ordering::Relaxed);
761        let rej_blurry = rejected_blurry.load(Ordering::Relaxed);
762        let rej_yaw = rejected_yaw.load(Ordering::Relaxed);
763        if rej_small + rej_lowconf + rej_blurry + rej_yaw > 0 {
764            tracing::info!(
765                "Quality filter: rejected {} small, {} low-confidence, {} blurry, {} high-yaw faces",
766                rej_small,
767                rej_lowconf,
768                rej_blurry,
769                rej_yaw
770            );
771        }
772
773        if was_cancelled {
774            stage_flag.store(2, Ordering::Relaxed);
775            if let Some(ref tx) = progress_tx {
776                let _ = tx.try_send(FaceProcessingProgress {
777                    processed: photos_processed,
778                    total,
779                    faces_found: total_faces,
780                    elapsed_secs: pipeline_start.elapsed().as_secs_f64(),
781                    stage: FaceProcessingStage::Done,
782                    chunks_flushed: chunks_flushed.load(Ordering::Relaxed) as u32,
783                    embedder_route,
784                });
785            }
786            let _ = progress_handle.join();
787            return Ok(FaceProcessingResult {
788                photos_processed,
789                faces_detected: total_faces,
790                clusters_created: 0,
791                rejected_small: rej_small,
792                rejected_lowconf: rej_lowconf,
793                rejected_blurry: rej_blurry,
794                rejected_yaw: rej_yaw,
795            });
796        }
797
798        // ---- Stage 3: Contextual Identity Propagation ----
799        // Build a brightness map from successful summaries so propagation
800        // can compare lighting between target and neighbor photos
801        // without reopening images. Errored photos are excluded — their
802        // brightness was never measured.
803        let summaries_vec =
804            std::mem::take(&mut *summaries.lock().unwrap_or_else(|e| e.into_inner()));
805        let brightness_map: HashMap<i64, f32> = summaries_vec
806            .iter()
807            .filter(|s| !s.had_error)
808            .map(|s| (s.photo_id, s.brightness))
809            .collect();
810
811        for summary in &summaries_vec {
812            if summary.had_error || summary.has_faces {
813                continue; // Only propagate for photos with no detected faces
814            }
815            if let Some(target_ts) = summary.taken_ts {
816                let params = ContextPropagationInput {
817                    drive_path,
818                    photo_id: summary.photo_id,
819                    file_path: &summary.file_path,
820                    target_ts,
821                    target_brightness: summary.brightness,
822                    brightness_map: &brightness_map,
823                };
824                let _ = Self::propagate_identity_from_context(&face_repo, &inferred_repo, &params);
825            }
826        }
827        // Mid-tail progress nudge so the UI sees an immediate "Finishing"
828        // tick once Stages 2-3 wrap up — the reporter would catch this on
829        // the next 250 ms cycle anyway, but an explicit send eliminates
830        // the visible gap before clustering kicks off.
831        if let Some(ref tx) = progress_tx {
832            let _ = tx.try_send(FaceProcessingProgress {
833                processed: photos_processed,
834                total,
835                faces_found: total_faces,
836                elapsed_secs: pipeline_start.elapsed().as_secs_f64(),
837                stage: FaceProcessingStage::Finishing,
838                chunks_flushed: chunks_flushed.load(Ordering::Relaxed) as u32,
839                embedder_route,
840            });
841        }
842
843        // ---- Stage 4: Clustering ----
844        let clusters_created =
845            Self::run_clustering(&face_repo, clustering_threshold, resolver_weights)?;
846
847        // Pipeline complete: flip stage so the reporter exits its loop
848        // and emit one final 100% Done tick for the UI.
849        stage_flag.store(2, Ordering::Relaxed);
850        if let Some(ref tx) = progress_tx {
851            let _ = tx.try_send(FaceProcessingProgress {
852                processed: photos_processed,
853                total,
854                faces_found: total_faces,
855                elapsed_secs: pipeline_start.elapsed().as_secs_f64(),
856                stage: FaceProcessingStage::Done,
857                chunks_flushed: chunks_flushed.load(Ordering::Relaxed) as u32,
858                embedder_route,
859            });
860        }
861
862        // Join the reporter now that we've signalled Done.
863        let _ = progress_handle.join();
864
865        tracing::info!(
866            "Face processing complete: {} photos, {} faces, {} clusters",
867            photos_processed,
868            total_faces,
869            clusters_created
870        );
871
872        Ok(FaceProcessingResult {
873            photos_processed,
874            faces_detected: total_faces,
875            clusters_created,
876            rejected_small: rej_small,
877            rejected_lowconf: rej_lowconf,
878            rejected_blurry: rej_blurry,
879            rejected_yaw: rej_yaw,
880        })
881    }
882
883    fn propagate_identity_from_context(
884        face_repo: &FaceRepo,
885        inferred_repo: &InferredIdentityRepo,
886        params: &ContextPropagationInput<'_>,
887    ) -> Result<usize, String> {
888        let folder_like = std::path::Path::new(params.file_path).parent().map(|p| {
889            let s = p.to_string_lossy();
890            if s.is_empty() {
891                "%".to_string()
892            } else {
893                format!("{}%", s)
894            }
895        });
896
897        let candidates = face_repo
898            .get_contextual_cluster_candidates(
899                params.photo_id,
900                folder_like.as_deref(),
901                params.target_ts,
902                Self::CONTEXT_WINDOW_SECS,
903            )
904            .map_err(|e| format!("Failed to query contextual candidates: {}", e))?;
905
906        if candidates.is_empty() {
907            return Ok(0);
908        }
909
910        let mut best_by_cluster: HashMap<i64, (i64, f32)> = HashMap::new();
911
912        for (source_photo_id, cluster_id, source_ts, source_file_path) in candidates {
913            let delta = (source_ts - params.target_ts).abs() as f32;
914            let temporal_score = 1.0 - (delta / Self::CONTEXT_WINDOW_SECS as f32).clamp(0.0, 1.0);
915            let mut confidence = 0.5 + (temporal_score * 0.4);
916
917            // Brightness lookup: in-run map (this run's photos) →
918            // photos.brightness column (prior runs) → on-disk recompute
919            // (legacy rows from before brightness was persisted, until
920            // they get reprocessed and rewritten).
921            let source_brightness = params
922                .brightness_map
923                .get(&source_photo_id)
924                .copied()
925                .or_else(|| {
926                    face_repo
927                        .get_photo_brightness(source_photo_id)
928                        .ok()
929                        .flatten()
930                })
931                .or_else(|| {
932                    Self::load_average_brightness_from_relative(
933                        params.drive_path,
934                        &source_file_path,
935                    )
936                });
937
938            if let Some(source_brightness) = source_brightness {
939                // Smooth falloff instead of hard cutoff (Phase 2 fix included)
940                let diff = (params.target_brightness - source_brightness).abs();
941                let brightness_bonus = 0.1 * (1.0 - (diff / 0.3).clamp(0.0, 1.0));
942                confidence += brightness_bonus;
943            }
944
945            confidence = confidence.clamp(0.0, 1.0);
946
947            if confidence < Self::CONTEXT_MIN_CONFIDENCE {
948                continue;
949            }
950
951            match best_by_cluster.get(&cluster_id) {
952                Some((_, existing)) if *existing >= confidence => {}
953                _ => {
954                    best_by_cluster.insert(cluster_id, (source_photo_id, confidence));
955                }
956            }
957        }
958
959        let mut inserted = 0usize;
960        for (cluster_id, (source_photo_id, confidence)) in best_by_cluster {
961            if inferred_repo
962                .insert_inferred_identity(params.photo_id, cluster_id, source_photo_id, confidence)
963                .is_ok()
964            {
965                inserted += 1;
966            }
967        }
968
969        Ok(inserted)
970    }
971
972    /// Minimum bbox area (pixels^2) for a face to be worth embedding.
973    /// 30x30 px = 900.
974    const MIN_FACE_AREA_PX2: f32 = 900.0;
975
976    /// Minimum detection confidence to accept a face for embedding, even if the
977    /// detector's own threshold is looser.
978    const MIN_FACE_CONFIDENCE: f32 = 0.55;
979
980    /// Minimum Laplacian variance on the 112x112 aligned crop. Below this the
981    /// face is too blurry to produce a reliable embedding.
982    const MIN_LAPLACIAN_VAR: f32 = 40.0;
983
984    /// Maximum absolute yaw angle (degrees) for face embedding. Side-profile
985    /// faces produce unreliable embeddings and create bridge edges in clustering.
986    const MAX_FACE_YAW_DEG: f32 = 35.0;
987
988    /// Laplacian-of-gaussian-style blur measure on a 112x112 aligned crop.
989    /// Higher = sharper. Very blurry faces score near 0.
990    fn laplacian_variance(img: &image::RgbImage) -> f32 {
991        let w = img.width() as i32;
992        let h = img.height() as i32;
993        if w < 3 || h < 3 {
994            return 0.0;
995        }
996
997        let mut gray = vec![0.0f32; (w * h) as usize];
998        for y in 0..h {
999            for x in 0..w {
1000                let p = img.get_pixel(x as u32, y as u32).0;
1001                gray[(y * w + x) as usize] =
1002                    0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32;
1003            }
1004        }
1005
1006        let mut sum = 0.0f32;
1007        let mut sum_sq = 0.0f32;
1008        let mut count = 0usize;
1009        for y in 1..(h - 1) {
1010            for x in 1..(w - 1) {
1011                let idx = (y * w + x) as usize;
1012                let center = gray[idx];
1013                let l = 4.0 * center
1014                    - gray[(y * w + (x - 1)) as usize]
1015                    - gray[(y * w + (x + 1)) as usize]
1016                    - gray[((y - 1) * w + x) as usize]
1017                    - gray[((y + 1) * w + x) as usize];
1018                sum += l;
1019                sum_sq += l * l;
1020                count += 1;
1021            }
1022        }
1023
1024        let n = count as f32;
1025        let mean = sum / n;
1026        (sum_sq / n) - mean * mean
1027    }
1028
1029    /// Build a resolver context for one face: co-occurrence counts between
1030    /// candidate clusters and the already-assigned clusters in this photo,
1031    /// plus temporal-neighbor assignments.
1032    fn build_resolver_context(
1033        face_repo: &FaceRepo,
1034        photo_id: i64,
1035        face_id: i64,
1036        hits: &[crate::ml::RetrievalHit],
1037    ) -> crate::ml::ResolverContext {
1038        const TEMPORAL_WINDOW_SECS: i64 = 60;
1039
1040        let photo_other_clusters = face_repo
1041            .get_photo_other_clusters(photo_id, face_id)
1042            .unwrap_or_default();
1043
1044        let mut cooccurrence_scores: std::collections::HashMap<i64, i64> =
1045            std::collections::HashMap::new();
1046        if !photo_other_clusters.is_empty() {
1047            for hit in hits {
1048                let mut total: i64 = 0;
1049                for other in &photo_other_clusters {
1050                    total += face_repo
1051                        .cooccurrence_count(hit.cluster_id, *other)
1052                        .unwrap_or(0);
1053                }
1054                if total > 0 {
1055                    cooccurrence_scores.insert(hit.cluster_id, total);
1056                }
1057            }
1058        }
1059
1060        let temporal_neighbor_clusters: std::collections::HashSet<i64> = face_repo
1061            .temporal_neighbor_clusters(photo_id, TEMPORAL_WINDOW_SECS)
1062            .unwrap_or_default()
1063            .into_iter()
1064            .map(|(cid, _)| cid)
1065            .collect();
1066
1067        crate::ml::ResolverContext {
1068            photo_other_clusters,
1069            cooccurrence_scores,
1070            temporal_neighbor_clusters,
1071        }
1072    }
1073
1074    fn average_brightness(image: &image::DynamicImage) -> f32 {
1075        let small = image
1076            .resize(64, 64, image::imageops::FilterType::Triangle)
1077            .to_rgb8();
1078        let mut total = 0.0f32;
1079        let mut count = 0.0f32;
1080
1081        for p in small.pixels() {
1082            let [r, g, b] = p.0;
1083            total += (0.2126 * (r as f32) + 0.7152 * (g as f32) + 0.0722 * (b as f32)) / 255.0;
1084            count += 1.0;
1085        }
1086
1087        if count == 0.0 {
1088            0.0
1089        } else {
1090            total / count
1091        }
1092    }
1093
1094    fn load_average_brightness_from_relative(
1095        drive_path: &Path,
1096        relative_path: &str,
1097    ) -> Option<f32> {
1098        let path = match safe_join_relative(drive_path, relative_path) {
1099            Ok(path) => path,
1100            Err(e) => {
1101                tracing::trace!(
1102                    "context-brightness lookup skipped invalid path {}: {}",
1103                    relative_path,
1104                    e
1105                );
1106                return None;
1107            }
1108        };
1109        match crate::services::image_io::open_image(&path) {
1110            Ok(image) => Some(Self::average_brightness(&image)),
1111            Err(e) => {
1112                tracing::trace!(
1113                    "context-brightness lookup failed for {}: {}",
1114                    path.display(),
1115                    e
1116                );
1117                None
1118            }
1119        }
1120    }
1121}
1122
1123/// Commit one streamed chunk of per-photo results: insert detected
1124/// faces, mark photos `faces_processed=TRUE`, save face crops to disk.
1125/// On cancellation, photos with `had_error && faces.is_empty()` are
1126/// left as `faces_processed=FALSE` so a later run will retry them.
1127///
1128/// After the commit, runs an inline Stage A pass: each freshly-inserted
1129/// face is matched against existing cluster galleries and assigned a
1130/// `cluster_id` if a HIGH-confidence match exists. This is what makes
1131/// faces appear in the People view *during* the run — without it,
1132/// faces carry `cluster_id = NULL` until end-of-pipeline clustering
1133/// and the grid stays empty.
1134///
1135/// Returns `(faces_added, photos_added)` for stats only — durable state
1136/// is the SQL writes.
1137fn flush_result_chunk(
1138    db: &Database,
1139    faces_dir: &Path,
1140    chunk: &[PhotoFaceResult],
1141    was_cancelled: bool,
1142    clustering_threshold: f32,
1143    resolver_weights: crate::ml::ResolverWeights,
1144) -> Result<(usize, usize), String> {
1145    let mut faces_added = 0usize;
1146    let mut photos_added = 0usize;
1147
1148    let tx = db
1149        .conn
1150        .unchecked_transaction()
1151        .map_err(|e| format!("Failed to begin chunk transaction: {}", e))?;
1152
1153    for result in chunk {
1154        if result.had_error && result.faces.is_empty() {
1155            // Mark errored photos processed only when we're not in the
1156            // middle of a cancel — otherwise leave them queued for next run.
1157            if !was_cancelled {
1158                let _ = tx.execute(
1159                    "UPDATE photos SET faces_processed = TRUE WHERE id = ?1",
1160                    rusqlite::params![result.photo_id],
1161                );
1162                photos_added += 1;
1163            }
1164            continue;
1165        }
1166
1167        let _ = tx.execute(
1168            "DELETE FROM photo_inferred_identities WHERE photo_id = ?1",
1169            rusqlite::params![result.photo_id],
1170        );
1171
1172        for face in &result.faces {
1173            match tx.execute(
1174                r#"
1175                INSERT INTO faces (
1176                    photo_id,
1177                    bbox_x, bbox_y, bbox_width, bbox_height,
1178                    confidence, embedding
1179                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1180                "#,
1181                rusqlite::params![
1182                    result.photo_id,
1183                    face.bbox_normalized.0,
1184                    face.bbox_normalized.1,
1185                    face.bbox_normalized.2,
1186                    face.bbox_normalized.3,
1187                    face.confidence,
1188                    face.embedding.to_bytes(),
1189                ],
1190            ) {
1191                Ok(_) => {
1192                    let face_id = tx.last_insert_rowid();
1193                    let crop_path = faces_dir.join(format!("{}.jpg", face_id));
1194                    if let Err(e) = FaceProcessor::save_face_crop(&face.aligned_face, &crop_path) {
1195                        tracing::warn!("Failed to save face crop {}: {}", face_id, e);
1196                    }
1197                    faces_added += 1;
1198                }
1199                Err(e) => {
1200                    tracing::warn!("Failed to insert face: {}", e);
1201                }
1202            }
1203        }
1204
1205        // Persist brightness alongside the processed flag so future
1206        // runs and propagation lookups can read it from SQL instead of
1207        // re-decoding the JPEG to compute luma again.
1208        let _ = tx.execute(
1209            "UPDATE photos SET faces_processed = TRUE, brightness = ?2 WHERE id = ?1",
1210            rusqlite::params![result.photo_id, result.brightness],
1211        );
1212        photos_added += 1;
1213    }
1214
1215    tx.commit()
1216        .map_err(|e| format!("Failed to commit chunk: {}", e))?;
1217
1218    // Per-chunk Stage A: assign HIGH-band gallery matches now so the
1219    // user sees faces appear in People mid-run for everyone they've
1220    // already named (or who survived a previous run). New people are
1221    // still introduced at the end-of-pipeline clustering pass.
1222    let face_repo = FaceRepo::new(&db.conn);
1223    if let Err(e) = FaceProcessor::stream_assign_existing_clusters(
1224        &face_repo,
1225        clustering_threshold,
1226        resolver_weights,
1227    ) {
1228        tracing::debug!("Per-chunk Stage A skipped: {}", e);
1229    }
1230
1231    Ok((faces_added, photos_added))
1232}
1233
1234mod clustering;