feat: Enhance Identify component with loading progress indicators and date filter updates

This commit improves the Identify component by adding a loading progress bar to provide user feedback during face loading and similarity calculations. The date filters have been updated for consistency, simplifying the date selection process. Additionally, the API has been adjusted to support the new date parameters, ensuring a seamless user experience. The CSS has been modified to style the scrollbar for the similar faces container, enhancing the overall UI. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-11-07 15:31:09 -05:00
parent b1cb9decb5
commit 3e78e90061
4 changed files with 213 additions and 60 deletions

View File

@ -13,3 +13,30 @@ body {
min-height: 100vh;
}
/* Custom scrollbar styling for similar faces container */
.similar-faces-scrollable {
/* Firefox */
scrollbar-width: auto;
scrollbar-color: #4B5563 #F3F4F6;
}
.similar-faces-scrollable::-webkit-scrollbar {
/* Chrome, Safari, Edge */
width: 12px;
}
.similar-faces-scrollable::-webkit-scrollbar-track {
background: #F3F4F6;
border-radius: 6px;
}
.similar-faces-scrollable::-webkit-scrollbar-thumb {
background: #4B5563;
border-radius: 6px;
border: 2px solid #F3F4F6;
}
.similar-faces-scrollable::-webkit-scrollbar-thumb:hover {
background: #374151;
}

View File

