diff --git a/frontend/src/api/faces.ts b/frontend/src/api/faces.ts index 665df58..a7fbe17 100644 --- a/frontend/src/api/faces.ts +++ b/frontend/src/api/faces.ts @@ -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 => { diff --git a/frontend/src/pages/Identify.tsx b/frontend/src/pages/Identify.tsx index 7256e4a..fbaabb7 100644 --- a/frontend/src/pages/Identify.tsx +++ b/frontend/src/pages/Identify.tsx @@ -13,8 +13,10 @@ export default function Identify() { const [minQuality, setMinQuality] = useState(0.0) const [sortBy, setSortBy] = useState('quality') const [sortDir, setSortDir] = useState('desc') - const [dateFrom, setDateFrom] = useState('') - const [dateTo, setDateTo] = useState('') + const [dateTakenFrom, setDateTakenFrom] = useState('') + const [dateTakenTo, setDateTakenTo] = useState('') + const [dateProcessedFrom, setDateProcessedFrom] = useState('') + const [dateProcessedTo, setDateProcessedTo] = useState('') 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 { 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()) } - // 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() 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() {

Identify

- {/* Loading Progress Bar */} - {loadingFaces && ( -
-
- - {loadingProgress.message || 'Loading faces...'} - - {loadingProgress.total > 0 && ( - - {loadingProgress.current} / {loadingProgress.total} - {loadingProgress.total > 0 && ( - - ({Math.round((loadingProgress.current / loadingProgress.total) * 100)}%) - - )} - - )} -
-
- {loadingProgress.total > 0 ? ( -
- ) : ( -
-
- -
- )} -
-
- )}
{/* Left: Controls and current face */} @@ -518,13 +423,23 @@ export default function Identify() {
- - setDateFrom(e.target.value)} + + setDateTakenFrom(e.target.value)} className="mt-1 block w-full border rounded px-2 py-1" />
- - setDateTo(e.target.value)} + + setDateTakenTo(e.target.value)} + className="mt-1 block w-full border rounded px-2 py-1" /> +
+
+ + setDateProcessedFrom(e.target.value)} + className="mt-1 block w-full border rounded px-2 py-1" /> +
+
+ + setDateProcessedTo(e.target.value)} className="mt-1 block w-full border rounded px-2 py-1" />
@@ -546,20 +461,6 @@ export default function Identify() {
- -

- Hide duplicates with ≥60% match confidence -

-
-
)} +
+ +

+ Hide duplicates with ≥60% match confidence +

+
diff --git a/scripts/drop_all_tables_web.py b/scripts/drop_all_tables_web.py index eb28a78..26d82d1 100644 --- a/scripts/drop_all_tables_web.py +++ b/scripts/drop_all_tables_web.py @@ -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__": diff --git a/src/web/api/faces.py b/src/web/api/faces.py index d527acf..e241e54 100644 --- a/src/web/api/faces.py +++ b/src/web/api/faces.py @@ -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, ) diff --git a/src/web/app.py b/src/web/app.py index af4f12d..e9436f5 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -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 diff --git a/src/web/services/face_service.py b/src/web/services/face_service.py index 22b0391..cb4a71c 100644 --- a/src/web/services/face_service.py +++ b/src/web/services/face_service.py @@ -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 )