feat: Add landmarks column to faces and update face processing for pose detection
This commit introduces a new column for storing facial landmarks in the database schema, enhancing the face processing capabilities. The FaceProcessor class has been updated to extract and serialize landmarks during face detection, improving pose classification accuracy. Additionally, the Identify and AutoMatch components have been modified to support loading progress indicators and provide user feedback during face loading operations. Documentation has been updated to reflect these changes, ensuring a better user experience and improved functionality.
This commit is contained in:
@@ -19,6 +19,7 @@ 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
|
||||
@@ -269,14 +270,68 @@ export default function AutoMatch() {
|
||||
/>
|
||||
<span className="text-xs text-gray-500">(lower = stricter matching)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={startAutoMatch}
|
||||
disabled={busy || hasNoResults}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
title={hasNoResults ? 'No matches found. Adjust tolerance or process more photos.' : ''}
|
||||
>
|
||||
{busy ? 'Processing...' : hasNoResults ? 'No Matches Available' : '🚀 Run Auto-Match'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={startAutoMatch}
|
||||
disabled={busy || hasNoResults}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
title={hasNoResults ? 'No matches found. Adjust tolerance or process more photos.' : ''}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Auto-Accept Threshold:</label>
|
||||
<input
|
||||
@@ -292,6 +347,9 @@ export default function AutoMatch() {
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
|
||||
@@ -34,6 +34,8 @@ export default function Identify() {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(false)
|
||||
const [loadingFaces, setLoadingFaces] = useState(false)
|
||||
const [loadingProgress, setLoadingProgress] = useState({ current: 0, total: 0, message: '' })
|
||||
|
||||
// Store form data per face ID (matching desktop behavior)
|
||||
const [faceFormData, setFaceFormData] = useState<Record<number, {
|
||||
@@ -53,26 +55,35 @@ export default function Identify() {
|
||||
}, [personId, firstName, lastName, dob, currentFace])
|
||||
|
||||
const loadFaces = async () => {
|
||||
const res = await facesApi.getUnidentified({
|
||||
page: 1,
|
||||
page_size: pageSize,
|
||||
min_quality: minQuality,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
sort_by: sortBy,
|
||||
sort_dir: sortDir,
|
||||
})
|
||||
setLoadingFaces(true)
|
||||
setLoadingProgress({ current: 0, total: 0, message: 'Loading faces...' })
|
||||
|
||||
// Apply unique faces filter if enabled
|
||||
if (uniqueFacesOnly) {
|
||||
const filtered = await filterUniqueFaces(res.items)
|
||||
setFaces(filtered)
|
||||
setTotal(filtered.length)
|
||||
} else {
|
||||
setFaces(res.items)
|
||||
setTotal(res.total)
|
||||
try {
|
||||
const res = await facesApi.getUnidentified({
|
||||
page: 1,
|
||||
page_size: pageSize,
|
||||
min_quality: minQuality,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
sort_by: sortBy,
|
||||
sort_dir: sortDir,
|
||||
})
|
||||
|
||||
// Apply unique faces filter if enabled
|
||||
if (uniqueFacesOnly) {
|
||||
setLoadingProgress({ current: 0, total: res.items.length, message: 'Filtering unique faces...' })
|
||||
const filtered = await filterUniqueFaces(res.items)
|
||||
setFaces(filtered)
|
||||
setTotal(filtered.length)
|
||||
} else {
|
||||
setFaces(res.items)
|
||||
setTotal(res.total)
|
||||
}
|
||||
setCurrentIdx(0)
|
||||
} finally {
|
||||
setLoadingFaces(false)
|
||||
setLoadingProgress({ current: 0, total: 0, message: '' })
|
||||
}
|
||||
setCurrentIdx(0)
|
||||
}
|
||||
|
||||
const filterUniqueFaces = async (faces: FaceItem[]): Promise<FaceItem[]> => {
|
||||
@@ -84,9 +95,17 @@ export default function Identify() {
|
||||
// Build similarity graph: for each face, find all similar faces (≥60% confidence) in current list
|
||||
const similarityMap = new Map<number, Set<number>>()
|
||||
|
||||
for (const face of faces) {
|
||||
for (let i = 0; i < faces.length; i++) {
|
||||
const face = faces[i]
|
||||
const similarSet = new Set<number>()
|
||||
|
||||
// Update progress
|
||||
setLoadingProgress({
|
||||
current: i + 1,
|
||||
total: faces.length,
|
||||
message: `Checking face ${i + 1} of ${faces.length}...`
|
||||
})
|
||||
|
||||
try {
|
||||
const similarRes = await facesApi.getSimilar(face.id)
|
||||
for (const similar of similarRes.items) {
|
||||
@@ -343,6 +362,33 @@ export default function Identify() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Identify</h1>
|
||||
|
||||
{/* Loading Progress Bar */}
|
||||
{loadingFaces && (
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{loadingProgress.message || 'Loading faces...'}
|
||||
</span>
|
||||
{loadingProgress.total > 0 && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{loadingProgress.current} / {loadingProgress.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: loadingProgress.total > 0
|
||||
? `${(loadingProgress.current / loadingProgress.total) * 100}%`
|
||||
: '100%'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Left: Controls and current face */}
|
||||
<div className="col-span-4">
|
||||
|
||||
Reference in New Issue
Block a user