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 []