rethinks commited on
Commit
1c26659
·
verified ·
1 Parent(s): 1495405

Upload 4 files

Browse files
app.py CHANGED
@@ -344,6 +344,242 @@ def process_photos_face_filter_only(job_id, upload_dir, session_id=None):
344
  traceback.print_exc()
345
 
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  def save_photos_by_month(job_id, upload_dir, selected_photos, rejected_photos, month_stats):
348
  """
349
  Automatically save both selected and not-selected photos organized by month.
@@ -1477,7 +1713,16 @@ def import_from_drive():
1477
  # Start download in background thread
1478
  def download_and_process():
1479
  try:
1480
- def progress_callback(current, total, filename):
 
 
 
 
 
 
 
 
 
1481
  pct = int(5 + (current / total) * 25) # 5% to 30%
1482
  processing_jobs[job_id]['progress'] = pct
1483
  processing_jobs[job_id]['message'] = f'Downloading from Drive: {current}/{total}'
@@ -1500,14 +1745,9 @@ def import_from_drive():
1500
 
1501
  print(f"[Job {job_id}] Downloaded {downloaded_count} photos from Google Drive")
1502
 
1503
- # Now start the face filtering or quality selection
1504
- if has_references:
1505
- processing_jobs[job_id]['message'] = f'Scanning {downloaded_count} photos for faces...'
1506
- process_photos_face_filter_only(job_id, upload_dir, face_session_id)
1507
- else:
1508
- # No face filtering - use all downloaded photos as confirmed
1509
- processing_jobs[job_id]['message'] = f'Selecting best from {downloaded_count} photos...'
1510
- process_photos_quality_selection(job_id, upload_dir, quality_mode, similarity_threshold, downloaded_files)
1511
 
1512
  except Exception as e:
1513
  print(f"[Job {job_id}] Drive import error: {e}")
 
344
  traceback.print_exc()
345
 
346
 
347
+ def process_drive_with_parallel_face_detection(job_id, folder_id, upload_dir, face_matcher):
348
+ """
349
+ HYBRID APPROACH: Download files from Google Drive while running face detection in parallel.
350
+
351
+ This overlaps network I/O (downloading) with GPU compute (face detection) for faster processing.
352
+
353
+ Flow:
354
+ - Download thread: Downloads files and adds paths to queue
355
+ - Face detection thread: Processes files from queue as they become ready
356
+ - Both run simultaneously for maximum efficiency
357
+ """
358
+ import queue
359
+ import threading
360
+
361
+ print(f"\n{'='*60}")
362
+ print(f"[Job {job_id}] HYBRID MODE: Parallel Download + Face Detection")
363
+ print(f"{'='*60}")
364
+
365
+ # Shared state
366
+ file_queue = queue.Queue()
367
+ results_lock = threading.Lock()
368
+ matched_photos = []
369
+ unmatched_photos = []
370
+ no_faces_photos = []
371
+ error_photos = []
372
+
373
+ # Counters
374
+ download_complete = threading.Event()
375
+ total_files = [0]
376
+ downloaded_count = [0]
377
+ processed_count = [0]
378
+
379
+ # Face detection worker
380
+ def face_detection_worker():
381
+ """Process files from queue as they become available."""
382
+ while True:
383
+ try:
384
+ # Wait for file or check if download is complete
385
+ try:
386
+ filepath = file_queue.get(timeout=1.0)
387
+ except queue.Empty:
388
+ # Check if download is complete and queue is empty
389
+ if download_complete.is_set() and file_queue.empty():
390
+ break
391
+ continue
392
+
393
+ if filepath is None: # Poison pill
394
+ break
395
+
396
+ # Process the file
397
+ result = face_matcher.check_photo_for_target(filepath)
398
+
399
+ with results_lock:
400
+ processed_count[0] += 1
401
+
402
+ if 'error' in result:
403
+ error_photos.append({'path': filepath, 'error': result['error']})
404
+ elif result['num_faces'] == 0:
405
+ no_faces_photos.append({'path': filepath, 'num_faces': 0})
406
+ elif result['contains_target']:
407
+ matched_photos.append({
408
+ 'path': filepath,
409
+ 'similarity': result['best_match_similarity'],
410
+ 'num_faces': result['num_faces'],
411
+ 'all_similarities': result.get('all_face_similarities', []),
412
+ 'face_bboxes': result.get('face_bboxes', [])
413
+ })
414
+ else:
415
+ unmatched_photos.append({
416
+ 'path': filepath,
417
+ 'best_similarity': result['best_match_similarity'],
418
+ 'num_faces': result['num_faces']
419
+ })
420
+
421
+ # Update progress (use unified message format)
422
+ if processed_count[0] % 10 == 0:
423
+ # After downloads complete, show scan-only progress
424
+ if download_complete.is_set():
425
+ pct = 30 + int((processed_count[0] / max(total_files[0], 1)) * 40)
426
+ processing_jobs[job_id]['progress'] = min(pct, 70)
427
+ processing_jobs[job_id]['message'] = f'Scanning faces: {processed_count[0]}/{total_files[0]} ({len(matched_photos)} matched)'
428
+ processing_jobs[job_id]['photos_checked'] = processed_count[0]
429
+ print(f"[Job {job_id}] [HYBRID] Downloaded: {downloaded_count[0]}, Face checked: {processed_count[0]}, Matched: {len(matched_photos)}")
430
+
431
+ file_queue.task_done()
432
+
433
+ except Exception as e:
434
+ print(f"[Job {job_id}] Face detection error: {e}")
435
+ continue
436
+
437
+ # Callback when file is downloaded
438
+ def on_file_ready(filepath):
439
+ """Called by download_folder when each file is ready."""
440
+ with results_lock:
441
+ downloaded_count[0] += 1
442
+ file_queue.put(filepath)
443
+
444
+ # Progress callback for download
445
+ def download_progress(current, total, _filename):
446
+ total_files[0] = total
447
+ pct = 5 + int((current / total) * 25) # 5-30%
448
+ processing_jobs[job_id]['progress'] = pct
449
+ processing_jobs[job_id]['message'] = f'Downloading: {current}/{total}, Scanning: {processed_count[0]}'
450
+ processing_jobs[job_id]['total_files'] = total
451
+
452
+ try:
453
+ processing_jobs[job_id]['status'] = 'processing'
454
+ processing_jobs[job_id]['progress'] = 5
455
+ processing_jobs[job_id]['message'] = 'Starting parallel download and face detection...'
456
+
457
+ # Start face detection workers (use multiple threads for better throughput)
458
+ num_workers = 4 # Face detection threads
459
+ workers = []
460
+ for _ in range(num_workers):
461
+ t = threading.Thread(target=face_detection_worker)
462
+ t.daemon = True
463
+ t.start()
464
+ workers.append(t)
465
+
466
+ print(f"[Job {job_id}] Started {num_workers} face detection workers")
467
+
468
+ # Start download (this will call on_file_ready for each file)
469
+ print(f"[Job {job_id}] Starting Google Drive download with parallel face detection...")
470
+
471
+ download_folder(
472
+ folder_id,
473
+ upload_dir,
474
+ progress_callback=download_progress,
475
+ file_ready_callback=on_file_ready
476
+ )
477
+
478
+ # Signal download complete
479
+ download_complete.set()
480
+ print(f"[Job {job_id}] Download complete. Waiting for face detection to finish...")
481
+
482
+ # Wait for queue to be processed
483
+ file_queue.join()
484
+
485
+ # Send poison pills to stop workers
486
+ for _ in workers:
487
+ file_queue.put(None)
488
+
489
+ # Wait for workers to finish
490
+ for t in workers:
491
+ t.join(timeout=5.0)
492
+
493
+ print(f"\n[Job {job_id}] HYBRID Face Detection Results:")
494
+ print(f" - Photos with your child: {len(matched_photos)}")
495
+ print(f" - Photos without match: {len(unmatched_photos)}")
496
+ print(f" - Photos with no faces: {len(no_faces_photos)}")
497
+
498
+ # Now create thumbnails and prepare review data
499
+ processing_jobs[job_id]['progress'] = 75
500
+ processing_jobs[job_id]['message'] = f'Creating thumbnails for {len(matched_photos)} photos...'
501
+
502
+ thumbs_dir = os.path.join(upload_dir, 'thumbnails')
503
+ os.makedirs(thumbs_dir, exist_ok=True)
504
+
505
+ filtered_photos = []
506
+ for i, match in enumerate(matched_photos):
507
+ filename = os.path.basename(match['path'])
508
+ thumb_name = get_thumbnail_name(filename)
509
+ thumb_path = os.path.join(thumbs_dir, thumb_name)
510
+
511
+ create_thumbnail(match['path'], thumb_path)
512
+
513
+ filtered_photos.append({
514
+ 'filename': filename,
515
+ 'thumbnail': thumb_name,
516
+ 'face_match_score': match['similarity'],
517
+ 'num_faces': match['num_faces'],
518
+ 'face_bboxes': match.get('face_bboxes', [])
519
+ })
520
+
521
+ if (i + 1) % 20 == 0:
522
+ processing_jobs[job_id]['message'] = f'Creating thumbnails: {i + 1}/{len(matched_photos)}'
523
+
524
+ # Sort by face match score
525
+ filtered_photos.sort(key=lambda x: x['face_match_score'], reverse=True)
526
+
527
+ # Prepare unmatched data
528
+ unmatched_data = []
529
+ for unmatch in unmatched_photos:
530
+ filename = os.path.basename(unmatch['path'])
531
+ unmatched_data.append({
532
+ 'filename': filename,
533
+ 'best_similarity': unmatch.get('best_similarity', 0),
534
+ 'num_faces': unmatch.get('num_faces', 0)
535
+ })
536
+
537
+ for no_face in no_faces_photos:
538
+ filename = os.path.basename(no_face['path'])
539
+ unmatched_data.append({
540
+ 'filename': filename,
541
+ 'best_similarity': 0,
542
+ 'num_faces': 0
543
+ })
544
+
545
+ # Store results
546
+ review_data = {
547
+ 'total_uploaded': total_files[0],
548
+ 'filtered_photos': filtered_photos,
549
+ 'unmatched_photos': unmatched_data,
550
+ 'statistics': {
551
+ 'total_scanned': total_files[0],
552
+ 'matched': len(matched_photos),
553
+ 'unmatched': len(unmatched_photos),
554
+ 'no_faces': len(no_faces_photos),
555
+ 'errors': len(error_photos),
556
+ 'match_rate': f"{(len(matched_photos) / max(total_files[0], 1) * 100):.1f}%"
557
+ },
558
+ 'reference_count': face_matcher.get_reference_count()
559
+ }
560
+
561
+ # Save review data
562
+ review_file = os.path.join(RESULTS_FOLDER, f"{job_id}_review.json")
563
+ with open(review_file, 'w') as f:
564
+ json.dump(review_data, f, indent=2, default=str)
565
+
566
+ processing_jobs[job_id]['progress'] = 100
567
+ processing_jobs[job_id]['status'] = 'review_pending'
568
+ processing_jobs[job_id]['message'] = f'Found your child in {len(filtered_photos)} of {total_files[0]} photos!'
569
+ processing_jobs[job_id]['review_data'] = review_data
570
+
571
+ print(f"\n[Job {job_id}] HYBRID MODE COMPLETE!")
572
+ print(f" - Found {len(filtered_photos)} photos of your child")
573
+ print(f"{'='*60}\n")
574
+
575
+ except Exception as e:
576
+ print(f"[Job {job_id}] HYBRID EXCEPTION: {str(e)}")
577
+ processing_jobs[job_id]['status'] = 'error'
578
+ processing_jobs[job_id]['message'] = str(e)
579
+ import traceback
580
+ traceback.print_exc()
581
+
582
+
583
  def save_photos_by_month(job_id, upload_dir, selected_photos, rejected_photos, month_stats):
584
  """
