Upload 6 files
Browse files- app.py +89 -15
- photo_selector/monthly_selector.py +84 -1
- supabase_storage.py +188 -0
- templates/reupload_photos.html +3 -453
- templates/step2_upload.html +36 -253
- templates/step4_results.html +298 -10
app.py
CHANGED
|
@@ -821,30 +821,102 @@ def process_photos_quality_selection(job_id, upload_dir, quality_mode, similarit
|
|
| 821 |
|
| 822 |
print(f"[Job {job_id}] Target per month: {target_per_month}")
|
| 823 |
|
| 824 |
-
# Step 1: Generate embeddings for confirmed photos
|
| 825 |
processing_jobs[job_id]['progress'] = 10
|
| 826 |
-
processing_jobs[job_id]['message'] = f'
|
| 827 |
|
| 828 |
-
print(f"[Job {job_id}]
|
| 829 |
|
| 830 |
-
|
| 831 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 833 |
for i, filename in enumerate(confirmed_photos):
|
| 834 |
filepath = os.path.join(upload_dir, filename)
|
| 835 |
if os.path.exists(filepath):
|
| 836 |
-
|
| 837 |
-
if
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 842 |
|
| 843 |
-
#
|
| 844 |
-
|
| 845 |
-
|
|
|
|
|
|
|
| 846 |
|
| 847 |
-
print(f"[Job {job_id}]
|
| 848 |
|
| 849 |
# Step 2: Initialize monthly selector
|
| 850 |
processing_jobs[job_id]['progress'] = 35
|
|
@@ -1056,6 +1128,7 @@ def process_photos_quality_selection(job_id, upload_dir, quality_mode, similarit
|
|
| 1056 |
'category': photo.get('category', 'unknown'),
|
| 1057 |
'num_faces': int(photo.get('num_faces', 0)),
|
| 1058 |
'cluster_id': cid,
|
|
|
|
| 1059 |
'cluster_total': cluster_total,
|
| 1060 |
'cluster_selected': cluster_selected,
|
| 1061 |
'event_id': photo.get('event_id', -1),
|
|
@@ -1114,6 +1187,7 @@ def process_photos_quality_selection(job_id, upload_dir, quality_mode, similarit
|
|
| 1114 |
'month': month,
|
| 1115 |
'category': photo.get('category', 'unknown'),
|
| 1116 |
'cluster_id': cid,
|
|
|
|
| 1117 |
'cluster_total': cluster_total,
|
| 1118 |
'cluster_selected': cluster_selected,
|
| 1119 |
'event_id': photo.get('event_id', -1),
|
|
|
|
| 821 |
|
| 822 |
print(f"[Job {job_id}] Target per month: {target_per_month}")
|
| 823 |
|
| 824 |
+
# Step 1: Generate embeddings for confirmed photos (with caching)
|
| 825 |
processing_jobs[job_id]['progress'] = 10
|
| 826 |
+
processing_jobs[job_id]['message'] = f'Checking embedding cache...'
|
| 827 |
|
| 828 |
+
print(f"[Job {job_id}] Processing {len(confirmed_photos)} photos for {model_display_name} embeddings...")
|
| 829 |
|
| 830 |
+
# Import cache functions
|
| 831 |
+
from supabase_storage import (
|
| 832 |
+
compute_file_hash,
|
| 833 |
+
get_cached_embeddings_batch,
|
| 834 |
+
save_embeddings_batch,
|
| 835 |
+
is_supabase_available
|
| 836 |
+
)
|
| 837 |
|
| 838 |
+
# Step 1a: Compute hashes for all files
|
| 839 |
+
file_hashes = {} # filename -> hash
|
| 840 |
+
hash_to_filename = {} # hash -> filename (for reverse lookup)
|
| 841 |
+
|
| 842 |
+
print(f"[Job {job_id}] Computing file hashes...")
|
| 843 |
for i, filename in enumerate(confirmed_photos):
|
| 844 |
filepath = os.path.join(upload_dir, filename)
|
| 845 |
if os.path.exists(filepath):
|
| 846 |
+
file_hash = compute_file_hash(filepath)
|
| 847 |
+
if file_hash:
|
| 848 |
+
file_hashes[filename] = file_hash
|
| 849 |
+
hash_to_filename[file_hash] = filename
|
| 850 |
+
|
| 851 |
+
# Update progress (10-15%)
|
| 852 |
+
if i % 100 == 0:
|
| 853 |
+
progress = 10 + int((i / len(confirmed_photos)) * 5)
|
| 854 |
+
processing_jobs[job_id]['progress'] = progress
|
| 855 |
+
|
| 856 |
+
print(f"[Job {job_id}] Computed {len(file_hashes)} hashes")
|
| 857 |
+
|
| 858 |
+
# Step 1b: Check cache for existing embeddings
|
| 859 |
+
embeddings = {}
|
| 860 |
+
cached_count = 0
|
| 861 |
+
uncached_filenames = []
|
| 862 |
+
|
| 863 |
+
if is_supabase_available() and file_hashes:
|
| 864 |
+
processing_jobs[job_id]['message'] = f'Checking embedding cache...'
|
| 865 |
+
all_hashes = list(file_hashes.values())
|
| 866 |
+
|
| 867 |
+
# Query cache in batches (Supabase has query limits)
|
| 868 |
+
cached_embeddings = {}
|
| 869 |
+
batch_size = 500
|
| 870 |
+
for i in range(0, len(all_hashes), batch_size):
|
| 871 |
+
batch_hashes = all_hashes[i:i + batch_size]
|
| 872 |
+
batch_result = get_cached_embeddings_batch(batch_hashes, embedding_model)
|
| 873 |
+
cached_embeddings.update(batch_result)
|
| 874 |
+
|
| 875 |
+
# Map cached embeddings back to filenames
|
| 876 |
+
for filename, file_hash in file_hashes.items():
|
| 877 |
+
if file_hash in cached_embeddings:
|
| 878 |
+
embeddings[filename] = cached_embeddings[file_hash]
|
| 879 |
+
cached_count += 1
|
| 880 |
+
else:
|
| 881 |
+
uncached_filenames.append(filename)
|
| 882 |
+
|
| 883 |
+
print(f"[Job {job_id}] Cache hit: {cached_count}/{len(file_hashes)} embeddings")
|
| 884 |
+
else:
|
| 885 |
+
uncached_filenames = list(file_hashes.keys())
|
| 886 |
+
print(f"[Job {job_id}] Cache not available, computing all embeddings")
|
| 887 |
+
|
| 888 |
+
# Step 1c: Compute embeddings for uncached files only
|
| 889 |
+
newly_computed = {}
|
| 890 |
+
if uncached_filenames:
|
| 891 |
+
processing_jobs[job_id]['message'] = f'Analyzing {len(uncached_filenames)} photos with {model_display_name}...'
|
| 892 |
+
print(f"[Job {job_id}] Computing {model_display_name} embeddings for {len(uncached_filenames)} uncached photos...")
|
| 893 |
+
|
| 894 |
+
embedder = Embedder()
|
| 895 |
+
|
| 896 |
+
for i, filename in enumerate(uncached_filenames):
|
| 897 |
+
filepath = os.path.join(upload_dir, filename)
|
| 898 |
+
if os.path.exists(filepath):
|
| 899 |
+
img = embedder.load_image(filepath)
|
| 900 |
+
if img is not None:
|
| 901 |
+
embedding = embedder.get_embedding(img)
|
| 902 |
+
if embedding is not None:
|
| 903 |
+
embeddings[filename] = embedding
|
| 904 |
+
newly_computed[filename] = embedding
|
| 905 |
+
img.close()
|
| 906 |
+
|
| 907 |
+
# Update progress (15-30%)
|
| 908 |
+
progress = 15 + int((i / len(uncached_filenames)) * 15)
|
| 909 |
+
processing_jobs[job_id]['progress'] = progress
|
| 910 |
+
|
| 911 |
+
print(f"[Job {job_id}] Computed {len(newly_computed)} new embeddings")
|
| 912 |
|
| 913 |
+
# Step 1d: Save newly computed embeddings to cache
|
| 914 |
+
if newly_computed and is_supabase_available():
|
| 915 |
+
processing_jobs[job_id]['message'] = 'Saving embeddings to cache...'
|
| 916 |
+
saved = save_embeddings_batch(newly_computed, file_hashes, embedding_model)
|
| 917 |
+
print(f"[Job {job_id}] Saved {saved} embeddings to cache")
|
| 918 |
|
| 919 |
+
print(f"[Job {job_id}] Total embeddings: {len(embeddings)} (cached: {cached_count}, computed: {len(newly_computed)})")
|
| 920 |
|
| 921 |
# Step 2: Initialize monthly selector
|
| 922 |
processing_jobs[job_id]['progress'] = 35
|
|
|
|
| 1128 |
'category': photo.get('category', 'unknown'),
|
| 1129 |
'num_faces': int(photo.get('num_faces', 0)),
|
| 1130 |
'cluster_id': cid,
|
| 1131 |
+
'original_cluster_id': photo.get('original_cluster_id', cid),
|
| 1132 |
'cluster_total': cluster_total,
|
| 1133 |
'cluster_selected': cluster_selected,
|
| 1134 |
'event_id': photo.get('event_id', -1),
|
|
|
|
| 1187 |
'month': month,
|
| 1188 |
'category': photo.get('category', 'unknown'),
|
| 1189 |
'cluster_id': cid,
|
| 1190 |
+
'original_cluster_id': photo.get('original_cluster_id', cid),
|
| 1191 |
'cluster_total': cluster_total,
|
| 1192 |
'cluster_selected': cluster_selected,
|
| 1193 |
'event_id': photo.get('event_id', -1),
|
photo_selector/monthly_selector.py
CHANGED
|
@@ -809,6 +809,79 @@ class MonthlyPhotoSelector:
|
|
| 809 |
|
| 810 |
return merged
|
| 811 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 812 |
def select_hybrid_hdbscan(self, photos: List[Dict],
|
| 813 |
embeddings: Dict[str, np.ndarray],
|
| 814 |
target: int,
|
|
@@ -849,11 +922,21 @@ class MonthlyPhotoSelector:
|
|
| 849 |
# Step 1: Cluster photos with HDBSCAN (for cluster_id info in results)
|
| 850 |
clusters = self.cluster_photos_hdbscan(photos, embeddings)
|
| 851 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
# Step 1.5: Merge clusters that have photos too similar to each other
|
| 853 |
# This prevents "too similar" rejections during selection by ensuring
|
| 854 |
# similar photos are in the same cluster from the start
|
| 855 |
clusters = self.merge_similar_clusters(clusters, embeddings, similarity_threshold=0.80)
|
| 856 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 857 |
|
| 858 |
# Step 2: Flatten all photos with cluster_id and sort by score
|
| 859 |
all_photos = []
|
|
|
|
| 809 |
|
| 810 |
return merged
|
| 811 |
|
| 812 |
+
def merge_same_event_clusters(self, clusters: Dict[int, List[Dict]]) -> Dict[int, List[Dict]]:
|
| 813 |
+
"""
|
| 814 |
+
Merge clusters that share the same event_id.
|
| 815 |
+
|
| 816 |
+
If any photo in Cluster A has the same event_id as any photo in Cluster B,
|
| 817 |
+
merge them into one cluster. This ensures photos from the same event
|
| 818 |
+
appear together in the cluster view.
|
| 819 |
+
|
| 820 |
+
Note: original_cluster_id should already be saved before this function is called.
|
| 821 |
+
|
| 822 |
+
Args:
|
| 823 |
+
clusters: Dict mapping cluster_id to list of photos
|
| 824 |
+
|
| 825 |
+
Returns:
|
| 826 |
+
Merged clusters dict
|
| 827 |
+
"""
|
| 828 |
+
if len(clusters) <= 1:
|
| 829 |
+
return clusters
|
| 830 |
+
|
| 831 |
+
cluster_ids = list(clusters.keys())
|
| 832 |
+
n_clusters = len(cluster_ids)
|
| 833 |
+
|
| 834 |
+
# Build a union-find structure for merging
|
| 835 |
+
parent = {cid: cid for cid in cluster_ids}
|
| 836 |
+
|
| 837 |
+
def find(x):
|
| 838 |
+
if parent[x] != x:
|
| 839 |
+
parent[x] = find(parent[x])
|
| 840 |
+
return parent[x]
|
| 841 |
+
|
| 842 |
+
def union(x, y):
|
| 843 |
+
px, py = find(x), find(y)
|
| 844 |
+
if px != py:
|
| 845 |
+
parent[px] = py
|
| 846 |
+
|
| 847 |
+
# Build event_id -> cluster_ids mapping
|
| 848 |
+
event_to_clusters = {}
|
| 849 |
+
for cid, photos in clusters.items():
|
| 850 |
+
for photo in photos:
|
| 851 |
+
event_id = photo.get('event_id', -1)
|
| 852 |
+
if event_id != -1:
|
| 853 |
+
if event_id not in event_to_clusters:
|
| 854 |
+
event_to_clusters[event_id] = set()
|
| 855 |
+
event_to_clusters[event_id].add(cid)
|
| 856 |
+
|
| 857 |
+
# Merge clusters that share the same event_id
|
| 858 |
+
merge_count = 0
|
| 859 |
+
for event_id, cluster_set in event_to_clusters.items():
|
| 860 |
+
cluster_list = list(cluster_set)
|
| 861 |
+
if len(cluster_list) > 1:
|
| 862 |
+
# Merge all clusters in this event
|
| 863 |
+
first_cid = cluster_list[0]
|
| 864 |
+
for other_cid in cluster_list[1:]:
|
| 865 |
+
if find(first_cid) != find(other_cid):
|
| 866 |
+
union(first_cid, other_cid)
|
| 867 |
+
merge_count += 1
|
| 868 |
+
|
| 869 |
+
if merge_count == 0:
|
| 870 |
+
print(f" No clusters needed event-based merging")
|
| 871 |
+
return clusters
|
| 872 |
+
|
| 873 |
+
# Build merged clusters
|
| 874 |
+
merged = {}
|
| 875 |
+
for cid in cluster_ids:
|
| 876 |
+
root = find(cid)
|
| 877 |
+
if root not in merged:
|
| 878 |
+
merged[root] = []
|
| 879 |
+
merged[root].extend(clusters[cid])
|
| 880 |
+
|
| 881 |
+
print(f" Event-merged {merge_count} cluster pairs: {n_clusters} -> {len(merged)} clusters")
|
| 882 |
+
|
| 883 |
+
return merged
|
| 884 |
+
|
| 885 |
def select_hybrid_hdbscan(self, photos: List[Dict],
|
| 886 |
embeddings: Dict[str, np.ndarray],
|
| 887 |
target: int,
|
|
|
|
| 922 |
# Step 1: Cluster photos with HDBSCAN (for cluster_id info in results)
|
| 923 |
clusters = self.cluster_photos_hdbscan(photos, embeddings)
|
| 924 |
|
| 925 |
+
# Step 1.1: Save original_cluster_id BEFORE any merging
|
| 926 |
+
# This preserves the true HDBSCAN cluster assignment for display
|
| 927 |
+
for cid, cluster_photos in clusters.items():
|
| 928 |
+
for photo in cluster_photos:
|
| 929 |
+
photo['original_cluster_id'] = int(cid)
|
| 930 |
+
|
| 931 |
# Step 1.5: Merge clusters that have photos too similar to each other
|
| 932 |
# This prevents "too similar" rejections during selection by ensuring
|
| 933 |
# similar photos are in the same cluster from the start
|
| 934 |
clusters = self.merge_similar_clusters(clusters, embeddings, similarity_threshold=0.80)
|
| 935 |
+
|
| 936 |
+
# Step 1.6: Merge clusters that share the same event_id
|
| 937 |
+
# This ensures photos from the same event appear together in cluster view
|
| 938 |
+
clusters = self.merge_same_event_clusters(clusters)
|
| 939 |
+
print(f" Final cluster count after event merge: {len(clusters)}")
|
| 940 |
|
| 941 |
# Step 2: Flatten all photos with cluster_id and sort by score
|
| 942 |
all_photos = []
|
supabase_storage.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
| 1 |
"""
|
| 2 |
Supabase Storage Integration for Photo Selection App
|
| 3 |
Handles persistent storage of dataset metadata (not photos) in Supabase.
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import os
|
| 7 |
import json
|
|
|
|
|
|
|
|
|
|
| 8 |
from typing import Optional, List, Dict, Any
|
| 9 |
|
| 10 |
# Supabase credentials
|
|
@@ -343,3 +347,187 @@ def check_dataset_exists_in_supabase(dataset_name: str) -> bool:
|
|
| 343 |
return len(files) > 0
|
| 344 |
except:
|
| 345 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Supabase Storage Integration for Photo Selection App
|
| 3 |
Handles persistent storage of dataset metadata (not photos) in Supabase.
|
| 4 |
+
Also provides global embedding cache for CLIP/SigLIP embeddings.
|
| 5 |
"""
|
| 6 |
|
| 7 |
import os
|
| 8 |
import json
|
| 9 |
+
import base64
|
| 10 |
+
import hashlib
|
| 11 |
+
import numpy as np
|
| 12 |
from typing import Optional, List, Dict, Any
|
| 13 |
|
| 14 |
# Supabase credentials
|
|
|
|
| 347 |
return len(files) > 0
|
| 348 |
except:
|
| 349 |
return False
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# =============================================================================
|
| 353 |
+
# GLOBAL EMBEDDING CACHE
|
| 354 |
+
# =============================================================================
|
| 355 |
+
# Stores CLIP/SigLIP embeddings in Supabase database table for reuse.
|
| 356 |
+
# Table schema (create in Supabase Dashboard):
|
| 357 |
+
#
|
| 358 |
+
# CREATE TABLE image_embeddings (
|
| 359 |
+
# id BIGSERIAL PRIMARY KEY,
|
| 360 |
+
# image_hash TEXT NOT NULL,
|
| 361 |
+
# embedding_model TEXT NOT NULL,
|
| 362 |
+
# embedding TEXT NOT NULL,
|
| 363 |
+
# embedding_dim INTEGER NOT NULL,
|
| 364 |
+
# created_at TIMESTAMPTZ DEFAULT NOW(),
|
| 365 |
+
# UNIQUE(image_hash, embedding_model)
|
| 366 |
+
# );
|
| 367 |
+
# CREATE INDEX idx_image_embeddings_hash_model ON image_embeddings(image_hash, embedding_model);
|
| 368 |
+
# =============================================================================
|
| 369 |
+
|
| 370 |
+
EMBEDDING_TABLE = 'image_embeddings'
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def compute_file_hash(filepath: str) -> Optional[str]:
|
| 374 |
+
"""
|
| 375 |
+
Compute MD5 hash of a file.
|
| 376 |
+
|
| 377 |
+
Args:
|
| 378 |
+
filepath: Path to the image file
|
| 379 |
+
|
| 380 |
+
Returns:
|
| 381 |
+
MD5 hash string or None if error
|
| 382 |
+
"""
|
| 383 |
+
try:
|
| 384 |
+
md5 = hashlib.md5()
|
| 385 |
+
with open(filepath, 'rb') as f:
|
| 386 |
+
# Read in chunks for memory efficiency
|
| 387 |
+
for chunk in iter(lambda: f.read(8192), b''):
|
| 388 |
+
md5.update(chunk)
|
| 389 |
+
return md5.hexdigest()
|
| 390 |
+
except Exception as e:
|
| 391 |
+
print(f"[EmbeddingCache] Error hashing {filepath}: {e}")
|
| 392 |
+
return None
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def _embedding_to_base64(embedding: np.ndarray) -> str:
|
| 396 |
+
"""Convert numpy embedding to base64 string for storage."""
|
| 397 |
+
return base64.b64encode(embedding.astype(np.float32).tobytes()).decode('utf-8')
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def _base64_to_embedding(b64_str: str, dim: int) -> np.ndarray:
|
| 401 |
+
"""Convert base64 string back to numpy embedding."""
|
| 402 |
+
bytes_data = base64.b64decode(b64_str)
|
| 403 |
+
return np.frombuffer(bytes_data, dtype=np.float32).reshape(dim)
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def get_cached_embeddings_batch(
|
| 407 |
+
image_hashes: List[str],
|
| 408 |
+
embedding_model: str
|
| 409 |
+
) -> Dict[str, np.ndarray]:
|
| 410 |
+
"""
|
| 411 |
+
Get cached embeddings for multiple images in one query.
|
| 412 |
+
|
| 413 |
+
Args:
|
| 414 |
+
image_hashes: List of MD5 hashes to look up
|
| 415 |
+
embedding_model: Model name ('siglip' or 'clip')
|
| 416 |
+
|
| 417 |
+
Returns:
|
| 418 |
+
Dict mapping hash -> embedding for found entries
|
| 419 |
+
"""
|
| 420 |
+
client = get_supabase_client()
|
| 421 |
+
if not client or not image_hashes:
|
| 422 |
+
return {}
|
| 423 |
+
|
| 424 |
+
try:
|
| 425 |
+
# Query all hashes at once
|
| 426 |
+
response = client.table(EMBEDDING_TABLE).select(
|
| 427 |
+
'image_hash, embedding, embedding_dim'
|
| 428 |
+
).in_('image_hash', image_hashes).eq('embedding_model', embedding_model).execute()
|
| 429 |
+
|
| 430 |
+
result = {}
|
| 431 |
+
for row in response.data:
|
| 432 |
+
embedding = _base64_to_embedding(row['embedding'], row['embedding_dim'])
|
| 433 |
+
result[row['image_hash']] = embedding
|
| 434 |
+
|
| 435 |
+
print(f"[EmbeddingCache] Found {len(result)}/{len(image_hashes)} cached embeddings for {embedding_model}")
|
| 436 |
+
return result
|
| 437 |
+
|
| 438 |
+
except Exception as e:
|
| 439 |
+
print(f"[EmbeddingCache] Error fetching batch: {e}")
|
| 440 |
+
return {}
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def save_embeddings_batch(
|
| 444 |
+
embeddings: Dict[str, np.ndarray],
|
| 445 |
+
image_hashes: Dict[str, str],
|
| 446 |
+
embedding_model: str
|
| 447 |
+
) -> int:
|
| 448 |
+
"""
|
| 449 |
+
Save multiple embeddings to cache.
|
| 450 |
+
|
| 451 |
+
Args:
|
| 452 |
+
embeddings: Dict mapping filename -> embedding
|
| 453 |
+
image_hashes: Dict mapping filename -> hash
|
| 454 |
+
embedding_model: Model name ('siglip' or 'clip')
|
| 455 |
+
|
| 456 |
+
Returns:
|
| 457 |
+
Number of embeddings saved
|
| 458 |
+
"""
|
| 459 |
+
client = get_supabase_client()
|
| 460 |
+
if not client or not embeddings:
|
| 461 |
+
return 0
|
| 462 |
+
|
| 463 |
+
try:
|
| 464 |
+
# Prepare batch insert data
|
| 465 |
+
rows = []
|
| 466 |
+
for filename, embedding in embeddings.items():
|
| 467 |
+
img_hash = image_hashes.get(filename)
|
| 468 |
+
if img_hash and embedding is not None:
|
| 469 |
+
rows.append({
|
| 470 |
+
'image_hash': img_hash,
|
| 471 |
+
'embedding_model': embedding_model,
|
| 472 |
+
'embedding': _embedding_to_base64(embedding),
|
| 473 |
+
'embedding_dim': len(embedding)
|
| 474 |
+
})
|
| 475 |
+
|
| 476 |
+
if not rows:
|
| 477 |
+
return 0
|
| 478 |
+
|
| 479 |
+
# Insert with upsert (ignore conflicts)
|
| 480 |
+
# Batch in chunks of 100 to avoid request size limits
|
| 481 |
+
saved = 0
|
| 482 |
+
chunk_size = 100
|
| 483 |
+
for i in range(0, len(rows), chunk_size):
|
| 484 |
+
chunk = rows[i:i + chunk_size]
|
| 485 |
+
try:
|
| 486 |
+
client.table(EMBEDDING_TABLE).upsert(
|
| 487 |
+
chunk,
|
| 488 |
+
on_conflict='image_hash,embedding_model'
|
| 489 |
+
).execute()
|
| 490 |
+
saved += len(chunk)
|
| 491 |
+
except Exception as e:
|
| 492 |
+
print(f"[EmbeddingCache] Error saving chunk {i//chunk_size}: {e}")
|
| 493 |
+
|
| 494 |
+
print(f"[EmbeddingCache] Saved {saved} new embeddings for {embedding_model}")
|
| 495 |
+
return saved
|
| 496 |
+
|
| 497 |
+
except Exception as e:
|
| 498 |
+
print(f"[EmbeddingCache] Error saving batch: {e}")
|
| 499 |
+
return 0
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def get_embedding_cache_stats() -> Dict[str, Any]:
|
| 503 |
+
"""Get statistics about the embedding cache."""
|
| 504 |
+
client = get_supabase_client()
|
| 505 |
+
if not client:
|
| 506 |
+
return {'available': False}
|
| 507 |
+
|
| 508 |
+
try:
|
| 509 |
+
# Count by model
|
| 510 |
+
response = client.table(EMBEDDING_TABLE).select(
|
| 511 |
+
'embedding_model',
|
| 512 |
+
count='exact'
|
| 513 |
+
).execute()
|
| 514 |
+
|
| 515 |
+
# Get counts per model
|
| 516 |
+
siglip_count = client.table(EMBEDDING_TABLE).select(
|
| 517 |
+
'id', count='exact'
|
| 518 |
+
).eq('embedding_model', 'siglip').execute()
|
| 519 |
+
|
| 520 |
+
clip_count = client.table(EMBEDDING_TABLE).select(
|
| 521 |
+
'id', count='exact'
|
| 522 |
+
).eq('embedding_model', 'clip').execute()
|
| 523 |
+
|
| 524 |
+
return {
|
| 525 |
+
'available': True,
|
| 526 |
+
'siglip_count': siglip_count.count or 0,
|
| 527 |
+
'clip_count': clip_count.count or 0,
|
| 528 |
+
'total': (siglip_count.count or 0) + (clip_count.count or 0)
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
except Exception as e:
|
| 532 |
+
print(f"[EmbeddingCache] Error getting stats: {e}")
|
| 533 |
+
return {'available': False, 'error': str(e)}
|
templates/reupload_photos.html
CHANGED
|
@@ -453,26 +453,8 @@
|
|
| 453 |
|
| 454 |
<!-- Upload Area -->
|
| 455 |
<div class="upload-card">
|
| 456 |
-
<!-- Upload Method Tabs -->
|
| 457 |
-
<div class="upload-tabs">
|
| 458 |
-
<button class="upload-tab active" onclick="switchUploadMethod('browser')">Browser Upload</button>
|
| 459 |
-
<button class="upload-tab" id="drive-tab" onclick="switchUploadMethod('drive')" style="display: none;">Google Drive</button>
|
| 460 |
-
</div>
|
| 461 |
-
|
| 462 |
-
<!-- Browser Upload Area -->
|
| 463 |
-
<div class="upload-area" id="upload-area" onclick="document.getElementById('file-input').click()">
|
| 464 |
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 465 |
-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
| 466 |
-
<polyline points="17 8 12 3 7 8"/>
|
| 467 |
-
<line x1="12" y1="3" x2="12" y2="15"/>
|
| 468 |
-
</svg>
|
| 469 |
-
<h3>Upload Your Photos</h3>
|
| 470 |
-
<p>Drag and drop photos or a .zip file here, or click to browse</p>
|
| 471 |
-
<span class="btn">Select Photos or Zip</span>
|
| 472 |
-
</div>
|
| 473 |
-
|
| 474 |
<!-- Google Drive Input Area -->
|
| 475 |
-
<div class="drive-input-area" id="drive-input-area">
|
| 476 |
<svg viewBox="0 0 24 24" width="50" height="50" fill="none" stroke="#667eea" stroke-width="1.5" style="margin-bottom: 15px;">
|
| 477 |
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
| 478 |
</svg>
|
|
@@ -491,11 +473,6 @@
|
|
| 491 |
</button>
|
| 492 |
</div>
|
| 493 |
|
| 494 |
-
<input type="file" id="file-input" multiple accept="image/*,.heic,.heif,.zip">
|
| 495 |
-
|
| 496 |
-
<!-- File List Preview -->
|
| 497 |
-
<div class="file-list" id="file-list" style="display: none;"></div>
|
| 498 |
-
|
| 499 |
<!-- Progress Section -->
|
| 500 |
<div class="progress-section" id="progress-section">
|
| 501 |
<div class="progress-header">
|
|
@@ -526,50 +503,8 @@
|
|
| 526 |
|
| 527 |
<script>
|
| 528 |
const datasetName = "{{ dataset_name }}";
|
| 529 |
-
let selectedFiles = [];
|
| 530 |
-
let uploadMethod = 'browser'; // 'browser' or 'drive'
|
| 531 |
-
let driveAvailable = false;
|
| 532 |
let driveFolderVerified = false;
|
| 533 |
|
| 534 |
-
// Check if Google Drive is available on page load
|
| 535 |
-
async function checkDriveAvailability() {
|
| 536 |
-
try {
|
| 537 |
-
const response = await fetch('/check_drive_status');
|
| 538 |
-
const data = await response.json();
|
| 539 |
-
driveAvailable = data.available;
|
| 540 |
-
if (driveAvailable) {
|
| 541 |
-
document.getElementById('drive-tab').style.display = '';
|
| 542 |
-
}
|
| 543 |
-
} catch (e) {
|
| 544 |
-
console.log('Drive check failed:', e);
|
| 545 |
-
}
|
| 546 |
-
}
|
| 547 |
-
checkDriveAvailability();
|
| 548 |
-
|
| 549 |
-
// Upload method switcher
|
| 550 |
-
function switchUploadMethod(method) {
|
| 551 |
-
uploadMethod = method;
|
| 552 |
-
document.querySelectorAll('.upload-tab').forEach(tab => {
|
| 553 |
-
const tabText = tab.textContent.toLowerCase();
|
| 554 |
-
const isActive = (method === 'browser' && tabText.includes('browser')) ||
|
| 555 |
-
(method === 'drive' && tabText.includes('drive'));
|
| 556 |
-
tab.classList.toggle('active', isActive);
|
| 557 |
-
});
|
| 558 |
-
|
| 559 |
-
// Show/hide upload areas
|
| 560 |
-
document.getElementById('upload-area').style.display = method === 'browser' ? 'block' : 'none';
|
| 561 |
-
document.getElementById('drive-input-area').classList.toggle('active', method === 'drive');
|
| 562 |
-
|
| 563 |
-
// Reset file list when switching
|
| 564 |
-
if (method === 'drive') {
|
| 565 |
-
document.getElementById('file-list').style.display = 'none';
|
| 566 |
-
selectedFiles = [];
|
| 567 |
-
document.getElementById('btn-process').disabled = !driveFolderVerified;
|
| 568 |
-
} else {
|
| 569 |
-
document.getElementById('btn-process').disabled = selectedFiles.length === 0;
|
| 570 |
-
}
|
| 571 |
-
}
|
| 572 |
-
|
| 573 |
// Preview Google Drive folder
|
| 574 |
async function previewDriveFolder() {
|
| 575 |
const driveUrl = document.getElementById('drive-folder-url').value.trim();
|
|
@@ -616,91 +551,9 @@
|
|
| 616 |
document.getElementById('drive-folder-url').addEventListener('input', function() {
|
| 617 |
driveFolderVerified = false;
|
| 618 |
document.getElementById('drive-preview').classList.remove('show');
|
| 619 |
-
|
| 620 |
-
document.getElementById('btn-process').disabled = true;
|
| 621 |
-
}
|
| 622 |
});
|
| 623 |
|
| 624 |
-
// File input change
|
| 625 |
-
document.getElementById('file-input').addEventListener('change', function(e) {
|
| 626 |
-
handleFiles(e.target.files);
|
| 627 |
-
});
|
| 628 |
-
|
| 629 |
-
// Drag and drop
|
| 630 |
-
const uploadArea = document.getElementById('upload-area');
|
| 631 |
-
|
| 632 |
-
uploadArea.addEventListener('dragover', (e) => {
|
| 633 |
-
e.preventDefault();
|
| 634 |
-
uploadArea.classList.add('dragover');
|
| 635 |
-
});
|
| 636 |
-
|
| 637 |
-
uploadArea.addEventListener('dragleave', () => {
|
| 638 |
-
uploadArea.classList.remove('dragover');
|
| 639 |
-
});
|
| 640 |
-
|
| 641 |
-
uploadArea.addEventListener('drop', (e) => {
|
| 642 |
-
e.preventDefault();
|
| 643 |
-
uploadArea.classList.remove('dragover');
|
| 644 |
-
handleFiles(e.dataTransfer.files);
|
| 645 |
-
});
|
| 646 |
-
|
| 647 |
-
let isZipUpload = false;
|
| 648 |
-
|
| 649 |
-
function handleFiles(files) {
|
| 650 |
-
const allFiles = Array.from(files);
|
| 651 |
-
|
| 652 |
-
// Check if a zip file was selected
|
| 653 |
-
const zipFile = allFiles.find(f => f.name.toLowerCase().endsWith('.zip'));
|
| 654 |
-
|
| 655 |
-
if (zipFile) {
|
| 656 |
-
// Zip file upload mode
|
| 657 |
-
isZipUpload = true;
|
| 658 |
-
selectedFiles = [zipFile];
|
| 659 |
-
|
| 660 |
-
const fileList = document.getElementById('file-list');
|
| 661 |
-
fileList.style.display = 'block';
|
| 662 |
-
fileList.innerHTML = `
|
| 663 |
-
<div class="file-item">
|
| 664 |
-
<span class="file-name">${zipFile.name}</span>
|
| 665 |
-
<span class="file-size">${(zipFile.size / 1024 / 1024).toFixed(1)} MB (zip)</span>
|
| 666 |
-
</div>
|
| 667 |
-
<div class="file-item" style="color: #28a745;">
|
| 668 |
-
<span class="file-name">✓ Zip upload is faster and more reliable</span>
|
| 669 |
-
</div>
|
| 670 |
-
`;
|
| 671 |
-
document.getElementById('btn-process').disabled = false;
|
| 672 |
-
} else {
|
| 673 |
-
// Individual photos mode
|
| 674 |
-
isZipUpload = false;
|
| 675 |
-
selectedFiles = allFiles.filter(f =>
|
| 676 |
-
f.type.startsWith('image/') ||
|
| 677 |
-
f.name.toLowerCase().endsWith('.heic') ||
|
| 678 |
-
f.name.toLowerCase().endsWith('.heif')
|
| 679 |
-
);
|
| 680 |
-
|
| 681 |
-
// Update file list
|
| 682 |
-
const fileList = document.getElementById('file-list');
|
| 683 |
-
if (selectedFiles.length > 0) {
|
| 684 |
-
fileList.style.display = 'block';
|
| 685 |
-
fileList.innerHTML = selectedFiles.slice(0, 10).map(f => `
|
| 686 |
-
<div class="file-item">
|
| 687 |
-
<span class="file-name">${f.name}</span>
|
| 688 |
-
<span class="file-size">${(f.size / 1024 / 1024).toFixed(1)} MB</span>
|
| 689 |
-
</div>
|
| 690 |
-
`).join('') + (selectedFiles.length > 10 ? `
|
| 691 |
-
<div class="file-item">
|
| 692 |
-
<span class="file-name">... and ${selectedFiles.length - 10} more files</span>
|
| 693 |
-
</div>
|
| 694 |
-
` : '');
|
| 695 |
-
|
| 696 |
-
document.getElementById('btn-process').disabled = false;
|
| 697 |
-
} else {
|
| 698 |
-
fileList.style.display = 'none';
|
| 699 |
-
document.getElementById('btn-process').disabled = true;
|
| 700 |
-
}
|
| 701 |
-
}
|
| 702 |
-
}
|
| 703 |
-
|
| 704 |
let processingInterval = null;
|
| 705 |
const processingSteps = [
|
| 706 |
'Loading dataset from Supabase...',
|
|
@@ -719,8 +572,6 @@
|
|
| 719 |
}
|
| 720 |
}
|
| 721 |
|
| 722 |
-
const CHUNK_SIZE = 20; // Upload 20 photos per batch (smaller = more reliable on HuggingFace)
|
| 723 |
-
|
| 724 |
async function processPhotos() {
|
| 725 |
const btn = document.getElementById('btn-process');
|
| 726 |
btn.disabled = true;
|
|
@@ -729,28 +580,7 @@
|
|
| 729 |
const progressSection = document.getElementById('progress-section');
|
| 730 |
progressSection.classList.add('active');
|
| 731 |
|
| 732 |
-
// Google Drive import
|
| 733 |
-
if (uploadMethod === 'drive') {
|
| 734 |
-
await importFromDrive();
|
| 735 |
-
return;
|
| 736 |
-
}
|
| 737 |
-
|
| 738 |
-
// Browser upload modes
|
| 739 |
-
if (selectedFiles.length === 0) return;
|
| 740 |
-
|
| 741 |
-
// If zip upload, use single request (zip is already one file)
|
| 742 |
-
if (isZipUpload) {
|
| 743 |
-
await uploadZipFile();
|
| 744 |
-
return;
|
| 745 |
-
}
|
| 746 |
-
|
| 747 |
-
// For individual photos, use chunked upload
|
| 748 |
-
await uploadInChunks();
|
| 749 |
-
}
|
| 750 |
-
|
| 751 |
-
// Google Drive import function
|
| 752 |
-
async function importFromDrive() {
|
| 753 |
-
const btn = document.getElementById('btn-process');
|
| 754 |
const driveUrl = document.getElementById('drive-folder-url').value.trim();
|
| 755 |
|
| 756 |
if (!driveUrl || !driveFolderVerified) {
|
|
@@ -819,286 +649,6 @@
|
|
| 819 |
}
|
| 820 |
}
|
| 821 |
|
| 822 |
-
async function uploadZipFile() {
|
| 823 |
-
const btn = document.getElementById('btn-process');
|
| 824 |
-
const formData = new FormData();
|
| 825 |
-
formData.append('zipfile', selectedFiles[0]);
|
| 826 |
-
|
| 827 |
-
try {
|
| 828 |
-
const xhr = new XMLHttpRequest();
|
| 829 |
-
|
| 830 |
-
xhr.upload.addEventListener('progress', (e) => {
|
| 831 |
-
if (e.lengthComputable) {
|
| 832 |
-
const percent = Math.round((e.loaded / e.total) * 100);
|
| 833 |
-
document.getElementById('progress-fill').style.width = percent + '%';
|
| 834 |
-
document.getElementById('progress-percent').textContent = percent + '%';
|
| 835 |
-
|
| 836 |
-
if (percent < 100) {
|
| 837 |
-
const mbUploaded = (e.loaded / 1024 / 1024).toFixed(1);
|
| 838 |
-
const mbTotal = (e.total / 1024 / 1024).toFixed(1);
|
| 839 |
-
document.getElementById('progress-status').textContent =
|
| 840 |
-
`Uploading zip file... (${mbUploaded} MB / ${mbTotal} MB)`;
|
| 841 |
-
} else {
|
| 842 |
-
document.getElementById('progress-text').textContent = 'Processing...';
|
| 843 |
-
document.getElementById('progress-status').innerHTML =
|
| 844 |
-
'<strong>Extracting zip file...</strong><br>' +
|
| 845 |
-
'<span style="color: #888; font-size: 12px;">This may take a moment for large files</span>';
|
| 846 |
-
currentStep = 0;
|
| 847 |
-
updateProcessingStatus();
|
| 848 |
-
processingInterval = setInterval(updateProcessingStatus, 1500);
|
| 849 |
-
}
|
| 850 |
-
}
|
| 851 |
-
});
|
| 852 |
-
|
| 853 |
-
xhr.addEventListener('load', () => handleUploadComplete(xhr, btn));
|
| 854 |
-
xhr.addEventListener('error', () => handleUploadError(btn));
|
| 855 |
-
|
| 856 |
-
xhr.open('POST', `/process_reupload/${datasetName}`);
|
| 857 |
-
xhr.send(formData);
|
| 858 |
-
} catch (err) {
|
| 859 |
-
handleUploadError(btn, err.message);
|
| 860 |
-
}
|
| 861 |
-
}
|
| 862 |
-
|
| 863 |
-
const PARALLEL_UPLOADS = 4; // Upload 4 chunks simultaneously
|
| 864 |
-
|
| 865 |
-
async function uploadInChunks() {
|
| 866 |
-
const btn = document.getElementById('btn-process');
|
| 867 |
-
const totalFiles = selectedFiles.length;
|
| 868 |
-
const totalChunks = Math.ceil(totalFiles / CHUNK_SIZE);
|
| 869 |
-
|
| 870 |
-
// First, start an upload session
|
| 871 |
-
let sessionResponse;
|
| 872 |
-
try {
|
| 873 |
-
document.getElementById('progress-status').textContent = 'Starting upload session...';
|
| 874 |
-
const startRes = await fetch(`/start_chunked_upload/${datasetName}`, {
|
| 875 |
-
method: 'POST',
|
| 876 |
-
headers: { 'Content-Type': 'application/json' },
|
| 877 |
-
body: JSON.stringify({ total_files: totalFiles, total_chunks: totalChunks })
|
| 878 |
-
});
|
| 879 |
-
sessionResponse = await startRes.json();
|
| 880 |
-
if (!sessionResponse.upload_id) {
|
| 881 |
-
throw new Error(sessionResponse.error || 'Failed to start upload session');
|
| 882 |
-
}
|
| 883 |
-
} catch (err) {
|
| 884 |
-
showResult(false, 'Failed to start upload: ' + err.message);
|
| 885 |
-
btn.disabled = false;
|
| 886 |
-
btn.innerHTML = 'Continue Processing';
|
| 887 |
-
return;
|
| 888 |
-
}
|
| 889 |
-
|
| 890 |
-
const uploadId = sessionResponse.upload_id;
|
| 891 |
-
let uploadedCount = 0;
|
| 892 |
-
let failedChunks = [];
|
| 893 |
-
|
| 894 |
-
// Prepare all chunk data
|
| 895 |
-
const chunks = [];
|
| 896 |
-
for (let i = 0; i < totalChunks; i++) {
|
| 897 |
-
const start = i * CHUNK_SIZE;
|
| 898 |
-
const end = Math.min(start + CHUNK_SIZE, totalFiles);
|
| 899 |
-
chunks.push({
|
| 900 |
-
index: i,
|
| 901 |
-
files: selectedFiles.slice(start, end),
|
| 902 |
-
start: start,
|
| 903 |
-
end: end
|
| 904 |
-
});
|
| 905 |
-
}
|
| 906 |
-
|
| 907 |
-
// Upload chunks in parallel batches
|
| 908 |
-
for (let batchStart = 0; batchStart < totalChunks; batchStart += PARALLEL_UPLOADS) {
|
| 909 |
-
const batchEnd = Math.min(batchStart + PARALLEL_UPLOADS, totalChunks);
|
| 910 |
-
const batchChunks = chunks.slice(batchStart, batchEnd);
|
| 911 |
-
|
| 912 |
-
document.getElementById('progress-text').textContent =
|
| 913 |
-
`Uploading batches ${batchStart + 1}-${batchEnd} of ${totalChunks}`;
|
| 914 |
-
document.getElementById('progress-status').textContent =
|
| 915 |
-
`Parallel upload: ${PARALLEL_UPLOADS} batches at once for faster upload`;
|
| 916 |
-
|
| 917 |
-
// Upload batch in parallel
|
| 918 |
-
const uploadPromises = batchChunks.map(chunk =>
|
| 919 |
-
uploadChunkWithRetry(uploadId, chunk, totalChunks)
|
| 920 |
-
);
|
| 921 |
-
|
| 922 |
-
const results = await Promise.allSettled(uploadPromises);
|
| 923 |
-
|
| 924 |
-
// Process results
|
| 925 |
-
for (let i = 0; i < results.length; i++) {
|
| 926 |
-
if (results[i].status === 'fulfilled' && results[i].value.success) {
|
| 927 |
-
uploadedCount += batchChunks[i].files.length;
|
| 928 |
-
} else {
|
| 929 |
-
failedChunks.push(batchChunks[i].index);
|
| 930 |
-
}
|
| 931 |
-
}
|
| 932 |
-
|
| 933 |
-
// Update progress
|
| 934 |
-
const percent = Math.round((uploadedCount / totalFiles) * 100);
|
| 935 |
-
document.getElementById('progress-fill').style.width = percent + '%';
|
| 936 |
-
document.getElementById('progress-percent').textContent = percent + '%';
|
| 937 |
-
}
|
| 938 |
-
|
| 939 |
-
// Retry failed chunks one more time (sequentially for reliability)
|
| 940 |
-
if (failedChunks.length > 0) {
|
| 941 |
-
document.getElementById('progress-status').textContent =
|
| 942 |
-
`Retrying ${failedChunks.length} failed batches...`;
|
| 943 |
-
|
| 944 |
-
for (const chunkIndex of failedChunks) {
|
| 945 |
-
const chunk = chunks[chunkIndex];
|
| 946 |
-
const result = await uploadChunkWithRetry(uploadId, chunk, totalChunks, 2);
|
| 947 |
-
if (result.success) {
|
| 948 |
-
uploadedCount += chunk.files.length;
|
| 949 |
-
const percent = Math.round((uploadedCount / totalFiles) * 100);
|
| 950 |
-
document.getElementById('progress-fill').style.width = percent + '%';
|
| 951 |
-
document.getElementById('progress-percent').textContent = percent + '%';
|
| 952 |
-
} else {
|
| 953 |
-
showResult(false, `Upload failed at batch ${chunkIndex + 1}. Please try again.`);
|
| 954 |
-
btn.disabled = false;
|
| 955 |
-
btn.innerHTML = 'Continue Processing';
|
| 956 |
-
return;
|
| 957 |
-
}
|
| 958 |
-
}
|
| 959 |
-
}
|
| 960 |
-
|
| 961 |
-
// All chunks uploaded, finalize
|
| 962 |
-
document.getElementById('progress-text').textContent = 'Processing...';
|
| 963 |
-
document.getElementById('progress-status').textContent = 'Finalizing upload...';
|
| 964 |
-
currentStep = 0;
|
| 965 |
-
updateProcessingStatus();
|
| 966 |
-
processingInterval = setInterval(updateProcessingStatus, 1500);
|
| 967 |
-
|
| 968 |
-
try {
|
| 969 |
-
const finalRes = await fetch(`/finish_chunked_upload/${datasetName}`, {
|
| 970 |
-
method: 'POST',
|
| 971 |
-
headers: { 'Content-Type': 'application/json' },
|
| 972 |
-
body: JSON.stringify({ upload_id: uploadId })
|
| 973 |
-
});
|
| 974 |
-
const result = await finalRes.json();
|
| 975 |
-
|
| 976 |
-
if (processingInterval) {
|
| 977 |
-
clearInterval(processingInterval);
|
| 978 |
-
processingInterval = null;
|
| 979 |
-
}
|
| 980 |
-
|
| 981 |
-
if (result.success) {
|
| 982 |
-
document.getElementById('progress-status').innerHTML =
|
| 983 |
-
`<strong>Success!</strong> Matched ${result.matched_photos} of ${result.total_uploaded} photos.`;
|
| 984 |
-
showResult(true,
|
| 985 |
-
`Matched ${result.matched_photos} photos from ${result.total_uploaded} uploaded. Redirecting...`
|
| 986 |
-
);
|
| 987 |
-
setTimeout(() => {
|
| 988 |
-
window.location.href = result.redirect_url;
|
| 989 |
-
}, 1500);
|
| 990 |
-
} else {
|
| 991 |
-
showResult(false, result.error || 'Failed to process upload');
|
| 992 |
-
btn.disabled = false;
|
| 993 |
-
btn.innerHTML = 'Continue Processing';
|
| 994 |
-
}
|
| 995 |
-
} catch (err) {
|
| 996 |
-
if (processingInterval) {
|
| 997 |
-
clearInterval(processingInterval);
|
| 998 |
-
processingInterval = null;
|
| 999 |
-
}
|
| 1000 |
-
showResult(false, 'Failed to finalize upload: ' + err.message);
|
| 1001 |
-
btn.disabled = false;
|
| 1002 |
-
btn.innerHTML = 'Continue Processing';
|
| 1003 |
-
}
|
| 1004 |
-
}
|
| 1005 |
-
|
| 1006 |
-
async function uploadChunkWithRetry(uploadId, chunk, totalChunks, maxRetries = 5) {
|
| 1007 |
-
let retries = maxRetries;
|
| 1008 |
-
let delay = 2000; // Start with 2 second delay
|
| 1009 |
-
while (retries > 0) {
|
| 1010 |
-
try {
|
| 1011 |
-
const success = await uploadChunk(uploadId, chunk.index, chunk.files, totalChunks);
|
| 1012 |
-
if (success) {
|
| 1013 |
-
return { success: true };
|
| 1014 |
-
}
|
| 1015 |
-
} catch (err) {
|
| 1016 |
-
retries--;
|
| 1017 |
-
console.log(`Chunk ${chunk.index} failed: ${err.message}. Retries left: ${retries}`);
|
| 1018 |
-
if (retries > 0) {
|
| 1019 |
-
document.getElementById('progress-status').textContent =
|
| 1020 |
-
`Batch ${chunk.index + 1} failed, retrying in ${delay/1000}s... (${retries} attempts left)`;
|
| 1021 |
-
await new Promise(r => setTimeout(r, delay));
|
| 1022 |
-
delay = Math.min(delay * 1.5, 10000); // Increase delay up to 10s max
|
| 1023 |
-
}
|
| 1024 |
-
}
|
| 1025 |
-
}
|
| 1026 |
-
return { success: false };
|
| 1027 |
-
}
|
| 1028 |
-
|
| 1029 |
-
async function uploadChunk(uploadId, chunkIndex, files, totalChunks) {
|
| 1030 |
-
return new Promise((resolve, reject) => {
|
| 1031 |
-
const formData = new FormData();
|
| 1032 |
-
formData.append('upload_id', uploadId);
|
| 1033 |
-
formData.append('chunk_index', chunkIndex);
|
| 1034 |
-
formData.append('total_chunks', totalChunks);
|
| 1035 |
-
files.forEach(file => {
|
| 1036 |
-
formData.append('photos', file);
|
| 1037 |
-
});
|
| 1038 |
-
|
| 1039 |
-
const xhr = new XMLHttpRequest();
|
| 1040 |
-
xhr.timeout = 120000; // 2 minute timeout per chunk
|
| 1041 |
-
|
| 1042 |
-
xhr.addEventListener('load', () => {
|
| 1043 |
-
if (xhr.status === 200) {
|
| 1044 |
-
const response = JSON.parse(xhr.responseText);
|
| 1045 |
-
if (response.success) {
|
| 1046 |
-
resolve(true);
|
| 1047 |
-
} else {
|
| 1048 |
-
reject(new Error(response.error || 'Chunk upload failed'));
|
| 1049 |
-
}
|
| 1050 |
-
} else {
|
| 1051 |
-
reject(new Error('Server error: ' + xhr.status));
|
| 1052 |
-
}
|
| 1053 |
-
});
|
| 1054 |
-
|
| 1055 |
-
xhr.addEventListener('error', () => reject(new Error('Network error')));
|
| 1056 |
-
xhr.addEventListener('timeout', () => reject(new Error('Upload timeout')));
|
| 1057 |
-
|
| 1058 |
-
xhr.open('POST', `/upload_reupload_chunk/${datasetName}`);
|
| 1059 |
-
xhr.send(formData);
|
| 1060 |
-
});
|
| 1061 |
-
}
|
| 1062 |
-
|
| 1063 |
-
function handleUploadComplete(xhr, btn) {
|
| 1064 |
-
if (processingInterval) {
|
| 1065 |
-
clearInterval(processingInterval);
|
| 1066 |
-
processingInterval = null;
|
| 1067 |
-
}
|
| 1068 |
-
|
| 1069 |
-
if (xhr.status === 200) {
|
| 1070 |
-
const response = JSON.parse(xhr.responseText);
|
| 1071 |
-
if (response.success) {
|
| 1072 |
-
document.getElementById('progress-status').innerHTML =
|
| 1073 |
-
`<strong>Success!</strong> Matched ${response.matched_photos} of ${response.total_uploaded} photos.`;
|
| 1074 |
-
showResult(true,
|
| 1075 |
-
`Matched ${response.matched_photos} photos from ${response.total_uploaded} uploaded. Redirecting...`
|
| 1076 |
-
);
|
| 1077 |
-
setTimeout(() => {
|
| 1078 |
-
window.location.href = response.redirect_url;
|
| 1079 |
-
}, 1500);
|
| 1080 |
-
} else {
|
| 1081 |
-
showResult(false, response.error || 'Unknown error');
|
| 1082 |
-
btn.disabled = false;
|
| 1083 |
-
btn.innerHTML = 'Continue Processing';
|
| 1084 |
-
}
|
| 1085 |
-
} else {
|
| 1086 |
-
showResult(false, 'Upload failed. Please try again.');
|
| 1087 |
-
btn.disabled = false;
|
| 1088 |
-
btn.innerHTML = 'Continue Processing';
|
| 1089 |
-
}
|
| 1090 |
-
}
|
| 1091 |
-
|
| 1092 |
-
function handleUploadError(btn, message = 'Network error. Please try again.') {
|
| 1093 |
-
if (processingInterval) {
|
| 1094 |
-
clearInterval(processingInterval);
|
| 1095 |
-
processingInterval = null;
|
| 1096 |
-
}
|
| 1097 |
-
showResult(false, message);
|
| 1098 |
-
btn.disabled = false;
|
| 1099 |
-
btn.innerHTML = 'Continue Processing';
|
| 1100 |
-
}
|
| 1101 |
-
|
| 1102 |
function showResult(success, message) {
|
| 1103 |
const resultSection = document.getElementById('result-section');
|
| 1104 |
const resultTitle = document.getElementById('result-title');
|
|
|
|
| 453 |
|
| 454 |
<!-- Upload Area -->
|
| 455 |
<div class="upload-card">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
<!-- Google Drive Input Area -->
|
| 457 |
+
<div class="drive-input-area active" id="drive-input-area">
|
| 458 |
<svg viewBox="0 0 24 24" width="50" height="50" fill="none" stroke="#667eea" stroke-width="1.5" style="margin-bottom: 15px;">
|
| 459 |
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
| 460 |
</svg>
|
|
|
|
| 473 |
</button>
|
| 474 |
</div>
|
| 475 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
<!-- Progress Section -->
|
| 477 |
<div class="progress-section" id="progress-section">
|
| 478 |
<div class="progress-header">
|
|
|
|
| 503 |
|
| 504 |
<script>
|
| 505 |
const datasetName = "{{ dataset_name }}";
|
|
|
|
|
|
|
|
|
|
| 506 |
let driveFolderVerified = false;
|
| 507 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
// Preview Google Drive folder
|
| 509 |
async function previewDriveFolder() {
|
| 510 |
const driveUrl = document.getElementById('drive-folder-url').value.trim();
|
|
|
|
| 551 |
document.getElementById('drive-folder-url').addEventListener('input', function() {
|
| 552 |
driveFolderVerified = false;
|
| 553 |
document.getElementById('drive-preview').classList.remove('show');
|
| 554 |
+
document.getElementById('btn-process').disabled = true;
|
|
|
|
|
|
|
| 555 |
});
|
| 556 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
let processingInterval = null;
|
| 558 |
const processingSteps = [
|
| 559 |
'Loading dataset from Supabase...',
|
|
|
|
| 572 |
}
|
| 573 |
}
|
| 574 |
|
|
|
|
|
|
|
| 575 |
async function processPhotos() {
|
| 576 |
const btn = document.getElementById('btn-process');
|
| 577 |
btn.disabled = true;
|
|
|
|
| 580 |
const progressSection = document.getElementById('progress-section');
|
| 581 |
progressSection.classList.add('active');
|
| 582 |
|
| 583 |
+
// Google Drive import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
const driveUrl = document.getElementById('drive-folder-url').value.trim();
|
| 585 |
|
| 586 |
if (!driveUrl || !driveFolderVerified) {
|
|
|
|
| 649 |
}
|
| 650 |
}
|
| 651 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
function showResult(success, message) {
|
| 653 |
const resultSection = document.getElementById('result-section');
|
| 654 |
const resultTitle = document.getElementById('result-title');
|
templates/step2_upload.html
CHANGED
|
@@ -739,25 +739,8 @@
|
|
| 739 |
<h2>Upload Your Event Photos</h2>
|
| 740 |
<p>Upload all photos from the event - we'll find the best ones of your child</p>
|
| 741 |
|
| 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 |
-
|
| 748 |
-
<!-- Browser Upload Area -->
|
| 749 |
-
<div class="upload-area" id="drop-zone">
|
| 750 |
-
<div class="upload-icon">📂</div>
|
| 751 |
-
<h3>Drag & Drop Photos Here</h3>
|
| 752 |
-
<p>Supports JPG, PNG, HEIC, WebP (recommended for <500 photos)</p>
|
| 753 |
-
<input type="file" id="file-input" multiple accept=".jpg,.jpeg,.png,.heic,.heif,.webp" hidden>
|
| 754 |
-
<button class="btn btn-primary" onclick="document.getElementById('file-input').click()">
|
| 755 |
-
Select Files
|
| 756 |
-
</button>
|
| 757 |
-
</div>
|
| 758 |
-
|
| 759 |
<!-- Google Drive Input -->
|
| 760 |
-
<div class="folder-input-area
|
| 761 |
<div class="upload-icon" style="font-size: 48px;">☁</div>
|
| 762 |
<h3>Import from Google Drive</h3>
|
| 763 |
<p>Paste a Google Drive folder URL (folder must be shared with the service account)</p>
|
|
@@ -854,52 +837,10 @@
|
|
| 854 |
|
| 855 |
<script>
|
| 856 |
const hasReferences = {{ reference_count }} > 0;
|
| 857 |
-
let selectedFiles = [];
|
| 858 |
let qualityMode = 'balanced';
|
| 859 |
let jobId = null;
|
| 860 |
-
let uploadMethod = 'browser'; // 'browser', 'folder', or 'drive'
|
| 861 |
-
let driveAvailable = false;
|
| 862 |
let driveFolderVerified = false;
|
| 863 |
|
| 864 |
-
// Check if Google Drive is available on page load
|
| 865 |
-
async function checkDriveAvailability() {
|
| 866 |
-
try {
|
| 867 |
-
const response = await fetch('/check_drive_status');
|
| 868 |
-
const data = await response.json();
|
| 869 |
-
driveAvailable = data.available;
|
| 870 |
-
if (driveAvailable) {
|
| 871 |
-
document.getElementById('drive-tab').style.display = '';
|
| 872 |
-
}
|
| 873 |
-
} catch (e) {
|
| 874 |
-
console.log('Drive check failed:', e);
|
| 875 |
-
}
|
| 876 |
-
}
|
| 877 |
-
checkDriveAvailability();
|
| 878 |
-
|
| 879 |
-
// Upload method switcher
|
| 880 |
-
function switchUploadMethod(method) {
|
| 881 |
-
uploadMethod = method;
|
| 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 {
|
| 896 |
-
// Browser mode - show preview if files selected
|
| 897 |
-
if (selectedFiles.length > 0) {
|
| 898 |
-
document.getElementById('file-preview').classList.remove('hidden');
|
| 899 |
-
}
|
| 900 |
-
}
|
| 901 |
-
}
|
| 902 |
-
|
| 903 |
function updateDrivePreview() {
|
| 904 |
const driveUrl = document.getElementById('drive-folder-url').value.trim();
|
| 905 |
const preview = document.getElementById('file-preview');
|
|
@@ -957,9 +898,6 @@
|
|
| 957 |
}
|
| 958 |
}
|
| 959 |
|
| 960 |
-
// Folder path input handler
|
| 961 |
-
document.getElementById('folder-path').addEventListener('input', updateFolderPreview);
|
| 962 |
-
|
| 963 |
// Drive URL input handler - reset verification on change
|
| 964 |
document.getElementById('drive-folder-url').addEventListener('input', function() {
|
| 965 |
driveFolderVerified = false;
|
|
@@ -967,69 +905,6 @@
|
|
| 967 |
document.getElementById('file-preview').classList.add('hidden');
|
| 968 |
});
|
| 969 |
|
| 970 |
-
// File input handler
|
| 971 |
-
document.getElementById('file-input').addEventListener('change', function(e) {
|
| 972 |
-
handleFiles(e.target.files);
|
| 973 |
-
});
|
| 974 |
-
|
| 975 |
-
// Drag and drop
|
| 976 |
-
const dropZone = document.getElementById('drop-zone');
|
| 977 |
-
|
| 978 |
-
dropZone.addEventListener('dragover', (e) => {
|
| 979 |
-
e.preventDefault();
|
| 980 |
-
dropZone.classList.add('drag-over');
|
| 981 |
-
});
|
| 982 |
-
|
| 983 |
-
dropZone.addEventListener('dragleave', () => {
|
| 984 |
-
dropZone.classList.remove('drag-over');
|
| 985 |
-
});
|
| 986 |
-
|
| 987 |
-
dropZone.addEventListener('drop', (e) => {
|
| 988 |
-
e.preventDefault();
|
| 989 |
-
dropZone.classList.remove('drag-over');
|
| 990 |
-
handleFiles(e.dataTransfer.files);
|
| 991 |
-
});
|
| 992 |
-
|
| 993 |
-
function handleFiles(files) {
|
| 994 |
-
selectedFiles = Array.from(files);
|
| 995 |
-
updatePreview();
|
| 996 |
-
}
|
| 997 |
-
|
| 998 |
-
function updatePreview() {
|
| 999 |
-
const preview = document.getElementById('file-preview');
|
| 1000 |
-
const grid = document.getElementById('preview-grid');
|
| 1001 |
-
const countEl = document.getElementById('file-count');
|
| 1002 |
-
|
| 1003 |
-
if (selectedFiles.length === 0) {
|
| 1004 |
-
preview.classList.add('hidden');
|
| 1005 |
-
return;
|
| 1006 |
-
}
|
| 1007 |
-
|
| 1008 |
-
preview.classList.remove('hidden');
|
| 1009 |
-
countEl.textContent = `${selectedFiles.length} photos`;
|
| 1010 |
-
grid.innerHTML = '';
|
| 1011 |
-
|
| 1012 |
-
const maxPreview = 11;
|
| 1013 |
-
const filesToShow = selectedFiles.slice(0, maxPreview);
|
| 1014 |
-
|
| 1015 |
-
filesToShow.forEach(file => {
|
| 1016 |
-
const div = document.createElement('div');
|
| 1017 |
-
div.className = 'preview-item';
|
| 1018 |
-
|
| 1019 |
-
const img = document.createElement('img');
|
| 1020 |
-
img.src = URL.createObjectURL(file);
|
| 1021 |
-
div.appendChild(img);
|
| 1022 |
-
grid.appendChild(div);
|
| 1023 |
-
});
|
| 1024 |
-
|
| 1025 |
-
if (selectedFiles.length > maxPreview) {
|
| 1026 |
-
const more = document.createElement('div');
|
| 1027 |
-
more.className = 'preview-item more-indicator';
|
| 1028 |
-
more.textContent = `+${selectedFiles.length - maxPreview}`;
|
| 1029 |
-
grid.appendChild(more);
|
| 1030 |
-
}
|
| 1031 |
-
}
|
| 1032 |
-
|
| 1033 |
function setQualityMode(mode) {
|
| 1034 |
qualityMode = mode;
|
| 1035 |
document.querySelectorAll('.quality-btn').forEach(btn => {
|
|
@@ -1046,140 +921,48 @@
|
|
| 1046 |
// Show processing overlay
|
| 1047 |
document.getElementById('processing-overlay').classList.remove('hidden');
|
| 1048 |
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
}
|
| 1057 |
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
try {
|
| 1067 |
-
const response = await fetch('/import_from_drive', {
|
| 1068 |
-
method: 'POST',
|
| 1069 |
-
headers: { 'Content-Type': 'application/json' },
|
| 1070 |
-
body: JSON.stringify({
|
| 1071 |
-
folder_url: driveUrl,
|
| 1072 |
-
quality_mode: qualityMode,
|
| 1073 |
-
similarity_threshold: parseFloat(document.getElementById('similarity').value)
|
| 1074 |
-
})
|
| 1075 |
-
});
|
| 1076 |
-
|
| 1077 |
-
const data = await response.json();
|
| 1078 |
-
|
| 1079 |
-
if (data.error) {
|
| 1080 |
-
alert('Error: ' + data.error);
|
| 1081 |
-
document.getElementById('processing-overlay').classList.add('hidden');
|
| 1082 |
-
return;
|
| 1083 |
-
}
|
| 1084 |
-
|
| 1085 |
-
jobId = data.job_id;
|
| 1086 |
-
pollStatus();
|
| 1087 |
-
|
| 1088 |
-
} catch (error) {
|
| 1089 |
-
console.error('Error:', error);
|
| 1090 |
-
alert('Failed to import from Google Drive. Please try again.');
|
| 1091 |
-
document.getElementById('processing-overlay').classList.add('hidden');
|
| 1092 |
-
}
|
| 1093 |
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1098 |
document.getElementById('processing-overlay').classList.add('hidden');
|
| 1099 |
return;
|
| 1100 |
}
|
| 1101 |
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
try {
|
| 1110 |
-
// First, start a new upload session
|
| 1111 |
-
const initResponse = await fetch('/upload_init', {
|
| 1112 |
-
method: 'POST',
|
| 1113 |
-
headers: { 'Content-Type': 'application/json' },
|
| 1114 |
-
body: JSON.stringify({
|
| 1115 |
-
total_files: totalFiles,
|
| 1116 |
-
quality_mode: qualityMode,
|
| 1117 |
-
similarity_threshold: parseFloat(document.getElementById('similarity').value)
|
| 1118 |
-
})
|
| 1119 |
-
});
|
| 1120 |
-
|
| 1121 |
-
const initData = await initResponse.json();
|
| 1122 |
-
if (initData.error) {
|
| 1123 |
-
alert('Error: ' + initData.error);
|
| 1124 |
-
document.getElementById('processing-overlay').classList.add('hidden');
|
| 1125 |
-
return;
|
| 1126 |
-
}
|
| 1127 |
-
|
| 1128 |
-
const sessionId = initData.session_id;
|
| 1129 |
-
|
| 1130 |
-
// Upload files in chunks
|
| 1131 |
-
for (let i = 0; i < totalFiles; i += CHUNK_SIZE) {
|
| 1132 |
-
const chunk = selectedFiles.slice(i, i + CHUNK_SIZE);
|
| 1133 |
-
const formData = new FormData();
|
| 1134 |
-
|
| 1135 |
-
chunk.forEach(file => formData.append('files', file));
|
| 1136 |
-
formData.append('session_id', sessionId);
|
| 1137 |
-
formData.append('chunk_index', Math.floor(i / CHUNK_SIZE));
|
| 1138 |
-
|
| 1139 |
-
const chunkResponse = await fetch('/upload_chunk', {
|
| 1140 |
-
method: 'POST',
|
| 1141 |
-
body: formData
|
| 1142 |
-
});
|
| 1143 |
-
|
| 1144 |
-
const chunkData = await chunkResponse.json();
|
| 1145 |
-
if (chunkData.error) {
|
| 1146 |
-
alert('Error uploading chunk: ' + chunkData.error);
|
| 1147 |
-
document.getElementById('processing-overlay').classList.add('hidden');
|
| 1148 |
-
return;
|
| 1149 |
-
}
|
| 1150 |
-
|
| 1151 |
-
uploadedCount += chunk.length;
|
| 1152 |
-
const percent = Math.round((uploadedCount / totalFiles) * 30); // Upload is 30% of progress
|
| 1153 |
-
document.getElementById('processing-message').textContent = `Uploading photos (${uploadedCount}/${totalFiles})...`;
|
| 1154 |
-
document.getElementById('progress-fill').style.width = `${percent}%`;
|
| 1155 |
-
document.getElementById('progress-percent').textContent = percent;
|
| 1156 |
-
}
|
| 1157 |
-
|
| 1158 |
-
// All chunks uploaded, start processing
|
| 1159 |
-
document.getElementById('processing-message').textContent = 'Starting AI processing...';
|
| 1160 |
-
|
| 1161 |
-
const processResponse = await fetch('/upload_complete', {
|
| 1162 |
-
method: 'POST',
|
| 1163 |
-
headers: { 'Content-Type': 'application/json' },
|
| 1164 |
-
body: JSON.stringify({ session_id: sessionId })
|
| 1165 |
-
});
|
| 1166 |
-
|
| 1167 |
-
const processData = await processResponse.json();
|
| 1168 |
-
|
| 1169 |
-
if (processData.error) {
|
| 1170 |
-
alert('Error: ' + processData.error);
|
| 1171 |
-
document.getElementById('processing-overlay').classList.add('hidden');
|
| 1172 |
-
return;
|
| 1173 |
-
}
|
| 1174 |
-
|
| 1175 |
-
jobId = processData.job_id;
|
| 1176 |
-
pollStatus();
|
| 1177 |
-
|
| 1178 |
-
} catch (error) {
|
| 1179 |
-
console.error('Upload error:', error);
|
| 1180 |
-
alert('Upload failed. Please try again.');
|
| 1181 |
-
document.getElementById('processing-overlay').classList.add('hidden');
|
| 1182 |
-
}
|
| 1183 |
}
|
| 1184 |
}
|
| 1185 |
|
|
|
|
| 739 |
<h2>Upload Your Event Photos</h2>
|
| 740 |
<p>Upload all photos from the event - we'll find the best ones of your child</p>
|
| 741 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 742 |
<!-- Google Drive Input -->
|
| 743 |
+
<div class="folder-input-area" id="drive-input-area">
|
| 744 |
<div class="upload-icon" style="font-size: 48px;">☁</div>
|
| 745 |
<h3>Import from Google Drive</h3>
|
| 746 |
<p>Paste a Google Drive folder URL (folder must be shared with the service account)</p>
|
|
|
|
| 837 |
|
| 838 |
<script>
|
| 839 |
const hasReferences = {{ reference_count }} > 0;
|
|
|
|
| 840 |
let qualityMode = 'balanced';
|
| 841 |
let jobId = null;
|
|
|
|
|
|
|
| 842 |
let driveFolderVerified = false;
|
| 843 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
function updateDrivePreview() {
|
| 845 |
const driveUrl = document.getElementById('drive-folder-url').value.trim();
|
| 846 |
const preview = document.getElementById('file-preview');
|
|
|
|
| 898 |
}
|
| 899 |
}
|
| 900 |
|
|
|
|
|
|
|
|
|
|
| 901 |
// Drive URL input handler - reset verification on change
|
| 902 |
document.getElementById('drive-folder-url').addEventListener('input', function() {
|
| 903 |
driveFolderVerified = false;
|
|
|
|
| 905 |
document.getElementById('file-preview').classList.add('hidden');
|
| 906 |
});
|
| 907 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 908 |
function setQualityMode(mode) {
|
| 909 |
qualityMode = mode;
|
| 910 |
document.querySelectorAll('.quality-btn').forEach(btn => {
|
|
|
|
| 921 |
// Show processing overlay
|
| 922 |
document.getElementById('processing-overlay').classList.remove('hidden');
|
| 923 |
|
| 924 |
+
// Google Drive import mode
|
| 925 |
+
const driveUrl = document.getElementById('drive-folder-url').value.trim();
|
| 926 |
+
if (!driveUrl) {
|
| 927 |
+
alert('Please enter a Google Drive folder URL');
|
| 928 |
+
document.getElementById('processing-overlay').classList.add('hidden');
|
| 929 |
+
return;
|
| 930 |
+
}
|
|
|
|
| 931 |
|
| 932 |
+
if (!driveFolderVerified) {
|
| 933 |
+
alert('Please click "Preview Folder" first to verify access');
|
| 934 |
+
document.getElementById('processing-overlay').classList.add('hidden');
|
| 935 |
+
return;
|
| 936 |
+
}
|
| 937 |
|
| 938 |
+
document.getElementById('processing-message').textContent = 'Connecting to Google Drive...';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 939 |
|
| 940 |
+
try {
|
| 941 |
+
const response = await fetch('/import_from_drive', {
|
| 942 |
+
method: 'POST',
|
| 943 |
+
headers: { 'Content-Type': 'application/json' },
|
| 944 |
+
body: JSON.stringify({
|
| 945 |
+
folder_url: driveUrl,
|
| 946 |
+
quality_mode: qualityMode,
|
| 947 |
+
similarity_threshold: parseFloat(document.getElementById('similarity').value)
|
| 948 |
+
})
|
| 949 |
+
});
|
| 950 |
+
|
| 951 |
+
const data = await response.json();
|
| 952 |
+
|
| 953 |
+
if (data.error) {
|
| 954 |
+
alert('Error: ' + data.error);
|
| 955 |
document.getElementById('processing-overlay').classList.add('hidden');
|
| 956 |
return;
|
| 957 |
}
|
| 958 |
|
| 959 |
+
jobId = data.job_id;
|
| 960 |
+
pollStatus();
|
| 961 |
+
|
| 962 |
+
} catch (error) {
|
| 963 |
+
console.error('Error:', error);
|
| 964 |
+
alert('Failed to import from Google Drive. Please try again.');
|
| 965 |
+
document.getElementById('processing-overlay').classList.add('hidden');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 966 |
}
|
| 967 |
}
|
| 968 |
|
templates/step4_results.html
CHANGED
|
@@ -1295,6 +1295,188 @@
|
|
| 1295 |
filter: grayscale(30%);
|
| 1296 |
}
|
| 1297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1298 |
.cluster-photo-badge {
|
| 1299 |
position: absolute;
|
| 1300 |
top: 6px;
|
|
@@ -1470,9 +1652,11 @@
|
|
| 1470 |
<!-- Cluster View Container -->
|
| 1471 |
<div class="cluster-view-container">
|
| 1472 |
<div class="cluster-view-header">
|
| 1473 |
-
<h3>Photo Clusters</h3>
|
| 1474 |
<p class="cluster-view-subtitle">Photos grouped by visual similarity. Click to expand and see all photos in each cluster.</p>
|
| 1475 |
</div>
|
|
|
|
|
|
|
| 1476 |
<div id="cluster-grid" class="cluster-grid"></div>
|
| 1477 |
</div>
|
| 1478 |
</div>
|
|
@@ -1992,9 +2176,26 @@
|
|
| 1992 |
</div>
|
| 1993 |
`;
|
| 1994 |
|
| 1995 |
-
// Click to expand/collapse
|
| 1996 |
card.onclick = (e) => {
|
| 1997 |
-
// Don't toggle if clicking on
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1998 |
if (e.target.closest('.cluster-photo-item')) return;
|
| 1999 |
toggleClusterCard(card, cluster.id);
|
| 2000 |
};
|
|
@@ -2003,8 +2204,6 @@
|
|
| 2003 |
}
|
| 2004 |
|
| 2005 |
function renderClusterCardPhotos(cluster) {
|
| 2006 |
-
let html = '';
|
| 2007 |
-
|
| 2008 |
// Ensure arrays exist
|
| 2009 |
const selectedPhotos = cluster.selected || [];
|
| 2010 |
const rejectedPhotos = cluster.rejected || [];
|
|
@@ -2020,12 +2219,88 @@
|
|
| 2020 |
return '<div style="padding: 20px; color: #888; text-align: center;">No photos in this cluster</div>';
|
| 2021 |
}
|
| 2022 |
|
| 2023 |
-
// Render
|
| 2024 |
-
html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2025 |
for (const photo of allPhotos) {
|
| 2026 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2027 |
}
|
| 2028 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2029 |
|
| 2030 |
return html;
|
| 2031 |
}
|
|
@@ -2272,7 +2547,20 @@
|
|
| 2272 |
const uniqueness = photo.uniqueness ? (photo.uniqueness * 100).toFixed(0) : 'N/A';
|
| 2273 |
const eventId = photo.event_id !== undefined ? photo.event_id : -1;
|
| 2274 |
const clusterId = photo.cluster_id !== undefined ? photo.cluster_id : -1;
|
| 2275 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2276 |
const similarity = photo.max_similarity !== undefined ? (photo.max_similarity * 100).toFixed(1) : '0.0';
|
| 2277 |
const category = photo.category || 'Unknown';
|
| 2278 |
const datetime = photo.date || 'N/A';
|
|
|
|
| 1295 |
filter: grayscale(30%);
|
| 1296 |
}
|
| 1297 |
|
| 1298 |
+
/* Cluster Details Table */
|
| 1299 |
+
.cluster-details-table {
|
| 1300 |
+
width: 100%;
|
| 1301 |
+
border-collapse: separate;
|
| 1302 |
+
border-spacing: 0;
|
| 1303 |
+
font-size: 13px;
|
| 1304 |
+
border: 1px solid #e2e8f0;
|
| 1305 |
+
border-radius: 10px;
|
| 1306 |
+
overflow: hidden;
|
| 1307 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
| 1308 |
+
}
|
| 1309 |
+
|
| 1310 |
+
.cluster-details-table th {
|
| 1311 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 1312 |
+
padding: 14px 12px;
|
| 1313 |
+
text-align: center;
|
| 1314 |
+
font-weight: 600;
|
| 1315 |
+
color: white;
|
| 1316 |
+
border-right: 1px solid rgba(255,255,255,0.15);
|
| 1317 |
+
white-space: nowrap;
|
| 1318 |
+
font-size: 11px;
|
| 1319 |
+
text-transform: uppercase;
|
| 1320 |
+
letter-spacing: 0.8px;
|
| 1321 |
+
}
|
| 1322 |
+
|
| 1323 |
+
.cluster-details-table th:first-child {
|
| 1324 |
+
text-align: left;
|
| 1325 |
+
padding-left: 16px;
|
| 1326 |
+
}
|
| 1327 |
+
|
| 1328 |
+
.cluster-details-table th:last-child {
|
| 1329 |
+
border-right: none;
|
| 1330 |
+
}
|
| 1331 |
+
|
| 1332 |
+
.cluster-details-table td {
|
| 1333 |
+
padding: 14px 10px;
|
| 1334 |
+
border-bottom: 1px solid #f1f5f9;
|
| 1335 |
+
border-right: 1px solid #f8fafc;
|
| 1336 |
+
vertical-align: middle;
|
| 1337 |
+
text-align: center;
|
| 1338 |
+
}
|
| 1339 |
+
|
| 1340 |
+
.cluster-details-table td:first-child {
|
| 1341 |
+
text-align: left;
|
| 1342 |
+
padding-left: 12px;
|
| 1343 |
+
}
|
| 1344 |
+
|
| 1345 |
+
.cluster-details-table td:last-child {
|
| 1346 |
+
border-right: none;
|
| 1347 |
+
}
|
| 1348 |
+
|
| 1349 |
+
.cluster-details-table tbody tr {
|
| 1350 |
+
cursor: pointer;
|
| 1351 |
+
transition: all 0.15s ease;
|
| 1352 |
+
}
|
| 1353 |
+
|
| 1354 |
+
.cluster-details-table tbody tr:hover {
|
| 1355 |
+
background: #f8fafc;
|
| 1356 |
+
}
|
| 1357 |
+
|
| 1358 |
+
.cluster-details-table tbody tr:last-child td {
|
| 1359 |
+
border-bottom: none;
|
| 1360 |
+
}
|
| 1361 |
+
|
| 1362 |
+
.cluster-details-table tr.selected-row {
|
| 1363 |
+
background: linear-gradient(90deg, #f0fdf4 0%, #ffffff 100%);
|
| 1364 |
+
}
|
| 1365 |
+
|
| 1366 |
+
.cluster-details-table tr.selected-row:hover {
|
| 1367 |
+
background: linear-gradient(90deg, #dcfce7 0%, #f0fdf4 100%);
|
| 1368 |
+
}
|
| 1369 |
+
|
| 1370 |
+
.cluster-details-table tr.rejected-row {
|
| 1371 |
+
background: #ffffff;
|
| 1372 |
+
}
|
| 1373 |
+
|
| 1374 |
+
.cluster-details-table tr.rejected-row:hover {
|
| 1375 |
+
background: linear-gradient(90deg, #fef2f2 0%, #ffffff 100%);
|
| 1376 |
+
}
|
| 1377 |
+
|
| 1378 |
+
.cluster-table-photo {
|
| 1379 |
+
display: flex;
|
| 1380 |
+
flex-direction: column;
|
| 1381 |
+
align-items: center;
|
| 1382 |
+
gap: 6px;
|
| 1383 |
+
}
|
| 1384 |
+
|
| 1385 |
+
.cluster-table-photo img {
|
| 1386 |
+
width: 120px;
|
| 1387 |
+
height: 120px;
|
| 1388 |
+
object-fit: cover;
|
| 1389 |
+
border-radius: 8px;
|
| 1390 |
+
border: 3px solid transparent;
|
| 1391 |
+
}
|
| 1392 |
+
|
| 1393 |
+
.cluster-table-photo.selected img {
|
| 1394 |
+
border-color: #10b981;
|
| 1395 |
+
}
|
| 1396 |
+
|
| 1397 |
+
.cluster-table-photo.rejected img {
|
| 1398 |
+
border-color: #ef4444;
|
| 1399 |
+
opacity: 0.85;
|
| 1400 |
+
}
|
| 1401 |
+
|
| 1402 |
+
.cluster-table-photo .photo-filename {
|
| 1403 |
+
font-size: 11px;
|
| 1404 |
+
color: #64748b;
|
| 1405 |
+
max-width: 120px;
|
| 1406 |
+
word-break: break-all;
|
| 1407 |
+
text-align: center;
|
| 1408 |
+
}
|
| 1409 |
+
|
| 1410 |
+
.cluster-table-photo .photo-score {
|
| 1411 |
+
font-size: 14px;
|
| 1412 |
+
font-weight: 700;
|
| 1413 |
+
}
|
| 1414 |
+
|
| 1415 |
+
.cluster-table-photo.selected .photo-score {
|
| 1416 |
+
color: #10b981;
|
| 1417 |
+
}
|
| 1418 |
+
|
| 1419 |
+
.cluster-table-photo.rejected .photo-score {
|
| 1420 |
+
color: #ef4444;
|
| 1421 |
+
}
|
| 1422 |
+
|
| 1423 |
+
.cluster-table-photo .photo-badge {
|
| 1424 |
+
position: absolute;
|
| 1425 |
+
top: 4px;
|
| 1426 |
+
right: 4px;
|
| 1427 |
+
width: 22px;
|
| 1428 |
+
height: 22px;
|
| 1429 |
+
border-radius: 50%;
|
| 1430 |
+
display: flex;
|
| 1431 |
+
align-items: center;
|
| 1432 |
+
justify-content: center;
|
| 1433 |
+
font-size: 12px;
|
| 1434 |
+
font-weight: bold;
|
| 1435 |
+
color: white;
|
| 1436 |
+
}
|
| 1437 |
+
|
| 1438 |
+
.cluster-table-photo.selected .photo-badge {
|
| 1439 |
+
background: #10b981;
|
| 1440 |
+
}
|
| 1441 |
+
|
| 1442 |
+
.cluster-table-photo.rejected .photo-badge {
|
| 1443 |
+
background: #ef4444;
|
| 1444 |
+
}
|
| 1445 |
+
|
| 1446 |
+
.cluster-table-reason {
|
| 1447 |
+
max-width: 200px;
|
| 1448 |
+
font-size: 12px;
|
| 1449 |
+
color: #475569;
|
| 1450 |
+
}
|
| 1451 |
+
|
| 1452 |
+
.cluster-table-meta {
|
| 1453 |
+
font-size: 12px;
|
| 1454 |
+
color: #64748b;
|
| 1455 |
+
white-space: nowrap;
|
| 1456 |
+
}
|
| 1457 |
+
|
| 1458 |
+
.cluster-table-meta .original {
|
| 1459 |
+
color: #94a3b8;
|
| 1460 |
+
font-size: 11px;
|
| 1461 |
+
}
|
| 1462 |
+
|
| 1463 |
+
.score-cell {
|
| 1464 |
+
text-align: center;
|
| 1465 |
+
font-weight: 500;
|
| 1466 |
+
}
|
| 1467 |
+
|
| 1468 |
+
.score-cell.good {
|
| 1469 |
+
color: #10b981;
|
| 1470 |
+
}
|
| 1471 |
+
|
| 1472 |
+
.score-cell.medium {
|
| 1473 |
+
color: #f59e0b;
|
| 1474 |
+
}
|
| 1475 |
+
|
| 1476 |
+
.score-cell.low {
|
| 1477 |
+
color: #ef4444;
|
| 1478 |
+
}
|
| 1479 |
+
|
| 1480 |
.cluster-photo-badge {
|
| 1481 |
position: absolute;
|
| 1482 |
top: 6px;
|
|
|
|
| 1652 |
<!-- Cluster View Container -->
|
| 1653 |
<div class="cluster-view-container">
|
| 1654 |
<div class="cluster-view-header">
|
| 1655 |
+
<h3 id="cluster-view-title">Photo Clusters</h3>
|
| 1656 |
<p class="cluster-view-subtitle">Photos grouped by visual similarity. Click to expand and see all photos in each cluster.</p>
|
| 1657 |
</div>
|
| 1658 |
+
|
| 1659 |
+
<!-- Clusters grid -->
|
| 1660 |
<div id="cluster-grid" class="cluster-grid"></div>
|
| 1661 |
</div>
|
| 1662 |
</div>
|
|
|
|
| 2176 |
</div>
|
| 2177 |
`;
|
| 2178 |
|
| 2179 |
+
// Click to expand/collapse (only on header, not on table)
|
| 2180 |
card.onclick = (e) => {
|
| 2181 |
+
// Don't toggle if clicking on the photo table or its contents
|
| 2182 |
+
if (e.target.closest('.cluster-details-table')) {
|
| 2183 |
+
// Handle table row click to open modal
|
| 2184 |
+
const row = e.target.closest('tr[data-filename]');
|
| 2185 |
+
if (row) {
|
| 2186 |
+
const filename = row.dataset.filename;
|
| 2187 |
+
const isSelected = row.dataset.selected === 'true';
|
| 2188 |
+
|
| 2189 |
+
// Find the photo in cluster data
|
| 2190 |
+
const allPhotos = [...(cluster.selected || []), ...(cluster.rejected || [])];
|
| 2191 |
+
const photo = allPhotos.find(p => p.filename === filename);
|
| 2192 |
+
if (photo) {
|
| 2193 |
+
openModal(photo, isSelected);
|
| 2194 |
+
}
|
| 2195 |
+
}
|
| 2196 |
+
return;
|
| 2197 |
+
}
|
| 2198 |
+
// Don't toggle if clicking on old photo items (fallback)
|
| 2199 |
if (e.target.closest('.cluster-photo-item')) return;
|
| 2200 |
toggleClusterCard(card, cluster.id);
|
| 2201 |
};
|
|
|
|
| 2204 |
}
|
| 2205 |
|
| 2206 |
function renderClusterCardPhotos(cluster) {
|
|
|
|
|
|
|
| 2207 |
// Ensure arrays exist
|
| 2208 |
const selectedPhotos = cluster.selected || [];
|
| 2209 |
const rejectedPhotos = cluster.rejected || [];
|
|
|
|
| 2219 |
return '<div style="padding: 20px; color: #888; text-align: center;">No photos in this cluster</div>';
|
| 2220 |
}
|
| 2221 |
|
| 2222 |
+
// Render table with all photo details
|
| 2223 |
+
let html = `
|
| 2224 |
+
<table class="cluster-details-table">
|
| 2225 |
+
<thead>
|
| 2226 |
+
<tr>
|
| 2227 |
+
<th>Photo</th>
|
| 2228 |
+
<th>Score</th>
|
| 2229 |
+
<th>Face Quality</th>
|
| 2230 |
+
<th>Aesthetic</th>
|
| 2231 |
+
<th>Emotional</th>
|
| 2232 |
+
<th>Uniqueness</th>
|
| 2233 |
+
<th>Reason</th>
|
| 2234 |
+
<th>Cluster / Event</th>
|
| 2235 |
+
</tr>
|
| 2236 |
+
</thead>
|
| 2237 |
+
<tbody>
|
| 2238 |
+
`;
|
| 2239 |
+
|
| 2240 |
for (const photo of allPhotos) {
|
| 2241 |
+
const isSelected = photo._isSelected;
|
| 2242 |
+
const filename = photo.filename || 'unknown';
|
| 2243 |
+
const thumbnail = getPhotoThumbnail(photo);
|
| 2244 |
+
const score = photo.score ? (photo.score * 100).toFixed(0) : '0';
|
| 2245 |
+
const faceQuality = photo.face_quality ? (photo.face_quality * 100).toFixed(0) : 'N/A';
|
| 2246 |
+
const aesthetic = photo.aesthetic_quality ? (photo.aesthetic_quality * 100).toFixed(0) : 'N/A';
|
| 2247 |
+
const emotional = photo.emotional_signal ? (photo.emotional_signal * 100).toFixed(0) : 'N/A';
|
| 2248 |
+
const uniqueness = photo.uniqueness ? (photo.uniqueness * 100).toFixed(0) : 'N/A';
|
| 2249 |
+
const reason = isSelected
|
| 2250 |
+
? (photo.selection_reason || 'Selected')
|
| 2251 |
+
: (photo.rejection_reason || photo.reason || 'Not selected');
|
| 2252 |
+
|
| 2253 |
+
const clusterId = photo.cluster_id !== undefined ? photo.cluster_id : -1;
|
| 2254 |
+
const originalClusterId = photo.original_cluster_id !== undefined ? photo.original_cluster_id : clusterId;
|
| 2255 |
+
const eventId = photo.event_id !== undefined ? photo.event_id : -1;
|
| 2256 |
+
|
| 2257 |
+
// Show original cluster if different
|
| 2258 |
+
let clusterDisplay = clusterId >= 0 ? `C:${clusterId}` : 'None';
|
| 2259 |
+
if (originalClusterId !== clusterId && originalClusterId >= 0) {
|
| 2260 |
+
clusterDisplay += ` <span class="original">(orig:${originalClusterId})</span>`;
|
| 2261 |
+
}
|
| 2262 |
+
const eventDisplay = eventId >= 0 ? `E:${eventId}` : '';
|
| 2263 |
+
|
| 2264 |
+
// Score color classes
|
| 2265 |
+
const getScoreClass = (val) => {
|
| 2266 |
+
if (val === 'N/A') return '';
|
| 2267 |
+
const num = parseInt(val);
|
| 2268 |
+
if (num >= 70) return 'good';
|
| 2269 |
+
if (num >= 50) return 'medium';
|
| 2270 |
+
return 'low';
|
| 2271 |
+
};
|
| 2272 |
+
|
| 2273 |
+
const rowClass = isSelected ? 'selected-row' : 'rejected-row';
|
| 2274 |
+
const photoClass = isSelected ? 'selected' : 'rejected';
|
| 2275 |
+
const badge = isSelected ? '✓' : '✗';
|
| 2276 |
+
|
| 2277 |
+
html += `
|
| 2278 |
+
<tr class="${rowClass}" data-filename="${filename}" data-selected="${isSelected}">
|
| 2279 |
+
<td>
|
| 2280 |
+
<div class="cluster-table-photo ${photoClass}">
|
| 2281 |
+
<div style="position: relative;">
|
| 2282 |
+
<img src="/thumbnail/${jobId}/${thumbnail}" alt="${filename}" loading="lazy">
|
| 2283 |
+
<span class="photo-badge">${badge}</span>
|
| 2284 |
+
</div>
|
| 2285 |
+
<span class="photo-score">${score}%</span>
|
| 2286 |
+
<span class="photo-filename">${filename}</span>
|
| 2287 |
+
</div>
|
| 2288 |
+
</td>
|
| 2289 |
+
<td class="score-cell ${getScoreClass(score)}">${score}%</td>
|
| 2290 |
+
<td class="score-cell ${getScoreClass(faceQuality)}">${faceQuality}%</td>
|
| 2291 |
+
<td class="score-cell ${getScoreClass(aesthetic)}">${aesthetic}%</td>
|
| 2292 |
+
<td class="score-cell ${getScoreClass(emotional)}">${emotional}%</td>
|
| 2293 |
+
<td class="score-cell ${getScoreClass(uniqueness)}">${uniqueness}%</td>
|
| 2294 |
+
<td class="cluster-table-reason">${reason}</td>
|
| 2295 |
+
<td class="cluster-table-meta">${clusterDisplay}<br>${eventDisplay}</td>
|
| 2296 |
+
</tr>
|
| 2297 |
+
`;
|
| 2298 |
}
|
| 2299 |
+
|
| 2300 |
+
html += `
|
| 2301 |
+
</tbody>
|
| 2302 |
+
</table>
|
| 2303 |
+
`;
|
| 2304 |
|
| 2305 |
return html;
|
| 2306 |
}
|
|
|
|
| 2547 |
const uniqueness = photo.uniqueness ? (photo.uniqueness * 100).toFixed(0) : 'N/A';
|
| 2548 |
const eventId = photo.event_id !== undefined ? photo.event_id : -1;
|
| 2549 |
const clusterId = photo.cluster_id !== undefined ? photo.cluster_id : -1;
|
| 2550 |
+
const originalClusterId = photo.original_cluster_id !== undefined ? photo.original_cluster_id : clusterId;
|
| 2551 |
+
|
| 2552 |
+
// Show original cluster if different from current (merged) cluster
|
| 2553 |
+
let clusterLabel;
|
| 2554 |
+
if (clusterId >= 0) {
|
| 2555 |
+
if (originalClusterId !== clusterId && originalClusterId >= 0) {
|
| 2556 |
+
clusterLabel = `${clusterId} (orig: ${originalClusterId})`;
|
| 2557 |
+
} else {
|
| 2558 |
+
clusterLabel = `Cluster ${clusterId}`;
|
| 2559 |
+
}
|
| 2560 |
+
} else {
|
| 2561 |
+
clusterLabel = 'None';
|
| 2562 |
+
}
|
| 2563 |
+
|
| 2564 |
const similarity = photo.max_similarity !== undefined ? (photo.max_similarity * 100).toFixed(1) : '0.0';
|
| 2565 |
const category = photo.category || 'Unknown';
|
| 2566 |
const datetime = photo.date || 'N/A';
|