1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum FaceProcessingStage {
28 Detecting,
30 Finishing,
34 Done,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum EmbedderRoute {
46 Local,
48 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#[derive(Debug, Clone)]
63pub struct FaceProcessingProgress {
64 pub processed: usize,
65 pub total: usize,
66 pub faces_found: usize,
67 pub elapsed_secs: f64,
69 pub stage: FaceProcessingStage,
70 pub chunks_flushed: u32,
75 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#[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
110struct 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
120struct 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
133struct 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
150pub struct FaceProcessor;
154
155impl FaceProcessor {
156 const CONTEXT_WINDOW_SECS: i64 = 60;
157 const CONTEXT_MIN_CONFIDENCE: f32 = 0.5;
158
159 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 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 let _ = face_repo.reset_if_no_faces();
180
181 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 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 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 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 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 let available_cpus = std::thread::available_parallelism()
264 .map(|n| n.get())
265 .unwrap_or(4);
266 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 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 let stage_flag = Arc::new(AtomicU8::new(0));
300 let chunks_flushed = Arc::new(AtomicUsize::new(0));
304
305 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 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 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 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 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 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 if cancel.load(Ordering::Relaxed) {
413 return;
414 }
415 let result: PhotoFaceResult = (|| -> PhotoFaceResult {
416
417 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 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 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 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 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 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 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 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 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 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 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 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 let _ = chunk_tx.send(chunk);
723 }
724 });
725 });
726
727 {
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(chunk_tx);
737
738 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 let _ = (&mut total_faces, &mut photos_processed);
749
750 stage_flag.store(1, Ordering::Relaxed);
755
756 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 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; }
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, ¶ms);
825 }
826 }
827 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 let clusters_created =
845 Self::run_clustering(&face_repo, clustering_threshold, resolver_weights)?;
846
847 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 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 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 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 const MIN_FACE_AREA_PX2: f32 = 900.0;
975
976 const MIN_FACE_CONFIDENCE: f32 = 0.55;
979
980 const MIN_LAPLACIAN_VAR: f32 = 40.0;
983
984 const MAX_FACE_YAW_DEG: f32 = 35.0;
987
988 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 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
1123fn 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 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 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 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;