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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user