diff --git a/frontend/src/index.css b/frontend/src/index.css index fa5f45d..b628835 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -13,3 +13,30 @@ body { min-height: 100vh; } +/* Custom scrollbar styling for similar faces container */ +.similar-faces-scrollable { + /* Firefox */ + scrollbar-width: auto; + scrollbar-color: #4B5563 #F3F4F6; +} + +.similar-faces-scrollable::-webkit-scrollbar { + /* Chrome, Safari, Edge */ + width: 12px; +} + +.similar-faces-scrollable::-webkit-scrollbar-track { + background: #F3F4F6; + border-radius: 6px; +} + +.similar-faces-scrollable::-webkit-scrollbar-thumb { + background: #4B5563; + border-radius: 6px; + border: 2px solid #F3F4F6; +} + +.similar-faces-scrollable::-webkit-scrollbar-thumb:hover { + background: #374151; +} + diff --git a/frontend/src/pages/Identify.tsx b/frontend/src/pages/Identify.tsx index fbaabb7..f7eaff8 100644 --- a/frontend/src/pages/Identify.tsx +++ b/frontend/src/pages/Identify.tsx @@ -13,10 +13,8 @@ export default function Identify() { const [minQuality, setMinQuality] = useState(0.0) const [sortBy, setSortBy] = useState('quality') const [sortDir, setSortDir] = useState('desc') - const [dateTakenFrom, setDateTakenFrom] = useState('') - const [dateTakenTo, setDateTakenTo] = useState('') - const [dateProcessedFrom, setDateProcessedFrom] = useState('') - const [dateProcessedTo, setDateProcessedTo] = useState('') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') const [currentIdx, setCurrentIdx] = useState(0) const currentFace = faces[currentIdx] @@ -37,6 +35,7 @@ export default function Identify() { const [imageLoading, setImageLoading] = useState(false) const [filtersCollapsed, setFiltersCollapsed] = useState(false) const [loadingFaces, setLoadingFaces] = useState(false) + const [loadingProgress, setLoadingProgress] = useState({ current: 0, total: 0, message: '' }) // Store form data per face ID (matching desktop behavior) const [faceFormData, setFaceFormData] = useState { setLoadingFaces(true) + setLoadingProgress({ current: 0, total: 0, message: 'Loading faces...' }) try { const res = await facesApi.getUnidentified({ page: 1, page_size: pageSize, min_quality: minQuality, - date_taken_from: dateTakenFrom || undefined, - date_taken_to: dateTakenTo || undefined, - date_processed_from: dateProcessedFrom || undefined, - date_processed_to: dateProcessedTo || undefined, + date_from: dateFrom || undefined, + date_to: dateTo || undefined, sort_by: sortBy, sort_dir: sortDir, }) // Apply unique faces filter if enabled if (uniqueFacesOnly) { + setLoadingProgress({ current: 0, total: res.items.length, message: 'Filtering unique faces...' }) const filtered = await filterUniqueFaces(res.items) setFaces(filtered) setTotal(filtered.length) @@ -85,6 +84,7 @@ export default function Identify() { setCurrentIdx(0) } finally { setLoadingFaces(false) + setLoadingProgress({ current: 0, total: 0, message: '' }) } } @@ -102,22 +102,58 @@ export default function Identify() { similarityMap.set(face.id, new Set()) } + // Update progress - loading all faces once + setLoadingProgress({ + current: 0, + total: faces.length, + message: 'Loading all faces from database...' + }) + try { // Get all face IDs const faceIds = faces.map(f => f.id) - // Call batch similarity endpoint - optimized with vectorized operations + // Update progress - calculating similarities + setLoadingProgress({ + current: 0, + total: faces.length, + message: `Calculating similarities for ${faces.length} faces (this may take a while)...` + }) + + // Call batch similarity endpoint - loads all faces once from DB + // Note: This is where the heavy computation happens (comparing N faces to M faces) + // The progress bar will show 0% during this time as we can't track backend progress const batchRes = await facesApi.batchSimilarity({ face_ids: faceIds, min_confidence: 60.0 }) + // Update progress - calculation complete, now processing results + const totalPairs = batchRes.pairs.length + setLoadingProgress({ + current: 0, + total: totalPairs, + message: `Similarity calculation complete! Processing ${totalPairs} results...` + }) + // Build similarity map from batch results // Note: results include similarities to all faces in DB, but we only care about // similarities between faces in the current list + let processedPairs = 0 for (const pair of batchRes.pairs) { // Only include pairs where both faces are in the current list if (!faceMap.has(pair.face_id_1) || !faceMap.has(pair.face_id_2)) { + processedPairs++ + // Update progress every 100 pairs or at the end + if (processedPairs % 100 === 0 || processedPairs === totalPairs) { + setLoadingProgress({ + current: processedPairs, + total: totalPairs, + message: `Processing similarity results... (${processedPairs} / ${totalPairs})` + }) + // Allow UI to update + await new Promise(resolve => setTimeout(resolve, 0)) + } continue } @@ -129,6 +165,18 @@ export default function Identify() { const set2 = similarityMap.get(pair.face_id_2) || new Set() set2.add(pair.face_id_1) similarityMap.set(pair.face_id_2, set2) + + processedPairs++ + // Update progress every 100 pairs or at the end + if (processedPairs % 100 === 0 || processedPairs === totalPairs) { + setLoadingProgress({ + current: processedPairs, + total: totalPairs, + message: `Processing similarity results... (${processedPairs} / ${totalPairs})` + }) + // Allow UI to update + await new Promise(resolve => setTimeout(resolve, 0)) + } } } catch (error) { // Silently skip on error - return original faces @@ -386,6 +434,53 @@ export default function Identify() {

Identify

+ {/* Loading Progress Bar */} + {loadingFaces && ( +
+
+ + {loadingProgress.message || 'Loading faces...'} + + {loadingProgress.total > 0 && ( + + {loadingProgress.current} / {loadingProgress.total} + {loadingProgress.total > 0 && ( + + ({Math.round((loadingProgress.current / loadingProgress.total) * 100)}%) + + )} + + )} +
+
+ {loadingProgress.total > 0 ? ( +
+ ) : ( +
+
+ +
+ )} +
+
+ )}
{/* Left: Controls and current face */} @@ -423,23 +518,13 @@ export default function Identify() {
- - setDateTakenFrom(e.target.value)} + + setDateFrom(e.target.value)} className="mt-1 block w-full border rounded px-2 py-1" />
- - setDateTakenTo(e.target.value)} - className="mt-1 block w-full border rounded px-2 py-1" /> -
-
- - setDateProcessedFrom(e.target.value)} - className="mt-1 block w-full border rounded px-2 py-1" /> -
-
- - setDateProcessedTo(e.target.value)} + + setDateTo(e.target.value)} className="mt-1 block w-full border rounded px-2 py-1" />
@@ -461,6 +546,20 @@ export default function Identify() {
+ +

+ Hide duplicates with ≥60% match confidence +

+
+
)} -
- -

