Upload 3 files
Browse files- app.py +8 -0
- photo_selector/monthly_selector.py +90 -1
- templates/step4_results.html +103 -36
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 848 |
border-radius: 12px;
|
| 849 |
overflow: hidden;
|
| 850 |
cursor: pointer;
|
|
@@ -856,20 +895,19 @@
|
|
| 856 |
|
| 857 |
.cluster-photo-img {
|
| 858 |
width: 100%;
|
| 859 |
-
height:
|
| 860 |
position: relative;
|
| 861 |
overflow: hidden;
|
| 862 |
}
|
| 863 |
|
| 864 |
.cluster-photo-filename {
|
| 865 |
-
padding:
|
| 866 |
-
font-size:
|
| 867 |
-
color: #
|
| 868 |
text-align: center;
|
| 869 |
-
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
| 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}
|
| 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 |
-
//
|
| 1940 |
-
|
| 1941 |
-
|
| 1942 |
-
|
| 1943 |
-
|
| 1944 |
-
for (const photo of sortedSelected) {
|
| 1945 |
-
html += createClusterPhotoItem(photo, true);
|
| 1946 |
-
}
|
| 1947 |
-
html += '</div>';
|
| 1948 |
-
}
|
| 1949 |
|
| 1950 |
-
//
|
| 1951 |
-
if (
|
| 1952 |
-
|
| 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 |
-
//
|
| 1963 |
-
|
| 1964 |
-
|
|
|
|
| 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"
|
| 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);
|