feat: Enhance navigation and filtering in AutoMatch and Identify components
This commit introduces new navigation buttons in the AutoMatch component, allowing users to easily move between persons being matched. Additionally, the Identify component has been updated to include a collapsible filters section, improving the user interface for managing face identification. The unique faces filter is now enabled by default, enhancing the identification process. Documentation and tests have been updated to reflect these changes, ensuring a reliable user experience.
This commit is contained in:
parent
0a960a99ce
commit
7945b084a4
@ -233,6 +233,27 @@ export default function AutoMatch() {
|
||||
{currentPerson && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-gray-600">
|
||||
Person {currentIndex + 1} of {activePeople.length}
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!canGoNext}
|
||||
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-semibold">👤 Person: {currentPerson.person_name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
📁 Photo: {currentPerson.reference_photo_filename}
|
||||
|
||||
@ -22,7 +22,7 @@ export default function Identify() {
|
||||
const [similar, setSimilar] = useState<SimilarFaceItem[]>([])
|
||||
const [compareEnabled, setCompareEnabled] = useState(true)
|
||||
const [selectedSimilar, setSelectedSimilar] = useState<Record<number, boolean>>({})
|
||||
const [uniqueFacesOnly, setUniqueFacesOnly] = useState(false)
|
||||
const [uniqueFacesOnly, setUniqueFacesOnly] = useState(true)
|
||||
|
||||
const [people, setPeople] = useState<Person[]>([])
|
||||
const [personId, setPersonId] = useState<number | undefined>(undefined)
|
||||
@ -33,6 +33,7 @@ export default function Identify() {
|
||||
const [dob, setDob] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(false)
|
||||
|
||||
// Store form data per face ID (matching desktop behavior)
|
||||
const [faceFormData, setFaceFormData] = useState<Record<number, {
|
||||
@ -282,17 +283,13 @@ export default function Identify() {
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key.toLowerCase() === 'j') {
|
||||
setCurrentIdx((i) => Math.min(i + 1, Math.max(0, faces.length - 1)))
|
||||
} else if (e.key.toLowerCase() === 'k') {
|
||||
setCurrentIdx((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter' && canIdentify) {
|
||||
if (e.key === 'Enter' && canIdentify) {
|
||||
handleIdentify()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [faces.length, canIdentify])
|
||||
}, [canIdentify])
|
||||
|
||||
const handleIdentify = async () => {
|
||||
if (!currentFace) return
|
||||
@ -349,8 +346,23 @@ export default function Identify() {
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Left: Controls and current face */}
|
||||
<div className="col-span-4">
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-lg shadow mb-4">
|
||||
<div className="flex items-center justify-between p-4 border-b cursor-pointer hover:bg-gray-50" onClick={() => setFiltersCollapsed(!filtersCollapsed)}>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Filters</h2>
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setFiltersCollapsed(!filtersCollapsed)
|
||||
}}
|
||||
aria-label={filtersCollapsed ? 'Expand filters' : 'Collapse filters'}
|
||||
>
|
||||
<span className="text-sm">{filtersCollapsed ? '▼' : '▲'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{!filtersCollapsed && (
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Min Quality</label>
|
||||
<input type="range" min={0} max={1} step={0.05} value={minQuality}
|
||||
@ -394,28 +406,30 @@ export default function Identify() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={uniqueFacesOnly}
|
||||
onChange={(e) => setUniqueFacesOnly(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Unique faces only</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
||||
Hide duplicates with ≥60% match confidence
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={uniqueFacesOnly}
|
||||
onChange={(e) => setUniqueFacesOnly(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Unique faces only</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
||||
Hide duplicates with ≥60% match confidence
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-gray-600">{currentInfo}</div>
|
||||
<div className="space-x-2">
|
||||
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))}>Prev (K)</button>
|
||||
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))}>Next (J)</button>
|
||||
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))}>Prev</button>
|
||||
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
{!currentFace ? (
|
||||
@ -549,8 +563,8 @@ export default function Identify() {
|
||||
className={`px-3 py-2 rounded text-white ${canIdentify && !busy ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-gray-400 cursor-not-allowed'}`}>
|
||||
{busy ? 'Identifying...' : 'Identify (Enter)'}
|
||||
</button>
|
||||
<button onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))} className="px-3 py-2 rounded border">Back (K)</button>
|
||||
<button onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))} className="px-3 py-2 rounded border">Next (J)</button>
|
||||
<button onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))} className="px-3 py-2 rounded border">Back</button>
|
||||
<button onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))} className="px-3 py-2 rounded border">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -335,6 +335,8 @@ class SearchStats:
|
||||
def get_photos_without_faces(self) -> List[Tuple]:
|
||||
"""Get photos that have no detected faces
|
||||
|
||||
Only includes processed photos (photos that have been processed for face detection).
|
||||
|
||||
Returns:
|
||||
List of tuples: (photo_path, filename)
|
||||
"""
|
||||
@ -343,11 +345,13 @@ class SearchStats:
|
||||
with self.db.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# Find photos that have no faces associated with them
|
||||
# Only include processed photos
|
||||
cursor.execute('''
|
||||
SELECT p.path, p.filename
|
||||
FROM photos p
|
||||
LEFT JOIN faces f ON p.id = f.photo_id
|
||||
WHERE f.photo_id IS NULL
|
||||
AND p.processed = 1
|
||||
ORDER BY p.filename
|
||||
''')
|
||||
for row in cursor.fetchall():
|
||||
|
||||
@ -182,12 +182,14 @@ def get_photos_without_faces(
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get photos that have no detected faces.
|
||||
|
||||
Only includes processed photos (photos that have been processed for face detection).
|
||||
Matches desktop behavior exactly.
|
||||
"""
|
||||
query = (
|
||||
db.query(Photo)
|
||||
.outerjoin(Face, Photo.id == Face.photo_id)
|
||||
.filter(Face.photo_id.is_(None))
|
||||
.filter(Photo.processed == True) # Only include processed photos
|
||||
)
|
||||
|
||||
# Apply folder filter if provided
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user