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:
parent
21c138a339
commit
20a8e4df5d
@ -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,
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user