- Hide duplicates with ≥60% match confidence -

-
@@ -664,7 +749,7 @@ export default function Identify() { ) : similar.length === 0 ? (
No similar faces found
) : ( -
+
{similar.map((s) => { // s.similarity is actually calibrated confidence in [0,1] range // Desktop uses calibrated confidence from _get_calibrated_confidence diff --git a/frontend/src/pages/Modify.tsx b/frontend/src/pages/Modify.tsx index f702bfb..7ee3d0e 100644 --- a/frontend/src/pages/Modify.tsx +++ b/frontend/src/pages/Modify.tsx @@ -190,7 +190,17 @@ export default function Modify() { const visibleFaces = res.items.filter((f) => !unmatchedFaces.has(f.id)) setFaces(visibleFaces) } catch (err: any) { - setError(err.response?.data?.detail || err.message || 'Failed to load faces') + // If person not found (404), clear selection instead of showing error + // This can happen if person was deleted after selection + const errorDetail = err.response?.data?.detail || err.message || '' + if (err.response?.status === 404 && errorDetail.includes('Person') && errorDetail.includes('not found')) { + setSelectedPersonId(null) + setSelectedPersonName('') + setFaces([]) + setError(null) // Don't show error for deleted person + } else { + setError(errorDetail || 'Failed to load faces') + } } finally { setBusy(false) } @@ -314,13 +324,24 @@ export default function Modify() { setUnmatchedFaces(new Set()) setUnmatchedByPerson({}) - // Reload faces to reflect changes - if (selectedPersonId) { - await loadPersonFaces(selectedPersonId) - } + // Reload people list first to update face counts and check if person still exists + const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined) + setPeople(peopleRes.items) - // Reload people list to update face counts - await loadPeople() + // Check if selected person still exists and handle accordingly + if (selectedPersonId) { + const personStillExists = peopleRes.items.some(p => p.id === selectedPersonId) + if (personStillExists) { + // Person still exists, reload their faces + await loadPersonFaces(selectedPersonId) + } else { + // Person was deleted, clear selection and any errors + setSelectedPersonId(null) + setSelectedPersonName('') + setFaces([]) + setError(null) // Clear any error that might have been set + } + } setSuccess(`Successfully unlinked ${faceIds.length} face(s)`) setTimeout(() => setSuccess(null), 3000) diff --git a/src/web/api/faces.py b/src/web/api/faces.py index e241e54..41cb80a 100644 --- a/src/web/api/faces.py +++ b/src/web/api/faces.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.responses import FileResponse, Response from rq import Queue from redis import Redis +from sqlalchemy import func from sqlalchemy.orm import Session from src.web.db.session import get_db @@ -101,12 +102,8 @@ def get_unidentified_faces( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), min_quality: float = Query(0.0, ge=0.0, le=1.0), - date_from: str | None = Query(None, description="Legacy: date from (filters by date_taken or date_added)"), - date_to: str | None = Query(None, description="Legacy: date to (filters by date_taken or date_added)"), - date_taken_from: str | None = Query(None, description="Date taken from (YYYY-MM-DD)"), - date_taken_to: str | None = Query(None, description="Date taken to (YYYY-MM-DD)"), - date_processed_from: str | None = Query(None, description="Date processed from (YYYY-MM-DD)"), - date_processed_to: str | None = Query(None, description="Date processed to (YYYY-MM-DD)"), + date_from: str | None = Query(None), + date_to: str | None = Query(None), sort_by: str = Query("quality"), sort_dir: str = Query("desc"), db: Session = Depends(get_db), @@ -116,10 +113,6 @@ def get_unidentified_faces( df = _date.fromisoformat(date_from) if date_from else None dt = _date.fromisoformat(date_to) if date_to else None - dtf = _date.fromisoformat(date_taken_from) if date_taken_from else None - dtt = _date.fromisoformat(date_taken_to) if date_taken_to else None - dpf = _date.fromisoformat(date_processed_from) if date_processed_from else None - dpt = _date.fromisoformat(date_processed_to) if date_processed_to else None faces, total = list_unidentified_faces( db, @@ -128,10 +121,6 @@ def get_unidentified_faces( min_quality=min_quality, date_from=df, date_to=dt, - date_taken_from=dtf, - date_taken_to=dtt, - date_processed_from=dpf, - date_processed_to=dpt, sort_by=sort_by, sort_dir=sort_dir, ) @@ -452,6 +441,9 @@ def batch_unmatch_faces(request: BatchUnmatchRequest, db: Session = Depends(get_ # Unmatch all matched faces face_ids_to_unmatch = [f.id for f in matched_faces] + # Collect person_ids that will be affected (before unlinking) + affected_person_ids = {f.person_id for f in matched_faces if f.person_id is not None} + for face in matched_faces: face.person_id = None @@ -467,10 +459,38 @@ def batch_unmatch_faces(request: BatchUnmatchRequest, db: Session = Depends(get_ detail=f"Failed to batch unmatch faces: {str(e)}", ) + # After committing, check which people have no faces left and delete them + # This only happens in batch_unmatch (called from Modify Save changes button) + deleted_person_ids = [] + if affected_person_ids: + for person_id in affected_person_ids: + # Check if person has any faces left + face_count = db.query(func.count(Face.id)).filter(Face.person_id == person_id).scalar() + if face_count == 0: + # Person has no faces left, delete them + person = db.query(Person).filter(Person.id == person_id).first() + if person: + db.delete(person) + deleted_person_ids.append(person_id) + + if deleted_person_ids: + try: + db.commit() + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete people with no faces: {str(e)}", + ) + + message = f"Successfully unlinked {len(face_ids_to_unmatch)} face(s)" + if deleted_person_ids: + message += f" and deleted {len(deleted_person_ids)} person(s) with no faces" + return BatchUnmatchResponse( unmatched_face_ids=face_ids_to_unmatch, count=len(face_ids_to_unmatch), - message=f"Successfully unlinked {len(face_ids_to_unmatch)} face(s)", + message=message, )