diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 084924d..1155b2e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import Identify from './pages/Identify' import AutoMatch from './pages/AutoMatch' import Modify from './pages/Modify' import Tags from './pages/Tags' +import FacesMaintenance from './pages/FacesMaintenance' import Settings from './pages/Settings' import Layout from './components/Layout' @@ -41,6 +42,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/faces.ts b/frontend/src/api/faces.ts index e4150d3..197d7e0 100644 --- a/frontend/src/api/faces.ts +++ b/frontend/src/api/faces.ts @@ -136,6 +136,31 @@ export interface AcceptMatchesRequest { face_ids: number[] } +export interface MaintenanceFaceItem { + id: number + photo_id: number + photo_path: string + photo_filename: string + quality_score: number + person_id: number | null + person_name: string | null +} + +export interface MaintenanceFacesResponse { + items: MaintenanceFaceItem[] + total: number +} + +export interface DeleteFacesRequest { + face_ids: number[] +} + +export interface DeleteFacesResponse { + deleted_face_ids: number[] + count: number + message: string +} + export const facesApi = { /** * Start face processing job @@ -189,6 +214,21 @@ export const facesApi = { const response = await apiClient.post('/api/v1/faces/auto-match', request) return response.data }, + getMaintenanceFaces: async (params: { + page?: number + page_size?: number + min_quality?: number + max_quality?: number + }): Promise => { + const response = await apiClient.get('/api/v1/faces/maintenance', { + params, + }) + return response.data + }, + deleteFaces: async (request: DeleteFacesRequest): Promise => { + const response = await apiClient.post('/api/v1/faces/delete', request) + return response.data + }, } export default facesApi diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 182c7f1..f8aa0d7 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -14,6 +14,7 @@ export default function Layout() { { path: '/auto-match', label: 'Auto-Match', icon: '🤖' }, { path: '/modify', label: 'Modify', icon: '✏️' }, { path: '/tags', label: 'Tags', icon: '🏷️' }, + { path: '/faces-maintenance', label: 'Faces Maintenance', icon: '🔧' }, { path: '/settings', label: 'Settings', icon: '⚙️' }, ] diff --git a/frontend/src/pages/FacesMaintenance.tsx b/frontend/src/pages/FacesMaintenance.tsx new file mode 100644 index 0000000..b723769 --- /dev/null +++ b/frontend/src/pages/FacesMaintenance.tsx @@ -0,0 +1,343 @@ +import { useEffect, useState, useMemo } from 'react' +import facesApi, { MaintenanceFaceItem } from '../api/faces' +import { apiClient } from '../api/client' + +type SortColumn = 'person_name' | 'quality' +type SortDir = 'asc' | 'desc' + +export default function FacesMaintenance() { + const [faces, setFaces] = useState([]) + const [total, setTotal] = useState(0) + const [pageSize, setPageSize] = useState(50) + const [minQuality, setMinQuality] = useState(0.0) + const [maxQuality, setMaxQuality] = useState(1.0) + const [selectedFaces, setSelectedFaces] = useState>(new Set()) + const [loading, setLoading] = useState(false) + const [deleting, setDeleting] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [sortColumn, setSortColumn] = useState(null) + const [sortDir, setSortDir] = useState('asc') + + const loadFaces = async () => { + setLoading(true) + try { + const res = await facesApi.getMaintenanceFaces({ + page: 1, + page_size: pageSize, + min_quality: minQuality, + max_quality: maxQuality, + }) + setFaces(res.items) + setTotal(res.total) + setSelectedFaces(new Set()) // Clear selection when reloading + } catch (error) { + console.error('Error loading faces:', error) + alert('Error loading faces. Please try again.') + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadFaces() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageSize, minQuality, maxQuality]) + + const toggleSelection = (faceId: number) => { + setSelectedFaces(prev => { + const newSet = new Set(prev) + if (newSet.has(faceId)) { + newSet.delete(faceId) + } else { + newSet.add(faceId) + } + return newSet + }) + } + + const selectAll = () => { + setSelectedFaces(new Set(sortedFaces.map(f => f.id))) + } + + const unselectAll = () => { + setSelectedFaces(new Set()) + } + + const handleSort = (column: SortColumn) => { + if (sortColumn === column) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc') + } else { + setSortColumn(column) + setSortDir('asc') + } + } + + const sortedFaces = useMemo(() => { + if (!sortColumn) return faces + + return [...faces].sort((a, b) => { + let aVal: any + let bVal: any + + switch (sortColumn) { + case 'person_name': + aVal = a.person_name || 'Unidentified' + bVal = b.person_name || 'Unidentified' + break + case 'quality': + aVal = a.quality_score + bVal = b.quality_score + break + } + + if (typeof aVal === 'string') { + aVal = aVal.toLowerCase() + bVal = bVal.toLowerCase() + } + + if (aVal < bVal) return sortDir === 'asc' ? -1 : 1 + if (aVal > bVal) return sortDir === 'asc' ? 1 : -1 + return 0 + }) + }, [faces, sortColumn, sortDir]) + + const handleDelete = async () => { + if (selectedFaces.size === 0) { + alert('Please select at least one face to delete.') + return + } + setShowDeleteConfirm(true) + } + + const confirmDelete = async () => { + setShowDeleteConfirm(false) + setDeleting(true) + try { + await facesApi.deleteFaces({ + face_ids: Array.from(selectedFaces), + }) + // Reload faces after deletion + await loadFaces() + alert(`Successfully deleted ${selectedFaces.size} face(s)`) + } catch (error) { + console.error('Error deleting faces:', error) + alert('Error deleting faces. Please try again.') + } finally { + setDeleting(false) + } + } + + return ( +
+

Faces Maintenance

+ + {/* Controls */} +
+
+ {/* Quality Range Selector */} +
+ +
+ setMinQuality(parseFloat(e.target.value))} + className="flex-1" + /> + setMaxQuality(parseFloat(e.target.value))} + className="flex-1" + /> +
+
+ Min: {(minQuality * 100).toFixed(0)}% + Max: {(maxQuality * 100).toFixed(0)}% +
+
+ + {/* Batch Size */} +
+ + +
+ + {/* Action Buttons */} +
+ + + +
+
+
+ + {/* Results */} +
+
+ + Total: {total} face(s) + + {selectedFaces.size > 0 && ( + + Selected: {selectedFaces.size} face(s) + + )} +
+ + {loading ? ( +
Loading faces...
+ ) : sortedFaces.length === 0 ? ( +
No faces found
+ ) : ( +
+ + + + + + + + + + + + {sortedFaces.map((face) => ( + + + + + + + + ))} + +
Thumbnail handleSort('person_name')} + > + Person Name {sortColumn === 'person_name' && (sortDir === 'asc' ? '↑' : '↓')} + File Path handleSort('quality')} + > + Quality {sortColumn === 'quality' && (sortDir === 'asc' ? '↑' : '↓')} +
+ toggleSelection(face.id)} + className="cursor-pointer" + /> + +
{ + const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${face.photo_id}/image` + window.open(photoUrl, '_blank') + }} + title="Click to open full photo" + > + {`Face { + const target = e.target as HTMLImageElement + target.style.display = 'none' + const parent = target.parentElement + if (parent && !parent.querySelector('.error-fallback')) { + const fallback = document.createElement('div') + fallback.className = 'text-gray-400 text-xs error-fallback' + fallback.textContent = `#${face.id}` + parent.appendChild(fallback) + } + }} + /> +
+
+
+ {face.person_name || ( + Unidentified + )} + + + {face.photo_path} + + + {(face.quality_score * 100).toFixed(1)}% +
+
+ )} +
+ + {/* Delete Confirmation Dialog */} + {showDeleteConfirm && ( +
+
+

Confirm Delete

+

+ Are you sure you want to delete {selectedFaces.size} face(s) from + the database? This action cannot be undone. +

+
+ + +
+
+
+ )} +
+ ) +} + diff --git a/src/web/api/faces.py b/src/web/api/faces.py index fc4c563..4f40842 100644 --- a/src/web/api/faces.py +++ b/src/web/api/faces.py @@ -31,9 +31,13 @@ from src.web.schemas.faces import ( AutoMatchPersonItem, AutoMatchFaceItem, AcceptMatchesRequest, + MaintenanceFacesResponse, + MaintenanceFaceItem, + DeleteFacesRequest, + DeleteFacesResponse, ) from src.web.schemas.people import PersonCreateRequest, PersonResponse -from src.web.db.models import Face, Person, PersonEncoding +from src.web.db.models import Face, Person, PersonEncoding, Photo from src.web.services.face_service import ( list_unidentified_faces, find_similar_faces, @@ -688,4 +692,151 @@ def auto_match_faces( ) +@router.get("/maintenance", response_model=MaintenanceFacesResponse) +def list_all_faces( + page: int = Query(1, ge=1), + 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), + db: Session = Depends(get_db), +) -> MaintenanceFacesResponse: + """List all faces with person info and file path for maintenance. + + Returns all faces (both identified and unidentified) with their associated + person information (if identified) and photo file path. + """ + # Build query with quality filter + query = ( + db.query(Face, Photo, Person) + .join(Photo, Face.photo_id == Photo.id) + .outerjoin(Person, Face.person_id == Person.id) + .filter(Face.quality_score >= min_quality) + .filter(Face.quality_score <= max_quality) + ) + + # Get total count + total = query.count() + + # Apply pagination + offset = (page - 1) * page_size + results = query.order_by(Face.id.desc()).offset(offset).limit(page_size).all() + + # Build response items + items = [] + for face, photo, person in results: + person_name = None + if person: + # Build full name + name_parts = [] + if person.first_name: + name_parts.append(person.first_name) + if person.middle_name: + name_parts.append(person.middle_name) + if person.last_name: + name_parts.append(person.last_name) + if person.maiden_name: + name_parts.append(f"({person.maiden_name})") + person_name = " ".join(name_parts) if name_parts else None + + items.append( + MaintenanceFaceItem( + id=face.id, + photo_id=face.photo_id, + photo_path=photo.path, + photo_filename=photo.filename, + quality_score=float(face.quality_score), + person_id=face.person_id, + person_name=person_name, + ) + ) + + return MaintenanceFacesResponse(items=items, total=total) + + +@router.post("/delete", response_model=DeleteFacesResponse) +def delete_faces( + request: DeleteFacesRequest, + db: Session = Depends(get_db), +) -> DeleteFacesResponse: + """Delete multiple faces from the database. + + This permanently removes faces and their associated encodings. + Also removes person_encodings associated with these faces. + + If a face is identified (has a person_id), we check if that person will be + left without any faces after deletion. If so, the person is also deleted + from the database. + """ + if not request.face_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="face_ids list cannot be empty", + ) + + # Validate all faces exist + faces = db.query(Face).filter(Face.id.in_(request.face_ids)).all() + found_ids = {f.id for f in faces} + missing_ids = set(request.face_ids) - found_ids + + if missing_ids: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Faces not found: {sorted(missing_ids)}", + ) + + # Collect person_ids that will be affected (before deletion) + # Only include faces that are identified (have a person_id) + affected_person_ids = {f.person_id for f in faces if f.person_id is not None} + + # Delete associated person_encodings for these faces + db.query(PersonEncoding).filter(PersonEncoding.face_id.in_(request.face_ids)).delete(synchronize_session=False) + + # Delete the faces + db.query(Face).filter(Face.id.in_(request.face_ids)).delete(synchronize_session=False) + + try: + db.commit() + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete faces: {str(e)}", + ) + + # After committing, check which people have no faces left and delete them + # This ensures that if a person was identified by the deleted faces and has + # no other faces remaining, the person is also removed from the database + deleted_person_ids = [] + if affected_person_ids: + for person_id in affected_person_ids: + # Check if person has any faces left after deletion + face_count = db.query(func.count(Face.id)).filter(Face.person_id == person_id).scalar() + if face_count == 0: + # Person has no faces left, delete them + person = db.query(Person).filter(Person.id == person_id).first() + if person: + db.delete(person) + deleted_person_ids.append(person_id) + + if deleted_person_ids: + try: + db.commit() + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete people with no faces: {str(e)}", + ) + + message = f"Successfully deleted {len(request.face_ids)} face(s)" + if deleted_person_ids: + message += f" and deleted {len(deleted_person_ids)} person(s) with no faces" + + return DeleteFacesResponse( + deleted_face_ids=request.face_ids, + count=len(request.face_ids), + message=message, + ) + + diff --git a/src/web/schemas/faces.py b/src/web/schemas/faces.py index 059ab5c..c2a2f9c 100644 --- a/src/web/schemas/faces.py +++ b/src/web/schemas/faces.py @@ -268,3 +268,44 @@ class AcceptMatchesRequest(BaseModel): model_config = ConfigDict(protected_namespaces=()) face_ids: list[int] = Field(..., min_items=0, description="Face IDs to identify with this person") + + +class MaintenanceFaceItem(BaseModel): + """Face item for maintenance view with person info and file path.""" + + model_config = ConfigDict(protected_namespaces=()) + + id: int + photo_id: int + photo_path: str + photo_filename: str + quality_score: float + person_id: Optional[int] = None + person_name: Optional[str] = None # Full name if identified + + +class MaintenanceFacesResponse(BaseModel): + """Response containing all faces for maintenance.""" + + model_config = ConfigDict(protected_namespaces=()) + + items: list[MaintenanceFaceItem] + total: int + + +class DeleteFacesRequest(BaseModel): + """Request to delete multiple faces.""" + + model_config = ConfigDict(protected_namespaces=()) + + face_ids: list[int] = Field(..., min_items=1, description="Face IDs to delete") + + +class DeleteFacesResponse(BaseModel): + """Response after deleting faces.""" + + model_config = ConfigDict(protected_namespaces=()) + + deleted_face_ids: list[int] + count: int + message: str