feat: Add tag filtering and developer mode options in Identify and AutoMatch components

This commit introduces new features for tag filtering in the Identify and AutoMatch components. Users can now filter unidentified faces by tags, with options to match all or any specified tags. The UI has been updated to include tag selection and management, enhancing user experience. Additionally, developer mode options have been added to display tolerance and auto-accept threshold settings conditionally. The API has been updated to support these new parameters, ensuring seamless integration. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-11-11 12:17:23 -05:00
parent 21c138a339
commit 20a8e4df5d
6 changed files with 181 additions and 116 deletions

View File

@ -156,6 +156,8 @@ export const facesApi = {
date_processed_to?: string
sort_by?: 'quality' | 'date_taken' | 'date_added'
sort_dir?: 'asc' | 'desc'
tag_names?: string
match_all?: boolean
}): Promise<UnidentifiedFacesResponse> => {
const response = await apiClient.get<UnidentifiedFacesResponse>('/api/v1/faces/unidentified', {
params,

View File

@ -2,10 +2,12 @@ import { useState, useEffect, useMemo } from 'react'
import facesApi, { AutoMatchResponse, AutoMatchPersonItem, AutoMatchFaceItem } from '../api/faces'
import peopleApi from '../api/people'
import { apiClient } from '../api/client'
import { useDeveloperMode } from '../context/DeveloperModeContext'
const DEFAULT_TOLERANCE = 0.6
export default function AutoMatch() {
const { isDeveloperMode } = useDeveloperMode()
const [tolerance, setTolerance] = useState(DEFAULT_TOLERANCE)
const [autoAcceptThreshold, setAutoAcceptThreshold] = useState(70)
const [isActive, setIsActive] = useState(false)
@ -19,7 +21,6 @@ export default function AutoMatch() {
const [saving, setSaving] = useState(false)
const [hasNoResults, setHasNoResults] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [showHelpTooltip, setShowHelpTooltip] = useState(false)
const currentPerson = useMemo(() => {
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
@ -260,20 +261,22 @@ export default function AutoMatch() {
>
{isRefreshing ? 'Refreshing...' : '🔄 Refresh'}
</button>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Tolerance:</label>
<input
type="number"
min="0"
max="1"
step="0.1"
value={tolerance}
onChange={(e) => setTolerance(parseFloat(e.target.value) || 0)}
disabled={busy}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<span className="text-xs text-gray-500">(lower = stricter matching)</span>
</div>
{isDeveloperMode && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Tolerance:</label>
<input
type="number"
min="0"
max="1"
step="0.1"
value={tolerance}
onChange={(e) => setTolerance(parseFloat(e.target.value) || 0)}
disabled={busy}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<span className="text-xs text-gray-500">(lower = stricter matching)</span>
</div>
)}
<div className="flex items-center gap-2">
<button
onClick={startAutoMatch}
@ -283,76 +286,26 @@ export default function AutoMatch() {
>
{busy ? 'Processing...' : hasNoResults ? 'No Matches Available' : '🚀 Run Auto-Match'}
</button>
<div className="relative">
<button
type="button"
onClick={() => setShowHelpTooltip(!showHelpTooltip)}
onBlur={() => setTimeout(() => setShowHelpTooltip(false), 200)}
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:text-gray-600"
aria-label="Auto-match criteria help"
aria-expanded={showHelpTooltip}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
{showHelpTooltip && (
<div className="absolute left-0 top-8 w-80 bg-gray-900 text-white text-xs rounded-lg shadow-lg p-3 z-20">
<div className="space-y-2">
<div className="font-semibold text-sm mb-2">Auto-Match Criteria:</div>
<div>
<div className="font-medium mb-1">Face Pose:</div>
<div className="text-gray-300 pl-2">
Reference face: Frontal or tilted (not profile)
<br />
Match face: Frontal or tilted (not profile)
</div>
</div>
<div>
<div className="font-medium mb-1">Similarity Threshold:</div>
<div className="text-gray-300 pl-2">
Minimum: {autoAcceptThreshold}% similarity
<br />
Only matches {autoAcceptThreshold}% will be auto-accepted
</div>
</div>
<div className="pt-2 border-t border-gray-700 text-gray-400 text-xs">
Note: Profile faces are excluded for better accuracy
</div>
</div>
<div className="absolute -top-2 left-4 w-0 h-0 border-l-4 border-r-4 border-b-4 border-transparent border-b-gray-900"></div>
</div>
)}
</div>
{isDeveloperMode && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Auto-Accept Threshold:</label>
<input
type="number"
min="0"
max="100"
step="5"
value={autoAcceptThreshold}
onChange={(e) => setAutoAcceptThreshold(parseInt(e.target.value) || 70)}
disabled={busy || hasNoResults}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<span className="text-xs text-gray-500">% (min similarity)</span>
</div>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Auto-Accept Threshold:</label>
<input
type="number"
min="0"
max="100"
step="5"
value={autoAcceptThreshold}
onChange={(e) => setAutoAcceptThreshold(parseInt(e.target.value) || 70)}
disabled={busy || hasNoResults}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<span className="text-xs text-gray-500">% (min similarity)</span>
</div>
)}
</div>
<div className="mt-2 text-xs text-gray-600 bg-blue-50 border border-blue-200 rounded p-2">
<span className="font-medium"> Auto-Match Criteria:</span> Only frontal or tilted faces (not profile) with similarity {autoAcceptThreshold}% will be auto-accepted. Click the info icon for details.
<span className="font-medium"> Auto-Match Criteria:</span> Only faces with similarity higher than 70% will be auto-matched. Profile faces are excluded for better accuracy.
</div>
</div>
@ -574,11 +527,6 @@ export default function AutoMatch() {
</>
)}
{!isActive && (
<div className="bg-white rounded-lg shadow p-6 text-center text-gray-500">
<p>Click "Start Auto-Match" to begin matching unidentified faces with identified people.</p>
</div>
)}
</div>
)
}

View File

@ -2,11 +2,14 @@ import { useEffect, useMemo, useState, useRef } from 'react'
import facesApi, { FaceItem, SimilarFaceItem } from '../api/faces'
import peopleApi, { Person } from '../api/people'
import { apiClient } from '../api/client'
import tagsApi, { TagResponse } from '../api/tags'
import { useDeveloperMode } from '../context/DeveloperModeContext'
type SortBy = 'quality' | 'date_taken' | 'date_added'
type SortDir = 'asc' | 'desc'
export default function Identify() {
const { isDeveloperMode } = useDeveloperMode()
const [faces, setFaces] = useState<FaceItem[]>([])
const [, setTotal] = useState(0)
const [pageSize, setPageSize] = useState(50)
@ -33,8 +36,11 @@ export default function Identify() {
const [dob, setDob] = useState('')
const [busy, setBusy] = useState(false)
const [imageLoading, setImageLoading] = useState(false)
const [filtersCollapsed, setFiltersCollapsed] = useState(false)
const [filtersCollapsed, setFiltersCollapsed] = useState(true)
const [loadingFaces, setLoadingFaces] = useState(false)
const [availableTags, setAvailableTags] = useState<TagResponse[]>([])
const [selectedTags, setSelectedTags] = useState<string[]>([])
const [tagsExpanded, setTagsExpanded] = useState(false)
// Store form data per face ID (matching desktop behavior)
const [faceFormData, setFaceFormData] = useState<Record<number, {
@ -67,6 +73,8 @@ export default function Identify() {
date_to: dateTo || undefined,
sort_by: sortBy,
sort_dir: sortDir,
tag_names: selectedTags.length > 0 ? selectedTags.join(', ') : undefined,
match_all: false, // Default to match any tag
})
// Apply unique faces filter if enabled
@ -200,6 +208,15 @@ export default function Identify() {
setPeople(res.items)
}
const loadTags = async () => {
try {
const res = await tagsApi.list()
setAvailableTags(res.items)
} catch (error) {
console.error('Error loading tags:', error)
}
}
const loadSimilar = async (faceId: number) => {
if (!compareEnabled) {
setSimilar([])
@ -220,6 +237,7 @@ export default function Identify() {
initialLoadRef.current = true
loadFaces()
loadPeople()
loadTags()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@ -232,6 +250,14 @@ export default function Identify() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uniqueFacesOnly])
// Reload when pageSize changes (immediate reload)
useEffect(() => {
if (initialLoadRef.current) {
loadFaces()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize])
useEffect(() => {
if (currentFace) {
setImageLoading(true) // Show loading indicator when face changes
@ -398,20 +424,33 @@ export default function Identify() {
<div className="grid grid-cols-12 gap-4">
{/* Left: Controls and current face */}
<div className="col-span-4">
{/* Unique Faces Checkbox - Outside Filters */}
{/* Unique Faces Checkbox and Batch Size - Outside Filters */}
<div className="bg-white rounded-lg shadow mb-4 p-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={uniqueFacesOnly}
onChange={(e) => setUniqueFacesOnly(e.target.checked)}
className="rounded"
/>
<span className="text-sm font-medium 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 className="flex items-center justify-between gap-4">
<div className="flex-1">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={uniqueFacesOnly}
onChange={(e) => setUniqueFacesOnly(e.target.checked)}
className="rounded"
/>
<span className="text-sm font-medium 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="flex-shrink-0">
<label className="block text-sm font-medium text-gray-700 mb-1">Batch Size</label>
<select value={pageSize} onChange={(e) => setPageSize(parseInt(e.target.value))}
className="block w-full border rounded px-2 py-1 text-sm">
{[25, 50, 100, 200, 500, 1000, 1500, 2000].map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow mb-4">
@ -437,15 +476,6 @@ export default function Identify() {
onChange={(e) => setMinQuality(parseFloat(e.target.value))} className="w-full" />
<div className="text-xs text-gray-500">{(minQuality * 100).toFixed(0)}%</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Batch Size</label>
<select value={pageSize} onChange={(e) => setPageSize(parseInt(e.target.value))}
className="mt-1 block w-full border rounded px-2 py-1">
{[25, 50, 100, 200].map((n) => (
<option key={n} value={n}>{n}</option>
))}
</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)}
@ -474,6 +504,51 @@ export default function Identify() {
</select>
</div>
</div>
<div className="mt-4 pt-3 border-t">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-sm font-medium text-gray-700">With Tags</h3>
<button
onClick={() => setTagsExpanded(!tagsExpanded)}
className="text-lg text-gray-600 hover:text-gray-800"
title={tagsExpanded ? 'Collapse' : 'Expand'}
>
{tagsExpanded ? '▼' : '▶'}
</button>
</div>
{tagsExpanded && (
<div className="mt-2">
<div className="flex gap-2">
<select
multiple
value={selectedTags}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions, option => option.value)
setSelectedTags(selected)
}}
className="flex-1 border rounded px-3 py-2 min-h-[120px]"
size={Math.min(availableTags.length, 8)}
>
{availableTags.map(tag => (
<option key={tag.id} value={tag.tag_name}>
{tag.tag_name}
</option>
))}
</select>
<button
onClick={() => setSelectedTags([])}
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"
>
Clear
</button>
</div>
<p className="text-xs text-gray-500 mt-1">
Hold Ctrl/Cmd to select multiple tags
</p>
</div>
)}
</div>
<div className="mt-4 pt-3 border-t">
<button
onClick={loadFaces}
@ -536,7 +611,7 @@ export default function Identify() {
/>
</div>
{/* Pose mode display */}
{currentFace.pose_mode && (
{isDeveloperMode && currentFace.pose_mode && (
<div className="mb-2 text-sm text-gray-600">
<span className="font-medium">Pose:</span> {currentFace.pose_mode}
</div>
@ -738,7 +813,7 @@ export default function Identify() {
</div>
{/* Pose mode */}
{s.pose_mode && (
{isDeveloperMode && s.pose_mode && (
<div className="text-sm text-gray-600 flex-shrink-0">
{s.pose_mode}
</div>

View File

@ -523,9 +523,6 @@ export default function Search() {
{tagsExpanded && (
<div className="mt-3 space-y-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tags:
</label>
<select
multiple
value={selectedTags}

View File

@ -106,6 +106,8 @@ def get_unidentified_faces(
date_to: str | None = Query(None),
sort_by: str = Query("quality"),
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)"),
db: Session = Depends(get_db),
) -> UnidentifiedFacesResponse:
"""Get unidentified faces with filters and pagination."""
@ -114,6 +116,11 @@ def get_unidentified_faces(
df = _date.fromisoformat(date_from) if date_from else None
dt = _date.fromisoformat(date_to) if date_to else None
# Parse tag names
tag_names_list = None
if tag_names:
tag_names_list = [t.strip() for t in tag_names.split(',') if t.strip()]
faces, total = list_unidentified_faces(
db,
page=page,
@ -123,6 +130,8 @@ def get_unidentified_faces(
date_to=dt,
sort_by=sort_by,
sort_dir=sort_dir,
tag_names=tag_names_list,
match_all=match_all,
)
items = [

View File

@ -29,7 +29,7 @@ from src.core.config import (
)
from src.utils.exif_utils import EXIFOrientationHandler
from src.utils.pose_detection import PoseDetector, RETINAFACE_AVAILABLE
from src.web.db.models import Face, Photo, Person
from src.web.db.models import Face, Photo, Person, Tag, PhotoTagLinkage
def _pre_warm_deepface(
@ -1204,6 +1204,8 @@ def list_unidentified_faces(
date_processed_to: Optional[date] = None,
sort_by: str = "quality",
sort_dir: str = "desc",
tag_names: Optional[List[str]] = None,
match_all: bool = False,
) -> Tuple[List[Face], int]:
"""Return paginated unidentified faces with filters.
@ -1211,12 +1213,44 @@ def list_unidentified_faces(
- Min quality
- 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)
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))
# Tag filtering
if tag_names:
# Find tag IDs (case-insensitive)
tag_ids = (
db.query(Tag.id)
.filter(func.lower(Tag.tag_name).in_([t.lower().strip() for t in tag_names]))
.all()
)
tag_ids = [tid[0] for tid in tag_ids]
if tag_ids:
if match_all:
# Photos that have ALL specified tags
# Join with PhotoTagLinkage and filter
query = (
query.join(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id)
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
.group_by(Face.id, Photo.id)
.having(func.count(func.distinct(PhotoTagLinkage.tag_id)) == len(tag_ids))
)
else:
# Photos that have ANY of the specified tags
query = (
query.join(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id)
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
.distinct()
)
else:
# No matching tags found - return empty result
return [], 0
# Min quality (stored 0.0-1.0)
if min_quality is not None: