feat: Implement auto-match automation plan with enhanced API and frontend support

This commit introduces a comprehensive auto-match automation plan that automates the face matching process in the application. Key features include the ability to automatically identify faces based on pose and similarity thresholds, with configurable options for auto-acceptance. The API has been updated to support new parameters for auto-acceptance and pose filtering, while the frontend has been enhanced to allow users to set an auto-accept threshold and view results. Documentation has been updated to reflect these changes, improving user experience and functionality.
This commit is contained in:
tanyar09 2025-11-04 14:55:05 -05:00
parent 0dcfe327cd
commit e2cadf3232
6 changed files with 913 additions and 11 deletions

View File

@ -0,0 +1,680 @@
# Auto-Match Automation Plan
**Version:** 1.0
**Created:** November 2025
**Status:** Planning Phase
---
## Executive Summary
This plan outlines the implementation of automated face matching in the Auto-Match feature. Currently, users must manually select faces on the right panel to match with the person on the left panel. This plan automates that process by automatically identifying faces that meet specific criteria:
1. **Reference face (left panel)** must be frontal (not portrait)
2. **Match faces (right panel)** must be frontal (not portrait)
3. **Match similarity** must be high (configurable threshold, default ≥70%)
When these conditions are met, faces are automatically identified to the person without user intervention.
---
## Current State Analysis
### Current Auto-Match Workflow
**Frontend Flow:**
1. User navigates to Auto-Match tab
2. Auto-match starts automatically (calls `POST /api/v1/faces/auto-match`)
3. Results display:
- **Left Panel:** Identified person with reference face
- **Right Panel:** Unidentified faces that match the reference face
4. User manually selects faces via checkboxes
5. User clicks "Save changes" button
6. Frontend calls `POST /api/v1/people/{person_id}/accept-matches` with selected face IDs
**Backend Flow:**
1. `POST /api/v1/faces/auto-match` endpoint:
- Gets all identified people (one reference face per person)
- For each person, finds similar unidentified faces using cosine similarity
- Filters by tolerance (default 0.6, maps to confidence ≥40%)
- Returns matches with similarity percentage (0-100%)
2. `POST /api/v1/people/{person_id}/accept-matches` endpoint:
- Accepts list of face IDs
- Sets `person_id` on each face
- Creates `person_encodings` records
- Commits changes
### Data Available
**Face Model Fields:**
- `id`: Face ID
- `person_id`: Person ID (NULL for unidentified)
- `photo_id`: Photo ID
- `encoding`: Face encoding (512-dimensional vector)
- `location`: Face bounding box (JSON)
- `confidence`: Detection confidence
- `quality_score`: Quality score (0.0-1.0)
- `pose_mode`: Pose classification (default: 'frontal')
- `yaw_angle`: Yaw angle in degrees (optional)
- `pitch_angle`: Pitch angle in degrees (optional)
- `roll_angle`: Roll angle in degrees (optional)
**AutoMatchFaceItem Schema:**
```python
{
"id": int,
"photo_id": int,
"photo_filename": str,
"location": str,
"quality_score": float,
"similarity": float, # 0-100 percentage
"distance": float
}
```
**AutoMatchPersonItem Schema:**
```python
{
"person_id": int,
"person_name": str,
"reference_face_id": int,
"reference_photo_id": int,
"reference_photo_filename": str,
"reference_location": str,
"face_count": int,
"matches": [AutoMatchFaceItem],
"total_matches": int
}
```
### Limitations
1. **No pose_mode in API response:** Current API doesn't return `pose_mode` for faces
2. **No automatic acceptance:** All matches require manual selection
3. **No filtering by pose:** Cannot filter out profile/portrait faces
4. **No high-confidence threshold:** No automatic filtering by match confidence
---
## Requirements
### Functional Requirements
1. **Automated Face Matching:**
- When "Start Auto-Match" button is clicked, automatically identify faces that meet criteria
- Only process persons whose reference face is frontal (`pose_mode == 'frontal'`)
- Only match with unidentified faces that are frontal (`pose_mode == 'frontal'`)
- Only match faces with similarity ≥70% (configurable threshold)
- Automatically call accept-matches API for qualifying faces
2. **Pose-Based Filtering:**
- Check reference face `pose_mode` field
- Check match face `pose_mode` field
- Skip matching if reference face is not frontal
- Skip matching if match face is not frontal
3. **Similarity Threshold:**
- Configurable threshold (default: 70%)
- Only auto-match faces with similarity ≥ threshold
- Threshold should be adjustable via UI (optional for Phase 1)
4. **User Feedback:**
- Show progress/status during auto-matching
- Display summary: how many faces were auto-matched vs. skipped
- Show which persons were processed and which were skipped (due to non-frontal reference)
5. **Backward Compatibility:**
- Keep existing manual selection workflow
- Auto-match should be opt-in (button click)
- Manual selection should still work after auto-match
### Technical Requirements
1. **API Enhancements:**
- Add `pose_mode` to `AutoMatchFaceItem` response
- Add `pose_mode` to `AutoMatchPersonItem` reference face info
- Add optional `auto_accept` parameter to auto-match endpoint
- Add `auto_accept_threshold` parameter (default: 70.0)
2. **Backend Logic:**
- Filter reference faces by `pose_mode == 'frontal'` in auto-match query
- Filter match faces by `pose_mode == 'frontal'` in similarity search
- Filter matches by similarity threshold
- Automatically call `accept_auto_match_matches` for qualifying faces
3. **Frontend Changes:**
- Update "Start Auto-Match" button to trigger auto-acceptance
- Add loading state during auto-matching
- Display results summary (auto-matched count, skipped count)
- Update UI to show pose_mode for faces (optional)
---
## Technical Approach
### Architecture Overview
```
┌─────────────────────────────────────────────────────────┐
│ Auto-Match Automation Flow │
├─────────────────────────────────────────────────────────┤
│ │
│ 1. User clicks "Start Auto-Match" button │
│ └─> Frontend: POST /api/v1/faces/auto-match │
│ with auto_accept=true, threshold=70 │
│ │
│ 2. Backend: Find all identified people │
│ └─> Filter: Only persons with frontal reference face │
│ (pose_mode == 'frontal') │
│ │
│ 3. For each person (with frontal reference): │
│ └─> Find similar unidentified faces │
│ └─> Filter: Only frontal faces │
│ (pose_mode == 'frontal') │
│ └─> Filter: Similarity ≥ threshold (70%) │
│ └─> Auto-accept: Call accept_auto_match_matches │
│ │
│ 4. Return summary: │
│ └─> Total persons processed │
│ └─> Total faces auto-matched │
│ └─> Persons skipped (non-frontal reference) │
│ └─> Matches found but not auto-matched │
│ │
└─────────────────────────────────────────────────────────┘
```
### Phase 1: Backend API Enhancements
#### Step 1.1: Update Auto-Match Request Schema
**File: `src/web/schemas/faces.py`**
Add optional parameters to `AutoMatchRequest`:
```python
class AutoMatchRequest(BaseModel):
tolerance: float
auto_accept: bool = False # New: Enable auto-acceptance
auto_accept_threshold: float = 70.0 # New: Similarity threshold (0-100)
```
#### Step 1.2: Update Auto-Match Response Schema
**File: `src/web/schemas/faces.py`**
Add `pose_mode` to `AutoMatchFaceItem`:
```python
class AutoMatchFaceItem(BaseModel):
id: int
photo_id: int
photo_filename: str
location: str
quality_score: float
similarity: float # Confidence percentage (0-100)
distance: float
pose_mode: str = "frontal" # New: Pose classification
```
Add `pose_mode` to `AutoMatchPersonItem` reference face:
```python
class AutoMatchPersonItem(BaseModel):
person_id: int
person_name: str
reference_face_id: int
reference_photo_id: int
reference_photo_filename: str
reference_location: str
reference_pose_mode: str = "frontal" # New: Reference face pose
face_count: int
matches: list[AutoMatchFaceItem]
total_matches: int
```
Add summary fields to `AutoMatchResponse`:
```python
class AutoMatchResponse(BaseModel):
people: list[AutoMatchPersonItem]
total_people: int
total_matches: int
# New: Auto-accept summary
auto_accepted: bool = False
auto_accepted_faces: int = 0
skipped_persons: int = 0 # Persons with non-frontal reference
skipped_matches: int = 0 # Matches that didn't meet criteria
```
#### Step 1.3: Update Auto-Match Service Function
**File: `src/web/services/face_service.py`**
Modify `find_auto_match_matches` to:
1. Filter reference faces by `pose_mode == 'frontal'`
2. Include `pose_mode` in results
```python
def find_auto_match_matches(
db: Session,
tolerance: float = None,
filter_frontal_only: bool = True, # New parameter
) -> List[Tuple[int, int, Face, List[Tuple[Face, float, float]]]]:
"""Find auto-match matches for all identified people.
Args:
tolerance: Similarity tolerance (default: 0.6)
filter_frontal_only: Only include persons with frontal reference face
"""
# ... existing code ...
# Filter reference faces by pose_mode if requested
if filter_frontal_only:
identified_faces = [
f for f in identified_faces
if f.pose_mode == 'frontal'
]
# ... rest of existing code ...
```
#### Step 1.4: Update Auto-Match API Endpoint
**File: `src/web/api/faces.py`**
Modify `auto_match_faces` endpoint to:
1. Accept `auto_accept` and `auto_accept_threshold` parameters
2. Filter matches by pose_mode and similarity threshold
3. Auto-accept qualifying faces
4. Return summary statistics
```python
@router.post("/auto-match", response_model=AutoMatchResponse)
def auto_match_faces(
request: AutoMatchRequest,
db: Session = Depends(get_db),
) -> AutoMatchResponse:
"""Start auto-match process with optional auto-acceptance.
If auto_accept=True:
- Only processes persons with frontal reference faces
- Only matches with frontal unidentified faces
- Only auto-accepts matches with similarity ≥ threshold
"""
from src.web.db.models import Person, Photo, Face
# Find matches for all identified people
# Filter by frontal reference faces if auto_accept enabled
matches_data = find_auto_match_matches(
db,
tolerance=request.tolerance,
filter_frontal_only=request.auto_accept
)
# Track statistics
auto_accepted_faces = 0
skipped_persons = 0
skipped_matches = 0
# If auto_accept enabled, process matches automatically
if request.auto_accept:
for person_id, reference_face_id, reference_face, similar_faces in matches_data:
# Filter matches by criteria:
# 1. Match face must be frontal
# 2. Similarity must be ≥ threshold
qualifying_faces = []
for face, distance, confidence_pct in similar_faces:
# Check pose_mode
if face.pose_mode != 'frontal':
skipped_matches += 1
continue
# Check similarity threshold
if confidence_pct < request.auto_accept_threshold:
skipped_matches += 1
continue
qualifying_faces.append(face.id)
# Auto-accept qualifying faces
if qualifying_faces:
try:
identified_count, updated_count = accept_auto_match_matches(
db, person_id, qualifying_faces
)
auto_accepted_faces += identified_count
except Exception as e:
print(f"Error auto-accepting matches for person {person_id}: {e}")
# Build response (existing logic)
people_items = []
total_matches = 0
for person_id, reference_face_id, reference_face, similar_faces in matches_data:
# ... existing person building logic ...
# Add pose_mode to reference face info
reference_pose_mode = reference_face.pose_mode or 'frontal'
# Build matches list with pose_mode
match_items = []
for face, distance, confidence_pct in similar_faces:
match_photo = db.query(Photo).filter(Photo.id == face.photo_id).first()
if not match_photo:
continue
match_items.append(
AutoMatchFaceItem(
id=face.id,
photo_id=face.photo_id,
photo_filename=match_photo.filename,
location=face.location,
quality_score=float(face.quality_score),
similarity=confidence_pct,
distance=distance,
pose_mode=face.pose_mode or 'frontal', # New
)
)
if match_items:
people_items.append(
AutoMatchPersonItem(
person_id=person_id,
person_name=person_name,
reference_face_id=reference_face_id,
reference_photo_id=reference_face.photo_id,
reference_photo_filename=reference_photo.filename,
reference_location=reference_face.location,
reference_pose_mode=reference_pose_mode, # New
face_count=face_count,
matches=match_items,
total_matches=len(match_items),
)
)
total_matches += len(match_items)
return AutoMatchResponse(
people=people_items,
total_people=len(people_items),
total_matches=total_matches,
auto_accepted=request.auto_accept,
auto_accepted_faces=auto_accepted_faces,
skipped_persons=skipped_persons,
skipped_matches=skipped_matches,
)
```
#### Step 1.5: Update Similar Face Search to Include Pose Mode
**File: `src/web/services/face_service.py`**
Ensure `find_similar_faces` returns faces with pose_mode information:
```python
def find_similar_faces(
db: Session,
reference_face_id: int,
limit: int = 100,
tolerance: float = None,
filter_frontal_only: bool = False, # New parameter
) -> List[Tuple[Face, float, float]]:
"""Find similar faces to reference face.
Args:
filter_frontal_only: Only return frontal faces
"""
# ... existing similarity search ...
# Filter by pose_mode if requested
if filter_frontal_only:
similar_faces = [
(face, distance, confidence_pct)
for face, distance, confidence_pct in similar_faces
if face.pose_mode == 'frontal'
]
return similar_faces
```
### Phase 2: Frontend Updates
#### Step 2.1: Update AutoMatch API Interface
**File: `frontend/src/api/faces.ts`**
Update `AutoMatchRequest` interface:
```typescript
export interface AutoMatchRequest {
tolerance: number
auto_accept?: boolean // New: Enable auto-acceptance
auto_accept_threshold?: number // New: Similarity threshold (0-100)
}
```
Update `AutoMatchFaceItem` interface:
```typescript
export interface AutoMatchFaceItem {
id: number
photo_id: number
photo_filename: string
location: string
quality_score: number
similarity: number // Confidence percentage (0-100)
distance: number
pose_mode?: string // New: Pose classification
}
```
Update `AutoMatchPersonItem` interface:
```typescript
export interface AutoMatchPersonItem {
person_id: number
person_name: string
reference_face_id: number
reference_photo_id: number
reference_photo_filename: string
reference_location: string
reference_pose_mode?: string // New: Reference face pose
face_count: number
matches: AutoMatchFaceItem[]
total_matches: number
}
```
Update `AutoMatchResponse` interface:
```typescript
export interface AutoMatchResponse {
people: AutoMatchPersonItem[]
total_people: number
total_matches: number
auto_accepted?: boolean // New
auto_accepted_faces?: number // New
skipped_persons?: number // New
skipped_matches?: number // New
}
```
#### Step 2.2: Update AutoMatch Component
**File: `frontend/src/pages/AutoMatch.tsx`**
1. Add state for auto-accept threshold:
```typescript
const [autoAcceptThreshold, setAutoAcceptThreshold] = useState(70)
```
2. Update `startAutoMatch` function to use auto-accept:
```typescript
const startAutoMatch = async () => {
if (tolerance < 0 || tolerance > 1) {
alert('Please enter a valid tolerance value between 0.0 and 1.0.')
return
}
setBusy(true)
try {
const response = await facesApi.autoMatch({
tolerance,
auto_accept: true, // Enable auto-acceptance
auto_accept_threshold: autoAcceptThreshold
})
// Show summary
if (response.auto_accepted) {
const summary = [
`✅ Auto-matched ${response.auto_accepted_faces || 0} faces`,
response.skipped_persons ? `⚠️ Skipped ${response.skipped_persons} persons (non-frontal reference)` : '',
response.skipped_matches ? ` Skipped ${response.skipped_matches} matches (didn't meet criteria)` : ''
].filter(Boolean).join('\n')
alert(summary)
}
if (response.people.length === 0) {
alert('🔍 No similar faces found for auto-identification')
setBusy(false)
return
}
setPeople(response.people)
// ... rest of existing code ...
} catch (error) {
console.error('Auto-match failed:', error)
alert('Failed to start auto-match. Please try again.')
} finally {
setBusy(false)
}
}
```
3. Add threshold input field (optional):
```tsx
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Auto-Accept Threshold:</label>
<input
type="number"
min="0"
max="100"
step="5"
value={autoAcceptThreshold}
onChange={(e) => setAutoAcceptThreshold(parseInt(e.target.value) || 70)}
disabled={isActive}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<span className="text-xs text-gray-500">% (min similarity)</span>
</div>
```
4. Update UI to show pose_mode for faces (optional visual indicator):
```tsx
{/* Show pose mode badge if not frontal */}
{match.pose_mode && match.pose_mode !== 'frontal' && (
<span className="px-2 py-1 rounded text-xs bg-orange-100 text-orange-800">
{match.pose_mode}
</span>
)}
```
### Phase 3: Testing & Validation
#### Step 3.1: Unit Tests
**File: `tests/test_auto_match_automation.py`**
1. Test auto-match with auto_accept enabled
2. Test filtering by frontal faces only
3. Test similarity threshold filtering
4. Test skipping non-frontal reference faces
5. Test summary statistics
#### Step 3.2: Integration Tests
1. Test full auto-match flow with auto-acceptance
2. Test with mixed frontal/non-frontal faces
3. Test with various similarity thresholds
4. Verify database updates (person_id, person_encodings)
#### Step 3.3: Manual Testing
1. Create test data with frontal and non-frontal faces
2. Run auto-match with auto_accept enabled
3. Verify only frontal faces are matched
4. Verify only high-similarity faces are auto-accepted
5. Verify summary statistics are correct
---
## Implementation Plan
### Phase 1: Backend (2-3 days)
1. ✅ Update schemas (AutoMatchRequest, AutoMatchResponse, etc.)
2. ✅ Update `find_auto_match_matches` to filter by pose_mode
3. ✅ Update auto-match API endpoint with auto-accept logic
4. ✅ Update `find_similar_faces` to filter by pose_mode
5. ✅ Add unit tests for filtering logic
### Phase 2: Frontend (1-2 days)
1. ✅ Update API interfaces
2. ✅ Update AutoMatch component to use auto-accept
3. ✅ Add threshold input field (optional)
4. ✅ Add summary display
5. ✅ Add pose_mode indicators (optional)
### Phase 3: Testing (1-2 days)
1. ✅ Write unit tests
2. ✅ Write integration tests
3. ✅ Manual testing with real data
4. ✅ Performance testing
**Total Estimated Time: 4-7 days**
---
## Configuration
### Default Values
- **Auto-accept threshold:** 70% similarity
- **Pose mode filter:** Only frontal faces (`pose_mode == 'frontal'`)
- **Tolerance:** 0.6 (existing default)
### Future Enhancements (Not in Phase 1)
1. **Configurable pose mode filter:** Allow users to specify which pose modes to include
2. **Per-person threshold:** Different thresholds for different people
3. **Batch processing:** Process multiple persons in parallel
4. **Progress tracking:** Real-time progress updates during auto-matching
5. **Undo functionality:** Ability to undo auto-accepted matches
6. **Logging/Audit trail:** Track which faces were auto-matched and when
---
## Edge Cases & Error Handling
1. **No frontal reference faces:** Skip person, show in summary
2. **No frontal match faces:** Skip matches, show in summary
3. **Database errors:** Rollback transaction, show error message
4. **Missing pose_mode data:** Default to 'frontal' (backward compatibility)
5. **Concurrent updates:** Use database transactions to prevent conflicts
---
## Success Criteria
1. ✅ Auto-match automatically identifies faces meeting criteria
2. ✅ Only frontal faces (reference and matches) are processed
3. ✅ Only high-similarity matches (≥70%) are auto-accepted
4. ✅ Summary statistics are accurate
5. ✅ Manual selection still works after auto-match
6. ✅ No breaking changes to existing API
---
## Notes
- This plan assumes portrait detection is already implemented (which it is)
- The `pose_mode` field should already exist in the database
- Backward compatibility is maintained: existing API calls without `auto_accept` work as before
- The "Start Auto-Match" button will trigger auto-acceptance when clicked
- Users can still manually select/deselect faces after auto-matching

View File

@ -76,6 +76,8 @@ export interface BatchUnmatchResponse {
export interface AutoMatchRequest {
tolerance: number
auto_accept?: boolean
auto_accept_threshold?: number
}
export interface AutoMatchFaceItem {
@ -86,6 +88,7 @@ export interface AutoMatchFaceItem {
quality_score: number
similarity: number // Confidence percentage (0-100)
distance: number
pose_mode?: string
}
export interface AutoMatchPersonItem {
@ -95,6 +98,7 @@ export interface AutoMatchPersonItem {
reference_photo_id: number
reference_photo_filename: string
reference_location: string
reference_pose_mode?: string
face_count: number
matches: AutoMatchFaceItem[]
total_matches: number
@ -104,6 +108,10 @@ export interface AutoMatchResponse {
people: AutoMatchPersonItem[]
total_people: number
total_matches: number
auto_accepted?: boolean
auto_accepted_faces?: number
skipped_persons?: number
skipped_matches?: number
}
export interface AcceptMatchesRequest {

View File

@ -7,6 +7,7 @@ const DEFAULT_TOLERANCE = 0.6
export default function AutoMatch() {
const [tolerance, setTolerance] = useState(DEFAULT_TOLERANCE)
const [autoAcceptThreshold, setAutoAcceptThreshold] = useState(70)
const [isActive, setIsActive] = useState(false)
const [people, setPeople] = useState<AutoMatchPersonItem[]>([])
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonItem[]>([])
@ -16,6 +17,8 @@ export default function AutoMatch() {
const [originalSelectedFaces, setOriginalSelectedFaces] = useState<Record<number, boolean>>({})
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [hasNoResults, setHasNoResults] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const currentPerson = useMemo(() => {
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
@ -26,9 +29,48 @@ export default function AutoMatch() {
return currentPerson?.matches || []
}, [currentPerson])
// Auto-start auto-match when component mounts or tolerance changes
// Shared function for auto-load and refresh
const loadAutoMatch = async () => {
if (tolerance < 0 || tolerance > 1) {
return
}
setBusy(true)
setIsRefreshing(true)
try {
const response = await facesApi.autoMatch({
tolerance,
auto_accept: false // Don't auto-accept on load/refresh, only on button click
})
if (response.people.length === 0) {
setHasNoResults(true)
setPeople([])
setFilteredPeople([])
setIsActive(false)
setBusy(false)
setIsRefreshing(false)
return
}
setHasNoResults(false)
setPeople(response.people)
setFilteredPeople([])
setCurrentIndex(0)
setSelectedFaces({})
setOriginalSelectedFaces({})
setIsActive(true)
} catch (error) {
console.error('Auto-match failed:', error)
} finally {
setBusy(false)
setIsRefreshing(false)
}
}
// Auto-start auto-match when component mounts or tolerance changes (without auto-accept)
useEffect(() => {
startAutoMatch()
loadAutoMatch()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tolerance])
@ -64,16 +106,43 @@ export default function AutoMatch() {
return
}
if (autoAcceptThreshold < 0 || autoAcceptThreshold > 100) {
alert('Please enter a valid auto-accept threshold between 0 and 100.')
return
}
setBusy(true)
try {
const response = await facesApi.autoMatch({ tolerance })
const response = await facesApi.autoMatch({
tolerance,
auto_accept: true,
auto_accept_threshold: autoAcceptThreshold
})
// Show summary if auto-accept was performed
if (response.auto_accepted) {
const summary = [
`✅ Auto-matched ${response.auto_accepted_faces || 0} faces`,
response.skipped_persons ? `⚠️ Skipped ${response.skipped_persons} persons (non-frontal reference)` : '',
response.skipped_matches ? ` Skipped ${response.skipped_matches} matches (didn't meet criteria)` : ''
].filter(Boolean).join('\n')
if (summary) {
alert(summary)
}
}
if (response.people.length === 0) {
alert('🔍 No similar faces found for auto-identification')
setHasNoResults(true)
setPeople([])
setFilteredPeople([])
setIsActive(false)
setBusy(false)
return
}
setHasNoResults(false)
setPeople(response.people)
setFilteredPeople([])
setCurrentIndex(0)
@ -180,11 +249,11 @@ export default function AutoMatch() {
<div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="flex items-center gap-4">
<button
onClick={startAutoMatch}
disabled={busy || isActive}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
onClick={loadAutoMatch}
disabled={busy}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{busy ? 'Processing...' : '🚀 Start Auto-Match'}
{isRefreshing ? 'Refreshing...' : '🔄 Refresh'}
</button>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Tolerance:</label>
@ -195,11 +264,33 @@ export default function AutoMatch() {
step="0.1"
value={tolerance}
onChange={(e) => setTolerance(parseFloat(e.target.value) || 0)}
disabled={isActive}
disabled={busy}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<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' : '🚀 Start Auto-Match'}
</button>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Auto-Accept Threshold:</label>
<input
type="number"
min="0"
max="100"
step="5"
value={autoAcceptThreshold}
onChange={(e) => setAutoAcceptThreshold(parseInt(e.target.value) || 70)}
disabled={busy || hasNoResults}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<span className="text-xs text-gray-500">% (min similarity)</span>
</div>
</div>
</div>

View File

@ -425,24 +425,69 @@ def auto_match_faces(
request: AutoMatchRequest,
db: Session = Depends(get_db),
) -> AutoMatchResponse:
"""Start auto-match process with tolerance threshold.
"""Start auto-match process with tolerance threshold and optional auto-acceptance.
Matches desktop auto-match workflow exactly:
1. Gets all identified people (one face per person, best quality >= 0.3)
2. For each person, finds similar unidentified faces (confidence >= 40%)
3. Returns matches grouped by person, sorted by person name
If auto_accept=True:
- Only processes persons with frontal or tilted reference faces (not profile)
- Only matches with frontal or tilted unidentified faces (not profile)
- Only auto-accepts matches with similarity >= threshold
"""
from src.web.db.models import Person, Photo
from sqlalchemy import func
# Track statistics for auto-accept
auto_accepted_faces = 0
skipped_persons = 0
skipped_matches = 0
# Find matches for all identified people
matches_data = find_auto_match_matches(db, tolerance=request.tolerance)
# Filter by frontal reference faces if auto_accept enabled
matches_data = find_auto_match_matches(
db,
tolerance=request.tolerance,
filter_frontal_only=request.auto_accept
)
# If auto_accept enabled, process matches automatically
if request.auto_accept and matches_data:
for person_id, reference_face_id, reference_face, similar_faces in matches_data:
# Filter matches by criteria:
# 1. Match face must be frontal (already filtered by find_similar_faces)
# 2. Similarity must be >= threshold
qualifying_faces = []
for face, distance, confidence_pct in similar_faces:
# Check similarity threshold
if confidence_pct < request.auto_accept_threshold:
skipped_matches += 1
continue
qualifying_faces.append(face.id)
# Auto-accept qualifying faces
if qualifying_faces:
try:
identified_count, updated_count = accept_auto_match_matches(
db, person_id, qualifying_faces
)
auto_accepted_faces += identified_count
except Exception as e:
print(f"Error auto-accepting matches for person {person_id}: {e}")
if not matches_data:
return AutoMatchResponse(
people=[],
total_people=0,
total_matches=0,
auto_accepted=request.auto_accept,
auto_accepted_faces=auto_accepted_faces,
skipped_persons=skipped_persons,
skipped_matches=skipped_matches,
)
# Build response matching desktop format
@ -480,6 +525,9 @@ def auto_match_faces(
if not reference_photo:
continue
# Get reference face pose_mode
reference_pose_mode = reference_face.pose_mode or 'frontal'
# Build matches list
match_items = []
for face, distance, confidence_pct in similar_faces:
@ -497,6 +545,7 @@ def auto_match_faces(
quality_score=float(face.quality_score),
similarity=confidence_pct, # Confidence percentage (0-100)
distance=distance,
pose_mode=face.pose_mode or 'frontal',
)
)
@ -509,6 +558,7 @@ def auto_match_faces(
reference_photo_id=reference_face.photo_id,
reference_photo_filename=reference_photo.filename,
reference_location=reference_face.location,
reference_pose_mode=reference_pose_mode,
face_count=face_count,
matches=match_items,
total_matches=len(match_items),
@ -520,6 +570,10 @@ def auto_match_faces(
people=people_items,
total_people=len(people_items),
total_matches=total_matches,
auto_accepted=request.auto_accept,
auto_accepted_faces=auto_accepted_faces,
skipped_persons=skipped_persons,
skipped_matches=skipped_matches,
)

View File

@ -182,6 +182,8 @@ 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)")
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%)")
class AutoMatchFaceItem(BaseModel):
@ -196,6 +198,7 @@ class AutoMatchFaceItem(BaseModel):
quality_score: float
similarity: float # Confidence percentage (0-100)
distance: float
pose_mode: str = Field("frontal", description="Pose classification (frontal, profile_left, etc.)")
class AutoMatchPersonItem(BaseModel):
@ -209,6 +212,7 @@ class AutoMatchPersonItem(BaseModel):
reference_photo_id: int
reference_photo_filename: str
reference_location: str
reference_pose_mode: str = Field("frontal", description="Reference face pose classification")
face_count: int # Number of faces already identified for this person
matches: list[AutoMatchFaceItem]
total_matches: int
@ -222,6 +226,10 @@ class AutoMatchResponse(BaseModel):
people: list[AutoMatchPersonItem]
total_people: int
total_matches: int
auto_accepted: bool = Field(False, description="Whether auto-acceptance was performed")
auto_accepted_faces: int = Field(0, description="Number of faces automatically accepted")
skipped_persons: int = Field(0, description="Number of persons skipped (non-frontal reference)")
skipped_matches: int = Field(0, description="Number of matches skipped (didn't meet criteria)")
class AcceptMatchesRequest(BaseModel):

View File

@ -913,11 +913,47 @@ def calibrate_confidence(distance: float, tolerance: float = None) -> float:
return max(1, min(20, confidence))
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).
Args:
pose_mode: Pose classification string (e.g., 'frontal', 'tilted_left', 'profile_left')
Returns:
True if pose is acceptable (frontal or tilted), False if profile or other non-frontal
"""
if not pose_mode:
return True # Default to frontal if None
pose_mode = pose_mode.lower()
# Accept frontal faces
if pose_mode == 'frontal':
return True
# Accept tilted faces (but not profile)
# Check if it contains 'tilted' but NOT 'profile'
if 'tilted' in pose_mode and 'profile' not in pose_mode:
return True
# Reject profile faces and other non-frontal poses
if 'profile' in pose_mode:
return False
# For other combinations, check if they're still frontal-like
# (e.g., 'frontal_looking_up' would be acceptable)
if pose_mode.startswith('frontal'):
return True
return False
def find_similar_faces(
db: Session,
face_id: int,
limit: int = 20,
tolerance: float = 0.6, # DEFAULT_FACE_TOLERANCE from desktop
filter_frontal_only: bool = False, # New: Only return frontal or tilted faces (not profile)
) -> List[Tuple[Face, float, float]]: # Returns (face, distance, confidence_pct)
"""Find similar faces matching desktop logic exactly.
@ -931,6 +967,9 @@ def find_similar_faces(
1. Filters by person_id is None (unidentified)
2. Filters by confidence >= 40%
3. Sorts by distance
Args:
filter_frontal_only: Only return frontal or tilted faces (not profile)
"""
from src.core.config import DEFAULT_FACE_TOLERANCE
from src.web.db.models import Photo
@ -1050,6 +1089,12 @@ def find_similar_faces(
print(f"DEBUG: Will include? {is_unidentified and confidence_pct >= 40}")
if is_unidentified and confidence_pct >= 40:
# Filter by pose_mode if requested (only frontal or tilted faces)
if filter_frontal_only and not _is_acceptable_pose_for_auto_match(f.pose_mode):
if face_id in [111, 113, 1] or (face_id == 1 and len(matches) < 10):
print(f"DEBUG: ✗ Face {f.id} filtered out (not frontal/tilted: {f.pose_mode})")
continue
# Return calibrated confidence percentage (matching desktop)
# Desktop displays confidence_pct directly from _get_calibrated_confidence
matches.append((f, distance, confidence_pct))
@ -1081,6 +1126,7 @@ def find_similar_faces(
def find_auto_match_matches(
db: Session,
tolerance: float = 0.6,
filter_frontal_only: bool = False,
) -> List[Tuple[int, int, Face, List[Tuple[Face, float, float]]]]:
"""Find auto-match matches for all identified people, matching desktop logic exactly.
@ -1090,6 +1136,10 @@ def find_auto_match_matches(
3. For each person, find similar unidentified faces using _get_filtered_similar_faces
4. Return matches grouped by person
Args:
tolerance: Similarity tolerance (default: 0.6)
filter_frontal_only: Only include persons with frontal or tilted reference face (not profile)
Returns:
List of (person_id, reference_face_id, reference_face, matches) tuples
where matches is list of (face, distance, confidence_pct) tuples
@ -1116,6 +1166,16 @@ def find_auto_match_matches(
.all()
)
if not identified_faces:
return []
# Filter by pose_mode if requested (only frontal or tilted faces)
if filter_frontal_only:
identified_faces = [
f for f in identified_faces
if _is_acceptable_pose_for_auto_match(f.pose_mode)
]
if not identified_faces:
return []
@ -1158,7 +1218,8 @@ def find_auto_match_matches(
# reference_face_id, tolerance, include_same_photo=False, face_status=None)
# This filters by: person_id is None (unidentified), confidence >= 40%, sorts by distance
similar_faces = find_similar_faces(
db, reference_face_id, limit=1000, tolerance=tolerance
db, reference_face_id, limit=1000, tolerance=tolerance,
filter_frontal_only=filter_frontal_only
)
if similar_faces: