feat: Enhance face identification with unique faces filter and improved API logging

This commit introduces a new feature in the Identify component that allows users to filter for unique faces only, hiding duplicates with ≥60% match confidence. The API has been updated to log calls to the get_similar_faces endpoint, including warnings for non-existent faces and information on the number of results returned. Additionally, the SimilarFaceItem schema has been updated to include the filename, improving data handling and user experience. Documentation and tests have been updated accordingly.
This commit is contained in:
tanyar09
2025-11-03 14:00:25 -05:00
parent 817e95337f
commit 59dc01118e
10 changed files with 481 additions and 70 deletions
+1
View File
@@ -36,6 +36,7 @@ export interface SimilarFaceItem {
similarity: number
location: string
quality_score: number
filename: string
}
export interface SimilarFacesResponse {
+1
View File
@@ -36,3 +36,4 @@ export const peopleApi = {
export default peopleApi
+198 -44
View File
@@ -23,6 +23,7 @@ export default function Identify() {
const [similar, setSimilar] = useState<SimilarFaceItem[]>([])
const [compareEnabled, setCompareEnabled] = useState(true)
const [selectedSimilar, setSelectedSimilar] = useState<Record<number, boolean>>({})
const [uniqueFacesOnly, setUniqueFacesOnly] = useState(false)
const [people, setPeople] = useState<Person[]>([])
const [personId, setPersonId] = useState<number | undefined>(undefined)
@@ -60,11 +61,100 @@ export default function Identify() {
sort_by: sortBy,
sort_dir: sortDir,
})
setFaces(res.items)
setTotal(res.total)
// Apply unique faces filter if enabled
if (uniqueFacesOnly) {
const filtered = await filterUniqueFaces(res.items)
setFaces(filtered)
setTotal(filtered.length)
} else {
setFaces(res.items)
setTotal(res.total)
}
setCurrentIdx(0)
}
const filterUniqueFaces = async (faces: FaceItem[]): Promise<FaceItem[]> => {
if (faces.length < 2) return faces
// Create a map of face IDs to face objects for quick lookup
const faceMap = new Map(faces.map(f => [f.id, f]))
// Build similarity graph: for each face, find all similar faces (≥60% confidence) in current list
const similarityMap = new Map<number, Set<number>>()
for (const face of faces) {
const similarSet = new Set<number>()
try {
const similarRes = await facesApi.getSimilar(face.id)
for (const similar of similarRes.items) {
// Only include similar faces that are in the current list
if (!faceMap.has(similar.id)) continue
// Convert similarity back to percentage (similarity is in [0,1])
const confidencePct = Math.round(similar.similarity * 100)
if (confidencePct >= 60) {
similarSet.add(similar.id)
}
}
} catch (error) {
console.error(`Error checking similar faces for face ${face.id}:`, error)
}
similarityMap.set(face.id, similarSet)
}
// Find connected components (groups of similar faces)
const visited = new Set<number>()
const groups: Set<number>[] = []
const findGroup = (faceId: number, currentGroup: Set<number>) => {
if (visited.has(faceId)) return
visited.add(faceId)
currentGroup.add(faceId)
const similar = similarityMap.get(faceId) || new Set()
for (const similarId of similar) {
if (faceMap.has(similarId) && !visited.has(similarId)) {
findGroup(similarId, currentGroup)
}
}
}
for (const face of faces) {
if (!visited.has(face.id)) {
const group = new Set<number>()
findGroup(face.id, group)
if (group.size > 1) {
groups.push(group)
}
}
}
// Track which faces should be excluded (duplicates in groups)
const excludeSet = new Set<number>()
// For each group, keep only the first face and mark others for exclusion
for (const group of groups) {
let firstFaceFound = false
for (const face of faces) {
if (group.has(face.id)) {
if (!firstFaceFound) {
// Keep this face (first representative)
firstFaceFound = true
} else {
// Exclude this face (duplicate)
excludeSet.add(face.id)
}
}
}
}
// Return faces that are not excluded
return faces.filter(face => !excludeSet.has(face.id))
}
const loadPeople = async () => {
const res = await peopleApi.list()
setPeople(res.items)
@@ -75,16 +165,25 @@ export default function Identify() {
setSimilar([])
return
}
const res = await facesApi.getSimilar(faceId)
setSimilar(res.items)
setSelectedSimilar({})
try {
const res = await facesApi.getSimilar(faceId)
console.log('Similar faces response:', res)
console.log('Similar faces items:', res.items)
console.log('Similar faces count:', res.items?.length || 0)
setSimilar(res.items || [])
setSelectedSimilar({})
} catch (error) {
console.error('Error loading similar faces:', error)
console.error('Error details:', error instanceof Error ? error.message : String(error))
setSimilar([])
}
}
useEffect(() => {
loadFaces()
loadPeople()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo])
}, [page, pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly])
useEffect(() => {
if (currentFace) loadSimilar(currentFace.id)
@@ -264,6 +363,20 @@ export default function Identify() {
</select>
</div>
</div>
<div className="mt-3 pt-3 border-t">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={uniqueFacesOnly}
onChange={(e) => setUniqueFacesOnly(e.target.checked)}
className="rounded"
/>
<span className="text-sm text-gray-700">Unique faces only</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-6">
Hide duplicates with 60% match confidence
</p>
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
@@ -424,48 +537,89 @@ export default function Identify() {
</div>
</div>
{!compareEnabled ? (
<div className="text-gray-500">Comparison disabled.</div>
<div className="text-gray-500 py-4 text-center">Enable 'Compare similar faces' to see similar faces</div>
) : similar.length === 0 ? (
<div className="text-gray-500">No similar faces.</div>
<div className="text-gray-500 py-4 text-center">No similar faces found</div>
) : (
<div className="grid grid-cols-6 gap-3">
{similar.map((s) => (
<label key={s.id} className="border rounded p-2 flex flex-col gap-2 cursor-pointer">
<div
className="aspect-square bg-gray-100 rounded overflow-hidden flex items-center justify-center relative group"
onClick={(e) => {
e.stopPropagation() // Prevent triggering checkbox
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${s.photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
src={`${apiClient.defaults.baseURL}/api/v1/faces/${s.id}/crop?t=${Date.now()}`}
alt={`Face ${s.id}`}
className="max-w-full max-h-full object-contain pointer-events-none"
crossOrigin="anonymous"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className = 'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${s.photo_id}`
parent.appendChild(fallback)
}
}}
<div className="space-y-2 max-h-[600px] overflow-y-auto">
{similar.map((s) => {
// s.similarity is actually calibrated confidence in [0,1] range
// Desktop uses calibrated confidence from _get_calibrated_confidence
// Convert to percentage: confidence = similarity * 100
const confidencePct = Math.round(s.similarity * 100)
// Get confidence description matching desktop
let confidenceDesc: string
let confidenceColor: string
if (confidencePct >= 80) {
confidenceDesc = "(Very High)"
confidenceColor = "text-green-600"
} else if (confidencePct >= 70) {
confidenceDesc = "(High)"
confidenceColor = "text-orange-600"
} else if (confidencePct >= 60) {
confidenceDesc = "(Medium)"
confidenceColor = "text-red-600"
} else if (confidencePct >= 50) {
confidenceDesc = "(Low)"
confidenceColor = "text-red-600"
} else {
confidenceDesc = "(Very Low)"
confidenceColor = "text-gray-500"
}
return (
<div key={s.id} className="flex items-center gap-3 p-2 border rounded hover:bg-gray-50">
{/* Checkbox */}
<input
type="checkbox"
checked={!!selectedSimilar[s.id]}
onChange={(e) => setSelectedSimilar((prev) => ({ ...prev, [s.id]: e.target.checked }))}
className="flex-shrink-0"
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-opacity pointer-events-none" />
{/* Face image */}
<div
className="w-24 h-24 bg-gray-100 rounded overflow-hidden flex items-center justify-center relative group cursor-pointer flex-shrink-0"
onClick={(e) => {
e.stopPropagation()
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${s.photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
src={`${apiClient.defaults.baseURL}/api/v1/faces/${s.id}/crop?t=${Date.now()}`}
alt={`Face ${s.id}`}
className="max-w-full max-h-full object-contain pointer-events-none"
crossOrigin="anonymous"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className = 'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${s.photo_id}`
parent.appendChild(fallback)
}
}}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-opacity pointer-events-none" />
</div>
{/* Confidence percentage with description */}
<div className={`text-sm font-bold ${confidenceColor} flex-shrink-0`}>
{confidencePct}% {confidenceDesc}
</div>
{/* Filename */}
<div className="text-sm text-gray-700 flex-1 min-w-0 truncate" title={s.filename}>
{s.filename}
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={!!selectedSimilar[s.id]}
onChange={(e) => setSelectedSimilar((prev) => ({ ...prev, [s.id]: e.target.checked }))} />
<div className="text-gray-600">{Math.round(s.similarity * 100)}%</div>
</div>
</label>
))}
)
})}
</div>
)}
</div>