feat: Add Faces Maintenance page and API for managing face items

This commit introduces a new Faces Maintenance page in the frontend, allowing users to view, sort, and delete face items based on quality and person information. The API has been updated to include endpoints for retrieving and deleting faces, enhancing the management capabilities of the application. Additionally, new data models and schemas for maintenance face items have been added to support these features. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09
2025-11-11 14:09:47 -05:00
parent 20f1a4207f
commit f7accb925d
6 changed files with 579 additions and 1 deletions
+152 -1
View File
@@ -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,
)
+41
View File
@@ -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