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:
@@ -36,6 +36,7 @@ export interface SimilarFaceItem {
|
||||
similarity: number
|
||||
location: string
|
||||
quality_score: number
|
||||
filename: string
|
||||
}
|
||||
|
||||
export interface SimilarFacesResponse {
|
||||
|
||||
@@ -36,3 +36,4 @@ export const peopleApi = {
|
||||
export default peopleApi
|
||||
|
||||
|
||||
|
||||
|
||||
+198
-44
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user