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.
This commit is contained in:
@@ -305,7 +305,7 @@ export default function AutoMatch() {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-600 bg-blue-50 border border-blue-200 rounded p-2">
|
||||
<span className="font-medium">ℹ️ Auto-Match Criteria:</span> Only faces with similarity higher than 70% will be auto-matched. Profile faces are excluded for better accuracy.
|
||||
<span className="font-medium">ℹ️ Auto-Match Criteria:</span> Only faces with similarity higher than 70% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ export default function Identify() {
|
||||
const [compareEnabled, setCompareEnabled] = useState(true)
|
||||
const [selectedSimilar, setSelectedSimilar] = useState<Record<number, boolean>>({})
|
||||
const [uniqueFacesOnly, setUniqueFacesOnly] = useState(true)
|
||||
|
||||
// LocalStorage key for persisting settings
|
||||
const SETTINGS_KEY = 'identify_settings'
|
||||
|
||||
const [people, setPeople] = useState<Person[]>([])
|
||||
const [personId, setPersonId] = useState<number | undefined>(undefined)
|
||||
@@ -56,6 +59,8 @@ export default function Identify() {
|
||||
const prevFaceIdRef = useRef<number | undefined>(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(() => {
|
||||
|
||||
+134
-63
@@ -151,6 +151,7 @@ export default function Modify() {
|
||||
const [faces, setFaces] = useState<PersonFaceItem[]>([])
|
||||
const [unmatchedFaces, setUnmatchedFaces] = useState<Set<number>>(new Set())
|
||||
const [unmatchedByPerson, setUnmatchedByPerson] = useState<Record<number, Set<number>>>({})
|
||||
const [selectedFaces, setSelectedFaces] = useState<Set<number>>(new Set())
|
||||
const [editDialogPerson, setEditDialogPerson] = useState<PersonWithFaces | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 */}
|
||||
<div className="col-span-2">
|
||||
<div className="bg-white rounded-lg shadow p-4 h-full flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4">Faces</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Faces</h2>
|
||||
{selectedPersonId && (
|
||||
<div className="flex gap-2">
|
||||
{visibleFaces.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
disabled={busy}
|
||||
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUnselectAll}
|
||||
disabled={busy || selectedFaces.size === 0}
|
||||
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Unselect All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkUnmatch}
|
||||
disabled={busy || selectedFaces.size === 0}
|
||||
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Unmatch Selected
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleUndoChanges}
|
||||
disabled={!currentPersonHasUnmatched || busy}
|
||||
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
↶ Undo changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveChanges}
|
||||
disabled={unmatchedFaces.size === 0 || busy}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
💾 Save changes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedPersonId ? (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
@@ -497,32 +594,31 @@ export default function Modify() {
|
||||
>
|
||||
{visibleFaces.map((face) => (
|
||||
<div key={face.id} className="flex flex-col items-center">
|
||||
<div className="relative w-20 h-20 mb-2">
|
||||
<div className="w-20 h-20 mb-2">
|
||||
<img
|
||||
src={`/api/v1/faces/${face.id}/crop`}
|
||||
alt={`Face ${face.id}`}
|
||||
className="w-full h-full object-cover rounded"
|
||||
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => {
|
||||
// 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'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Open photo in new window (similar to desktop photo icon)
|
||||
window.open(`/api/v1/photos/${face.photo_id}/image`, '_blank')
|
||||
}}
|
||||
className="absolute top-0 right-0 bg-white bg-opacity-80 hover:bg-opacity-100 rounded p-1 text-xs"
|
||||
title="Show original photo"
|
||||
>
|
||||
📷
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUnmatchFace(face.id)}
|
||||
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Unmatch
|
||||
</button>
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFaces.has(face.id)}
|
||||
onChange={() => handleToggleFaceSelection(face.id)}
|
||||
className="rounded"
|
||||
disabled={busy}
|
||||
/>
|
||||
<span className="text-xs text-gray-700">Unmatch</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -537,31 +633,6 @@ export default function Modify() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={handleUndoChanges}
|
||||
disabled={!currentPersonHasUnmatched || busy}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
↶ Undo changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveChanges}
|
||||
disabled={unmatchedFaces.size === 0 || busy}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
💾 Save changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExit}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
❌ Exit Edit Identified
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Edit person dialog */}
|
||||
{editDialogPerson && (
|
||||
<EditPersonDialog
|
||||
|
||||
Reference in New Issue
Block a user