Upload 4 files
Browse files- app.py +249 -9
- google_drive.py +10 -0
- templates/step2_upload.html +1 -67
- templates/step3_review.html +48 -1
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 1504 |
-
|
| 1505 |
-
|
| 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">📁</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;">☁</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 === '
|
| 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;">☁</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 |
-
|
|
|
|
| 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>
|