feat: Implement excluded and identified filters in FacesMaintenance component

This commit adds functionality to filter faces based on their excluded and identified statuses in the FacesMaintenance component. New state variables and API parameters are introduced to manage these filters, enhancing the user experience. The UI is updated with dropdowns for selecting filter options, and the backend is modified to support these filters in the face listing API. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-12-05 11:57:02 -05:00
parent 47505249ce
commit d2852fbf1e
5 changed files with 208 additions and 25 deletions

View File

@ -168,6 +168,7 @@ export interface MaintenanceFaceItem {
quality_score: number
person_id: number | null
person_name: string | null
excluded: boolean
}
export interface MaintenanceFacesResponse {
@ -272,6 +273,8 @@ export const facesApi = {
page_size?: number
min_quality?: number
max_quality?: number
excluded_filter?: 'all' | 'excluded' | 'included'
identified_filter?: 'all' | 'identified' | 'unidentified'
}): Promise<MaintenanceFacesResponse> => {
const response = await apiClient.get<MaintenanceFacesResponse>('/api/v1/faces/maintenance', {
params,

View File

@ -2,18 +2,23 @@ import { useEffect, useState, useMemo } from 'react'
import facesApi, { MaintenanceFaceItem } from '../api/faces'
import { apiClient } from '../api/client'
type SortColumn = 'person_name' | 'quality'
type SortColumn = 'person_name' | 'quality' | 'photo_path' | 'excluded'
type SortDir = 'asc' | 'desc'
type ExcludedFilter = 'all' | 'excluded' | 'included'
type IdentifiedFilter = 'all' | 'identified' | 'unidentified'
export default function FacesMaintenance() {
const [faces, setFaces] = useState<MaintenanceFaceItem[]>([])
const [total, setTotal] = useState(0)
const [pageSize, setPageSize] = useState(50)
const [minQuality, setMinQuality] = useState(0.0)
const [maxQuality, setMaxQuality] = useState(0.45)
const [maxQuality, setMaxQuality] = useState(1.0)
const [excludedFilter, setExcludedFilter] = useState<ExcludedFilter>('all')
const [identifiedFilter, setIdentifiedFilter] = useState<IdentifiedFilter>('all')
const [selectedFaces, setSelectedFaces] = useState<Set<number>>(new Set())
const [loading, setLoading] = useState(false)
const [deleting, setDeleting] = useState(false)
const [excluding, setExcluding] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
const [sortDir, setSortDir] = useState<SortDir>('asc')
@ -26,6 +31,8 @@ export default function FacesMaintenance() {
page_size: pageSize,
min_quality: minQuality,
max_quality: maxQuality,
excluded_filter: excludedFilter,
identified_filter: identifiedFilter,
})
setFaces(res.items)
setTotal(res.total)
@ -41,7 +48,7 @@ export default function FacesMaintenance() {
useEffect(() => {
loadFaces()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, minQuality, maxQuality])
}, [pageSize, minQuality, maxQuality, excludedFilter, identifiedFilter])
const toggleSelection = (faceId: number) => {
setSelectedFaces(prev => {
@ -88,6 +95,14 @@ export default function FacesMaintenance() {
aVal = a.quality_score
bVal = b.quality_score
break
case 'photo_path':
aVal = a.photo_path
bVal = b.photo_path
break
case 'excluded':
aVal = a.excluded ? 1 : 0
bVal = b.excluded ? 1 : 0
break
}
if (typeof aVal === 'string') {
@ -127,12 +142,33 @@ export default function FacesMaintenance() {
}
}
const handleExclude = async () => {
if (selectedFaces.size === 0) {
alert('Please select at least one face to exclude.')
return
}
setExcluding(true)
try {
const faceIds = Array.from(selectedFaces)
// Exclude each selected face
await Promise.all(faceIds.map(faceId => facesApi.setExcluded(faceId, true)))
// Reload faces after exclusion
await loadFaces()
alert(`Successfully excluded ${selectedFaces.size} face(s)`)
} catch (error) {
console.error('Error excluding faces:', error)
alert('Error excluding faces. Please try again.')
} finally {
setExcluding(false)
}
}
return (
<div>
{/* Controls */}
<div className="bg-white rounded-lg shadow mb-4 p-4">
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-5 gap-4">
{/* Quality Range Selector */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
@ -164,6 +200,38 @@ export default function FacesMaintenance() {
</div>
</div>
{/* Excluded Faces Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Excluded Faces
</label>
<select
value={excludedFilter}
onChange={(e) => setExcludedFilter(e.target.value as ExcludedFilter)}
className="block w-auto border rounded px-2 py-1 text-sm"
>
<option value="all">All</option>
<option value="excluded">Excluded only</option>
<option value="included">Included only</option>
</select>
</div>
{/* Identified Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Identified
</label>
<select
value={identifiedFilter}
onChange={(e) => setIdentifiedFilter(e.target.value as IdentifiedFilter)}
className="block w-auto border rounded px-2 py-1 text-sm"
>
<option value="all">All</option>
<option value="identified">Identified only</option>
<option value="unidentified">Unidentified only</option>
</select>
</div>
{/* Batch Size */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
@ -172,7 +240,7 @@ export default function FacesMaintenance() {
<select
value={pageSize}
onChange={(e) => setPageSize(parseInt(e.target.value))}
className="block w-full border rounded px-2 py-1 text-sm"
className="block w-auto border rounded px-2 py-1 text-sm"
>
{[25, 50, 100, 200, 500, 1000, 1500, 2000].map((n) => (
<option key={n} value={n}>
@ -198,6 +266,13 @@ export default function FacesMaintenance() {
>
Unselect All
</button>
<button
onClick={handleExclude}
disabled={selectedFaces.size === 0 || excluding}
className="px-3 py-2 text-sm bg-orange-600 text-white rounded hover:bg-orange-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{excluding ? 'Excluding...' : 'Exclude Selected'}
</button>
<button
onClick={handleDelete}
disabled={selectedFaces.size === 0 || deleting}
@ -239,13 +314,24 @@ export default function FacesMaintenance() {
>
Person Name {sortColumn === 'person_name' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left p-2">File Path</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('photo_path')}
>
File Path {sortColumn === 'photo_path' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('quality')}
>
Quality {sortColumn === 'quality' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('excluded')}
>
Excluded {sortColumn === 'excluded' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
</tr>
</thead>
<tbody>
@ -302,6 +388,13 @@ export default function FacesMaintenance() {
<td className="p-2">
{(face.quality_score * 100).toFixed(1)}%
</td>
<td className="p-2">
{face.excluded ? (
<span className="text-red-600 font-medium">Yes</span>
) : (
<span className="text-gray-500">No</span>
)}
</td>
</tr>
))}
</tbody>

View File

@ -73,6 +73,10 @@ export default function Search() {
// Tags
const [availableTags, setAvailableTags] = useState<TagResponse[]>([])
const [tagSearchInput, setTagSearchInput] = useState('')
const [showTagDropdown, setShowTagDropdown] = useState(false)
const tagInputRef = useRef<HTMLInputElement>(null)
const tagDropdownRef = useRef<HTMLDivElement>(null)
// Tag modal
const [showTagModal, setShowTagModal] = useState(false)
@ -888,25 +892,90 @@ export default function Search() {
{tagsExpanded && (
<div className="mt-3 space-y-2">
<div>
<select
multiple
value={selectedTags}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, option => option.value)
setSelectedTags(selected)
}}
className="w-full border rounded px-3 py-2 min-h-[120px]"
size={Math.min(availableTags.length, 8)}
>
{availableTags.map(tag => (
<option key={tag.id} value={tag.tag_name}>
{tag.tag_name}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Hold Ctrl/Cmd to select multiple tags
</p>
<label className="block text-sm font-medium text-gray-700 mb-1">
Select Tags:
</label>
{/* Selected tags display */}
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{selectedTags.map(tag => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm"
>
{tag}
<button
onClick={() => {
setSelectedTags(selectedTags.filter(t => t !== tag))
}}
className="hover:text-blue-600"
type="button"
>
×
</button>
</span>
))}
</div>
)}
{/* Tag input and dropdown */}
<div className="relative">
<input
ref={tagInputRef}
type="text"
value={tagSearchInput}
onChange={(e) => {
setTagSearchInput(e.target.value)
setShowTagDropdown(true)
}}
onFocus={() => setShowTagDropdown(true)}
onBlur={(e) => {
// Delay to allow click on dropdown items
setTimeout(() => {
if (!tagDropdownRef.current?.contains(e.relatedTarget as Node)) {
setShowTagDropdown(false)
}
}, 200)
}}
placeholder="Type to search tags..."
className="w-full border rounded px-3 py-2 text-sm"
/>
{showTagDropdown && (
<div
ref={tagDropdownRef}
className="absolute z-10 w-full mt-1 bg-white border rounded shadow-lg max-h-60 overflow-auto"
onMouseDown={(e) => e.preventDefault()} // Prevent blur on click
>
{(() => {
const filtered = availableTags.filter(tag =>
tag.tag_name.toLowerCase().includes(tagSearchInput.toLowerCase()) &&
!selectedTags.includes(tag.tag_name)
)
return filtered.length > 0 ? (
filtered.map(tag => (
<div
key={tag.id}
onClick={() => {
if (!selectedTags.includes(tag.tag_name)) {
setSelectedTags([...selectedTags, tag.tag_name])
}
setTagSearchInput('')
setShowTagDropdown(false)
tagInputRef.current?.focus()
}}
className="px-3 py-2 hover:bg-gray-100 cursor-pointer text-sm"
>
{tag.tag_name}
</div>
))
) : (
<div className="px-3 py-2 text-gray-500 text-sm">
No tags found
</div>
)
})()}
</div>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">

View File

@ -861,6 +861,8 @@ def list_all_faces(
page_size: int = Query(50, ge=1, le=2000),
min_quality: float = Query(0.0, ge=0.0, le=1.0),
max_quality: float = Query(1.0, ge=0.0, le=1.0),
excluded_filter: str = Query("all", description="Filter by excluded status: all, excluded, included"),
identified_filter: str = Query("all", description="Filter by identified status: all, identified, unidentified"),
db: Session = Depends(get_db),
) -> MaintenanceFacesResponse:
"""List all faces with person info and file path for maintenance.
@ -877,6 +879,20 @@ def list_all_faces(
.filter(Face.quality_score <= max_quality)
)
# Filter by excluded status
if excluded_filter == "excluded":
query = query.filter(Face.excluded.is_(True))
elif excluded_filter == "included":
query = query.filter(Face.excluded.is_(False))
# "all" means no additional filter
# Filter by identified status
if identified_filter == "identified":
query = query.filter(Face.person_id.isnot(None))
elif identified_filter == "unidentified":
query = query.filter(Face.person_id.is_(None))
# "all" means no additional filter
# Get total count
total = query.count()
@ -910,6 +926,7 @@ def list_all_faces(
quality_score=float(face.quality_score),
person_id=face.person_id,
person_name=person_name,
excluded=face.excluded,
)
)

View File

@ -318,6 +318,7 @@ class MaintenanceFaceItem(BaseModel):
quality_score: float
person_id: Optional[int] = None
person_name: Optional[str] = None # Full name if identified
excluded: bool
class MaintenanceFacesResponse(BaseModel):