- ℹ️ 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 */}
{
+ // 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 []