feat: Add excluded face management and filtering capabilities in Identify component
This commit introduces functionality to manage excluded faces within the Identify component. A new state variable is added to toggle the inclusion of excluded faces in the displayed results. The API is updated to support setting and retrieving the excluded status of faces, including a new endpoint for toggling the excluded state. The UI is enhanced with a checkbox for users to include or exclude blocked faces from identification, improving user experience. Additionally, the database schema is updated to include an 'excluded' column in the faces table, ensuring proper data handling. Documentation has been updated to reflect these changes.
This commit is contained in:
parent
2f2e44c933
commit
47505249ce
@ -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<UnidentifiedFacesResponse> => {
|
||||
const response = await apiClient.get<UnidentifiedFacesResponse>('/api/v1/faces/unidentified', {
|
||||
params,
|
||||
@ -226,6 +228,12 @@ export const facesApi = {
|
||||
const response = await apiClient.post<IdentifyFaceResponse>(`/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<FaceUnmatchResponse> => {
|
||||
const response = await apiClient.post<FaceUnmatchResponse>(`/api/v1/faces/${faceId}/unmatch`)
|
||||
return response.data
|
||||
|
||||
@ -45,6 +45,9 @@ export default function Identify() {
|
||||
const [selectedSimilar, setSelectedSimilar] = useState<Record<number, boolean>>({})
|
||||
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() {
|
||||
<div className="text-xs text-gray-500">{(minQuality * 100).toFixed(0)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeExcludedFaces}
|
||||
onChange={(e) => setIncludeExcludedFaces(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Include excluded faces</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
||||
Show faces that have been blocked/excluded from identification
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-700">With Tags</h3>
|
||||
@ -1067,7 +1122,22 @@ export default function Identify() {
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-gray-600">{currentInfo}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">{currentInfo}</span>
|
||||
{currentFace && (
|
||||
<button
|
||||
onClick={toggleBlockFace}
|
||||
className={`px-2 py-1 text-sm rounded hover:bg-gray-100 ${
|
||||
currentFace.excluded
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-gray-50 text-gray-600'
|
||||
}`}
|
||||
title={currentFace.excluded ? 'Include this face' : 'Exclude this face from identification'}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))}>Prev</button>
|
||||
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))}>Next</button>
|
||||
|
||||
@ -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)."""
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user