From cfb94900efb2e3f4cfd6348c30202b71152b96ba Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Wed, 12 Nov 2025 14:17:16 -0500 Subject: [PATCH] feat: Enhance photo identification and tagging features with new filters and counts This commit introduces several enhancements to the photo identification and tagging functionalities. The Identify component now supports filtering by photo IDs, allowing users to view faces from specific photos. Additionally, the Tags component has been updated to include an unidentified face count for each photo, improving user awareness of untagged faces. The API has been modified to accommodate these new parameters, ensuring seamless integration with the frontend. Documentation has been updated to reflect these changes. --- README.md | 90 +++++++++-- frontend/src/api/faces.ts | 1 + frontend/src/api/tags.ts | 1 + frontend/src/pages/Identify.tsx | 100 +++++++++--- frontend/src/pages/Tags.tsx | 257 +++++++++++++++++++++++++------ src/web/api/faces.py | 10 ++ src/web/api/tags.py | 1 + src/web/schemas/tags.py | 1 + src/web/services/face_service.py | 6 + src/web/services/tag_service.py | 11 +- 10 files changed, 404 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 119c81f..ec2fe7a 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,9 @@ The web version uses the **exact same schema** as the desktop version for full c redis-server ``` -**Terminal 2 - Backend API (automatically starts RQ worker):** +#### Option 1: Manual Start (Recommended for Development) + +**Terminal 1 - Backend API:** ```bash cd /home/ladmin/Code/punimtag source venv/bin/activate @@ -126,33 +128,99 @@ uvicorn src.web.app:app --host 127.0.0.1 --port 8000 You should see: ``` +✅ Database already initialized (7 tables exist) ✅ RQ worker started in background subprocess (PID: ...) INFO: Started server process INFO: Uvicorn running on http://127.0.0.1:8000 ``` -**Note:** The RQ worker automatically starts in a background subprocess when the API starts. You'll see a confirmation message with the worker PID. If Redis isn't running, you'll see a warning message. - -**Terminal 3 - Frontend:** +**Terminal 2 - Frontend:** ```bash cd /home/ladmin/Code/punimtag/frontend npm run dev ``` -Then open your browser to **http://localhost:3000** +You should see: +``` +VITE v5.4.21 ready in 811 ms +➜ Local: http://localhost:3000/ +``` -**Default Login:** -- Username: `admin` -- Password: `admin` +#### Option 2: Using Helper Script (Backend + Worker) + +**Terminal 1 - Backend API + Worker:** +```bash +cd /home/ladmin/Code/punimtag +./run_api_with_worker.sh +``` + +This script will: +- Check if Redis is running (start it if needed) +- Start the RQ worker in the background +- Start the FastAPI server +- Handle cleanup on Ctrl+C + +**Terminal 2 - Frontend:** +```bash +cd /home/ladmin/Code/punimtag/frontend +npm run dev +``` + +#### Access the Application + +1. Open your browser to **http://localhost:3000** +2. Login with default credentials: + - Username: `admin` + - Password: `admin` +3. API documentation available at **http://127.0.0.1:8000/docs** + +#### Troubleshooting + +**Port 8000 already in use:** +```bash +# Find and kill the process using port 8000 +lsof -i :8000 +kill + +# Or use pkill +pkill -f "uvicorn.*app" +``` + +**Port 3000 already in use:** +```bash +# Find and kill the process using port 3000 +lsof -i :3000 +kill + +# Or change the port in frontend/vite.config.ts +``` + +**Redis not running:** +```bash +# Start Redis +sudo systemctl start redis-server +# Or +redis-server +``` + +**Database issues:** +```bash +# Recreate all tables (WARNING: This will delete all data!) +cd /home/ladmin/Code/punimtag +source venv/bin/activate +export PYTHONPATH=/home/ladmin/Code/punimtag +python scripts/recreate_tables_web.py +``` + +#### Important Notes -**Note:** - The database and tables are **automatically created on first startup** - no manual setup needed! - The RQ worker starts automatically in a background subprocess when the API server starts - Make sure Redis is running first, or the worker won't start - Worker names are unique to avoid conflicts when restarting - Photo uploads are stored in `data/uploads` (configurable via `PHOTO_STORAGE_DIR` env var) -- **DeepFace models download automatically on first use** (can take 5-10 minutes) -- **If port 8000 is in use**, kill the process: `lsof -i :8000` then `kill ` or `pkill -f "uvicorn.*app"` +- **DeepFace models download automatically on first use** (can take 5-10 minutes, ~100MB) +- First run is slower due to model downloads (subsequent runs are faster) --- diff --git a/frontend/src/api/faces.ts b/frontend/src/api/faces.ts index 197d7e0..d89bef0 100644 --- a/frontend/src/api/faces.ts +++ b/frontend/src/api/faces.ts @@ -184,6 +184,7 @@ export const facesApi = { sort_dir?: 'asc' | 'desc' tag_names?: string match_all?: boolean + photo_ids?: string }): Promise => { const response = await apiClient.get('/api/v1/faces/unidentified', { params, diff --git a/frontend/src/api/tags.ts b/frontend/src/api/tags.ts index 7f19df7..4b6e105 100644 --- a/frontend/src/api/tags.ts +++ b/frontend/src/api/tags.ts @@ -52,6 +52,7 @@ export interface PhotoWithTagsItem { date_taken?: string | null date_added?: string | null face_count: number + unidentified_face_count: number // Count of faces with person_id IS NULL tags: string // Comma-separated tags string } diff --git a/frontend/src/pages/Identify.tsx b/frontend/src/pages/Identify.tsx index 6483e2d..653f2a2 100644 --- a/frontend/src/pages/Identify.tsx +++ b/frontend/src/pages/Identify.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState, useRef } from 'react' +import { useSearchParams } from 'react-router-dom' import facesApi, { FaceItem, SimilarFaceItem } from '../api/faces' import peopleApi, { Person } from '../api/people' import { apiClient } from '../api/client' @@ -10,6 +11,7 @@ type SortDir = 'asc' | 'desc' export default function Identify() { const { isDeveloperMode } = useDeveloperMode() + const [searchParams, setSearchParams] = useSearchParams() const [faces, setFaces] = useState([]) const [, setTotal] = useState(0) const [pageSize, setPageSize] = useState(50) @@ -20,6 +22,14 @@ export default function Identify() { const [dateTo, setDateTo] = useState('') // dateProcessed filter is hidden, so state removed // const [dateProcessed, setDateProcessed] = useState('') + + // Parse photo_ids from URL parameter + const photoIds = useMemo(() => { + const photoIdsParam = searchParams.get('photo_ids') + if (!photoIdsParam) return null + const ids = photoIdsParam.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id)) + return ids.length > 0 ? ids : null + }, [searchParams]) const [currentIdx, setCurrentIdx] = useState(0) const currentFace = faces[currentIdx] @@ -48,6 +58,7 @@ export default function Identify() { const [availableTags, setAvailableTags] = useState([]) const [selectedTags, setSelectedTags] = useState([]) const [tagsExpanded, setTagsExpanded] = useState(false) + const [selectKey, setSelectKey] = useState(0) // Key to force select re-render when clearing // Store form data per face ID (matching desktop behavior) const [faceFormData, setFaceFormData] = useState { + const loadFaces = async (clearState: boolean = false, ignorePhotoIds: boolean = false) => { setLoadingFaces(true) try { @@ -95,6 +106,7 @@ export default function Identify() { sort_dir: sortDir, tag_names: selectedTags.length > 0 ? selectedTags.join(', ') : undefined, match_all: false, // Default to match any tag + photo_ids: (ignorePhotoIds || !photoIds) ? undefined : photoIds.join(','), // Filter by photo IDs if provided and not ignored }) // Apply unique faces filter if enabled @@ -258,8 +270,15 @@ export default function Identify() { } // Load settings from sessionStorage on mount + // If photoIds is provided, skip loading filter settings (we want to show all faces from those photos) useEffect(() => { try { + // If photoIds is in URL, don't load filter settings - we'll use defaults to show all faces + if (photoIds !== null) { + setSettingsLoaded(true) + return + } + const saved = sessionStorage.getItem(SETTINGS_KEY) if (saved) { const settings = JSON.parse(saved) @@ -281,11 +300,18 @@ export default function Identify() { setSettingsLoaded(true) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [photoIds]) // Load state from sessionStorage on mount (faces, current index, similar, form data) + // Skip state restoration if photoIds is provided in URL (we want fresh filtered data) useEffect(() => { try { + // If photoIds is in URL, don't restore state - we need to load fresh filtered faces + if (photoIds !== null) { + setStateRestored(true) + return + } + const saved = sessionStorage.getItem(STATE_KEY) if (saved) { const state = JSON.parse(saved) @@ -318,7 +344,7 @@ export default function Identify() { setStateRestored(true) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [photoIds]) // Save state to sessionStorage whenever it changes (but only after initial restore) useEffect(() => { @@ -395,12 +421,36 @@ export default function Identify() { } }, [pageSize, minQuality, sortBy, sortDir, dateFrom, dateTo, uniqueFacesOnly, compareEnabled, selectedTags, settingsLoaded]) + // Load tags and people on mount (always, regardless of other conditions) + useEffect(() => { + if (settingsLoaded) { + loadPeople() + loadTags() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settingsLoaded]) + + // Reset filters when photoIds is provided (to ensure all faces from those photos are shown) + useEffect(() => { + if (photoIds !== null && settingsLoaded) { + // Reset filters to defaults to show all faces from the selected photos + setMinQuality(0.0) + setDateFrom('') + setDateTo('') + setSelectedTags([]) + // Keep uniqueFacesOnly as is (user preference) + // Keep sortBy/sortDir as defaults (quality desc) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [photoIds, settingsLoaded]) + // Initial load on mount (after settings and state are loaded) useEffect(() => { if (!initialLoadRef.current && settingsLoaded && stateRestored) { initialLoadRef.current = true - // Only load if we didn't restore state (no faces means we need to load) - if (faces.length === 0) { + // Always load if photoIds is provided (from URL) - we need fresh filtered faces + // Otherwise, only load if we didn't restore state (no faces means we need to load) + if (photoIds !== null || faces.length === 0) { loadFaces() // If we're loading fresh, mark restoration as complete immediately restorationCompleteRef.current = true @@ -413,11 +463,9 @@ export default function Identify() { }, 50) } } - loadPeople() - loadTags() } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settingsLoaded, stateRestored]) + }, [settingsLoaded, stateRestored, photoIds]) // Reload when uniqueFacesOnly changes (immediate reload) // But only if restoration is complete (prevents reload during initial restoration) @@ -589,7 +637,14 @@ export default function Identify() { return (
-

Identify

+

+ Identify + {photoIds && ( + + (Filtered by {photoIds.length} photo{photoIds.length !== 1 ? 's' : ''}) + + )} +

{/* Left: Controls and current face */} @@ -695,6 +750,7 @@ export default function Identify() {
-
@@ -756,7 +807,18 @@ export default function Identify() { - - -
+
+ +
+ +
- )} +
+ + @@ -522,13 +557,26 @@ export default function Tags() { {photo.path} {photo.processed ? 'Yes' : 'No'} {photo.date_taken || 'Unknown'} - {photo.face_count} + + {photo.processed && (photo.unidentified_face_count ?? 0) > 0 ? ( + + ) : ( + {photo.face_count || 0} + )} +
{getPhotoTags(photo.id)} @@ -545,15 +593,138 @@ export default function Tags() { {viewMode === 'icons' && (
-

Icons view coming soon...

+ {folderGroups.map(folder => ( +
+ {/* Folder Header */} +
+
+ + + 📁 {folder.folderName} ({folder.photoCount} photos) + + +
+
+ + {/* Photo Grid */} + {folderStates[folder.folderPath] === true && ( +
+ {folder.photos.map(photo => { + const photoUrl = `/api/v1/photos/${photo.id}/image` + const isSelected = selectedPhotoIds.has(photo.id) + + return ( +
+ {/* Checkbox */} +
+ { + const newSet = new Set(selectedPhotoIds) + if (e.target.checked) { + newSet.add(photo.id) + } else { + newSet.delete(photo.id) + } + setSelectedPhotoIds(newSet) + }} + className="w-5 h-5 cursor-pointer" + onClick={(e) => e.stopPropagation()} + /> +
+ + {/* Action Buttons */} +
+ {photo.processed && (photo.unidentified_face_count ?? 0) > 0 && ( + + )} + +
+ + {/* Thumbnail */} +
+ {photo.filename} window.open(photoUrl, '_blank')} + onError={(e) => { + const target = e.target as HTMLImageElement + target.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="150" height="150"%3E%3Crect fill="%23ddd" width="150" height="150"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999"%3ENo Image%3C/text%3E%3C/svg%3E' + }} + /> +
+ + {/* Metadata Overlay */} +
+
+
+ ID: {photo.id} +
+
+ {photo.filename} +
+
+ + {photo.processed ? '✓ Processed' : '○ Unprocessed'} + +
+ {photo.date_taken && ( +
+ 📅 {photo.date_taken} +
+ )} +
+ 👤 {photo.face_count} face{photo.face_count !== 1 ? 's' : ''} +
+
+ 🏷️ {getPhotoTags(photo.id)} +
+
+
+
+ ) + })} +
+ )} +
+ ))}
)} - {viewMode === 'compact' && ( -
-

Compact view coming soon...

-
- )}
{/* Manage Tags Dialog */} diff --git a/src/web/api/faces.py b/src/web/api/faces.py index 4f40842..c9be616 100644 --- a/src/web/api/faces.py +++ b/src/web/api/faces.py @@ -113,6 +113,7 @@ def get_unidentified_faces( sort_dir: str = Query("desc"), tag_names: str | None = Query(None, description="Comma-separated tag names for filtering"), match_all: bool = Query(False, description="Match all tags (for tag filtering)"), + photo_ids: str | None = Query(None, description="Comma-separated photo IDs for filtering"), db: Session = Depends(get_db), ) -> UnidentifiedFacesResponse: """Get unidentified faces with filters and pagination.""" @@ -138,6 +139,14 @@ def get_unidentified_faces( if tag_names: tag_names_list = [t.strip() for t in tag_names.split(',') if t.strip()] + # Parse photo IDs + photo_ids_list = None + if photo_ids: + try: + photo_ids_list = [int(pid.strip()) for pid in photo_ids.split(',') if pid.strip()] + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid photo_ids format") + # Convert single date_processed to date_processed_from and date_processed_to (exact date match) date_processed_from = dp date_processed_to = dp @@ -155,6 +164,7 @@ def get_unidentified_faces( sort_dir=sort_dir, tag_names=tag_names_list, match_all=match_all, + photo_ids=photo_ids_list, ) items = [ diff --git a/src/web/api/tags.py b/src/web/api/tags.py index 93df7eb..21a6e5e 100644 --- a/src/web/api/tags.py +++ b/src/web/api/tags.py @@ -180,6 +180,7 @@ def get_photos_with_tags_endpoint(db: Session = Depends(get_db)) -> PhotosWithTa date_taken=p['date_taken'], date_added=p['date_added'], face_count=p['face_count'], + unidentified_face_count=p['unidentified_face_count'], tags=p['tags'], ) for p in photos_data diff --git a/src/web/schemas/tags.py b/src/web/schemas/tags.py index 4bed98d..03354c4 100644 --- a/src/web/schemas/tags.py +++ b/src/web/schemas/tags.py @@ -87,6 +87,7 @@ class PhotoWithTagsItem(BaseModel): date_taken: Optional[str] = None date_added: Optional[str] = None face_count: int + unidentified_face_count: int # Count of faces with person_id IS NULL tags: str # Comma-separated tags string (matching desktop) diff --git a/src/web/services/face_service.py b/src/web/services/face_service.py index bacb095..5635d41 100644 --- a/src/web/services/face_service.py +++ b/src/web/services/face_service.py @@ -1206,6 +1206,7 @@ def list_unidentified_faces( sort_dir: str = "desc", tag_names: Optional[List[str]] = None, match_all: bool = False, + photo_ids: Optional[List[int]] = None, ) -> Tuple[List[Face], int]: """Return paginated unidentified faces with filters. @@ -1214,6 +1215,7 @@ def list_unidentified_faces( - Date taken (date_taken_from, date_taken_to) - Date processed (date_processed_from, date_processed_to) - uses photo.date_added - Tags (tag_names, match_all) + - Photo IDs (photo_ids) Legacy parameters (date_from, date_to) are kept for backward compatibility and filter by date_taken when available, else date_added as fallback. @@ -1252,6 +1254,10 @@ def list_unidentified_faces( # No matching tags found - return empty result return [], 0 + # Photo ID filtering + if photo_ids: + query = query.filter(Face.photo_id.in_(photo_ids)) + # Min quality (stored 0.0-1.0) if min_quality is not None: query = query.filter(Face.quality_score >= min_quality) diff --git a/src/web/services/tag_service.py b/src/web/services/tag_service.py index ff6aea4..93050f2 100644 --- a/src/web/services/tag_service.py +++ b/src/web/services/tag_service.py @@ -252,13 +252,21 @@ def get_photos_with_tags(db: Session) -> List[dict]: result = [] for photo in photos: - # Get face count + # Get face count (all faces) face_count = ( db.query(func.count(Face.id)) .filter(Face.photo_id == photo.id) .scalar() or 0 ) + # Get unidentified face count (only faces with person_id IS NULL) + unidentified_face_count = ( + db.query(func.count(Face.id)) + .filter(Face.photo_id == photo.id) + .filter(Face.person_id.is_(None)) + .scalar() or 0 + ) + # Get tags as comma-separated string (matching desktop GROUP_CONCAT) tags_query = ( db.query(Tag.tag_name) @@ -277,6 +285,7 @@ def get_photos_with_tags(db: Session) -> List[dict]: 'date_taken': photo.date_taken.isoformat() if photo.date_taken else None, 'date_added': photo.date_added.isoformat() if photo.date_added else None, 'face_count': face_count, + 'unidentified_face_count': unidentified_face_count, 'tags': tags, })