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:
tanyar09
2025-12-04 16:18:32 -05:00
parent 2f2e44c933
commit 47505249ce
7 changed files with 158 additions and 7 deletions
+8
View File
@@ -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
+77 -7
View File
@@ -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>