diff --git a/frontend/src/api/faces.ts b/frontend/src/api/faces.ts index d6c918f..b1b3d58 100644 --- a/frontend/src/api/faces.ts +++ b/frontend/src/api/faces.ts @@ -21,6 +21,7 @@ export interface FaceItem { face_confidence: number location: string pose_mode?: string + excluded?: boolean } export interface UnidentifiedFacesResponse { @@ -208,6 +209,7 @@ export const facesApi = { tag_names?: string match_all?: boolean photo_ids?: string + include_excluded?: boolean }): Promise => { const response = await apiClient.get('/api/v1/faces/unidentified', { params, @@ -226,6 +228,12 @@ export const facesApi = { const response = await apiClient.post(`/api/v1/faces/${faceId}/identify`, payload) return response.data }, + setExcluded: async (faceId: number, excluded: boolean): Promise<{ face_id: number; excluded: boolean; message: string }> => { + const response = await apiClient.put<{ face_id: number; excluded: boolean; message: string }>( + `/api/v1/faces/${faceId}/excluded?excluded=${excluded}` + ) + return response.data + }, unmatch: async (faceId: number): Promise => { const response = await apiClient.post(`/api/v1/faces/${faceId}/unmatch`) return response.data diff --git a/frontend/src/pages/Identify.tsx b/frontend/src/pages/Identify.tsx index 7aa3c36..d4615e5 100644 --- a/frontend/src/pages/Identify.tsx +++ b/frontend/src/pages/Identify.tsx @@ -45,6 +45,9 @@ export default function Identify() { const [selectedSimilar, setSelectedSimilar] = useState>({}) const [uniqueFacesOnly, setUniqueFacesOnly] = useState(true) + // Excluded faces filter + const [includeExcludedFaces, setIncludeExcludedFaces] = useState(false) + // SessionStorage key for persisting settings (clears when tab/window closes) const SETTINGS_KEY = 'identify_settings' // SessionStorage key for persisting page state (faces, current index, etc.) @@ -153,17 +156,17 @@ export default function Identify() { tag_names: selectedTags.length > 0 ? selectedTags.join(', ') : undefined, match_all: false, // Default to match any tag photo_ids: (ignorePhotoIds || !photoIds) ? undefined : photoIds.join(','), // Filter by photo IDs if provided and not ignored + include_excluded: includeExcludedFaces, }) // Apply unique faces filter if enabled + let filtered = res.items if (uniqueFacesOnly) { - const filtered = await filterUniqueFaces(res.items) - setFaces(filtered) - setTotal(filtered.length) - } else { - setFaces(res.items) - setTotal(res.total) + filtered = await filterUniqueFaces(filtered) } + + setFaces(filtered) + setTotal(filtered.length) setCurrentIdx(0) // Clear form data when refreshing if (clearState) { @@ -497,6 +500,14 @@ export default function Identify() { } }, [pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly, compareEnabled, selectedTags, settingsLoaded]) + // Reload faces when includeExcludedFaces changes + useEffect(() => { + if (settingsLoaded && !photoIds) { + loadFaces(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [includeExcludedFaces]) + // Load tags and people on mount (always, regardless of other conditions) useEffect(() => { if (settingsLoaded) { @@ -722,6 +733,36 @@ export default function Identify() { return `Face ${currentIdx + 1} of ${faces.length}` }, [currentFace, currentIdx, faces.length]) + // Toggle excluded status for current face + const toggleBlockFace = useCallback(async () => { + if (!currentFace) return + + const newExcludedStatus = !currentFace.excluded + + try { + await facesApi.setExcluded(currentFace.id, newExcludedStatus) + + // Update the face in the local state + setFaces(prevFaces => + prevFaces.map(face => + face.id === currentFace.id ? { ...face, excluded: newExcludedStatus } : face + ) + ) + + // If excluding, move to next face if available + if (newExcludedStatus && currentIdx + 1 < faces.length) { + setCurrentIdx(currentIdx + 1) + } else if (newExcludedStatus && currentIdx > 0) { + setCurrentIdx(currentIdx - 1) + } + + // Don't reload faces - keep UI as is + } catch (error) { + console.error('Error toggling excluded status:', error) + alert('Failed to update excluded status') + } + }, [currentFace, currentIdx, faces.length]) + // Video functions const loadVideos = async () => { setVideosLoading(true) @@ -1001,6 +1042,20 @@ export default function Identify() {
{(minQuality * 100).toFixed(0)}%
+
+ +

+ Show faces that have been blocked/excluded from identification +

+

With Tags

@@ -1067,7 +1122,22 @@ export default function Identify() {
-
{currentInfo}
+
+ {currentInfo} + {currentFace && ( + + )} +
diff --git a/src/web/api/faces.py b/src/web/api/faces.py index 29920fa..cafb4dd 100644 --- a/src/web/api/faces.py +++ b/src/web/api/faces.py @@ -121,6 +121,7 @@ def get_unidentified_faces( tag_names: str | None = Query(None, description="Comma-separated tag names for filtering"), match_all: bool = Query(False, description="Match all tags (for tag filtering)"), photo_ids: str | None = Query(None, description="Comma-separated photo IDs for filtering"), + include_excluded: bool = Query(False, description="Include excluded faces in results"), db: Session = Depends(get_db), ) -> UnidentifiedFacesResponse: """Get unidentified faces with filters and pagination.""" @@ -172,6 +173,7 @@ def get_unidentified_faces( tag_names=tag_names_list, match_all=match_all, photo_ids=photo_ids_list, + include_excluded=include_excluded, ) items = [ @@ -182,6 +184,7 @@ def get_unidentified_faces( face_confidence=float(getattr(f, "face_confidence", 0.0)), location=f.location, pose_mode=getattr(f, "pose_mode", None) or "frontal", + excluded=getattr(f, "excluded", False), ) for f in faces ] @@ -431,6 +434,23 @@ def get_face_crop(face_id: int, db: Session = Depends(get_db)) -> Response: ) +@router.put("/{face_id}/excluded", response_model=dict) +def toggle_face_excluded( + face_id: int, + excluded: bool = Query(..., description="Set excluded status"), + db: Session = Depends(get_db), +) -> dict: + """Toggle excluded status for a face.""" + face = db.query(Face).filter(Face.id == face_id).first() + if not face: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Face {face_id} not found") + + face.excluded = excluded + db.commit() + + return {"face_id": face_id, "excluded": excluded, "message": f"Face {'excluded' if excluded else 'included'} successfully"} + + @router.post("/{face_id}/unmatch", response_model=FaceUnmatchResponse) def unmatch_face(face_id: int, db: Session = Depends(get_db)) -> FaceUnmatchResponse: """Unmatch a face from its person (set person_id to NULL).""" diff --git a/src/web/app.py b/src/web/app.py index 01ab43d..824ee6a 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -370,6 +370,50 @@ def ensure_photo_media_type_column(inspector) -> None: print("✅ Added media_type column to photos table") +def ensure_face_excluded_column(inspector) -> None: + """Ensure faces table contains excluded column.""" + if "faces" not in inspector.get_table_names(): + print("â„šī¸ Faces table does not exist yet - will be created with excluded column") + return + + columns = {column["name"] for column in inspector.get_columns("faces")} + if "excluded" in columns: + print("â„šī¸ excluded column already exists in faces table") + return + + print("🔄 Adding excluded column to faces table...") + + dialect = engine.dialect.name + + with engine.connect() as connection: + with connection.begin(): + if dialect == "postgresql": + # PostgreSQL: Add column with default value + connection.execute( + text("ALTER TABLE faces ADD COLUMN IF NOT EXISTS excluded BOOLEAN DEFAULT FALSE NOT NULL") + ) + # Create index + try: + connection.execute( + text("CREATE INDEX IF NOT EXISTS idx_faces_excluded ON faces(excluded)") + ) + except Exception: + pass # Index might already exist + else: + # SQLite + connection.execute( + text("ALTER TABLE faces ADD COLUMN excluded BOOLEAN DEFAULT 0 NOT NULL") + ) + # Create index + try: + connection.execute( + text("CREATE INDEX idx_faces_excluded ON faces(excluded)") + ) + except Exception: + pass # Index might already exist + print("✅ Added excluded column to faces table") + + def ensure_photo_person_linkage_table(inspector) -> None: """Ensure photo_person_linkage table exists for direct video-person associations.""" if "photo_person_linkage" in inspector.get_table_names(): @@ -482,6 +526,7 @@ async def lifespan(app: FastAPI): ensure_user_role_column(inspector) ensure_photo_media_type_column(inspector) ensure_photo_person_linkage_table(inspector) + ensure_face_excluded_column(inspector) ensure_role_permissions_table(inspector) except Exception as exc: print(f"❌ Database initialization failed: {exc}") diff --git a/src/web/db/models.py b/src/web/db/models.py index 8061245..52ad214 100644 --- a/src/web/db/models.py +++ b/src/web/db/models.py @@ -112,6 +112,7 @@ class Face(Base): roll_angle = Column(Numeric, nullable=True) landmarks = Column(Text, nullable=True) # JSON string of facial landmarks identified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + excluded = Column(Boolean, default=False, nullable=False, index=True) # Exclude from identification photo = relationship("Photo", back_populates="faces") person = relationship("Person", back_populates="faces") @@ -125,6 +126,7 @@ class Face(Base): Index("idx_faces_quality", "quality_score"), Index("idx_faces_pose_mode", "pose_mode"), Index("idx_faces_identified_by", "identified_by_user_id"), + Index("idx_faces_excluded", "excluded"), ) diff --git a/src/web/schemas/faces.py b/src/web/schemas/faces.py index cc51ea9..d8a7363 100644 --- a/src/web/schemas/faces.py +++ b/src/web/schemas/faces.py @@ -51,6 +51,7 @@ class FaceItem(BaseModel): face_confidence: float location: str pose_mode: Optional[str] = Field("frontal", description="Pose classification (frontal, profile_left, etc.)") + excluded: bool = Field(False, description="Whether this face is excluded from identification") class UnidentifiedFacesQuery(BaseModel): diff --git a/src/web/services/face_service.py b/src/web/services/face_service.py index 77fc52b..c30b0a0 100644 --- a/src/web/services/face_service.py +++ b/src/web/services/face_service.py @@ -1223,6 +1223,7 @@ def list_unidentified_faces( tag_names: Optional[List[str]] = None, match_all: bool = False, photo_ids: Optional[List[int]] = None, + include_excluded: bool = False, ) -> Tuple[List[Face], int]: """Return paginated unidentified faces with filters. @@ -1239,6 +1240,10 @@ def list_unidentified_faces( # Base query: faces with no person query = db.query(Face).join(Photo, Face.photo_id == Photo.id).filter(Face.person_id.is_(None)) + # Filter by excluded status (exclude excluded faces by default) + if not include_excluded: + query = query.filter(Face.excluded == False) + # Tag filtering if tag_names: # Find tag IDs (case-insensitive)