feat: Add date filters for face identification and enhance API for improved querying

This commit introduces new date filters for the face identification process, allowing users to filter faces based on the date taken and date processed. The API has been updated to support these new parameters, ensuring backward compatibility with legacy date filters. Additionally, the Identify component has been modified to incorporate these new filters in the user interface, enhancing the overall functionality and user experience. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-11-07 14:45:51 -05:00
parent 81b845c98f
commit b1cb9decb5
6 changed files with 105 additions and 132 deletions

View File

@ -150,6 +150,10 @@ export const facesApi = {
min_quality?: number
date_from?: string
date_to?: string
date_taken_from?: string
date_taken_to?: string
date_processed_from?: string
date_processed_to?: string
sort_by?: 'quality' | 'date_taken' | 'date_added'
sort_dir?: 'asc' | 'desc'
}): Promise<UnidentifiedFacesResponse> => {

View File

@ -13,8 +13,10 @@ export default function Identify() {
const [minQuality, setMinQuality] = useState(0.0)
const [sortBy, setSortBy] = useState<SortBy>('quality')
const [sortDir, setSortDir] = useState<SortDir>('desc')
const [dateFrom, setDateFrom] = useState<string>('')
const [dateTo, setDateTo] = useState<string>('')
const [dateTakenFrom, setDateTakenFrom] = useState<string>('')
const [dateTakenTo, setDateTakenTo] = useState<string>('')
const [dateProcessedFrom, setDateProcessedFrom] = useState<string>('')
const [dateProcessedTo, setDateProcessedTo] = useState<string>('')
const [currentIdx, setCurrentIdx] = useState(0)
const currentFace = faces[currentIdx]
@ -35,7 +37,6 @@ 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, {
@ -58,22 +59,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_from: dateFrom || undefined,
date_to: dateTo || undefined,
date_taken_from: dateTakenFrom || undefined,
date_taken_to: dateTakenTo || undefined,
date_processed_from: dateProcessedFrom || undefined,
date_processed_to: dateProcessedTo || 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)
@ -84,7 +85,6 @@ export default function Identify() {
setCurrentIdx(0)
} finally {
setLoadingFaces(false)
setLoadingProgress({ current: 0, total: 0, message: '' })
}
}
@ -102,58 +102,22 @@ 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)
// 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
// Call batch similarity endpoint - optimized with vectorized operations
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
}
@ -165,18 +129,6 @@ 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
@ -434,53 +386,6 @@ 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 */}
@ -518,13 +423,23 @@ export default function Identify() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Date From</label>
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
<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)}
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 To</label>
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
<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)}
className="mt-1 block w-full border rounded px-2 py-1" />
</div>
<div>
@ -546,20 +461,6 @@ 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}
@ -570,6 +471,20 @@ 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">

View File

@ -22,8 +22,8 @@ def drop_all_tables():
Base.metadata.drop_all(bind=engine)
print("✅ All tables dropped successfully!")
print("\nYou can now run migrations to recreate tables:")
print(" alembic upgrade head")
print("\nYou can now recreate tables using:")
print(" python scripts/recreate_tables_web.py")
if __name__ == "__main__":

View File

@ -101,8 +101,12 @@ 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),
date_to: str | None = Query(None),
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)"),
sort_by: str = Query("quality"),
sort_dir: str = Query("desc"),
db: Session = Depends(get_db),
@ -112,6 +116,10 @@ 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,
@ -120,6 +128,10 @@ 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,
)

View File

@ -96,8 +96,27 @@ async def lifespan(app: FastAPI):
db_path = database_url.replace("sqlite:///", "")
db_file = Path(db_path)
db_file.parent.mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=engine)
print("✅ Database initialized")
# Only create tables if they don't already exist (safety check)
from sqlalchemy import inspect
inspector = inspect(engine)
existing_tables = set(inspector.get_table_names())
# Check if required application tables exist (not just alembic_version)
required_tables = {"photos", "people", "faces", "tags", "phototaglinkage", "person_encodings"}
missing_tables = required_tables - existing_tables
if missing_tables:
# Some required tables are missing - create all tables
# create_all() only creates missing tables, won't drop existing ones
Base.metadata.create_all(bind=engine)
if len(missing_tables) == len(required_tables):
print("✅ Database initialized (first run - tables created)")
else:
print(f"✅ Database tables created (missing tables: {', '.join(missing_tables)})")
else:
# All required tables exist - don't recreate (prevents data loss)
print(f"✅ Database already initialized ({len(existing_tables)} tables exist)")
except Exception as exc:
print(f"❌ Database initialization failed: {exc}")
raise

View File

@ -1204,12 +1204,22 @@ def list_unidentified_faces(
min_quality: float = 0.0,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
date_taken_from: Optional[date] = None,
date_taken_to: Optional[date] = None,
date_processed_from: Optional[date] = None,
date_processed_to: Optional[date] = None,
sort_by: str = "quality",
sort_dir: str = "desc",
) -> Tuple[List[Face], int]:
"""Return paginated unidentified faces with filters.
Matches desktop behavior as closely as possible: filter by min quality and date_taken.
Supports filtering by:
- Min quality
- Date taken (date_taken_from, date_taken_to)
- Date processed (date_processed_from, date_processed_to) - uses photo.date_added
Legacy parameters (date_from, date_to) are kept for backward compatibility
and filter by date_taken when available, else date_added as fallback.
"""
# Base query: faces with no person
query = db.query(Face).join(Photo, Face.photo_id == Photo.id).filter(Face.person_id.is_(None))
@ -1218,7 +1228,20 @@ def list_unidentified_faces(
if min_quality is not None:
query = query.filter(Face.quality_score >= min_quality)
# Date range on photo.date_taken when available, else on date_added as fallback
# Date taken filters (new separate filters)
if date_taken_from is not None:
query = query.filter(Photo.date_taken >= date_taken_from)
if date_taken_to is not None:
query = query.filter(Photo.date_taken <= date_taken_to)
# Date processed filters (uses photo.date_added)
if date_processed_from is not None:
query = query.filter(func.date(Photo.date_added) >= date_processed_from)
if date_processed_to is not None:
query = query.filter(func.date(Photo.date_added) <= date_processed_to)
# Legacy date filters (backward compatibility)
# Filter by date_taken when available, else date_added as fallback
if date_from is not None:
query = query.filter(
(Photo.date_taken.is_not(None) & (Photo.date_taken >= date_from))
@ -1419,7 +1442,7 @@ def _is_acceptable_pose_for_auto_match(pose_mode: str) -> bool:
def find_similar_faces(
db: Session,
face_id: int,
limit: int = 20,
limit: int = 20000, # Very high default limit - effectively unlimited
tolerance: float = 0.6, # DEFAULT_FACE_TOLERANCE from desktop
filter_frontal_only: bool = False, # New: Only return frontal or tilted faces (not profile)
) -> List[Tuple[Face, float, float]]: # Returns (face, distance, confidence_pct)
@ -1757,7 +1780,7 @@ def find_auto_match_matches(
# reference_face_id, tolerance, include_same_photo=False, face_status=None)
# This filters by: person_id is None (unidentified), confidence >= 40%, sorts by distance
similar_faces = find_similar_faces(
db, reference_face_id, limit=1000, tolerance=tolerance,
db, reference_face_id, tolerance=tolerance,
filter_frontal_only=filter_frontal_only
)