Merge pull request 'fix/auto-match-stricter-filtering' (#35) from fix/auto-match-stricter-filtering into dev
Some checks failed
CI / skip-ci-check (pull_request) Has been cancelled
CI / lint-and-type-check (pull_request) Has been cancelled
CI / python-lint (pull_request) Has been cancelled
CI / test-backend (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / secret-scanning (pull_request) Has been cancelled
CI / dependency-scan (pull_request) Has been cancelled
CI / sast-scan (pull_request) Has been cancelled
CI / workflow-summary (pull_request) Has been cancelled
Some checks failed
CI / skip-ci-check (pull_request) Has been cancelled
CI / lint-and-type-check (pull_request) Has been cancelled
CI / python-lint (pull_request) Has been cancelled
CI / test-backend (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / secret-scanning (pull_request) Has been cancelled
CI / dependency-scan (pull_request) Has been cancelled
CI / sast-scan (pull_request) Has been cancelled
CI / workflow-summary (pull_request) Has been cancelled
Reviewed-on: #35
This commit is contained in:
commit
c5c3059409
@ -3,4 +3,8 @@
|
||||
# Backend API base URL (must be reachable from the browser)
|
||||
VITE_API_URL=
|
||||
|
||||
# Enable developer mode (shows additional debug info and options)
|
||||
# Set to "true" to enable, leave empty or unset to disable
|
||||
VITE_DEVELOPER_MODE=
|
||||
|
||||
|
||||
|
||||
67
admin-frontend/public/enable-dev-mode.html
Normal file
67
admin-frontend/public/enable-dev-mode.html
Normal file
@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Enable Developer Mode</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
.success {
|
||||
color: #10b981;
|
||||
font-weight: bold;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Enable Developer Mode</h1>
|
||||
<p>Click the button below to enable Developer Mode for PunimTag.</p>
|
||||
<button onclick="enableDevMode()">Enable Developer Mode</button>
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
<script>
|
||||
function enableDevMode() {
|
||||
localStorage.setItem('punimtag_developer_mode', 'true');
|
||||
const result = document.getElementById('result');
|
||||
result.innerHTML = '<p class="success">✅ Developer Mode enabled! Redirecting...</p>';
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// Check if already enabled
|
||||
if (localStorage.getItem('punimtag_developer_mode') === 'true') {
|
||||
document.getElementById('result').innerHTML = '<p class="success">✅ Developer Mode is already enabled!</p>';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@ -39,11 +39,27 @@ export interface SimilarFaceItem {
|
||||
quality_score: number
|
||||
filename: string
|
||||
pose_mode?: string
|
||||
debug_info?: {
|
||||
encoding_length: number
|
||||
encoding_min: number
|
||||
encoding_max: number
|
||||
encoding_mean: number
|
||||
encoding_std: number
|
||||
encoding_first_10: number[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface SimilarFacesResponse {
|
||||
base_face_id: number
|
||||
items: SimilarFaceItem[]
|
||||
debug_info?: {
|
||||
encoding_length: number
|
||||
encoding_min: number
|
||||
encoding_max: number
|
||||
encoding_mean: number
|
||||
encoding_std: number
|
||||
encoding_first_10: number[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface FaceSimilarityPair {
|
||||
@ -97,6 +113,7 @@ export interface AutoMatchRequest {
|
||||
tolerance: number
|
||||
auto_accept?: boolean
|
||||
auto_accept_threshold?: number
|
||||
use_distance_based_thresholds?: boolean
|
||||
}
|
||||
|
||||
export interface AutoMatchFaceItem {
|
||||
@ -217,11 +234,25 @@ export const facesApi = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
getSimilar: async (faceId: number, includeExcluded?: boolean): Promise<SimilarFacesResponse> => {
|
||||
getSimilar: async (faceId: number, includeExcluded?: boolean, debug?: boolean): Promise<SimilarFacesResponse> => {
|
||||
const response = await apiClient.get<SimilarFacesResponse>(`/api/v1/faces/${faceId}/similar`, {
|
||||
params: { include_excluded: includeExcluded || false },
|
||||
params: { include_excluded: includeExcluded || false, debug: debug || false },
|
||||
})
|
||||
return response.data
|
||||
const data = response.data
|
||||
|
||||
// Log debug info to browser console if available
|
||||
if (debug && data.debug_info) {
|
||||
console.log('🔍 Base Face Encoding Debug Info:', data.debug_info)
|
||||
}
|
||||
if (debug && data.items) {
|
||||
data.items.forEach((item, index) => {
|
||||
if (item.debug_info) {
|
||||
console.log(`🔍 Similar Face ${index + 1} (ID: ${item.id}) Encoding Debug Info:`, item.debug_info)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
batchSimilarity: async (request: BatchSimilarityRequest): Promise<BatchSimilarityResponse> => {
|
||||
const response = await apiClient.post<BatchSimilarityResponse>('/api/v1/faces/batch-similarity', request)
|
||||
@ -251,6 +282,7 @@ export const facesApi = {
|
||||
},
|
||||
getAutoMatchPeople: async (params?: {
|
||||
filter_frontal_only?: boolean
|
||||
tolerance?: number
|
||||
}): Promise<AutoMatchPeopleResponse> => {
|
||||
const response = await apiClient.get<AutoMatchPeopleResponse>('/api/v1/faces/auto-match/people', {
|
||||
params,
|
||||
|
||||
@ -1,32 +1,17 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
import { createContext, useContext, ReactNode } from 'react'
|
||||
|
||||
interface DeveloperModeContextType {
|
||||
isDeveloperMode: boolean
|
||||
setDeveloperMode: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
const DeveloperModeContext = createContext<DeveloperModeContextType | undefined>(undefined)
|
||||
|
||||
const STORAGE_KEY = 'punimtag_developer_mode'
|
||||
// Check environment variable (set at build time)
|
||||
const isDeveloperMode = import.meta.env.VITE_DEVELOPER_MODE === 'true'
|
||||
|
||||
export function DeveloperModeProvider({ children }: { children: ReactNode }) {
|
||||
const [isDeveloperMode, setIsDeveloperMode] = useState<boolean>(false)
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored !== null) {
|
||||
setIsDeveloperMode(stored === 'true')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setDeveloperMode = (enabled: boolean) => {
|
||||
setIsDeveloperMode(enabled)
|
||||
localStorage.setItem(STORAGE_KEY, enabled.toString())
|
||||
}
|
||||
|
||||
return (
|
||||
<DeveloperModeContext.Provider value={{ isDeveloperMode, setDeveloperMode }}>
|
||||
<DeveloperModeContext.Provider value={{ isDeveloperMode }}>
|
||||
{children}
|
||||
</DeveloperModeContext.Provider>
|
||||
)
|
||||
|
||||
@ -7,7 +7,8 @@ import peopleApi, { Person } from '../api/people'
|
||||
import { apiClient } from '../api/client'
|
||||
import { useDeveloperMode } from '../context/DeveloperModeContext'
|
||||
|
||||
const DEFAULT_TOLERANCE = 0.6
|
||||
const DEFAULT_TOLERANCE = 0.6 // Default for regular auto-match (more lenient)
|
||||
const RUN_AUTO_MATCH_TOLERANCE = 0.5 // Tolerance for Run auto-match button (stricter)
|
||||
|
||||
export default function AutoMatch() {
|
||||
const { isDeveloperMode } = useDeveloperMode()
|
||||
@ -16,8 +17,8 @@ export default function AutoMatch() {
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [people, setPeople] = useState<AutoMatchPersonSummary[]>([])
|
||||
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonSummary[]>([])
|
||||
// Store matches separately, keyed by person_id
|
||||
const [matchesCache, setMatchesCache] = useState<Record<number, AutoMatchFaceItem[]>>({})
|
||||
// Store matches separately, keyed by person_id_tolerance (composite key)
|
||||
const [matchesCache, setMatchesCache] = useState<Record<string, AutoMatchFaceItem[]>>({})
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [allPeople, setAllPeople] = useState<Person[]>([])
|
||||
@ -44,6 +45,8 @@ export default function AutoMatch() {
|
||||
const [stateRestored, setStateRestored] = useState(false)
|
||||
// Track if initial restoration is complete (prevents reload effects from firing during restoration)
|
||||
const restorationCompleteRef = useRef(false)
|
||||
// Track current tolerance in a ref to avoid stale closures
|
||||
const toleranceRef = useRef(tolerance)
|
||||
|
||||
const currentPerson = useMemo(() => {
|
||||
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
|
||||
@ -52,30 +55,49 @@ export default function AutoMatch() {
|
||||
|
||||
const currentMatches = useMemo(() => {
|
||||
if (!currentPerson) return []
|
||||
return matchesCache[currentPerson.person_id] || []
|
||||
}, [currentPerson, matchesCache])
|
||||
// Use ref tolerance to ensure we always get the current tolerance value
|
||||
const currentTolerance = toleranceRef.current
|
||||
const cacheKey = `${currentPerson.person_id}_${currentTolerance}`
|
||||
return matchesCache[cacheKey] || []
|
||||
}, [currentPerson, matchesCache, tolerance]) // Keep tolerance in deps to trigger recalculation when it changes
|
||||
|
||||
// Check if any matches are selected
|
||||
const hasSelectedMatches = useMemo(() => {
|
||||
return currentMatches.some(match => selectedFaces[match.id] === true)
|
||||
return currentMatches.some((match: AutoMatchFaceItem) => selectedFaces[match.id] === true)
|
||||
}, [currentMatches, selectedFaces])
|
||||
|
||||
// Update tolerance ref whenever tolerance changes
|
||||
useEffect(() => {
|
||||
toleranceRef.current = tolerance
|
||||
}, [tolerance])
|
||||
|
||||
// Load matches for a specific person (lazy loading)
|
||||
const loadPersonMatches = async (personId: number) => {
|
||||
// Skip if already cached
|
||||
if (matchesCache[personId]) {
|
||||
return
|
||||
const loadPersonMatches = async (personId: number, currentTolerance?: number) => {
|
||||
// Use provided tolerance, or ref tolerance (always current), or state tolerance as fallback
|
||||
const toleranceToUse = currentTolerance !== undefined ? currentTolerance : toleranceRef.current
|
||||
// Create cache key that includes tolerance to avoid stale matches
|
||||
const cacheKey = `${personId}_${toleranceToUse}`
|
||||
|
||||
// Double-check: if tolerance changed, don't use cached value
|
||||
if (toleranceToUse !== toleranceRef.current) {
|
||||
// Tolerance changed since this was called, don't use cache
|
||||
// Will fall through to load fresh matches
|
||||
} else {
|
||||
// Skip if already cached for this tolerance
|
||||
if (matchesCache[cacheKey]) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await facesApi.getAutoMatchPersonMatches(personId, {
|
||||
tolerance,
|
||||
tolerance: toleranceToUse,
|
||||
filter_frontal_only: false
|
||||
})
|
||||
|
||||
setMatchesCache(prev => ({
|
||||
...prev,
|
||||
[personId]: response.matches
|
||||
[cacheKey]: response.matches
|
||||
}))
|
||||
|
||||
// Update total_matches in people list
|
||||
@ -106,9 +128,10 @@ export default function AutoMatch() {
|
||||
} catch (error) {
|
||||
console.error('Failed to load matches for person:', error)
|
||||
// Set empty matches on error, and remove person from list
|
||||
// Use composite cache key
|
||||
setMatchesCache(prev => ({
|
||||
...prev,
|
||||
[personId]: []
|
||||
[cacheKey]: []
|
||||
}))
|
||||
// Remove person if matches failed to load (assume no matches)
|
||||
setPeople(prev => prev.filter(p => p.person_id !== personId))
|
||||
@ -118,7 +141,10 @@ export default function AutoMatch() {
|
||||
|
||||
// Shared function for auto-load and refresh (loads people list only - fast)
|
||||
const loadAutoMatch = async (clearState: boolean = false) => {
|
||||
if (tolerance < 0 || tolerance > 1) {
|
||||
// Use ref to get current tolerance (avoids stale closure)
|
||||
const currentTolerance = toleranceRef.current
|
||||
|
||||
if (currentTolerance < 0 || currentTolerance > 1) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -128,12 +154,30 @@ export default function AutoMatch() {
|
||||
// Clear saved state if explicitly requested (Refresh button)
|
||||
if (clearState) {
|
||||
sessionStorage.removeItem(STATE_KEY)
|
||||
setMatchesCache({}) // Clear matches cache
|
||||
// Clear ALL cache entries
|
||||
setMatchesCache({})
|
||||
} else {
|
||||
// Also clear any cache entries that don't match current tolerance (even if not explicitly clearing)
|
||||
setMatchesCache(prev => {
|
||||
const cleaned: Record<string, AutoMatchFaceItem[]> = {}
|
||||
// Only keep cache entries that match current tolerance
|
||||
Object.keys(prev).forEach(key => {
|
||||
const parts = key.split('_')
|
||||
if (parts.length >= 2) {
|
||||
const cachedTolerance = parseFloat(parts[parts.length - 1])
|
||||
if (!isNaN(cachedTolerance) && cachedTolerance === currentTolerance) {
|
||||
cleaned[key] = prev[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
return cleaned
|
||||
})
|
||||
}
|
||||
|
||||
// Load people list only (fast - no match calculations)
|
||||
const response = await facesApi.getAutoMatchPeople({
|
||||
filter_frontal_only: false
|
||||
filter_frontal_only: false,
|
||||
tolerance: currentTolerance
|
||||
})
|
||||
|
||||
if (response.people.length === 0) {
|
||||
@ -154,9 +198,9 @@ export default function AutoMatch() {
|
||||
setOriginalSelectedFaces({})
|
||||
setIsActive(true)
|
||||
|
||||
// Load matches for first person immediately
|
||||
// Load matches for first person immediately with current tolerance
|
||||
if (response.people.length > 0) {
|
||||
await loadPersonMatches(response.people[0].person_id)
|
||||
await loadPersonMatches(response.people[0].person_id, currentTolerance)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auto-match failed:', error)
|
||||
@ -261,7 +305,7 @@ export default function AutoMatch() {
|
||||
const matchesCacheRef = useRef(matchesCache)
|
||||
const isActiveRef = useRef(isActive)
|
||||
const hasNoResultsRef = useRef(hasNoResults)
|
||||
const toleranceRef = useRef(tolerance)
|
||||
// Note: toleranceRef is already declared above, don't redeclare
|
||||
|
||||
// Update refs whenever state changes
|
||||
useEffect(() => {
|
||||
@ -355,7 +399,15 @@ export default function AutoMatch() {
|
||||
if (initialLoadRef.current && restorationCompleteRef.current) {
|
||||
// Clear matches cache when tolerance changes (matches depend on tolerance)
|
||||
setMatchesCache({})
|
||||
loadAutoMatch()
|
||||
// Clear people list to force fresh load with new tolerance
|
||||
setPeople([])
|
||||
setFilteredPeople([])
|
||||
setSelectedFaces({})
|
||||
setOriginalSelectedFaces({})
|
||||
setCurrentIndex(0)
|
||||
setIsActive(false)
|
||||
// Reload with new tolerance
|
||||
loadAutoMatch(true) // Pass true to clear sessionStorage as well
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tolerance])
|
||||
@ -400,9 +452,10 @@ export default function AutoMatch() {
|
||||
setBusy(true)
|
||||
try {
|
||||
const response = await facesApi.autoMatch({
|
||||
tolerance,
|
||||
tolerance: RUN_AUTO_MATCH_TOLERANCE, // Use 0.5 for Run auto-match button (stricter)
|
||||
auto_accept: true,
|
||||
auto_accept_threshold: autoAcceptThreshold
|
||||
auto_accept_threshold: autoAcceptThreshold,
|
||||
use_distance_based_thresholds: true // Enable distance-based thresholds for Run auto-match button
|
||||
})
|
||||
|
||||
// Show summary if auto-accept was performed
|
||||
@ -457,7 +510,7 @@ export default function AutoMatch() {
|
||||
|
||||
const selectAll = () => {
|
||||
const newSelected: Record<number, boolean> = {}
|
||||
currentMatches.forEach(match => {
|
||||
currentMatches.forEach((match: AutoMatchFaceItem) => {
|
||||
newSelected[match.id] = true
|
||||
})
|
||||
setSelectedFaces(newSelected)
|
||||
@ -465,7 +518,7 @@ export default function AutoMatch() {
|
||||
|
||||
const clearAll = () => {
|
||||
const newSelected: Record<number, boolean> = {}
|
||||
currentMatches.forEach(match => {
|
||||
currentMatches.forEach((match: AutoMatchFaceItem) => {
|
||||
newSelected[match.id] = false
|
||||
})
|
||||
setSelectedFaces(newSelected)
|
||||
@ -477,14 +530,14 @@ export default function AutoMatch() {
|
||||
setSaving(true)
|
||||
try {
|
||||
const faceIds = currentMatches
|
||||
.filter(match => selectedFaces[match.id] === true)
|
||||
.map(match => match.id)
|
||||
.filter((match: AutoMatchFaceItem) => selectedFaces[match.id] === true)
|
||||
.map((match: AutoMatchFaceItem) => match.id)
|
||||
|
||||
await peopleApi.acceptMatches(currentPerson.person_id, faceIds)
|
||||
|
||||
// Update original selected faces to current state
|
||||
const newOriginal: Record<number, boolean> = {}
|
||||
currentMatches.forEach(match => {
|
||||
currentMatches.forEach((match: AutoMatchFaceItem) => {
|
||||
newOriginal[match.id] = selectedFaces[match.id] || false
|
||||
})
|
||||
setOriginalSelectedFaces(prev => ({ ...prev, ...newOriginal }))
|
||||
@ -498,33 +551,45 @@ export default function AutoMatch() {
|
||||
}
|
||||
}
|
||||
|
||||
// Load matches when current person changes (lazy loading)
|
||||
// Load matches when current person changes OR tolerance changes (lazy loading)
|
||||
useEffect(() => {
|
||||
if (currentPerson && restorationCompleteRef.current) {
|
||||
loadPersonMatches(currentPerson.person_id)
|
||||
// Always use ref tolerance (always current) to avoid stale matches
|
||||
const currentTolerance = toleranceRef.current
|
||||
|
||||
// Force reload when tolerance changes - clear cache for this person first
|
||||
const cacheKey = `${currentPerson.person_id}_${currentTolerance}`
|
||||
if (!matchesCache[cacheKey]) {
|
||||
// Only load if not already cached for current tolerance
|
||||
loadPersonMatches(currentPerson.person_id, currentTolerance)
|
||||
}
|
||||
|
||||
// Preload matches for next person in background
|
||||
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
|
||||
if (currentIndex + 1 < activePeople.length) {
|
||||
const nextPerson = activePeople[currentIndex + 1]
|
||||
loadPersonMatches(nextPerson.person_id)
|
||||
const nextCacheKey = `${nextPerson.person_id}_${currentTolerance}`
|
||||
if (!matchesCache[nextCacheKey]) {
|
||||
loadPersonMatches(nextPerson.person_id, currentTolerance)
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPerson?.person_id, currentIndex])
|
||||
}, [currentPerson?.person_id, currentIndex, tolerance])
|
||||
|
||||
// Restore selected faces when navigating to a different person
|
||||
useEffect(() => {
|
||||
if (currentPerson) {
|
||||
const matches = matchesCache[currentPerson.person_id] || []
|
||||
const cacheKey = `${currentPerson.person_id}_${tolerance}`
|
||||
const matches = matchesCache[cacheKey] || []
|
||||
const restored: Record<number, boolean> = {}
|
||||
matches.forEach(match => {
|
||||
matches.forEach((match: AutoMatchFaceItem) => {
|
||||
restored[match.id] = originalSelectedFaces[match.id] || false
|
||||
})
|
||||
setSelectedFaces(restored)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentIndex, filteredPeople.length, people.length, currentPerson?.person_id, matchesCache])
|
||||
}, [currentIndex, filteredPeople.length, people.length, currentPerson?.person_id, matchesCache, tolerance])
|
||||
|
||||
const goBack = () => {
|
||||
if (currentIndex > 0) {
|
||||
@ -695,7 +760,7 @@ export default function AutoMatch() {
|
||||
)}
|
||||
</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 faces with similarity higher than 70% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.
|
||||
<span className="font-medium">ℹ️ Auto-Match Criteria:</span> Only faces with similarity higher than 85% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -474,7 +474,7 @@ function AutoMatchPageHelp({ onBack }: { onBack: () => void }) {
|
||||
<li>Click "🚀 Run Auto-Match" button</li>
|
||||
<li>The system will automatically match unidentified faces to identified people based on:
|
||||
<ul className="list-disc list-inside ml-4 mt-1">
|
||||
<li>Similarity higher than 70%</li>
|
||||
<li>Similarity higher than 85%</li>
|
||||
<li>Picture quality higher than 50%</li>
|
||||
<li>Profile faces are excluded for better accuracy</li>
|
||||
</ul>
|
||||
|
||||
@ -348,7 +348,8 @@ export default function Identify() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await facesApi.getSimilar(faceId, includeExcludedFaces)
|
||||
// Enable debug mode to log encoding info to browser console
|
||||
const res = await facesApi.getSimilar(faceId, includeExcludedFaces, true)
|
||||
setSimilar(res.items || [])
|
||||
setSelectedSimilar({})
|
||||
} catch (error) {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useDeveloperMode } from '../context/DeveloperModeContext'
|
||||
|
||||
export default function Settings() {
|
||||
const { isDeveloperMode, setDeveloperMode } = useDeveloperMode()
|
||||
const { isDeveloperMode } = useDeveloperMode()
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -11,24 +11,23 @@ export default function Settings() {
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="developer-mode" className="text-sm font-medium text-gray-700">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Developer Mode
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Enable developer features. Additional features will be available when enabled.
|
||||
{isDeveloperMode
|
||||
? 'Developer mode is enabled (controlled by VITE_DEVELOPER_MODE environment variable)'
|
||||
: 'Developer mode is disabled. Set VITE_DEVELOPER_MODE=true in .env to enable.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="developer-mode"
|
||||
checked={isDeveloperMode}
|
||||
onChange={(e) => setDeveloperMode(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
<div className={`px-3 py-1 rounded text-sm font-medium ${
|
||||
isDeveloperMode
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{isDeveloperMode ? 'Enabled' : 'Disabled'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1
admin-frontend/src/vite-env.d.ts
vendored
1
admin-frontend/src/vite-env.d.ts
vendored
@ -2,6 +2,7 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string
|
||||
readonly VITE_DEVELOPER_MODE?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@ -90,9 +90,9 @@ def process_faces(request: ProcessFacesRequest) -> ProcessFacesResponse:
|
||||
job_timeout="1h", # Long timeout for face processing
|
||||
)
|
||||
|
||||
print(f"[Faces API] Enqueued face processing job: {job.id}")
|
||||
print(f"[Faces API] Job status: {job.get_status()}")
|
||||
print(f"[Faces API] Queue length: {len(queue)}")
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Enqueued face processing job: {job.id}, status: {job.get_status()}, queue length: {len(queue)}")
|
||||
|
||||
return ProcessFacesResponse(
|
||||
job_id=job.id,
|
||||
@ -197,12 +197,14 @@ def get_unidentified_faces(
|
||||
def get_similar_faces(
|
||||
face_id: int,
|
||||
include_excluded: bool = Query(False, description="Include excluded faces in results"),
|
||||
debug: bool = Query(False, description="Include debug information (encoding stats) in response"),
|
||||
db: Session = Depends(get_db)
|
||||
) -> SimilarFacesResponse:
|
||||
"""Return similar unidentified faces for a given face."""
|
||||
import logging
|
||||
import numpy as np
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"API: get_similar_faces called for face_id={face_id}, include_excluded={include_excluded}")
|
||||
logger.info(f"API: get_similar_faces called for face_id={face_id}, include_excluded={include_excluded}, debug={debug}")
|
||||
|
||||
# Validate face exists
|
||||
base = db.query(Face).filter(Face.id == face_id).first()
|
||||
@ -210,8 +212,23 @@ def get_similar_faces(
|
||||
logger.warning(f"API: Face {face_id} not found")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Face {face_id} not found")
|
||||
|
||||
# Load base encoding for debug info if needed
|
||||
base_debug_info = None
|
||||
if debug:
|
||||
from backend.services.face_service import load_face_encoding
|
||||
base_enc = load_face_encoding(base.encoding)
|
||||
base_debug_info = {
|
||||
"encoding_length": len(base_enc),
|
||||
"encoding_min": float(np.min(base_enc)),
|
||||
"encoding_max": float(np.max(base_enc)),
|
||||
"encoding_mean": float(np.mean(base_enc)),
|
||||
"encoding_std": float(np.std(base_enc)),
|
||||
"encoding_first_10": [float(x) for x in base_enc[:10].tolist()],
|
||||
}
|
||||
|
||||
logger.info(f"API: Calling find_similar_faces for face_id={face_id}, include_excluded={include_excluded}")
|
||||
results = find_similar_faces(db, face_id, include_excluded=include_excluded)
|
||||
# Use 0.6 tolerance for Identify People (more lenient for manual review)
|
||||
results = find_similar_faces(db, face_id, tolerance=0.6, include_excluded=include_excluded, debug=debug)
|
||||
logger.info(f"API: find_similar_faces returned {len(results)} results")
|
||||
|
||||
items = [
|
||||
@ -223,12 +240,13 @@ def get_similar_faces(
|
||||
quality_score=float(f.quality_score),
|
||||
filename=f.photo.filename if f.photo else "unknown",
|
||||
pose_mode=getattr(f, "pose_mode", None) or "frontal",
|
||||
debug_info=debug_info if debug else None,
|
||||
)
|
||||
for f, distance, confidence_pct in results
|
||||
for f, distance, confidence_pct, debug_info in results
|
||||
]
|
||||
|
||||
logger.info(f"API: Returning {len(items)} items for face_id={face_id}")
|
||||
return SimilarFacesResponse(base_face_id=face_id, items=items)
|
||||
return SimilarFacesResponse(base_face_id=face_id, items=items, debug_info=base_debug_info)
|
||||
|
||||
|
||||
@router.post("/batch-similarity", response_model=BatchSimilarityResponse)
|
||||
@ -246,10 +264,12 @@ def get_batch_similarities(
|
||||
logger.info(f"API: batch_similarity called for {len(request.face_ids)} faces")
|
||||
|
||||
# Calculate similarities between all pairs
|
||||
# Use 0.6 tolerance for Identify People (more lenient for manual review)
|
||||
pairs = calculate_batch_similarities(
|
||||
db,
|
||||
request.face_ids,
|
||||
min_confidence=request.min_confidence,
|
||||
tolerance=0.6,
|
||||
)
|
||||
|
||||
# Convert to response format
|
||||
@ -435,7 +455,9 @@ def get_face_crop(face_id: int, db: Session = Depends(get_db)) -> Response:
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[Faces API] get_face_crop error for face {face_id}: {e}")
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"get_face_crop error for face {face_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to extract face crop: {str(e)}",
|
||||
@ -607,10 +629,12 @@ def auto_match_faces(
|
||||
|
||||
# Find matches for all identified people
|
||||
# Filter by frontal reference faces if auto_accept enabled
|
||||
# Use distance-based thresholds only when auto_accept is enabled (Run auto-match button)
|
||||
matches_data = find_auto_match_matches(
|
||||
db,
|
||||
tolerance=request.tolerance,
|
||||
filter_frontal_only=request.auto_accept
|
||||
filter_frontal_only=request.auto_accept,
|
||||
use_distance_based_thresholds=request.use_distance_based_thresholds or request.auto_accept
|
||||
)
|
||||
|
||||
# If auto_accept enabled, process matches automatically
|
||||
@ -644,7 +668,9 @@ def auto_match_faces(
|
||||
)
|
||||
auto_accepted_faces += identified_count
|
||||
except Exception as e:
|
||||
print(f"Error auto-accepting matches for person {person_id}: {e}")
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error auto-accepting matches for person {person_id}: {e}")
|
||||
|
||||
if not matches_data:
|
||||
return AutoMatchResponse(
|
||||
@ -747,7 +773,7 @@ def auto_match_faces(
|
||||
@router.get("/auto-match/people", response_model=AutoMatchPeopleResponse)
|
||||
def get_auto_match_people(
|
||||
filter_frontal_only: bool = Query(False, description="Only include frontal/tilted reference faces"),
|
||||
tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold"),
|
||||
tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold (default 0.6 for regular auto-match)"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AutoMatchPeopleResponse:
|
||||
"""Get list of people for auto-match (without matches) - fast initial load.
|
||||
@ -810,7 +836,7 @@ def get_auto_match_people(
|
||||
@router.get("/auto-match/people/{person_id}/matches", response_model=AutoMatchPersonMatchesResponse)
|
||||
def get_auto_match_person_matches(
|
||||
person_id: int,
|
||||
tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold"),
|
||||
tolerance: float = Query(0.6, ge=0.0, le=1.0, description="Tolerance threshold (default 0.6 for regular auto-match)"),
|
||||
filter_frontal_only: bool = Query(False, description="Only return frontal/tilted faces"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AutoMatchPersonMatchesResponse:
|
||||
|
||||
@ -22,8 +22,13 @@ MIN_FACE_SIZE = 40
|
||||
MAX_FACE_SIZE = 1500
|
||||
|
||||
# Matching tolerance and calibration options
|
||||
DEFAULT_FACE_TOLERANCE = 0.6
|
||||
DEFAULT_FACE_TOLERANCE = 0.5 # Lowered from 0.6 for stricter matching
|
||||
USE_CALIBRATED_CONFIDENCE = True
|
||||
CONFIDENCE_CALIBRATION_METHOD = "empirical" # "empirical", "linear", or "sigmoid"
|
||||
|
||||
# Auto-match face size filtering
|
||||
# Minimum face size as percentage of image area (0.5% = 0.005)
|
||||
# Faces smaller than this are excluded from auto-match to avoid generic encodings
|
||||
MIN_AUTO_MATCH_FACE_SIZE_RATIO = 0.005 # 0.5% of image area
|
||||
|
||||
|
||||
|
||||
@ -89,6 +89,7 @@ class SimilarFaceItem(BaseModel):
|
||||
quality_score: float
|
||||
filename: str
|
||||
pose_mode: Optional[str] = Field("frontal", description="Pose classification (frontal, profile_left, etc.)")
|
||||
debug_info: Optional[dict] = Field(None, description="Debug information (encoding stats) when debug mode is enabled")
|
||||
|
||||
|
||||
class SimilarFacesResponse(BaseModel):
|
||||
@ -98,6 +99,7 @@ class SimilarFacesResponse(BaseModel):
|
||||
|
||||
base_face_id: int
|
||||
items: list[SimilarFaceItem]
|
||||
debug_info: Optional[dict] = Field(None, description="Debug information (base face encoding stats) when debug mode is enabled")
|
||||
|
||||
|
||||
class BatchSimilarityRequest(BaseModel):
|
||||
@ -212,9 +214,10 @@ class AutoMatchRequest(BaseModel):
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
tolerance: float = Field(0.6, ge=0.0, le=1.0, description="Tolerance threshold (lower = stricter matching)")
|
||||
tolerance: float = Field(0.5, ge=0.0, le=1.0, description="Tolerance threshold (lower = stricter matching)")
|
||||
auto_accept: bool = Field(False, description="Enable automatic acceptance of matching faces")
|
||||
auto_accept_threshold: float = Field(70.0, ge=0.0, le=100.0, description="Similarity threshold for auto-acceptance (0-100%)")
|
||||
use_distance_based_thresholds: bool = Field(False, description="Use distance-based confidence thresholds (stricter for borderline distances)")
|
||||
|
||||
|
||||
class AutoMatchFaceItem(BaseModel):
|
||||
|
||||
@ -6,6 +6,7 @@ import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Tuple, List, Dict
|
||||
from datetime import date
|
||||
|
||||
@ -34,6 +35,7 @@ from backend.config import (
|
||||
MAX_FACE_SIZE,
|
||||
MIN_FACE_CONFIDENCE,
|
||||
MIN_FACE_SIZE,
|
||||
MIN_AUTO_MATCH_FACE_SIZE_RATIO,
|
||||
USE_CALIBRATED_CONFIDENCE,
|
||||
)
|
||||
from src.utils.exif_utils import EXIFOrientationHandler
|
||||
@ -526,7 +528,9 @@ def process_photo_faces(
|
||||
_print_with_stderr(f"[FaceService] Debug - face_confidence value: {face_confidence}")
|
||||
_print_with_stderr(f"[FaceService] Debug - result['face_confidence'] exists: {'face_confidence' in result}")
|
||||
|
||||
encoding = np.array(result['embedding'])
|
||||
# DeepFace returns float32 embeddings, but we store as float64 for consistency
|
||||
# Convert to float64 explicitly to match how we read them back
|
||||
encoding = np.array(result['embedding'], dtype=np.float64)
|
||||
|
||||
# Convert to location format (JSON string like desktop version)
|
||||
location = {
|
||||
@ -627,17 +631,21 @@ def process_photo_faces(
|
||||
if face_width is None:
|
||||
face_width = matched_pose_face.get('face_width')
|
||||
pose_mode = PoseDetector.classify_pose_mode(
|
||||
yaw_angle, pitch_angle, roll_angle, face_width
|
||||
yaw_angle, pitch_angle, roll_angle, face_width, landmarks
|
||||
)
|
||||
else:
|
||||
# Can't calculate yaw, use face_width
|
||||
# Can't calculate yaw, use face_width and landmarks for single-eye detection
|
||||
pose_mode = PoseDetector.classify_pose_mode(
|
||||
yaw_angle, pitch_angle, roll_angle, face_width
|
||||
yaw_angle, pitch_angle, roll_angle, face_width, landmarks
|
||||
)
|
||||
elif face_width is not None:
|
||||
# No landmarks available, use face_width only
|
||||
# Try to get landmarks from matched_pose_face if available
|
||||
landmarks_for_classification = None
|
||||
if matched_pose_face:
|
||||
landmarks_for_classification = matched_pose_face.get('landmarks')
|
||||
pose_mode = PoseDetector.classify_pose_mode(
|
||||
yaw_angle, pitch_angle, roll_angle, face_width
|
||||
yaw_angle, pitch_angle, roll_angle, face_width, landmarks_for_classification
|
||||
)
|
||||
else:
|
||||
# No landmarks and no face_width, use default
|
||||
@ -1669,6 +1677,47 @@ def list_unidentified_faces(
|
||||
return items, total
|
||||
|
||||
|
||||
def load_face_encoding(encoding_bytes: bytes) -> np.ndarray:
|
||||
"""Load face encoding from bytes, auto-detecting dtype (float32 or float64).
|
||||
|
||||
ArcFace encodings are 512 dimensions:
|
||||
- float32: 512 * 4 bytes = 2048 bytes
|
||||
- float64: 512 * 8 bytes = 4096 bytes
|
||||
|
||||
Args:
|
||||
encoding_bytes: Raw encoding bytes from database
|
||||
|
||||
Returns:
|
||||
numpy array of encoding (always float64 for consistency)
|
||||
"""
|
||||
encoding_size = len(encoding_bytes)
|
||||
|
||||
# Auto-detect dtype based on size
|
||||
if encoding_size == 2048:
|
||||
# float32 encoding (old format)
|
||||
encoding = np.frombuffer(encoding_bytes, dtype=np.float32)
|
||||
# Convert to float64 for consistency
|
||||
return encoding.astype(np.float64)
|
||||
elif encoding_size == 4096:
|
||||
# float64 encoding (new format)
|
||||
return np.frombuffer(encoding_bytes, dtype=np.float64)
|
||||
else:
|
||||
# Unexpected size - try float64 first, fallback to float32
|
||||
# This handles edge cases or future changes
|
||||
try:
|
||||
encoding = np.frombuffer(encoding_bytes, dtype=np.float64)
|
||||
if len(encoding) == 512:
|
||||
return encoding
|
||||
except:
|
||||
pass
|
||||
# Fallback to float32
|
||||
encoding = np.frombuffer(encoding_bytes, dtype=np.float32)
|
||||
if len(encoding) == 512:
|
||||
return encoding.astype(np.float64)
|
||||
else:
|
||||
raise ValueError(f"Unexpected encoding size: {encoding_size} bytes (expected 2048 or 4096)")
|
||||
|
||||
|
||||
def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> float:
|
||||
"""Calculate cosine distance between two face encodings, matching desktop exactly.
|
||||
|
||||
@ -1701,7 +1750,6 @@ def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> f
|
||||
# Normalize encodings (matching desktop exactly)
|
||||
norm1 = np.linalg.norm(enc1)
|
||||
norm2 = np.linalg.norm(enc2)
|
||||
|
||||
if norm1 == 0 or norm2 == 0:
|
||||
return 2.0
|
||||
|
||||
@ -1724,6 +1772,32 @@ def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> f
|
||||
return 2.0 # Maximum distance on error
|
||||
|
||||
|
||||
def get_distance_based_min_confidence(distance: float) -> float:
|
||||
"""Get minimum confidence threshold based on distance.
|
||||
|
||||
For borderline distances, require higher confidence to reduce false positives.
|
||||
This is used only when use_distance_based_thresholds=True (e.g., in auto-match).
|
||||
|
||||
Args:
|
||||
distance: Cosine distance between faces (0 = identical, 2 = opposite)
|
||||
|
||||
Returns:
|
||||
Minimum confidence percentage (0-100) required for this distance
|
||||
"""
|
||||
if distance <= 0.15:
|
||||
# Very close matches: standard threshold
|
||||
return 50.0
|
||||
elif distance <= 0.20:
|
||||
# Borderline matches: require higher confidence
|
||||
return 70.0
|
||||
elif distance <= 0.25:
|
||||
# Near threshold: require very high confidence
|
||||
return 85.0
|
||||
else:
|
||||
# Far matches: require extremely high confidence
|
||||
return 95.0
|
||||
|
||||
|
||||
def calculate_adaptive_tolerance(base_tolerance: float, face_quality: float) -> float:
|
||||
"""Calculate adaptive tolerance based on face quality, matching desktop exactly."""
|
||||
# Start with base tolerance
|
||||
@ -1734,7 +1808,10 @@ def calculate_adaptive_tolerance(base_tolerance: float, face_quality: float) ->
|
||||
tolerance *= quality_factor
|
||||
|
||||
# Ensure tolerance stays within reasonable bounds for DeepFace
|
||||
return max(0.2, min(0.6, tolerance))
|
||||
# Allow tolerance down to 0.0 (user can set very strict matching)
|
||||
# Allow tolerance up to 1.0 (matching API validation range)
|
||||
# The quality factor can increase tolerance up to 1.1x, so cap at 1.0 to stay within API limits
|
||||
return max(0.0, min(1.0, tolerance))
|
||||
|
||||
|
||||
def calibrate_confidence(distance: float, tolerance: float = None) -> float:
|
||||
@ -1768,27 +1845,34 @@ def calibrate_confidence(distance: float, tolerance: float = None) -> float:
|
||||
else: # "empirical" - default method (matching desktop exactly)
|
||||
# Empirical calibration parameters for DeepFace ArcFace model
|
||||
# These are derived from analysis of distance distributions for matching/non-matching pairs
|
||||
# Moderate calibration: stricter than original but not too strict
|
||||
|
||||
# For very close distances (< 0.12): very high confidence
|
||||
if distance <= 0.12:
|
||||
# Very close matches: exponential decay from 100%
|
||||
confidence = 100 * np.exp(-distance * 2.8)
|
||||
return min(100, max(92, confidence))
|
||||
|
||||
# For distances well below threshold: high confidence
|
||||
if distance <= tolerance * 0.5:
|
||||
# Very close matches: exponential decay from 100%
|
||||
confidence = 100 * np.exp(-distance * 2.5)
|
||||
return min(100, max(95, confidence))
|
||||
elif distance <= tolerance * 0.5:
|
||||
# Close matches: exponential decay
|
||||
confidence = 100 * np.exp(-distance * 2.6)
|
||||
return min(92, max(82, confidence))
|
||||
|
||||
# For distances near threshold: moderate confidence
|
||||
elif distance <= tolerance:
|
||||
# Near-threshold matches: sigmoid-like curve
|
||||
# Maps distance to probability based on empirical data
|
||||
normalized_distance = (distance - tolerance * 0.5) / (tolerance * 0.5)
|
||||
confidence = 95 - (normalized_distance * 40) # 95% to 55% range
|
||||
return max(55, min(95, confidence))
|
||||
confidence = 82 - (normalized_distance * 32) # 82% to 50% range
|
||||
return max(50, min(82, confidence))
|
||||
|
||||
# For distances above threshold: low confidence
|
||||
elif distance <= tolerance * 1.5:
|
||||
# Above threshold but not too far: rapid decay
|
||||
normalized_distance = (distance - tolerance) / (tolerance * 0.5)
|
||||
confidence = 55 - (normalized_distance * 35) # 55% to 20% range
|
||||
return max(20, min(55, confidence))
|
||||
confidence = 50 - (normalized_distance * 30) # 50% to 20% range
|
||||
return max(20, min(50, confidence))
|
||||
|
||||
# For very large distances: very low confidence
|
||||
else:
|
||||
@ -1797,6 +1881,46 @@ def calibrate_confidence(distance: float, tolerance: float = None) -> float:
|
||||
return max(1, min(20, confidence))
|
||||
|
||||
|
||||
def _calculate_face_size_ratio(face: Face, photo: Photo) -> float:
|
||||
"""Calculate face size as ratio of image area.
|
||||
|
||||
Args:
|
||||
face: Face model with location
|
||||
photo: Photo model (needed for path to load image dimensions)
|
||||
|
||||
Returns:
|
||||
Face size ratio (0.0-1.0), or 0.0 if cannot calculate
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
from PIL import Image
|
||||
|
||||
# Parse location
|
||||
location = json.loads(face.location) if isinstance(face.location, str) else face.location
|
||||
face_w = location.get('w', 0)
|
||||
face_h = location.get('h', 0)
|
||||
face_area = face_w * face_h
|
||||
|
||||
if face_area == 0:
|
||||
return 0.0
|
||||
|
||||
# Load image to get dimensions
|
||||
photo_path = Path(photo.path)
|
||||
if not photo_path.exists():
|
||||
return 0.0
|
||||
|
||||
img = Image.open(photo_path)
|
||||
img_width, img_height = img.size
|
||||
image_area = img_width * img_height
|
||||
|
||||
if image_area == 0:
|
||||
return 0.0
|
||||
|
||||
return face_area / image_area
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _is_acceptable_pose_for_auto_match(pose_mode: str) -> bool:
|
||||
"""Check if pose_mode is acceptable for auto-match (frontal or tilted, but not profile).
|
||||
|
||||
@ -1836,10 +1960,14 @@ def find_similar_faces(
|
||||
db: Session,
|
||||
face_id: int,
|
||||
limit: int = 20000, # Very high default limit - effectively unlimited
|
||||
tolerance: float = 0.6, # DEFAULT_FACE_TOLERANCE from desktop
|
||||
tolerance: float = 0.5, # DEFAULT_FACE_TOLERANCE
|
||||
filter_frontal_only: bool = False, # New: Only return frontal or tilted faces (not profile)
|
||||
include_excluded: bool = False, # Include excluded faces in results
|
||||
) -> List[Tuple[Face, float, float]]: # Returns (face, distance, confidence_pct)
|
||||
filter_small_faces: bool = False, # Filter out small faces (for auto-match)
|
||||
min_face_size_ratio: float = 0.005, # Minimum face size ratio (0.5% of image)
|
||||
debug: bool = False, # Include debug information (encoding stats)
|
||||
use_distance_based_thresholds: bool = False, # Use distance-based confidence thresholds (for auto-match)
|
||||
) -> List[Tuple[Face, float, float, dict | None]]: # Returns (face, distance, confidence_pct, debug_info)
|
||||
"""Find similar faces matching desktop logic exactly.
|
||||
|
||||
Desktop flow:
|
||||
@ -1866,32 +1994,48 @@ def find_similar_faces(
|
||||
base: Face = db.query(Face).filter(Face.id == face_id).first()
|
||||
if not base:
|
||||
return []
|
||||
|
||||
|
||||
# Load base encoding - desktop uses float64, ArcFace has 512 dimensions
|
||||
# Stored as float64: 512 * 8 bytes = 4096 bytes
|
||||
base_enc = np.frombuffer(base.encoding, dtype=np.float64)
|
||||
# Load base encoding - auto-detect dtype (supports both float32 and float64)
|
||||
base_enc = load_face_encoding(base.encoding)
|
||||
base_enc = base_enc.copy() # Make a copy to avoid buffer issues
|
||||
|
||||
# Desktop uses 0.5 as default quality for target face (hardcoded, matching desktop exactly)
|
||||
# Desktop: target_quality = 0.5 # Default quality for target face
|
||||
base_quality = 0.5
|
||||
# Use actual quality score of the reference face, defaulting to 0.5 if not set
|
||||
# This ensures adaptive tolerance is calculated correctly based on the actual face quality
|
||||
base_quality = float(base.quality_score) if base.quality_score is not None else 0.5
|
||||
|
||||
# Desktop: get ALL faces from database (matching get_all_face_encodings)
|
||||
# Desktop find_similar_faces gets ALL faces, doesn't filter by photo_id
|
||||
# Get all faces except itself, with photo loaded
|
||||
# However, for auto-match, we should exclude faces from the same photo to avoid
|
||||
# duplicate detections of the same face (same encoding stored multiple times)
|
||||
# Get all faces except itself and faces from the same photo, with photo loaded
|
||||
all_faces: List[Face] = (
|
||||
db.query(Face)
|
||||
.options(joinedload(Face.photo))
|
||||
.filter(Face.id != face_id)
|
||||
.filter(Face.photo_id != base.photo_id) # Exclude faces from same photo
|
||||
.all()
|
||||
)
|
||||
|
||||
matches: List[Tuple[Face, float, float]] = []
|
||||
|
||||
for f in all_faces:
|
||||
# Load other encoding - desktop uses float64, ArcFace has 512 dimensions
|
||||
other_enc = np.frombuffer(f.encoding, dtype=np.float64)
|
||||
# Load other encoding - auto-detect dtype (supports both float32 and float64)
|
||||
other_enc = load_face_encoding(f.encoding)
|
||||
other_enc = other_enc.copy() # Make a copy to avoid buffer issues
|
||||
|
||||
# Calculate debug info if requested
|
||||
debug_info = None
|
||||
if debug:
|
||||
debug_info = {
|
||||
"encoding_length": len(other_enc),
|
||||
"encoding_min": float(np.min(other_enc)),
|
||||
"encoding_max": float(np.max(other_enc)),
|
||||
"encoding_mean": float(np.mean(other_enc)),
|
||||
"encoding_std": float(np.std(other_enc)),
|
||||
"encoding_first_10": [float(x) for x in other_enc[:10].tolist()],
|
||||
}
|
||||
|
||||
other_quality = float(f.quality_score) if f.quality_score is not None else 0.5
|
||||
|
||||
# Calculate adaptive tolerance based on both face qualities (matching desktop exactly)
|
||||
@ -1906,14 +2050,22 @@ def find_similar_faces(
|
||||
# Get photo info (desktop does this in find_similar_faces)
|
||||
if f.photo:
|
||||
# Calculate calibrated confidence (matching desktop _get_filtered_similar_faces)
|
||||
confidence_pct = calibrate_confidence(distance, DEFAULT_FACE_TOLERANCE)
|
||||
# Use the actual tolerance parameter, not the default
|
||||
confidence_pct = calibrate_confidence(distance, tolerance)
|
||||
|
||||
# Desktop _get_filtered_similar_faces filters by:
|
||||
# 1. person_id is None (unidentified)
|
||||
# 2. confidence >= 40%
|
||||
# 2. confidence >= 50% (increased from 40% to reduce false matches)
|
||||
# OR confidence >= distance-based threshold if use_distance_based_thresholds=True
|
||||
is_unidentified = f.person_id is None
|
||||
|
||||
if is_unidentified and confidence_pct >= 40:
|
||||
# Calculate minimum confidence threshold
|
||||
if use_distance_based_thresholds:
|
||||
min_confidence = get_distance_based_min_confidence(distance)
|
||||
else:
|
||||
min_confidence = 50.0 # Standard threshold
|
||||
|
||||
if is_unidentified and confidence_pct >= min_confidence:
|
||||
# Filter by excluded status if not including excluded faces
|
||||
if not include_excluded and getattr(f, "excluded", False):
|
||||
continue
|
||||
@ -1922,9 +2074,16 @@ def find_similar_faces(
|
||||
if filter_frontal_only and not _is_acceptable_pose_for_auto_match(f.pose_mode):
|
||||
continue
|
||||
|
||||
# Filter by face size if requested (for auto-match)
|
||||
if filter_small_faces:
|
||||
if f.photo:
|
||||
face_size_ratio = _calculate_face_size_ratio(f, f.photo)
|
||||
if face_size_ratio < min_face_size_ratio:
|
||||
continue # Skip small faces
|
||||
|
||||
# Return calibrated confidence percentage (matching desktop)
|
||||
# Desktop displays confidence_pct directly from _get_calibrated_confidence
|
||||
matches.append((f, distance, confidence_pct))
|
||||
matches.append((f, distance, confidence_pct, debug_info))
|
||||
|
||||
# Sort by distance (lower is better) - matching desktop
|
||||
matches.sort(key=lambda x: x[1])
|
||||
@ -1937,6 +2096,7 @@ def calculate_batch_similarities(
|
||||
db: Session,
|
||||
face_ids: list[int],
|
||||
min_confidence: float = 60.0,
|
||||
tolerance: float = 0.6, # Use 0.6 for Identify People (more lenient for manual review)
|
||||
) -> list[tuple[int, int, float, float]]:
|
||||
"""Calculate similarities between N faces and all M faces in database.
|
||||
|
||||
@ -1986,7 +2146,7 @@ def calculate_batch_similarities(
|
||||
|
||||
for face in all_faces:
|
||||
# Pre-load encoding as numpy array
|
||||
all_encodings[face.id] = np.frombuffer(face.encoding, dtype=np.float64)
|
||||
all_encodings[face.id] = load_face_encoding(face.encoding)
|
||||
# Pre-cache quality score
|
||||
all_qualities[face.id] = float(face.quality_score) if face.quality_score is not None else 0.5
|
||||
|
||||
@ -2082,8 +2242,9 @@ def calculate_batch_similarities(
|
||||
|
||||
def find_auto_match_matches(
|
||||
db: Session,
|
||||
tolerance: float = 0.6,
|
||||
tolerance: float = 0.5,
|
||||
filter_frontal_only: bool = False,
|
||||
use_distance_based_thresholds: bool = False, # Use distance-based confidence thresholds
|
||||
) -> List[Tuple[int, int, Face, List[Tuple[Face, float, float]]]]:
|
||||
"""Find auto-match matches for all identified people, matching desktop logic exactly.
|
||||
|
||||
@ -2176,16 +2337,30 @@ def find_auto_match_matches(
|
||||
for person_id, reference_face, person_name in person_faces_list:
|
||||
reference_face_id = reference_face.id
|
||||
|
||||
# TEMPORARILY DISABLED: Check if reference face is too small (exclude from auto-match)
|
||||
# reference_photo = db.query(Photo).filter(Photo.id == reference_face.photo_id).first()
|
||||
# if reference_photo:
|
||||
# ref_size_ratio = _calculate_face_size_ratio(reference_face, reference_photo)
|
||||
# if ref_size_ratio < MIN_AUTO_MATCH_FACE_SIZE_RATIO:
|
||||
# # Skip this person - reference face is too small
|
||||
# continue
|
||||
|
||||
# Use find_similar_faces which matches desktop _get_filtered_similar_faces logic
|
||||
# Desktop: similar_faces = self.face_processor._get_filtered_similar_faces(
|
||||
# reference_face_id, tolerance, include_same_photo=False, face_status=None)
|
||||
# This filters by: person_id is None (unidentified), confidence >= 40%, sorts by distance
|
||||
# This filters by: person_id is None (unidentified), confidence >= 50% (increased from 40%), sorts by distance
|
||||
# Auto-match always excludes excluded faces
|
||||
similar_faces = find_similar_faces(
|
||||
# TEMPORARILY DISABLED: filter_small_faces=True to exclude small match faces
|
||||
similar_faces_with_debug = find_similar_faces(
|
||||
db, reference_face_id, tolerance=tolerance,
|
||||
filter_frontal_only=filter_frontal_only,
|
||||
include_excluded=False # Auto-match always excludes excluded faces
|
||||
include_excluded=False, # Auto-match always excludes excluded faces
|
||||
filter_small_faces=False, # TEMPORARILY DISABLED: Exclude small faces from auto-match
|
||||
min_face_size_ratio=MIN_AUTO_MATCH_FACE_SIZE_RATIO,
|
||||
use_distance_based_thresholds=use_distance_based_thresholds # Use distance-based thresholds if enabled
|
||||
)
|
||||
# Strip debug_info for internal use
|
||||
similar_faces = [(f, dist, conf) for f, dist, conf, _ in similar_faces_with_debug]
|
||||
|
||||
if similar_faces:
|
||||
results.append((person_id, reference_face_id, reference_face, similar_faces))
|
||||
@ -2196,7 +2371,7 @@ def find_auto_match_matches(
|
||||
def get_auto_match_people_list(
|
||||
db: Session,
|
||||
filter_frontal_only: bool = False,
|
||||
tolerance: float = 0.6,
|
||||
tolerance: float = 0.5,
|
||||
) -> List[Tuple[int, Face, str, int]]:
|
||||
"""Get list of people for auto-match (without matches) - fast initial load.
|
||||
|
||||
@ -2300,7 +2475,7 @@ def get_auto_match_people_list(
|
||||
def get_auto_match_person_matches(
|
||||
db: Session,
|
||||
person_id: int,
|
||||
tolerance: float = 0.6,
|
||||
tolerance: float = 0.5,
|
||||
filter_frontal_only: bool = False,
|
||||
) -> List[Tuple[Face, float, float]]:
|
||||
"""Get matches for a specific person - for lazy loading.
|
||||
@ -2329,11 +2504,13 @@ def get_auto_match_person_matches(
|
||||
|
||||
# Find similar faces using existing function
|
||||
# Auto-match always excludes excluded faces
|
||||
similar_faces = find_similar_faces(
|
||||
similar_faces_with_debug = find_similar_faces(
|
||||
db, reference_face.id, tolerance=tolerance,
|
||||
filter_frontal_only=filter_frontal_only,
|
||||
include_excluded=False # Auto-match always excludes excluded faces
|
||||
)
|
||||
# Strip debug_info for internal use
|
||||
similar_faces = [(f, dist, conf) for f, dist, conf, _ in similar_faces_with_debug]
|
||||
|
||||
return similar_faces
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ class PoseDetector:
|
||||
"""Detect face pose (yaw, pitch, roll) using RetinaFace landmarks"""
|
||||
|
||||
# Thresholds for pose detection (in degrees)
|
||||
PROFILE_YAW_THRESHOLD = 30.0 # Faces with |yaw| >= 30° are considered profile
|
||||
PROFILE_YAW_THRESHOLD = 15.0 # Faces with |yaw| >= 15° are considered profile
|
||||
EXTREME_YAW_THRESHOLD = 60.0 # Faces with |yaw| >= 60° are extreme profile
|
||||
|
||||
PITCH_THRESHOLD = 20.0 # Faces with |pitch| >= 20° are looking up/down
|
||||
@ -39,7 +39,7 @@ class PoseDetector:
|
||||
|
||||
Args:
|
||||
yaw_threshold: Yaw angle threshold for profile detection (degrees)
|
||||
Default: 30.0
|
||||
Default: 15.0
|
||||
pitch_threshold: Pitch angle threshold for up/down detection (degrees)
|
||||
Default: 20.0
|
||||
roll_threshold: Roll angle threshold for tilt detection (degrees)
|
||||
@ -53,17 +53,24 @@ class PoseDetector:
|
||||
self.roll_threshold = roll_threshold or self.ROLL_THRESHOLD
|
||||
|
||||
@staticmethod
|
||||
def detect_faces_with_landmarks(img_path: str) -> Dict:
|
||||
def detect_faces_with_landmarks(img_path: str, filter_estimated_landmarks: bool = False) -> Dict:
|
||||
"""Detect faces using RetinaFace directly
|
||||
|
||||
Args:
|
||||
img_path: Path to image file
|
||||
filter_estimated_landmarks: If True, remove landmarks that appear to be estimated
|
||||
(e.g., hidden eye in profile views) rather than actually visible.
|
||||
Uses heuristics: if eyes are very close together (< 20px) and
|
||||
yaw calculation suggests extreme profile, mark hidden eye as None.
|
||||
|
||||
Returns:
|
||||
Dictionary with face keys and landmark data:
|
||||
{
|
||||
'face_1': {
|
||||
'facial_area': {'x': x, 'y': y, 'w': w, 'h': h},
|
||||
'landmarks': {
|
||||
'left_eye': (x, y),
|
||||
'right_eye': (x, y),
|
||||
'left_eye': (x, y) or None,
|
||||
'right_eye': (x, y) or None,
|
||||
'nose': (x, y),
|
||||
'left_mouth': (x, y),
|
||||
'right_mouth': (x, y)
|
||||
@ -76,6 +83,42 @@ class PoseDetector:
|
||||
return {}
|
||||
|
||||
faces = RetinaFace.detect_faces(img_path)
|
||||
|
||||
# Post-process to filter estimated landmarks if requested
|
||||
if filter_estimated_landmarks:
|
||||
for face_key, face_data in faces.items():
|
||||
landmarks = face_data.get('landmarks', {})
|
||||
if not landmarks:
|
||||
continue
|
||||
|
||||
left_eye = landmarks.get('left_eye')
|
||||
right_eye = landmarks.get('right_eye')
|
||||
nose = landmarks.get('nose')
|
||||
|
||||
# Check if both eyes are present and very close together (profile view)
|
||||
if left_eye and right_eye and nose:
|
||||
face_width = abs(right_eye[0] - left_eye[0])
|
||||
|
||||
# If eyes are very close (< 20px), likely a profile view
|
||||
if face_width < 20.0:
|
||||
# Calculate which eye is likely hidden based on nose position
|
||||
eye_mid_x = (left_eye[0] + right_eye[0]) / 2
|
||||
nose_x = nose[0]
|
||||
|
||||
# If nose is closer to left eye, right eye is likely hidden (face turned left)
|
||||
# If nose is closer to right eye, left eye is likely hidden (face turned right)
|
||||
dist_to_left = abs(nose_x - left_eye[0])
|
||||
dist_to_right = abs(nose_x - right_eye[0])
|
||||
|
||||
if dist_to_left < dist_to_right:
|
||||
# Nose closer to left eye = face turned left = right eye hidden
|
||||
landmarks['right_eye'] = None
|
||||
else:
|
||||
# Nose closer to right eye = face turned right = left eye hidden
|
||||
landmarks['left_eye'] = None
|
||||
|
||||
face_data['landmarks'] = landmarks
|
||||
|
||||
return faces
|
||||
|
||||
@staticmethod
|
||||
@ -260,7 +303,8 @@ class PoseDetector:
|
||||
def classify_pose_mode(yaw: Optional[float],
|
||||
pitch: Optional[float],
|
||||
roll: Optional[float],
|
||||
face_width: Optional[float] = None) -> str:
|
||||
face_width: Optional[float] = None,
|
||||
landmarks: Optional[Dict] = None) -> str:
|
||||
"""Classify face pose mode from all three angles and optionally face width
|
||||
|
||||
Args:
|
||||
@ -268,8 +312,10 @@ class PoseDetector:
|
||||
pitch: Pitch angle in degrees
|
||||
roll: Roll angle in degrees
|
||||
face_width: Face width in pixels (eye distance). Used as indicator for profile detection.
|
||||
If face_width < 25px, indicates profile view. When yaw is available but < 30°,
|
||||
If face_width < 25px, indicates profile view. When yaw is available but < 15°,
|
||||
face_width can override yaw if it suggests profile (face_width < 25px).
|
||||
landmarks: Optional facial landmarks dictionary. Used to detect single-eye visibility
|
||||
for extreme profile views where only one eye is visible.
|
||||
|
||||
Returns:
|
||||
Pose mode classification string:
|
||||
@ -279,6 +325,28 @@ class PoseDetector:
|
||||
- 'tilted_left', 'tilted_right': roll variations
|
||||
- Combined modes: e.g., 'profile_left_looking_up'
|
||||
"""
|
||||
# Check for single-eye visibility to infer profile direction
|
||||
# This handles extreme profile views where only one eye is visible
|
||||
if landmarks:
|
||||
left_eye = landmarks.get('left_eye')
|
||||
right_eye = landmarks.get('right_eye')
|
||||
|
||||
# Only right eye visible -> face turned left -> profile_left
|
||||
if left_eye is None and right_eye is not None:
|
||||
# Infer profile_left when only right eye is visible
|
||||
inferred_profile = "profile_left"
|
||||
# Only left eye visible -> face turned right -> profile_right
|
||||
elif left_eye is not None and right_eye is None:
|
||||
# Infer profile_right when only left eye is visible
|
||||
inferred_profile = "profile_right"
|
||||
# No eyes visible -> extreme profile, default to profile_left
|
||||
elif left_eye is None and right_eye is None:
|
||||
inferred_profile = "profile_left"
|
||||
else:
|
||||
inferred_profile = None # Both eyes visible, use normal logic
|
||||
else:
|
||||
inferred_profile = None
|
||||
|
||||
# Default to frontal if angles unknown
|
||||
yaw_original = yaw
|
||||
if yaw is None:
|
||||
@ -290,20 +358,23 @@ class PoseDetector:
|
||||
|
||||
# Face width threshold for profile detection (in pixels)
|
||||
# Profile faces have very small eye distance (< 25 pixels typically)
|
||||
PROFILE_FACE_WIDTH_THRESHOLD = 10.0 #25.0
|
||||
PROFILE_FACE_WIDTH_THRESHOLD = 20.0
|
||||
|
||||
# Yaw classification - PRIMARY INDICATOR
|
||||
# Use yaw angle as the primary indicator (30° threshold)
|
||||
# Use yaw angle as the primary indicator (15° threshold)
|
||||
abs_yaw = abs(yaw)
|
||||
|
||||
# Primary classification based on yaw angle
|
||||
if abs_yaw < 30.0:
|
||||
if abs_yaw < 15.0:
|
||||
# Yaw indicates frontal view
|
||||
# Trust yaw when it's available and reasonable (< 30°)
|
||||
# Trust yaw when it's available and reasonable (< 15°)
|
||||
# Only use face_width as fallback when yaw is unavailable (None)
|
||||
if yaw_original is None:
|
||||
# Yaw unavailable - use face_width as fallback
|
||||
if face_width is not None:
|
||||
# Yaw unavailable - check for single-eye visibility first
|
||||
if inferred_profile is not None:
|
||||
# Single eye visible or no eyes visible -> use inferred profile direction
|
||||
yaw_mode = inferred_profile
|
||||
elif face_width is not None:
|
||||
if face_width < PROFILE_FACE_WIDTH_THRESHOLD:
|
||||
# Face width suggests profile view - use it when yaw is unavailable
|
||||
yaw_mode = "profile_left" # Default direction when yaw unavailable
|
||||
@ -311,16 +382,14 @@ class PoseDetector:
|
||||
# Face width is normal (>= 25px) - likely frontal
|
||||
yaw_mode = "frontal"
|
||||
else:
|
||||
# Both yaw and face_width unavailable - cannot determine reliably
|
||||
# This usually means landmarks are incomplete (missing nose and/or eyes)
|
||||
# For extreme profile views, both eyes might not be visible, which would
|
||||
# cause face_width to be None. In this case, we cannot reliably determine
|
||||
# pose without additional indicators (like face bounding box aspect ratio).
|
||||
# Default to frontal (conservative approach), but this might misclassify
|
||||
# some extreme profile faces.
|
||||
yaw_mode = "frontal"
|
||||
# Both yaw and face_width unavailable - check if we inferred profile from landmarks
|
||||
if inferred_profile is not None:
|
||||
yaw_mode = inferred_profile
|
||||
else:
|
||||
# Cannot determine reliably - default to frontal
|
||||
yaw_mode = "frontal"
|
||||
else:
|
||||
# Yaw is available and < 30° - but still check face_width
|
||||
# Yaw is available and < 15° - but still check face_width
|
||||
# If face_width is very small (< 25px), it suggests profile even with small yaw
|
||||
if face_width is not None:
|
||||
if face_width < PROFILE_FACE_WIDTH_THRESHOLD:
|
||||
@ -332,11 +401,11 @@ class PoseDetector:
|
||||
else:
|
||||
# No face_width provided - trust yaw, classify as frontal
|
||||
yaw_mode = "frontal"
|
||||
elif yaw <= -30.0:
|
||||
# abs_yaw >= 30.0 and yaw is negative - profile left
|
||||
elif yaw <= -15.0:
|
||||
# abs_yaw >= 15.0 and yaw is negative - profile left
|
||||
yaw_mode = "profile_left" # Negative yaw = face turned left = left profile visible
|
||||
elif yaw >= 30.0:
|
||||
# abs_yaw >= 30.0 and yaw is positive - profile right
|
||||
elif yaw >= 15.0:
|
||||
# abs_yaw >= 15.0 and yaw is positive - profile right
|
||||
yaw_mode = "profile_right" # Positive yaw = face turned right = right profile visible
|
||||
else:
|
||||
# This should never be reached, but handle edge case
|
||||
@ -411,8 +480,8 @@ class PoseDetector:
|
||||
# Calculate face width (eye distance) for profile detection
|
||||
face_width = self.calculate_face_width_from_landmarks(landmarks)
|
||||
|
||||
# Classify pose mode (using face width as additional indicator)
|
||||
pose_mode = self.classify_pose_mode(yaw_angle, pitch_angle, roll_angle, face_width)
|
||||
# Classify pose mode (using face width and landmarks as additional indicators)
|
||||
pose_mode = self.classify_pose_mode(yaw_angle, pitch_angle, roll_angle, face_width, landmarks)
|
||||
|
||||
# Normalize facial_area format (RetinaFace returns list [x, y, w, h] or dict)
|
||||
facial_area_raw = face_data.get('facial_area', {})
|
||||
|
||||
@ -111,3 +111,4 @@ In CI (GitHub Actions/Gitea Actions), test results appear in:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -207,3 +207,4 @@ echo ""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -148,3 +148,4 @@ testQueries()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -25,3 +25,4 @@ fi
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user