rethinks commited on
Commit
4aee874
·
verified ·
1 Parent(s): abf0b68

Upload 3 files

Browse files
app.py CHANGED
@@ -1126,6 +1126,14 @@ def process_photos_quality_selection(job_id, upload_dir, quality_mode, similarit
1126
 
1127
  results['rejection_breakdown'] = dict(rejection_counts)
1128
 
 
 
 
 
 
 
 
 
1129
  # Sort by score
1130
  results['selected'].sort(key=lambda x: x['score'], reverse=True)
1131
  results['rejected'].sort(key=lambda x: x['score'], reverse=True)
 
1126
 
1127
  results['rejection_breakdown'] = dict(rejection_counts)
1128
 
1129
+ # Add face filtering count to breakdown (photos where target face was not detected)
1130
+ face_filter_data = results['summary'].get('face_filtering', {})
1131
+ total_uploaded = face_filter_data.get('total_photos', 0)
1132
+ after_face_filter = face_filter_data.get('after_face_filter', 0)
1133
+ face_filtered_out = total_uploaded - after_face_filter
1134
+ if face_filtered_out > 0:
1135
+ results['rejection_breakdown']['Face not detected'] = face_filtered_out
1136
+
1137
  # Sort by score
1138
  results['selected'].sort(key=lambda x: x['score'], reverse=True)
1139
  results['rejected'].sort(key=lambda x: x['score'], reverse=True)
photo_selector/monthly_selector.py CHANGED
@@ -725,6 +725,90 @@ class MonthlyPhotoSelector:
725
 
726
  return clusters
727
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
728
  def select_hybrid_hdbscan(self, photos: List[Dict],
729
  embeddings: Dict[str, np.ndarray],
730
  target: int,
@@ -764,7 +848,12 @@ class MonthlyPhotoSelector:
764
 
765
  # Step 1: Cluster photos with HDBSCAN (for cluster_id info in results)
766
  clusters = self.cluster_photos_hdbscan(photos, embeddings)
767
- print(f" Found {len(clusters)} clusters")
 
 
 
 
 
768
 
769
  # Step 2: Flatten all photos with cluster_id and sort by score
770
  all_photos = []
 
725
 
726
  return clusters
727
 
728
+ def merge_similar_clusters(self, clusters: Dict[int, List[Dict]],
729
+ embeddings: Dict[str, np.ndarray],
730
+ similarity_threshold: float = 0.80) -> Dict[int, List[Dict]]:
731
+ """
732
+ Merge clusters that have photos too similar to each other.
733
+
734
+ If any photo in Cluster A is >similarity_threshold similar to any photo
735
+ in Cluster B, merge them into one cluster.
736
+
737
+ Args:
738
+ clusters: Dict mapping cluster_id to list of photos
739
+ embeddings: Dict mapping filename to embedding
740
+ similarity_threshold: Merge if similarity > this (default 0.80)
741
+
742
+ Returns:
743
+ Merged clusters dict
744
+ """
745
+ if len(clusters) <= 1:
746
+ return clusters
747
+
748
+ cluster_ids = list(clusters.keys())
749
+ n_clusters = len(cluster_ids)
750
+
751
+ # Build a union-find structure for merging
752
+ parent = {cid: cid for cid in cluster_ids}
753
+
754
+ def find(x):
755
+ if parent[x] != x:
756
+ parent[x] = find(parent[x])
757
+ return parent[x]
758
+
759
+ def union(x, y):
760
+ px, py = find(x), find(y)
761
+ if px != py:
762
+ parent[px] = py
763
+
764
+ # Check all pairs of clusters for similarity
765
+ merge_count = 0
766
+ for i in range(n_clusters):
767
+ for j in range(i + 1, n_clusters):
768
+ cid_a = cluster_ids[i]
769
+ cid_b = cluster_ids[j]
770
+
771
+ # Skip if already in same merged group
772
+ if find(cid_a) == find(cid_b):
773
+ continue
774
+
775
+ # Check if any photo in cluster A is too similar to any in cluster B
776
+ should_merge = False
777
+ for photo_a in clusters[cid_a]:
778
+ emb_a = embeddings.get(photo_a['filename'])
779
+ if emb_a is None:
780
+ continue
781
+ for photo_b in clusters[cid_b]:
782
+ emb_b = embeddings.get(photo_b['filename'])
783
+ if emb_b is None:
784
+ continue
785
+ sim = self.compute_similarity(emb_a, emb_b)
786
+ if sim > similarity_threshold:
787
+ should_merge = True
788
+ break
789
+ if should_merge:
790
+ break
791
+
792
+ if should_merge:
793
+ union(cid_a, cid_b)
794
+ merge_count += 1
795
+
796
+ if merge_count == 0:
797
+ print(f" No clusters needed merging (threshold: {similarity_threshold:.0%})")
798
+ return clusters
799
+
800
+ # Build merged clusters
801
+ merged = {}
802
+ for cid in cluster_ids:
803
+ root = find(cid)
804
+ if root not in merged:
805
+ merged[root] = []
806
+ merged[root].extend(clusters[cid])
807
+
808
+ print(f" Merged {merge_count} cluster pairs: {n_clusters} -> {len(merged)} clusters")
809
+
810
+ return merged
811
+
812
  def select_hybrid_hdbscan(self, photos: List[Dict],
813
  embeddings: Dict[str, np.ndarray],
814
  target: int,
 
848
 
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
+ print(f" Final cluster count: {len(clusters)}")
857
 
858
  # Step 2: Flatten all photos with cluster_id and sort by score
859
  all_photos = []
templates/step4_results.html CHANGED
@@ -442,6 +442,13 @@
442
  .month-table tfoot tr {
443
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
444
  color: white;
 
 
 
 
 
 
 
445
  }
446
 
447
  .month-table tfoot td {
@@ -775,6 +782,38 @@
775
  color: #991b1b;
776
  }
777
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
778
  .score-breakdown {
779
  display: grid;
780
  grid-template-columns: repeat(3, 1fr);
@@ -844,7 +883,7 @@
844
 
845
  .cluster-photo {
846
  position: relative;
847
- width: 160px;
848
  border-radius: 12px;
849
  overflow: hidden;
850
  cursor: pointer;
@@ -856,20 +895,19 @@
856
 
857
  .cluster-photo-img {
858
  width: 100%;
859
- height: 160px;
860
  position: relative;
861
  overflow: hidden;
862
  }
863
 
864
  .cluster-photo-filename {
865
- padding: 6px 8px;
866
- font-size: 11px;
867
- color: #555;
868
  text-align: center;
869
- white-space: nowrap;
870
- overflow: hidden;
871
- text-overflow: ellipsis;
872
  background: #f8f8f8;
 
873
  }
874
 
875
  .cluster-photo:hover {
@@ -1475,6 +1513,15 @@
1475
  <div class="modal-reason" id="modal-reason"></div>
1476
  </div>
1477
 
 
 
 
 
 
 
 
 
 
1478
  <div class="score-breakdown" id="modal-scores"></div>
1479
  <div class="embedding-preview" id="modal-embedding"></div>
1480
  </div>
@@ -1618,9 +1665,22 @@
1618
  <td></td>
1619
  <td class="selected-count">${totalSelected}</td>
1620
  `;
 
 
1621
  tfoot.appendChild(footRow);
1622
  }
1623
 
 
 
 
 
 
 
 
 
 
 
 
1624
  function toggleMonthAccordion(month) {
1625
  const tbody = document.getElementById('month-table-body');
1626
 
@@ -1754,19 +1814,32 @@
1754
  container.innerHTML = '';
1755
 
1756
  const breakdown = results.rejection_breakdown || {};
1757
- const total = results.rejected?.length || 1;
1758
 
1759
- // Sort by count
 
 
 
1760
  const sorted = Object.entries(breakdown).sort((a, b) => b[1] - a[1]);
1761
 
 
 
 
 
 
 
 
 
 
 
1762
  for (const [reason, count] of sorted) {
1763
  const percentage = Math.round((count / total) * 100);
 
1764
  const bar = document.createElement('div');
1765
  bar.className = 'rejection-bar';
1766
  bar.innerHTML = `
1767
  <div class="label">${reason}</div>
1768
  <div class="bar">
1769
- <div class="fill" style="width: ${percentage}%"></div>
1770
  </div>
1771
  <div class="count">${count}</div>
1772
  `;
@@ -1936,33 +2009,23 @@
1936
  const selectedPhotos = cluster.selected || [];
1937
  const rejectedPhotos = cluster.rejected || [];
1938
 
1939
- // Selected photos section
1940
- if (selectedPhotos.length > 0) {
1941
- html += `<div class="cluster-section-label selected-label">✓ Selected (${selectedPhotos.length})</div>`;
1942
- html += '<div class="cluster-photos-grid">';
1943
- const sortedSelected = [...selectedPhotos].sort((a, b) => (b.score || 0) - (a.score || 0));
1944
- for (const photo of sortedSelected) {
1945
- html += createClusterPhotoItem(photo, true);
1946
- }
1947
- html += '</div>';
1948
- }
1949
 
1950
- // Rejected photos section
1951
- if (rejectedPhotos.length > 0) {
1952
- html += `<div class="cluster-rejected-section">`;
1953
- html += `<div class="cluster-section-label rejected-label">✗ Not Selected (${rejectedPhotos.length})</div>`;
1954
- html += '<div class="cluster-photos-grid">';
1955
- const sortedRejected = [...rejectedPhotos].sort((a, b) => (b.score || 0) - (a.score || 0));
1956
- for (const photo of sortedRejected) {
1957
- html += createClusterPhotoItem(photo, false);
1958
- }
1959
- html += '</div></div>';
1960
  }
1961
 
1962
- // If no photos at all
1963
- if (selectedPhotos.length === 0 && rejectedPhotos.length === 0) {
1964
- html = '<div style="padding: 20px; color: #888; text-align: center;">No photos in this cluster</div>';
 
1965
  }
 
1966
 
1967
  return html;
1968
  }
@@ -2187,6 +2250,11 @@
2187
  function openModal(photo, isSelected) {
2188
  document.getElementById('modal-filename').textContent = photo.filename;
2189
 
 
 
 
 
 
2190
  const reasonDiv = document.getElementById('modal-reason');
2191
  if (isSelected) {
2192
  reasonDiv.className = 'modal-reason selected';
@@ -2326,7 +2394,6 @@
2326
  for (const p of clusterPhotos) {
2327
  const scorePercent = p.score ? (p.score * 100).toFixed(0) : '0';
2328
  const pThumb = getPhotoThumbnail(p);
2329
- const displayName = p.filename.length > 18 ? p.filename.substring(0, 15) + '...' : p.filename;
2330
 
2331
  const div = document.createElement('div');
2332
  div.className = `cluster-photo ${p.isSelected ? 'selected' : 'rejected'}`;
@@ -2338,7 +2405,7 @@
2338
  <div class="cluster-photo-score">${scorePercent}%</div>
2339
  <div class="cluster-photo-badge">${p.isSelected ? '✓' : '✗'}</div>
2340
  </div>
2341
- <div class="cluster-photo-filename" title="${p.filename}">${displayName}</div>
2342
  `;
2343
 
2344
  clusterPhotosDiv.appendChild(div);
 
442
  .month-table tfoot tr {
443
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
444
  color: white;
445
+ cursor: pointer;
446
+ transition: opacity 0.2s, transform 0.2s;
447
+ }
448
+
449
+ .month-table tfoot tr:hover {
450
+ opacity: 0.9;
451
+ transform: scale(1.01);
452
  }
453
 
454
  .month-table tfoot td {
 
782
  color: #991b1b;
783
  }
784
 
785
+ .current-photo-preview {
786
+ display: flex;
787
+ align-items: center;
788
+ gap: 20px;
789
+ margin-bottom: 20px;
790
+ padding: 15px;
791
+ background: #f8fafc;
792
+ border-radius: 12px;
793
+ border: 2px solid #e2e8f0;
794
+ }
795
+
796
+ .current-photo-preview img {
797
+ width: 120px;
798
+ height: 120px;
799
+ object-fit: cover;
800
+ border-radius: 10px;
801
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
802
+ }
803
+
804
+ .current-photo-preview .photo-label {
805
+ font-size: 13px;
806
+ color: #64748b;
807
+ margin-bottom: 4px;
808
+ }
809
+
810
+ .current-photo-preview .photo-name {
811
+ font-size: 16px;
812
+ font-weight: 600;
813
+ color: #1e293b;
814
+ word-break: break-all;
815
+ }
816
+
817
  .score-breakdown {
818
  display: grid;
819
  grid-template-columns: repeat(3, 1fr);
 
883
 
884
  .cluster-photo {
885
  position: relative;
886
+ width: 200px;
887
  border-radius: 12px;
888
  overflow: hidden;
889
  cursor: pointer;
 
895
 
896
  .cluster-photo-img {
897
  width: 100%;
898
+ height: 200px;
899
  position: relative;
900
  overflow: hidden;
901
  }
902
 
903
  .cluster-photo-filename {
904
+ padding: 8px 10px;
905
+ font-size: 12px;
906
+ color: #333;
907
  text-align: center;
908
+ word-break: break-all;
 
 
909
  background: #f8f8f8;
910
+ font-weight: 500;
911
  }
912
 
913
  .cluster-photo:hover {
 
1513
  <div class="modal-reason" id="modal-reason"></div>
1514
  </div>
1515
 
1516
+ <!-- Current photo preview -->
1517
+ <div class="current-photo-preview" id="current-photo-preview">
1518
+ <img id="current-photo-img" src="" alt="Current photo">
1519
+ <div>
1520
+ <div class="photo-label">Photo Selected</div>
1521
+ <div class="photo-name" id="current-photo-name"></div>
1522
+ </div>
1523
+ </div>
1524
+
1525
  <div class="score-breakdown" id="modal-scores"></div>
1526
  <div class="embedding-preview" id="modal-embedding"></div>
1527
  </div>
 
1665
  <td></td>
1666
  <td class="selected-count">${totalSelected}</td>
1667
  `;
1668
+ footRow.style.cursor = 'pointer';
1669
+ footRow.onclick = () => selectTotalRow();
1670
  tfoot.appendChild(footRow);
1671
  }
1672
 
1673
+ function selectTotalRow() {
1674
+ const tbody = document.getElementById('month-table-body');
1675
+
1676
+ // Remove active class from all month rows
1677
+ tbody.querySelectorAll('tr').forEach(row => row.classList.remove('active'));
1678
+
1679
+ // Reset to show all clusters
1680
+ activeMonth = null;
1681
+ filterClustersByMonth(null);
1682
+ }
1683
+
1684
  function toggleMonthAccordion(month) {
1685
  const tbody = document.getElementById('month-table-body');
1686
 
 
1814
  container.innerHTML = '';
1815
 
1816
  const breakdown = results.rejection_breakdown || {};
 
1817
 
1818
+ // Calculate total from all breakdown counts (includes face filtering)
1819
+ const total = Object.values(breakdown).reduce((sum, count) => sum + count, 0) || 1;
1820
+
1821
+ // Sort by count (highest first)
1822
  const sorted = Object.entries(breakdown).sort((a, b) => b[1] - a[1]);
1823
 
1824
+ // Color mapping for different rejection reasons
1825
+ const colorMap = {
1826
+ 'Face not detected': '#f97316', // Orange - face filtering
1827
+ 'Same event': '#667eea', // Purple - same as theme
1828
+ 'Same cluster': '#8b5cf6', // Violet
1829
+ 'Too similar': '#6366f1', // Indigo
1830
+ 'Target reached': '#a855f7', // Purple
1831
+ 'Other': '#94a3b8' // Gray
1832
+ };
1833
+
1834
  for (const [reason, count] of sorted) {
1835
  const percentage = Math.round((count / total) * 100);
1836
+ const barColor = colorMap[reason] || '#667eea';
1837
  const bar = document.createElement('div');
1838
  bar.className = 'rejection-bar';
1839
  bar.innerHTML = `
1840
  <div class="label">${reason}</div>
1841
  <div class="bar">
1842
+ <div class="fill" style="width: ${percentage}%; background: ${barColor};"></div>
1843
  </div>
1844
  <div class="count">${count}</div>
1845
  `;
 
2009
  const selectedPhotos = cluster.selected || [];
2010
  const rejectedPhotos = cluster.rejected || [];
2011
 
2012
+ // Combine all photos: selected first, then rejected
2013
+ const allPhotos = [
2014
+ ...selectedPhotos.map(p => ({ ...p, _isSelected: true })),
2015
+ ...rejectedPhotos.map(p => ({ ...p, _isSelected: false }))
2016
+ ];
 
 
 
 
 
2017
 
2018
+ // If no photos at all
2019
+ if (allPhotos.length === 0) {
2020
+ return '<div style="padding: 20px; color: #888; text-align: center;">No photos in this cluster</div>';
 
 
 
 
 
 
 
2021
  }
2022
 
2023
+ // Render all photos in one grid (selected first, then rejected)
2024
+ html += '<div class="cluster-photos-grid">';
2025
+ for (const photo of allPhotos) {
2026
+ html += createClusterPhotoItem(photo, photo._isSelected);
2027
  }
2028
+ html += '</div>';
2029
 
2030
  return html;
2031
  }
 
2250
  function openModal(photo, isSelected) {
2251
  document.getElementById('modal-filename').textContent = photo.filename;
2252
 
2253
+ // Set current photo preview
2254
+ const photoThumb = getPhotoThumbnail(photo);
2255
+ document.getElementById('current-photo-img').src = `/thumbnail/${jobId}/${photoThumb}`;
2256
+ document.getElementById('current-photo-name').textContent = photo.filename;
2257
+
2258
  const reasonDiv = document.getElementById('modal-reason');
2259
  if (isSelected) {
2260
  reasonDiv.className = 'modal-reason selected';
 
2394
  for (const p of clusterPhotos) {
2395
  const scorePercent = p.score ? (p.score * 100).toFixed(0) : '0';
2396
  const pThumb = getPhotoThumbnail(p);
 
2397
 
2398
  const div = document.createElement('div');
2399
  div.className = `cluster-photo ${p.isSelected ? 'selected' : 'rejected'}`;
 
2405
  <div class="cluster-photo-score">${scorePercent}%</div>
2406
  <div class="cluster-photo-badge">${p.isSelected ? '✓' : '✗'}</div>
2407
  </div>
2408
+ <div class="cluster-photo-filename">${p.filename}</div>
2409
  `;
2410
 
2411
  clusterPhotosDiv.appendChild(div);