@ -13,10 +13,8 @@ export default function Identify() {
const [minQuality, setMinQuality] = useState(0.0)
const [sortBy, setSortBy] = useState<SortBy>('quality')
const [sortDir, setSortDir] = useState<SortDir>('desc')
const [dateTakenFrom, setDateTakenFrom] = useState<string>('')
const [dateTakenTo, setDateTakenTo] = useState<string>('')
const [dateProcessedFrom, setDateProcessedFrom] = useState<string>('')
const [dateProcessedTo, setDateProcessedTo] = useState<string>('')
const [dateFrom, setDateFrom] = useState<string>('')
const [dateTo, setDateTo] = useState<string>('')
const [currentIdx, setCurrentIdx] = useState(0)
const currentFace = faces[currentIdx]
@ -37,6 +35,7 @@ export default function Identify() {
const [imageLoading, setImageLoading] = useState(false)
const [filtersCollapsed, setFiltersCollapsed] = useState(false)
const [loadingFaces, setLoadingFaces] = useState(false)
const [loadingProgress, setLoadingProgress] = useState({ current: 0, total: 0, message: '' })
// Store form data per face ID (matching desktop behavior)
const [faceFormData, setFaceFormData] = useState<Record<number, {
@ -59,22 +58,22 @@ export default function Identify() {
const loadFaces = async () => {
setLoadingFaces(true)
setLoadingProgress({ current: 0, total: 0, message: 'Loading faces...' })
try {
const res = await facesApi.getUnidentified({
page: 1,
page_size: pageSize,
min_quality: minQuality,
date_taken_from: dateTakenFrom || undefined,
date_taken_to: dateTakenTo || undefined,
date_processed_from: dateProcessedFrom || undefined,
date_processed_to: dateProcessedTo || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
sort_by: sortBy,
sort_dir: sortDir,
})
// Apply unique faces filter if enabled
if (uniqueFacesOnly) {
setLoadingProgress({ current: 0, total: res.items.length, message: 'Filtering unique faces...' })
const filtered = await filterUniqueFaces(res.items)
setFaces(filtered)
setTotal(filtered.length)
@ -85,6 +84,7 @@ export default function Identify() {
setCurrentIdx(0)
} finally {
setLoadingFaces(false)
setLoadingProgress({ current: 0, total: 0, message: '' })
}
}
@ -102,22 +102,58 @@ export default function Identify() {
similarityMap.set(face.id, new Set<number>())
}
// Update progress - loading all faces once
setLoadingProgress({
current: 0,
total: faces.length,
message: 'Loading all faces from database...'
})
try {
// Get all face IDs
const faceIds = faces.map(f => f.id)
// Call batch similarity endpoint - optimized with vectorized operations
// Update progress - calculating similarities
setLoadingProgress({
current: 0,
total: faces.length,
message: `Calculating similarities for ${faces.length} faces (this may take a while)...`
})
// Call batch similarity endpoint - loads all faces once from DB
// Note: This is where the heavy computation happens (comparing N faces to M faces)
// The progress bar will show 0% during this time as we can't track backend progress
const batchRes = await facesApi.batchSimilarity({
face_ids: faceIds,
min_confidence: 60.0
})
// Update progress - calculation complete, now processing results
const totalPairs = batchRes.pairs.length
setLoadingProgress({
current: 0,
total: totalPairs,
message: `Similarity calculation complete! Processing ${totalPairs} results...`
})
// Build similarity map from batch results
// Note: results include similarities to all faces in DB, but we only care about
// similarities between faces in the current list
let processedPairs = 0
for (const pair of batchRes.pairs) {
// Only include pairs where both faces are in the current list
if (!faceMap.has(pair.face_id_1) || !faceMap.has(pair.face_id_2)) {
processedPairs++
// Update progress every 100 pairs or at the end
if (processedPairs % 100 === 0 || processedPairs === totalPairs) {
setLoadingProgress({
current: processedPairs,
total: totalPairs,
message: `Processing similarity results... (${processedPairs} / ${totalPairs})`
})
// Allow UI to update
await new Promise(resolve => setTimeout(resolve, 0))
}
continue
}
@ -129,6 +165,18 @@ export default function Identify() {
const set2 = similarityMap.get(pair.face_id_2) || new Set<number>()
set2.add(pair.face_id_1)
similarityMap.set(pair.face_id_2, set2)
processedPairs++
// Update progress every 100 pairs or at the end
if (processedPairs % 100 === 0 || processedPairs === totalPairs) {
setLoadingProgress({
current: processedPairs,
total: totalPairs,
message: `Processing similarity results... (${processedPairs} / ${totalPairs})`
})
// Allow UI to update
await new Promise(resolve => setTimeout(resolve, 0))
}
}
} catch (error) {
// Silently skip on error - return original faces
@ -386,6 +434,53 @@ export default function Identify() {
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Identify</h1>
{/* Loading Progress Bar */}
{loadingFaces && (
<div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">
{loadingProgress.message || 'Loading faces...'}
</span>
{loadingProgress.total > 0 && (
<span className="text-sm text-gray-500">
{loadingProgress.current} / {loadingProgress.total}
{loadingProgress.total > 0 && (
<span className="ml-1">
({Math.round((loadingProgress.current / loadingProgress.total) * 100)}%)
</span>
)}
</span>
)}
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
{loadingProgress.total > 0 ? (
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{
width: `${Math.max(1, (loadingProgress.current / loadingProgress.total) * 100)}%`
}}
/>
) : (
<div className="relative h-2.5 overflow-hidden rounded-full bg-gray-200">
<div
className="absolute h-2.5 bg-blue-600 rounded-full"
style={{
width: '30%',
animation: 'slide 1.5s ease-in-out infinite',
left: '-30%'
}}
/>
<style>{`
@keyframes slide {
0% { left: -30%; }
100% { left: 100%; }
}
`}</style>
</div>
)}
</div>
</div>
)}
<div className="grid grid-cols-12 gap-4">
{/* Left: Controls and current face */}
@ -423,23 +518,13 @@ export default function Identify() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Date Taken From</label>
<input type="date" value={dateTakenFrom} onChange={(e) => setDateTakenFrom(e.target.value)}
<label className="block text-sm font-medium text-gray-700">Date From</label>
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
className="mt-1 block w-full border rounded px-2 py-1" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Date Taken To</label>
<input type="date" value={dateTakenTo} onChange={(e) => setDateTakenTo(e.target.value)}
className="mt-1 block w-full border rounded px-2 py-1" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Date Processed From</label>
<input type="date" value={dateProcessedFrom} onChange={(e) => setDateProcessedFrom(e.target.value)}
className="mt-1 block w-full border rounded px-2 py-1" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Date Processed To</label>
<input type="date" value={dateProcessedTo} onChange={(e) => setDateProcessedTo(e.target.value)}
<label className="block text-sm font-medium text-gray-700">Date To</label>
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
className="mt-1 block w-full border rounded px-2 py-1" />
</div>
<div>
@ -461,6 +546,20 @@ export default function Identify() {
</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 className="mt-4 pt-3 border-t">
<button
onClick={loadFaces}
disabled={loadingFaces}
@ -471,20 +570,6 @@ export default function Identify() {
</div>
</div>
)}
<div className="p-4 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">
@ -664,7 +749,7 @@ export default function Identify() {
) : similar.length === 0 ? (
<div className="text-gray-500 py-4 text-center">No similar faces found</div>
) : (
<div className="space-y-2 max-h-[600px] overflow-y-auto">
<div className="space-y-2 max-h-[600px] overflow-y-auto similar-faces-scrollable">
{similar.map((s) => {
// s.similarity is actually calibrated confidence in [0,1] range
// Desktop uses calibrated confidence from _get_calibrated_confidence

View File

@ -190,7 +190,17 @@ export default function Modify() {
const visibleFaces = res.items.filter((f) => !unmatchedFaces.has(f.id))
setFaces(visibleFaces)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load faces')
// If person not found (404), clear selection instead of showing error
// This can happen if person was deleted after selection
const errorDetail = err.response?.data?.detail || err.message || ''
if (err.response?.status === 404 && errorDetail.includes('Person') && errorDetail.includes('not found')) {
setSelectedPersonId(null)
setSelectedPersonName('')
setFaces([])
setError(null) // Don't show error for deleted person
} else {
setError(errorDetail || 'Failed to load faces')
}
} finally {
setBusy(false)
}
@ -314,13 +324,24 @@ export default function Modify() {
setUnmatchedFaces(new Set())
setUnmatchedByPerson({})
// Reload faces to reflect changes
if (selectedPersonId) {
await loadPersonFaces(selectedPersonId)
}
// Reload people list first to update face counts and check if person still exists
const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
setPeople(peopleRes.items)
// Reload people list to update face counts
await loadPeople()
// Check if selected person still exists and handle accordingly
if (selectedPersonId) {
const personStillExists = peopleRes.items.some(p => p.id === selectedPersonId)
if (personStillExists) {
// Person still exists, reload their faces
await loadPersonFaces(selectedPersonId)
} else {
// Person was deleted, clear selection and any errors
setSelectedPersonId(null)
setSelectedPersonName('')
setFaces([])
setError(null) // Clear any error that might have been set
}
}
setSuccess(`Successfully unlinked ${faceIds.length} face(s)`)
setTimeout(() => setSuccess(null), 3000)

View File

@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse, Response
from rq import Queue
from redis import Redis
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.web.db.session import get_db
@ -101,12 +102,8 @@ def get_unidentified_faces(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
min_quality: float = Query(0.0, ge=0.0, le=1.0),
date_from: str | None = Query(None, description="Legacy: date from (filters by date_taken or date_added)"),
date_to: str | None = Query(None, description="Legacy: date to (filters by date_taken or date_added)"),
date_taken_from: str | None = Query(None, description="Date taken from (YYYY-MM-DD)"),
date_taken_to: str | None = Query(None, description="Date taken to (YYYY-MM-DD)"),
date_processed_from: str | None = Query(None, description="Date processed from (YYYY-MM-DD)"),
date_processed_to: str | None = Query(None, description="Date processed to (YYYY-MM-DD)"),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
sort_by: str = Query("quality"),
sort_dir: str = Query("desc"),
db: Session = Depends(get_db),
@ -116,10 +113,6 @@ def get_unidentified_faces(
df = _date.fromisoformat(date_from) if date_from else None
dt = _date.fromisoformat(date_to) if date_to else None
dtf = _date.fromisoformat(date_taken_from) if date_taken_from else None
dtt = _date.fromisoformat(date_taken_to) if date_taken_to else None
dpf = _date.fromisoformat(date_processed_from) if date_processed_from else None
dpt = _date.fromisoformat(date_processed_to) if date_processed_to else None
faces, total = list_unidentified_faces(
db,
@ -128,10 +121,6 @@ def get_unidentified_faces(
min_quality=min_quality,
date_from=df,
date_to=dt,
date_taken_from=dtf,
date_taken_to=dtt,
date_processed_from=dpf,
date_processed_to=dpt,
sort_by=sort_by,
sort_dir=sort_dir,
)
@ -452,6 +441,9 @@ def batch_unmatch_faces(request: BatchUnmatchRequest, db: Session = Depends(get_
# Unmatch all matched faces
face_ids_to_unmatch = [f.id for f in matched_faces]
# Collect person_ids that will be affected (before unlinking)
affected_person_ids = {f.person_id for f in matched_faces if f.person_id is not None}
for face in matched_faces:
face.person_id = None
@ -467,10 +459,38 @@ def batch_unmatch_faces(request: BatchUnmatchRequest, db: Session = Depends(get_
detail=f"Failed to batch unmatch faces: {str(e)}",
)
# After committing, check which people have no faces left and delete them
# This only happens in batch_unmatch (called from Modify Save changes button)
deleted_person_ids = []
if affected_person_ids:
for person_id in affected_person_ids:
# Check if person has any faces left
face_count = db.query(func.count(Face.id)).filter(Face.person_id == person_id).scalar()
if face_count == 0:
# Person has no faces left, delete them
person = db.query(Person).filter(Person.id == person_id).first()
if person:
db.delete(person)
deleted_person_ids.append(person_id)
if deleted_person_ids:
try:
db.commit()
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete people with no faces: {str(e)}",
)
message = f"Successfully unlinked {len(face_ids_to_unmatch)} face(s)"
if deleted_person_ids:
message += f" and deleted {len(deleted_person_ids)} person(s) with no faces"
return BatchUnmatchResponse(
unmatched_face_ids=face_ids_to_unmatch,
count=len(face_ids_to_unmatch),
message=f"Successfully unlinked {len(face_ids_to_unmatch)} face(s)",
message=message,
)