diff --git a/admin-frontend/.env_example b/admin-frontend/.env_example
index 58c6cb3..e9e5bd3 100644
--- a/admin-frontend/.env_example
+++ b/admin-frontend/.env_example
@@ -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=
+
diff --git a/admin-frontend/public/enable-dev-mode.html b/admin-frontend/public/enable-dev-mode.html
new file mode 100644
index 0000000..c4e659a
--- /dev/null
+++ b/admin-frontend/public/enable-dev-mode.html
@@ -0,0 +1,67 @@
+
+
+
+ Enable Developer Mode
+
+
+
+
+
Enable Developer Mode
+
Click the button below to enable Developer Mode for PunimTag.
+
+
+
+
+
+
+
+
diff --git a/admin-frontend/src/api/faces.ts b/admin-frontend/src/api/faces.ts
index e173cd1..af9bcf0 100644
--- a/admin-frontend/src/api/faces.ts
+++ b/admin-frontend/src/api/faces.ts
@@ -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 => {
+ getSimilar: async (faceId: number, includeExcluded?: boolean, debug?: boolean): Promise => {
const response = await apiClient.get(`/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 => {
const response = await apiClient.post('/api/v1/faces/batch-similarity', request)
@@ -251,6 +282,7 @@ export const facesApi = {
},
getAutoMatchPeople: async (params?: {
filter_frontal_only?: boolean
+ tolerance?: number
}): Promise => {
const response = await apiClient.get('/api/v1/faces/auto-match/people', {
params,
diff --git a/admin-frontend/src/context/DeveloperModeContext.tsx b/admin-frontend/src/context/DeveloperModeContext.tsx
index 25a1bfa..426be3e 100644
--- a/admin-frontend/src/context/DeveloperModeContext.tsx
+++ b/admin-frontend/src/context/DeveloperModeContext.tsx
@@ -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(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(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 (
-
+
{children}
)
diff --git a/admin-frontend/src/pages/AutoMatch.tsx b/admin-frontend/src/pages/AutoMatch.tsx
index 6ecde7b..45a0b17 100644
--- a/admin-frontend/src/pages/AutoMatch.tsx
+++ b/admin-frontend/src/pages/AutoMatch.tsx
@@ -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([])
const [filteredPeople, setFilteredPeople] = useState([])
- // Store matches separately, keyed by person_id
- const [matchesCache, setMatchesCache] = useState>({})
+ // Store matches separately, keyed by person_id_tolerance (composite key)
+ const [matchesCache, setMatchesCache] = useState>({})
const [currentIndex, setCurrentIndex] = useState(0)
const [searchQuery, setSearchQuery] = useState('')
const [allPeople, setAllPeople] = useState([])
@@ -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 = {}
+ // 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 = {}
- 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 = {}
- 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 = {}
- 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 = {}
- 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() {
)}
- ℹ️ Auto-Match Criteria: Only faces with similarity higher than 70% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.
+ ℹ️ Auto-Match Criteria: Only faces with similarity higher than 85% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.