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.
This commit is contained in:
tanyar09 2025-11-12 14:17:16 -05:00
parent 89a63cbf57
commit cfb94900ef
10 changed files with 404 additions and 74 deletions

View File

@ -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 <PID>
# 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 <PID>
# 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 <PID>` 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)
---

View File

@ -184,6 +184,7 @@ export const facesApi = {
sort_dir?: 'asc' | 'desc'
tag_names?: string
match_all?: boolean
photo_ids?: string
}): Promise<UnidentifiedFacesResponse> => {
const response = await apiClient.get<UnidentifiedFacesResponse>('/api/v1/faces/unidentified', {
params,

View File

@ -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
}

View File

@ -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<FaceItem[]>([])
const [, setTotal] = useState(0)
const [pageSize, setPageSize] = useState(50)
@ -20,6 +22,14 @@ export default function Identify() {
const [dateTo, setDateTo] = useState<string>('')
// dateProcessed filter is hidden, so state removed
// const [dateProcessed, setDateProcessed] = useState<string>('')
// 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<TagResponse[]>([])
const [selectedTags, setSelectedTags] = useState<string[]>([])
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<Record<number, {
@ -74,7 +85,7 @@ export default function Identify() {
return Boolean((personId && currentFace) || (firstName && lastName && dob && currentFace))
}, [personId, firstName, lastName, dob, currentFace])
const loadFaces = async (clearState: boolean = false) => {
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 (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Identify</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Identify
{photoIds && (
<span className="ml-2 text-sm font-normal text-gray-600">
(Filtered by {photoIds.length} photo{photoIds.length !== 1 ? 's' : ''})
</span>
)}
</h1>
<div className="grid grid-cols-12 gap-4">
{/* Left: Controls and current face */}
@ -695,6 +750,7 @@ export default function Identify() {
<div className="mt-2">
<div className="flex gap-2">
<select
key={selectKey}
multiple
value={selectedTags}
onChange={(e) => {
@ -711,7 +767,10 @@ export default function Identify() {
))}
</select>
<button
onClick={() => setSelectedTags([])}
onClick={() => {
setSelectedTags([])
setSelectKey(prev => prev + 1) // Force select to re-render and clear visual selection
}}
disabled={selectedTags.length === 0}
className="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed text-sm self-start"
title="Clear all tag selections"
@ -734,14 +793,6 @@ export default function Identify() {
>
{loadingFaces ? 'Loading...' : 'Apply Filters'}
</button>
<button
onClick={() => loadFaces(true)}
disabled={loadingFaces}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
title="Refresh and start from beginning"
>
{loadingFaces ? 'Refreshing...' : '🔄 Refresh'}
</button>
</div>
</div>
</div>
@ -756,7 +807,18 @@ export default function Identify() {
<button className="px-2 py-1 text-sm border rounded" onClick={() => setCurrentIdx((i) => Math.min(faces.length - 1, i + 1))}>Next</button>
<button
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
onClick={() => loadFaces(true)}
onClick={() => {
// Clear photo_ids from URL if present
if (photoIds !== null) {
const newParams = new URLSearchParams(searchParams)
newParams.delete('photo_ids')
setSearchParams(newParams, { replace: true })
// Load faces without photo_ids filter (ignorePhotoIds = true)
loadFaces(true, true)
} else {
loadFaces(true)
}
}}
disabled={loadingFaces}
title="Refresh and start from beginning"
>

View File

@ -99,6 +99,11 @@ export default function Tags() {
tagsApi.getPhotosWithTags(),
tagsApi.list(),
])
// Debug: Check if unidentified_face_count is present
if (photosRes.items.length > 0) {
console.log('Sample photo data:', photosRes.items[0])
console.log('unidentified_face_count:', photosRes.items[0].unidentified_face_count)
}
setPhotos(photosRes.items)
setTags(tagsRes.items)
} catch (error) {
@ -139,6 +144,14 @@ export default function Tags() {
return sortedFolders
}, [photos])
// Count selected photos with unidentified faces (for Identify Faces button)
const selectedPhotosWithFacesCount = useMemo(() => {
return Array.from(selectedPhotoIds).filter(photoId => {
const photo = photos.find(p => p.id === photoId)
return photo && photo.processed && photo.unidentified_face_count > 0
}).length
}, [selectedPhotoIds, photos])
// Get tags for a photo (including pending changes)
const getPhotoTags = (photoId: number): string => {
const photo = photos.find(p => p.id === photoId)
@ -174,6 +187,23 @@ export default function Tags() {
}))
}
// Handle identifying faces from selected photos
const handleIdentifyFaces = (photoIds: number[]) => {
// Filter to only processed photos with faces
const processedPhotos = photos.filter(p =>
photoIds.includes(p.id) && p.processed && p.unidentified_face_count > 0
)
if (processedPhotos.length === 0) {
alert('No processed photos with faces selected. Please select photos that have been processed and contain faces.')
return
}
// Navigate to Identify page with photo IDs in new tab
const photoIdsStr = processedPhotos.map(p => p.id).join(',')
window.open(`/identify?photo_ids=${photoIdsStr}`, '_blank')
}
// Add tag to photo (with optional immediate save)
const addTagToPhoto = async (photoId: number, tagName: string, linkageType: number = 0, saveImmediately: boolean = false) => {
const tag = tags.find(t => t.tag_name.toLowerCase() === tagName.toLowerCase().trim())
@ -384,43 +414,31 @@ export default function Tags() {
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-gray-900">Photos tagging interface</h1>
<div className="flex items-center gap-4">
{isDeveloperMode && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">View:</label>
<div className="flex gap-2">
<button
onClick={() => setViewMode('list')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'list'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
List
</button>
<button
onClick={() => setViewMode('icons')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'icons'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Icons
</button>
<button
onClick={() => setViewMode('compact')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'compact'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Compact
</button>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">View:</label>
<div className="flex gap-2">
<button
onClick={() => setViewMode('list')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'list'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
List
</button>
<button
onClick={() => setViewMode('icons')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'icons'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Icons
</button>
</div>
)}
</div>
<button
onClick={() => setShowTagSelectedDialog(true)}
disabled={selectedPhotoIds.size === 0}
@ -428,6 +446,22 @@ export default function Tags() {
>
Tag Selected Photos ({selectedPhotoIds.size})
</button>
<button
onClick={() => handleIdentifyFaces(Array.from(selectedPhotoIds))}
disabled={selectedPhotosWithFacesCount === 0}
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
title="Open Identify page in new tab with faces from selected photos"
>
🔍 Identify Faces ({selectedPhotosWithFacesCount})
</button>
<button
onClick={() => setSelectedPhotoIds(new Set())}
disabled={selectedPhotoIds.size === 0}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed"
title="Clear all selected photos"
>
Clear Selected
</button>
<button
onClick={() => setShowManageTags(true)}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
@ -484,6 +518,7 @@ export default function Tags() {
<button
onClick={() => setShowBulkTagDialog(folder.folderPath)}
className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title="Manage bulk tags for this folder"
>
🔗
</button>
@ -522,13 +557,26 @@ export default function Tags() {
<td className="p-2 text-sm text-gray-600">{photo.path}</td>
<td className="p-2">{photo.processed ? 'Yes' : 'No'}</td>
<td className="p-2">{photo.date_taken || 'Unknown'}</td>
<td className="p-2">{photo.face_count}</td>
<td className="p-2">
{photo.processed && (photo.unidentified_face_count ?? 0) > 0 ? (
<button
onClick={() => handleIdentifyFaces([photo.id])}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title="Identify faces in this photo"
>
🔍
</button>
) : (
<span>{photo.face_count || 0}</span>
)}
</td>
<td className="p-2">
<div className="flex items-center gap-2">
<span className="text-sm">{getPhotoTags(photo.id)}</span>
<button
onClick={() => setShowTagDialog(photo.id)}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title="Tag photos"
>
🔗
</button>
@ -545,15 +593,138 @@ export default function Tags() {
{viewMode === 'icons' && (
<div className="p-4">
<p className="text-gray-600">Icons view coming soon...</p>
{folderGroups.map(folder => (
<div key={folder.folderPath} className="mb-6">
{/* Folder Header */}
<div className="bg-gray-50 border-b p-3 mb-3 rounded-t">
<div className="flex items-center gap-2">
<button
onClick={() => toggleFolder(folder.folderPath)}
className="px-2 py-1 text-sm hover:bg-gray-200 rounded"
>
{folderStates[folder.folderPath] === true ? '▼' : '▶'}
</button>
<span className="font-semibold">
📁 {folder.folderName} ({folder.photoCount} photos)
</span>
<button
onClick={() => setShowBulkTagDialog(folder.folderPath)}
className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200"
title="Manage bulk tags for this folder"
>
🔗
</button>
</div>
</div>
{/* Photo Grid */}
{folderStates[folder.folderPath] === true && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{folder.photos.map(photo => {
const photoUrl = `/api/v1/photos/${photo.id}/image`
const isSelected = selectedPhotoIds.has(photo.id)
return (
<div
key={photo.id}
className="relative bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg transition-shadow"
>
{/* Checkbox */}
<div className="absolute top-2 left-2 z-10">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
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()}
/>
</div>
{/* Action Buttons */}
<div className="absolute top-2 right-2 z-10 flex gap-1">
{photo.processed && (photo.unidentified_face_count ?? 0) > 0 && (
<button
onClick={(e) => {
e.stopPropagation()
handleIdentifyFaces([photo.id])
}}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200 shadow"
title="Identify faces in this photo"
>
🔍
</button>
)}
<button
onClick={(e) => {
e.stopPropagation()
setShowTagDialog(photo.id)
}}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200 shadow"
title="Tag photos"
>
🔗
</button>
</div>
{/* Thumbnail */}
<div className="w-full aspect-square bg-gray-100 overflow-hidden">
<img
src={photoUrl}
alt={photo.filename}
className="w-full h-full object-cover cursor-pointer"
onClick={() => 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'
}}
/>
</div>
{/* Metadata Overlay */}
<div className="p-2 bg-white">
<div className="text-xs space-y-1">
<div className="font-semibold text-gray-900 truncate" title={`ID: ${photo.id}`}>
ID: {photo.id}
</div>
<div className="text-gray-700 truncate" title={photo.filename}>
{photo.filename}
</div>
<div className="flex items-center gap-2 text-gray-600">
<span className={photo.processed ? 'text-green-600' : 'text-orange-600'}>
{photo.processed ? '✓ Processed' : '○ Unprocessed'}
</span>
</div>
{photo.date_taken && (
<div className="text-gray-600 truncate" title={photo.date_taken}>
📅 {photo.date_taken}
</div>
)}
<div className="text-gray-600">
👤 {photo.face_count} face{photo.face_count !== 1 ? 's' : ''}
</div>
<div className="text-gray-600 truncate" title={getPhotoTags(photo.id)}>
🏷 {getPhotoTags(photo.id)}
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
))}
</div>
)}
{viewMode === 'compact' && (
<div className="p-4">
<p className="text-gray-600">Compact view coming soon...</p>
</div>
)}
</div>
{/* Manage Tags Dialog */}

View File

@ -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 = [

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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,
})