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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user