585
  Automatically save both selected and not-selected photos organized by month.
 
1713
  # Start download in background thread
1714
  def download_and_process():
1715
  try:
1716
+ # HYBRID MODE: If we have face references, use parallel download + face detection
1717
+ if has_references:
1718
+ face_matcher = face_matchers.get(face_session_id)
1719
+ if face_matcher and face_matcher.get_reference_count() > 0:
1720
+ print(f"[Job {job_id}] Using HYBRID MODE: Parallel download + face detection")
1721
+ process_drive_with_parallel_face_detection(job_id, folder_id, upload_dir, face_matcher)
1722
+ return
1723
+
1724
+ # SEQUENTIAL MODE: Download all first, then process (for auto mode without face filtering)
1725
+ def progress_callback(current, total, _filename):
1726
  pct = int(5 + (current / total) * 25) # 5% to 30%
1727
  processing_jobs[job_id]['progress'] = pct
1728
  processing_jobs[job_id]['message'] = f'Downloading from Drive: {current}/{total}'
 
1745
 
1746
  print(f"[Job {job_id}] Downloaded {downloaded_count} photos from Google Drive")
1747
 
1748
+ # No face filtering - use all downloaded photos (auto mode)
1749
+ processing_jobs[job_id]['message'] = f'Selecting best from {downloaded_count} photos...'
1750
+ process_photos_quality_selection(job_id, upload_dir, quality_mode, similarity_threshold, downloaded_files)
 
 
 
 
 
