feat: Integrate DeepFace for face processing with configurable options

This commit introduces the DeepFace integration for face processing, allowing users to configure detector backends and models through the new Process tab in the GUI. Key features include batch processing, job cancellation support, and real-time progress tracking. The README has been updated to reflect these enhancements, including instructions for automatic model downloads and handling of processing-intensive tasks. Additionally, the API has been expanded to support job management for face processing tasks, ensuring a robust user experience.
This commit is contained in:
tanyar09
2025-10-31 14:06:40 -04:00
parent 2f039a1d48
commit dd92d1ec14
12 changed files with 1325 additions and 45 deletions
+29
View File
@@ -0,0 +1,29 @@
import apiClient from './client'
import { JobResponse } from './jobs'
export interface ProcessFacesRequest {
batch_size?: number
detector_backend: string
model_name: string
}
export interface ProcessFacesResponse {
job_id: string
message: string
batch_size?: number
detector_backend: string
model_name: string
}
export const facesApi = {
/**
* Start face processing job
*/
processFaces: async (request: ProcessFacesRequest): Promise<ProcessFacesResponse> => {
const response = await apiClient.post<ProcessFacesResponse>('/api/v1/faces/process', request)
return response.data
},
}
export default facesApi
+12
View File
@@ -24,5 +24,17 @@ export const jobsApi = {
)
return data
},
streamJobProgress: (jobId: string): EventSource => {
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}`)
},
cancelJob: async (jobId: string): Promise<{ message: string; status: string }> => {
const { data } = await apiClient.delete<{ message: string; status: string }>(
`/api/v1/jobs/${jobId}`
)
return data
},
}
+421 -6
View File
@@ -1,12 +1,427 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { facesApi, ProcessFacesRequest } from '../api/faces'
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
interface JobProgress {
id: string
status: string
progress: number
message: string
processed?: number
total?: number
faces_detected?: number
faces_stored?: number
}
const DETECTOR_OPTIONS = ['retinaface', 'mtcnn', 'opencv', 'ssd']
const MODEL_OPTIONS = ['ArcFace', 'Facenet', 'Facenet512', 'VGG-Face']
export default function Process() {
const [batchSize, setBatchSize] = useState<number | undefined>(undefined)
const [detectorBackend, setDetectorBackend] = useState('retinaface')
const [modelName, setModelName] = useState('ArcFace')
const [isProcessing, setIsProcessing] = useState(false)
const [currentJob, setCurrentJob] = useState<JobResponse | null>(null)
const [jobProgress, setJobProgress] = useState<JobProgress | null>(null)
const [processingResult, setProcessingResult] = useState<{
photos_processed?: number
faces_detected?: number
faces_stored?: number
} | null>(null)
const [error, setError] = useState<string | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
// Cleanup event source on unmount
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
}
}, [])
const handleStartProcessing = async () => {
setIsProcessing(true)
setError(null)
setProcessingResult(null)
setCurrentJob(null)
setJobProgress(null)
try {
const request: ProcessFacesRequest = {
batch_size: batchSize || undefined,
detector_backend: detectorBackend,
model_name: modelName,
}
const response = await facesApi.processFaces(request)
// Set processing state immediately
setIsProcessing(true)
setCurrentJob({
id: response.job_id,
status: JobStatus.PENDING,
progress: 0,
message: response.message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
// Start SSE stream for job progress
startJobProgressStream(response.job_id)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Processing failed')
setIsProcessing(false)
}
}
const handleStopProcessing = async () => {
if (!currentJob) {
return
}
try {
// Call API to cancel the job
const result = await jobsApi.cancelJob(currentJob.id)
console.log('Job cancellation:', result)
// Close SSE stream
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
// Update UI state
setIsProcessing(false)
setError(`Job cancelled: ${result.message}`)
// Update job status
if (currentJob) {
setCurrentJob({
...currentJob,
status: JobStatus.FAILURE,
message: 'Cancelled by user',
})
}
} catch (err: any) {
console.error('Error cancelling job:', err)
setError(err.response?.data?.detail || err.message || 'Failed to cancel job')
}
}
const startJobProgressStream = (jobId: string) => {
// Close existing stream if any
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
const eventSource = jobsApi.streamJobProgress(jobId)
eventSourceRef.current = eventSource
eventSource.onmessage = (event) => {
try {
const data: JobProgress = JSON.parse(event.data)
setJobProgress(data)
// Update job status
const statusMap: Record<string, JobStatus> = {
pending: JobStatus.PENDING,
started: JobStatus.STARTED,
progress: JobStatus.PROGRESS,
success: JobStatus.SUCCESS,
failure: JobStatus.FAILURE,
}
const jobStatus = statusMap[data.status] || JobStatus.PENDING
setCurrentJob({
id: data.id,
status: jobStatus,
progress: data.progress,
message: data.message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
// Keep processing state true while job is running
if (jobStatus === JobStatus.STARTED || jobStatus === JobStatus.PROGRESS) {
setIsProcessing(true)
}
// Check if job is complete
if (jobStatus === JobStatus.SUCCESS || jobStatus === JobStatus.FAILURE) {
setIsProcessing(false)
eventSource.close()
eventSourceRef.current = null
// Fetch final job result to get processing stats
if (jobStatus === JobStatus.SUCCESS) {
fetchJobResult(jobId)
}
}
} catch (err) {
console.error('Error parsing SSE event:', err)
}
}
eventSource.onerror = (err) => {
console.error('SSE error:', err)
// Don't automatically set isProcessing to false on error
// Job might still be running even if SSE connection failed
// Check job status directly instead
if (currentJob) {
// Try to fetch job status directly
jobsApi.getJob(currentJob.id).then((job) => {
const stillRunning = job.status === JobStatus.STARTED || job.status === JobStatus.PROGRESS
setIsProcessing(stillRunning)
setCurrentJob(job)
}).catch(() => {
// If we can't get status, assume job might still be running
console.warn('Could not fetch job status after SSE error')
})
}
}
}
const fetchJobResult = async (jobId: string) => {
try {
const job = await jobsApi.getJob(jobId)
setCurrentJob(job)
// Extract result data from job progress
if (jobProgress) {
setProcessingResult({
photos_processed: jobProgress.processed,
faces_detected: jobProgress.faces_detected,
faces_stored: jobProgress.faces_stored,
})
}
} catch (err) {
console.error('Error fetching job result:', err)
}
}
const getStatusColor = (status: JobStatus) => {
switch (status) {
case JobStatus.SUCCESS:
return 'text-green-600'
case JobStatus.FAILURE:
return 'text-red-600'
case JobStatus.STARTED:
case JobStatus.PROGRESS:
return 'text-blue-600'
default:
return 'text-gray-600'
}
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Process</h1>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-gray-600">Face processing controls coming in Phase 2.</p>
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Process Faces</h1>
<div className="space-y-6">
{/* Configuration Section */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Processing Configuration
</h2>
<div className="space-y-4">
{/* Batch Size */}
<div>
<label
htmlFor="batch-size"
className="block text-sm font-medium text-gray-700 mb-2"
>
Batch Size
</label>
<input
id="batch-size"
type="number"
min="1"
value={batchSize || ''}
onChange={(e) =>
setBatchSize(
e.target.value ? parseInt(e.target.value, 10) : undefined
)
}
placeholder="All unprocessed photos"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
disabled={isProcessing}
/>
<p className="mt-1 text-sm text-gray-500">
Leave empty to process all unprocessed photos
</p>
</div>
{/* Detector Backend */}
<div>
<label
htmlFor="detector-backend"
className="block text-sm font-medium text-gray-700 mb-2"
>
Face Detector
</label>
<select
id="detector-backend"
value={detectorBackend}
onChange={(e) => setDetectorBackend(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
disabled={isProcessing}
>
{DETECTOR_OPTIONS.map((option) => (
<option key={option} value={option}>
{option.charAt(0).toUpperCase() + option.slice(1)}
</option>
))}
</select>
<p className="mt-1 text-sm text-gray-500">
RetinaFace recommended for best accuracy
</p>
</div>
{/* Model Name */}
<div>
<label
htmlFor="model-name"
className="block text-sm font-medium text-gray-700 mb-2"
>
Recognition Model
</label>
<select
id="model-name"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
disabled={isProcessing}
>
{MODEL_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<p className="mt-1 text-gray-500">
ArcFace recommended for best accuracy
</p>
</div>
{/* Control Buttons */}
<div className="flex gap-2 pt-4">
<button
type="button"
onClick={handleStartProcessing}
disabled={isProcessing}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? 'Processing...' : 'Start Processing'}
</button>
{isProcessing && (
<button
type="button"
onClick={handleStopProcessing}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Stop
</button>
)}
</div>
</div>
</div>
{/* Progress Section */}
{(currentJob || jobProgress) && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Processing Progress
</h2>
{currentJob && (
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span
className={`text-sm font-medium ${getStatusColor(
currentJob.status
)}`}
>
{currentJob.status === JobStatus.SUCCESS && '✓ '}
{currentJob.status === JobStatus.FAILURE && '✗ '}
{currentJob.status.charAt(0).toUpperCase() +
currentJob.status.slice(1)}
</span>
<span className="text-sm text-gray-600">
{currentJob.progress}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${currentJob.progress}%` }}
/>
</div>
</div>
{jobProgress && (
<div className="space-y-2 text-sm text-gray-600">
{jobProgress.processed !== undefined &&
jobProgress.total !== undefined && (
<p>
Photos processed: {jobProgress.processed} /{' '}
{jobProgress.total}
</p>
)}
{jobProgress.faces_detected !== undefined && (
<p>Faces detected: {jobProgress.faces_detected}</p>
)}
{jobProgress.faces_stored !== undefined && (
<p>Faces stored: {jobProgress.faces_stored}</p>
)}
{jobProgress.message && (
<p className="mt-1 font-medium">{jobProgress.message}</p>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Results Section */}
{processingResult && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Processing Results
</h2>
<div className="space-y-2 text-sm">
{processingResult.photos_processed !== undefined && (
<p className="text-green-600">
{processingResult.photos_processed} photos processed
</p>
)}
{processingResult.faces_detected !== undefined && (
<p className="text-gray-600">
{processingResult.faces_detected} faces detected
</p>
)}
{processingResult.faces_stored !== undefined && (
<p className="text-gray-700 font-medium">
{processingResult.faces_stored} faces stored in database
</p>
)}
</div>
</div>
)}
{/* Error Section */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
</div>
</div>
)
}