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:
parent
47505249ce
commit
d2852fbf1e
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user