1751
 
1752
  except Exception as e:
1753
  print(f"[Job {job_id}] Drive import error: {e}")
google_drive.py CHANGED
@@ -209,6 +209,7 @@ def download_folder(
209
  folder_id: str,
210
  output_dir: str,
211
  progress_callback: Optional[Callable[[int, int, str], None]] = None,
 
212
  include_subfolders: bool = False,
213
  max_workers: int = 10, # Parallel downloads (reduced for stability)
214
  skip_existing: bool = True # Skip already downloaded files (resume support)
@@ -220,6 +221,7 @@ def download_folder(
220
  folder_id: The Google Drive folder ID
221
  output_dir: Local directory to save files
222
  progress_callback: Optional callback(current, total, filename) for progress updates
 
223
  include_subfolders: Whether to include subfolders
224
  max_workers: Number of parallel download threads (default 10)
225
  skip_existing: Skip files that already exist (enables resume, default True)
@@ -321,9 +323,17 @@ def download_folder(
321
  if status == 'success':
322
  downloaded_count[0] += 1
323
  downloaded_files.append(filename)
 
 
 
 
324
  elif status == 'skipped':
325
  skipped_count[0] += 1
326
  downloaded_files.append(filename) # Include skipped files in list
 
 
 
 
327
  else:
328
  failed_count[0] += 1
329
 
 
209
  folder_id: str,
210
  output_dir: str,
211
  progress_callback: Optional[Callable[[int, int, str], None]] = None,
212
+ file_ready_callback: Optional[Callable[[str], None]] = None,
213
  include_subfolders: bool = False,
214
  max_workers: int = 10, # Parallel downloads (reduced for stability)
215
  skip_existing: bool = True # Skip already downloaded files (resume support)
 
221
  folder_id: The Google Drive folder ID
222
  output_dir: Local directory to save files
223
  progress_callback: Optional callback(current, total, filename) for progress updates
224
+ file_ready_callback: Optional callback(filepath) called when each file is ready for processing
225
  include_subfolders: Whether to include subfolders
226
  max_workers: Number of parallel download threads (default 10)
227
  skip_existing: Skip files that already exist (enables resume, default True)
 
323
  if status == 'success':
324
  downloaded_count[0] += 1
325
  downloaded_files.append(filename)
326
+ # Notify that file is ready for processing
327
+ if file_ready_callback:
328
+ filepath = os.path.join(output_dir, filename)
329
+ file_ready_callback(filepath)
330
  elif status == 'skipped':
331
  skipped_count[0] += 1
332
  downloaded_files.append(filename) # Include skipped files in list
333
+ # Also notify for skipped files (they exist and are ready)
334
+ if file_ready_callback:
335
+ filepath = os.path.join(output_dir, filename)
336
+ file_ready_callback(filepath)
337
  else:
338
  failed_count[0] += 1
339
 
templates/step2_upload.html CHANGED
@@ -742,7 +742,6 @@
742
  <!-- Upload Method Tabs -->
743
  <div class="upload-tabs">
744
  <button class="upload-tab active" onclick="switchUploadMethod('browser')">Browser Upload</button>
745
- <button class="upload-tab" onclick="switchUploadMethod('folder')">Local Folder Path</button>
746
  <button class="upload-tab" id="drive-tab" onclick="switchUploadMethod('drive')" style="display: none;">Google Drive</button>
747
  </div>
748
 
@@ -757,16 +756,6 @@
757
  </button>
758
  </div>
759
 
760
- <!-- Local Folder Path Input -->
761
- <div class="folder-input-area hidden" id="folder-input-area">
762
- <div class="upload-icon">&#128193;</div>
763
- <h3>Enter Local Folder Path</h3>
764
- <p>For large batches (1000+ photos), paste the full path to your photos folder</p>
765
- <input type="text" id="folder-path" class="folder-path-input"
766
- placeholder="C:\Users\YourName\Photos\EventPhotos">
767
- <p class="folder-hint">Example: C:\Users\tanis\Downloads\SchoolPhotos</p>
768
- </div>
769
-
770
  <!-- Google Drive Input -->
771
  <div class="folder-input-area hidden" id="drive-input-area">
772
  <div class="upload-icon" style="font-size: 48px;">&#9729;</div>
@@ -893,19 +882,14 @@
893
  document.querySelectorAll('.upload-tab').forEach(tab => {
894
  const tabText = tab.textContent.toLowerCase();
895
  const isActive = (method === 'browser' && tabText.includes('browser')) ||
896
- (method === 'folder' && tabText.includes('local')) ||
897
  (method === 'drive' && tabText.includes('drive'));
898
  tab.classList.toggle('active', isActive);
899
  });
900
  document.getElementById('drop-zone').classList.toggle('hidden', method !== 'browser');
901
- document.getElementById('folder-input-area').classList.toggle('hidden', method !== 'folder');
902
  document.getElementById('drive-input-area').classList.toggle('hidden', method !== 'drive');
903
 
904
  // Update file preview visibility based on method
905
- if (method === 'folder') {
906
- document.getElementById('file-preview').classList.add('hidden');
907
- updateFolderPreview();
908
- } else if (method === 'drive') {
909
  document.getElementById('file-preview').classList.add('hidden');
910
  updateDrivePreview();
911
  } else {
@@ -916,19 +900,6 @@
916
  }
917
  }
918
 
919
- function updateFolderPreview() {
920
- const folderPath = document.getElementById('folder-path').value.trim();
921
- const preview = document.getElementById('file-preview');
922
-
923
- if (folderPath) {
924
- preview.classList.remove('hidden');
925
- document.getElementById('file-count').textContent = 'Local folder';
926
- document.getElementById('preview-grid').innerHTML = '<div style="padding: 20px; color: #666; font-size: 14px;">Will scan: ' + folderPath + '</div>';
927
- } else {
928
- preview.classList.add('hidden');
929
- }
930
- }
931
-
932
  function updateDrivePreview() {
933
  const driveUrl = document.getElementById('drive-folder-url').value.trim();
934
  const preview = document.getElementById('file-preview');
@@ -1120,43 +1091,6 @@
1120
  document.getElementById('processing-overlay').classList.add('hidden');
1121
  }
1122
 
1123
- } else if (uploadMethod === 'folder') {
1124
- // Local folder path mode
1125
- const folderPath = document.getElementById('folder-path').value.trim();
1126
- if (!folderPath) {
1127
- alert('Please enter a folder path');
1128
- document.getElementById('processing-overlay').classList.add('hidden');
1129
- return;
1130
- }
1131
-
1132
- try {
1133
- const response = await fetch('/upload_folder', {
1134
- method: 'POST',
1135
- headers: { 'Content-Type': 'application/json' },
1136
- body: JSON.stringify({
1137
- folder_path: folderPath,
1138
- quality_mode: qualityMode,
1139
- similarity_threshold: parseFloat(document.getElementById('similarity').value)
1140
- })
1141
- });
1142
-
1143
- const data = await response.json();
1144
-
1145
- if (data.error) {
1146
- alert('Error: ' + data.error);
1147
- document.getElementById('processing-overlay').classList.add('hidden');
1148
- return;
1149
- }
1150
-
1151
- jobId = data.job_id;
1152
- pollStatus();
1153
-
1154
- } catch (error) {
1155
- console.error('Error:', error);
1156
- alert('Failed to process folder. Please check the path and try again.');
1157
- document.getElementById('processing-overlay').classList.add('hidden');
1158
- }
1159
-
1160
  } else {
1161
  // Browser upload mode
1162
  if (selectedFiles.length === 0) {
 
742
  <!-- Upload Method Tabs -->
743
  <div class="upload-tabs">
744
  <button class="upload-tab active" onclick="switchUploadMethod('browser')">Browser Upload</button>
 
745
  <button class="upload-tab" id="drive-tab" onclick="switchUploadMethod('drive')" style="display: none;">Google Drive</button>
746
  </div>
747
 
 
756
  </button>
757
  </div>
758
 
 
 
 
 
 
 
 
 
 
 
759
  <!-- Google Drive Input -->
760
  <div class="folder-input-area hidden" id="drive-input-area">
761
  <div class="upload-icon" style="font-size: 48px;">&#9729;</div>
 
882
  document.querySelectorAll('.upload-tab').forEach(tab => {
883
  const tabText = tab.textContent.toLowerCase();
884
  const isActive = (method === 'browser' && tabText.includes('browser')) ||
 
885
  (method === 'drive' && tabText.includes('drive'));
886
  tab.classList.toggle('active', isActive);
887
  });
888
  document.getElementById('drop-zone').classList.toggle('hidden', method !== 'browser');
 
889
  document.getElementById('drive-input-area').classList.toggle('hidden', method !== 'drive');
890
 
891
  // Update file preview visibility based on method
892
+ if (method === 'drive') {
 
 
 
893
  document.getElementById('file-preview').classList.add('hidden');
894
  updateDrivePreview();
895
  } else {
 
900
  }
901
  }
902
 
 
 
 
 
 
 
 
 
 
 
 
 
 
903
  function updateDrivePreview() {
904
  const driveUrl = document.getElementById('drive-folder-url').value.trim();
905
  const preview = document.getElementById('file-preview');
 
1091
  document.getElementById('processing-overlay').classList.add('hidden');
1092
  }
1093
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1094
  } else {
1095
  // Browser upload mode
1096
  if (selectedFiles.length === 0) {
templates/step3_review.html CHANGED
@@ -765,10 +765,51 @@
765
  box-shadow: 0 2px 10px rgba(0,0,0,0.08);
766
  }
767
 
 
 
 
 
768
  .not-found-card img {
769
  width: 100%;
770
  aspect-ratio: 1;
771
  object-fit: cover;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772
  }
773
 
774
  .not-found-info {
@@ -1079,12 +1120,18 @@
1079
  const facesText = photo.num_faces === 0 ? 'No faces detected' :
1080
  photo.num_faces === 1 ? '1 face (no match)' :
1081
  `${photo.num_faces} faces (no match)`;
 
1082
  html += `
1083
  <div class="not-found-card">
 
 
 
 
1084
  <img src="/thumbnail/${jobId}/${encodeURIComponent(photo.filename)}"
1085
  alt="${photo.filename}"
1086
  loading="lazy"
1087
- onerror="this.src='/static/placeholder.png'">
 
1088
  <div class="not-found-info">
1089
  ${facesText}
1090
  </div>
 
765
  box-shadow: 0 2px 10px rgba(0,0,0,0.08);
766
  }
767
 
768
+ .not-found-card {
769
+ position: relative;
770
+ }
771
+
772
  .not-found-card img {
773
  width: 100%;
774
  aspect-ratio: 1;
775
  object-fit: cover;
776
+ background: #f5f5f5;
777
+ }
778
+
779
+ .not-found-card .loading-placeholder {
780
+ position: absolute;
781
+ top: 0;
782
+ left: 0;
783
+ right: 0;
784
+ bottom: 40px;
785
+ background: #f5f5f5;
786
+ display: flex;
787
+ flex-direction: column;
788
+ align-items: center;
789
+ justify-content: center;
790
+ gap: 10px;
791
+ }
792
+
793
+ .not-found-card .loading-placeholder.hidden {
794
+ display: none;
795
+ }
796
+
797
+ .not-found-card .loading-spinner {
798
+ width: 30px;
799
+ height: 30px;
800
+ border: 3px solid #e0e0e0;
801
+ border-top-color: #7c3aed;
802
+ border-radius: 50%;
803
+ animation: spin 1s linear infinite;
804
+ }
805
+
806
+ .not-found-card .loading-text {
807
+ font-size: 12px;
808
+ color: #888;
809
+ }
810
+
811
+ @keyframes spin {
812
+ to { transform: rotate(360deg); }
813
  }
814
 
815
  .not-found-info {
 
1120
  const facesText = photo.num_faces === 0 ? 'No faces detected' :
1121
  photo.num_faces === 1 ? '1 face (no match)' :
1122
  `${photo.num_faces} faces (no match)`;
1123
+ const cardId = `card-${photo.filename.replace(/[^a-zA-Z0-9]/g, '_')}`;
1124
  html += `
1125
  <div class="not-found-card">
1126
+ <div class="loading-placeholder" id="${cardId}-loader">
1127
+ <div class="loading-spinner"></div>
1128
+ <div class="loading-text">Loading...</div>
1129
+ </div>
1130
  <img src="/thumbnail/${jobId}/${encodeURIComponent(photo.filename)}"
1131
  alt="${photo.filename}"
1132
  loading="lazy"
1133
+ onload="document.getElementById('${cardId}-loader').classList.add('hidden')"
1134
+ onerror="document.getElementById('${cardId}-loader').classList.add('hidden'); this.src='/static/placeholder.png'">
1135
  <div class="not-found-info">
1136
  ${facesText}
1137
  </div>