From 17aeb5b82364efcf56dfae8467841b6a2d3d347d Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Tue, 11 Nov 2025 12:48:26 -0500 Subject: [PATCH] feat: Enhance AutoMatch and Identify components with quality criteria for face matching This commit updates the AutoMatch component to include a new criterion for auto-matching faces based on picture quality, requiring a minimum quality score of 50%. The Identify component has been modified to persist user settings in localStorage, improving user experience by retaining preferences across sessions. Additionally, the Modify component introduces functionality for selecting and unmatching faces in bulk, enhancing the management of face items. Documentation has been updated to reflect these changes. --- frontend/src/pages/AutoMatch.tsx | 2 +- frontend/src/pages/Identify.tsx | 56 ++++++++- frontend/src/pages/Modify.tsx | 197 +++++++++++++++++++++---------- src/web/api/faces.py | 9 ++ src/web/services/face_service.py | 15 ++- 5 files changed, 210 insertions(+), 69 deletions(-) diff --git a/frontend/src/pages/AutoMatch.tsx b/frontend/src/pages/AutoMatch.tsx index 8afa4ab..4c2ec2f 100644 --- a/frontend/src/pages/AutoMatch.tsx +++ b/frontend/src/pages/AutoMatch.tsx @@ -305,7 +305,7 @@ export default function AutoMatch() { )}
- ℹ️ Auto-Match Criteria: Only faces with similarity higher than 70% will be auto-matched. Profile faces are excluded for better accuracy. + ℹ️ Auto-Match Criteria: Only faces with similarity higher than 70% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.
diff --git a/frontend/src/pages/Identify.tsx b/frontend/src/pages/Identify.tsx index 440be6e..ab422b4 100644 --- a/frontend/src/pages/Identify.tsx +++ b/frontend/src/pages/Identify.tsx @@ -26,6 +26,9 @@ export default function Identify() { const [compareEnabled, setCompareEnabled] = useState(true) const [selectedSimilar, setSelectedSimilar] = useState>({}) const [uniqueFacesOnly, setUniqueFacesOnly] = useState(true) + + // LocalStorage key for persisting settings + const SETTINGS_KEY = 'identify_settings' const [people, setPeople] = useState([]) const [personId, setPersonId] = useState(undefined) @@ -56,6 +59,8 @@ export default function Identify() { const prevFaceIdRef = useRef(undefined) // Track if initial load has happened const initialLoadRef = useRef(false) + // Track if settings have been loaded from localStorage + const [settingsLoaded, setSettingsLoaded] = useState(false) const canIdentify = useMemo(() => { return Boolean((personId && currentFace) || (firstName && lastName && dob && currentFace)) @@ -231,16 +236,61 @@ export default function Identify() { } } - // Initial load on mount + // Load settings from localStorage on mount useEffect(() => { - if (!initialLoadRef.current) { + try { + const saved = localStorage.getItem(SETTINGS_KEY) + if (saved) { + const settings = JSON.parse(saved) + if (settings.pageSize !== undefined) setPageSize(settings.pageSize) + if (settings.minQuality !== undefined) setMinQuality(settings.minQuality) + if (settings.sortBy !== undefined) setSortBy(settings.sortBy) + if (settings.sortDir !== undefined) setSortDir(settings.sortDir) + if (settings.dateFrom !== undefined) setDateFrom(settings.dateFrom) + if (settings.dateTo !== undefined) setDateTo(settings.dateTo) + if (settings.uniqueFacesOnly !== undefined) setUniqueFacesOnly(settings.uniqueFacesOnly) + if (settings.compareEnabled !== undefined) setCompareEnabled(settings.compareEnabled) + if (settings.selectedTags !== undefined) setSelectedTags(settings.selectedTags) + } + } catch (error) { + console.error('Error loading settings from localStorage:', error) + } finally { + setSettingsLoaded(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Save settings to localStorage whenever they change (but only after initial load) + useEffect(() => { + if (!settingsLoaded) return // Don't save during initial load + try { + const settings = { + pageSize, + minQuality, + sortBy, + sortDir, + dateFrom, + dateTo, + uniqueFacesOnly, + compareEnabled, + selectedTags, + } + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)) + } catch (error) { + console.error('Error saving settings to localStorage:', error) + } + }, [pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly, compareEnabled, selectedTags, settingsLoaded]) + + // Initial load on mount (after settings are loaded) + useEffect(() => { + if (!initialLoadRef.current && settingsLoaded) { initialLoadRef.current = true loadFaces() loadPeople() loadTags() } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [settingsLoaded]) // Reload when uniqueFacesOnly changes (immediate reload) useEffect(() => { diff --git a/frontend/src/pages/Modify.tsx b/frontend/src/pages/Modify.tsx index 9011f4d..2931432 100644 --- a/frontend/src/pages/Modify.tsx +++ b/frontend/src/pages/Modify.tsx @@ -151,6 +151,7 @@ export default function Modify() { const [faces, setFaces] = useState([]) const [unmatchedFaces, setUnmatchedFaces] = useState>(new Set()) const [unmatchedByPerson, setUnmatchedByPerson] = useState>>({}) + const [selectedFaces, setSelectedFaces] = useState>(new Set()) const [editDialogPerson, setEditDialogPerson] = useState(null) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) @@ -214,6 +215,8 @@ export default function Modify() { useEffect(() => { if (selectedPersonId) { loadPersonFaces(selectedPersonId) + // Clear selected faces when person changes + setSelectedFaces(new Set()) } }, [selectedPersonId, loadPersonFaces]) @@ -277,12 +280,76 @@ export default function Modify() { setUnmatchedByPerson(newByPerson) } + // Remove from selected faces + const newSelected = new Set(selectedFaces) + newSelected.delete(faceId) + setSelectedFaces(newSelected) + // Immediately refresh display to hide unmatched face if (selectedPersonId) { await loadPersonFaces(selectedPersonId) } } + const handleToggleFaceSelection = (faceId: number) => { + const newSelected = new Set(selectedFaces) + if (newSelected.has(faceId)) { + newSelected.delete(faceId) + } else { + newSelected.add(faceId) + } + setSelectedFaces(newSelected) + } + + const handleSelectAll = () => { + const allFaceIds = new Set(visibleFaces.map(f => f.id)) + setSelectedFaces(allFaceIds) + } + + const handleUnselectAll = () => { + setSelectedFaces(new Set()) + } + + const handleBulkUnmatch = async () => { + if (selectedFaces.size === 0) return + + try { + setBusy(true) + setError(null) + + // Add all selected faces to unmatched set + const newUnmatched = new Set(unmatchedFaces) + const faceIds = Array.from(selectedFaces) + faceIds.forEach(id => newUnmatched.add(id)) + setUnmatchedFaces(newUnmatched) + + // Track by person + if (selectedPersonId) { + const newByPerson = { ...unmatchedByPerson } + if (!newByPerson[selectedPersonId]) { + newByPerson[selectedPersonId] = new Set() + } + faceIds.forEach(id => newByPerson[selectedPersonId].add(id)) + setUnmatchedByPerson(newByPerson) + } + + // Clear selected faces + setSelectedFaces(new Set()) + + // Immediately refresh display to hide unmatched faces + if (selectedPersonId) { + await loadPersonFaces(selectedPersonId) + } + + setSuccess(`Marked ${faceIds.length} face(s) for unmatching`) + setTimeout(() => setSuccess(null), 3000) + } catch (err: any) { + setError(err.response?.data?.detail || err.message || 'Failed to unmatch faces') + } finally { + setBusy(false) + } + } + const handleUndoChanges = () => { if (!selectedPersonId) return @@ -300,6 +367,9 @@ export default function Modify() { delete newByPerson[selectedPersonId] setUnmatchedByPerson(newByPerson) + // Clear selected faces + setSelectedFaces(new Set()) + // Reload faces to show restored faces if (selectedPersonId) { loadPersonFaces(selectedPersonId) @@ -323,6 +393,7 @@ export default function Modify() { // Clear unmatched sets setUnmatchedFaces(new Set()) setUnmatchedByPerson({}) + setSelectedFaces(new Set()) // Reload people list first to update face counts and check if person still exists const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined) @@ -359,25 +430,6 @@ export default function Modify() { } } - const handleExit = () => { - if (unmatchedFaces.size > 0) { - const confirmed = window.confirm( - `You have ${unmatchedFaces.size} unsaved changes.\n\n` + - 'Do you want to save them before exiting?\n\n' + - 'OK: Save changes and exit\n' + - 'Cancel: Return to modify' - ) - if (confirmed) { - handleSaveChanges().then(() => { - // Navigate to home after save - window.location.href = '/' - }) - } - } else { - window.location.href = '/' - } - } - const visibleFaces = faces.filter((f) => !unmatchedFaces.has(f.id)) const currentPersonHasUnmatched = selectedPersonId ? Boolean(unmatchedByPerson[selectedPersonId]?.size) @@ -478,7 +530,52 @@ export default function Modify() { {/* Right panel: Faces grid */}
-

Faces

+
+

Faces

+ {selectedPersonId && ( +
+ {visibleFaces.length > 0 && ( + <> + + + + + )} + + +
+ )} +
{selectedPersonId ? (
@@ -497,32 +594,31 @@ export default function Modify() { > {visibleFaces.map((face) => (
-
+
{`Face { + // Open photo in new window + window.open(`/api/v1/photos/${face.photo_id}/image`, '_blank') + }} + title="Click to show original photo" onError={(e) => { e.currentTarget.src = '/placeholder.png' }} /> -
- +
))}
@@ -537,31 +633,6 @@ export default function Modify() {
- {/* Control buttons */} -
- - - -
- {/* Edit person dialog */} {editDialogPerson && ( 50% (quality_score > 0.5) - Only matches with frontal or tilted unidentified faces (not profile) - Only auto-accepts matches with similarity >= threshold + - Only auto-accepts faces with quality > 50% (quality_score > 0.5) """ from src.web.db.models import Person, Photo from sqlalchemy import func @@ -542,6 +544,7 @@ def auto_match_faces( # Filter matches by criteria: # 1. Match face must be frontal (already filtered by find_similar_faces) # 2. Similarity must be >= threshold + # 3. Quality must be > 50% (quality_score > 0.5) qualifying_faces = [] for face, distance, confidence_pct in similar_faces: @@ -550,6 +553,12 @@ def auto_match_faces( skipped_matches += 1 continue + # Check quality threshold (only accept faces with quality > 50%) + face_quality = float(face.quality_score) if face.quality_score is not None else 0.0 + if face_quality <= 0.5: + skipped_matches += 1 + continue + qualifying_faces.append(face.id) # Auto-accept qualifying faces diff --git a/src/web/services/face_service.py b/src/web/services/face_service.py index bacb095..6029ac2 100644 --- a/src/web/services/face_service.py +++ b/src/web/services/face_service.py @@ -1728,7 +1728,8 @@ def find_auto_match_matches( Args: tolerance: Similarity tolerance (default: 0.6) - filter_frontal_only: Only include persons with frontal or tilted reference face (not profile) + filter_frontal_only: Only include persons with frontal or tilted reference face (not profile). + When True (auto-accept mode), also requires reference faces to have quality > 0.5 Returns: List of (person_id, reference_face_id, reference_face, matches) tuples @@ -1747,15 +1748,25 @@ def find_auto_match_matches( # JOIN photos p ON f.photo_id = p.id # WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3 # ORDER BY f.person_id, f.quality_score DESC + # + # For auto-accept mode (filter_frontal_only=True), also require quality > 0.5 + quality_threshold = 0.3 identified_faces: List[Face] = ( db.query(Face) .join(Photo, Face.photo_id == Photo.id) .filter(Face.person_id.isnot(None)) - .filter(Face.quality_score >= 0.3) + .filter(Face.quality_score >= quality_threshold) .order_by(Face.person_id, Face.quality_score.desc()) .all() ) + # For auto-accept mode, filter out reference faces with quality <= 0.5 + if filter_frontal_only: + identified_faces = [ + f for f in identified_faces + if f.quality_score is not None and float(f.quality_score) > 0.5 + ] + if not identified_faces: return []