diff --git a/ROADMAP.md b/ROADMAP.md index 8fff2e4..d956c03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -106,7 +106,7 @@ Living plan for product quality, auth/email reliability, and automation. ### Face recognition accuracy (2026-08) - [x] **Phase 1 — quality sharpness** — Laplacian variance computed on the face crop (was kernel constant); unit tests in `tests/test_face_quality_score.py`; error-tag checklist in `docs/FACE_MATCH_ERROR_TAGGING.md` -- [ ] **Phase 2 — trusted refs + stricter Auto-Match + accept/reject log** +- [x] **Phase 2 — trusted refs + stricter Auto-Match + accept/reject log** — ref quality floor 0.5; browse/run tolerances 0.5/0.4; auto-accept default 85%; `match_decisions` table + logging on Save / auto-accept; tests in `tests/test_auto_match_phase2.py` - [ ] **Phase 3 — recalibrate confidence on labeled pairs; re-score corpus** - [ ] **Phase 4 — multi-ref / ensemble embeddings** - [ ] **Phase 5 — harder reject of junk detections (tiny/blur/pose)** diff --git a/admin-frontend/src/api/faces.ts b/admin-frontend/src/api/faces.ts index e6f91d9..f2ba250 100644 --- a/admin-frontend/src/api/faces.ts +++ b/admin-frontend/src/api/faces.ts @@ -177,6 +177,18 @@ export interface AutoMatchResponse { export interface AcceptMatchesRequest { face_ids: number[] + accepted_matches?: Array<{ + face_id: number + similarity?: number + distance?: number + reference_face_id?: number + }> + rejected_matches?: Array<{ + face_id: number + similarity?: number + distance?: number + reference_face_id?: number + }> } export interface MaintenanceFaceItem { diff --git a/admin-frontend/src/api/people.ts b/admin-frontend/src/api/people.ts index aae5d0e..7611fc4 100644 --- a/admin-frontend/src/api/people.ts +++ b/admin-frontend/src/api/people.ts @@ -73,8 +73,29 @@ export const peopleApi = { const res = await apiClient.get(`/api/v1/people/${personId}/videos`) return res.data }, - acceptMatches: async (personId: number, faceIds: number[]): Promise => { - const res = await apiClient.post(`/api/v1/people/${personId}/accept-matches`, { face_ids: faceIds }) + acceptMatches: async ( + personId: number, + body: { + face_ids: number[] + accepted_matches?: Array<{ + face_id: number + similarity?: number + distance?: number + reference_face_id?: number + }> + rejected_matches?: Array<{ + face_id: number + similarity?: number + distance?: number + reference_face_id?: number + }> + } | number[], + ): Promise => { + const payload = Array.isArray(body) ? { face_ids: body } : body + const res = await apiClient.post( + `/api/v1/people/${personId}/accept-matches`, + payload, + ) return res.data }, delete: async (personId: number): Promise => { diff --git a/admin-frontend/src/pages/AutoMatch.tsx b/admin-frontend/src/pages/AutoMatch.tsx index 3321359..1af0076 100644 --- a/admin-frontend/src/pages/AutoMatch.tsx +++ b/admin-frontend/src/pages/AutoMatch.tsx @@ -10,15 +10,16 @@ import { useDeveloperMode } from '../context/DeveloperModeContext' import { useToast } from '../context/ToastContext' import { useConfirm } from '../context/ConfirmContext' -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) +const DEFAULT_TOLERANCE = 0.5 // Browse Auto-Match (Phase 2: stricter than 0.6) +const RUN_AUTO_MATCH_TOLERANCE = 0.4 // Run auto-match button (stricter still) +const DEFAULT_AUTO_ACCEPT_THRESHOLD = 85 export default function AutoMatch() { const { isDeveloperMode } = useDeveloperMode() const { showToast } = useToast() const { confirm } = useConfirm() const [tolerance, setTolerance] = useState(DEFAULT_TOLERANCE) - const [autoAcceptThreshold, setAutoAcceptThreshold] = useState(70) + const [autoAcceptThreshold, setAutoAcceptThreshold] = useState(DEFAULT_AUTO_ACCEPT_THRESHOLD) const [isActive, setIsActive] = useState(false) const [people, setPeople] = useState([]) const [filteredPeople, setFilteredPeople] = useState([]) @@ -611,11 +612,29 @@ export default function AutoMatch() { setSaving(true) try { - const faceIds = currentMatches - .filter((match: AutoMatchFaceItem) => selectedFaces[match.id] === true) - .map((match: AutoMatchFaceItem) => match.id) + const accepted = currentMatches.filter( + (match: AutoMatchFaceItem) => selectedFaces[match.id] === true + ) + const rejected = currentMatches.filter( + (match: AutoMatchFaceItem) => selectedFaces[match.id] !== true + ) + const refId = currentPerson.reference_face_id - await peopleApi.acceptMatches(currentPerson.person_id, faceIds) + await peopleApi.acceptMatches(currentPerson.person_id, { + face_ids: accepted.map((m) => m.id), + accepted_matches: accepted.map((m) => ({ + face_id: m.id, + similarity: m.similarity, + distance: m.distance, + reference_face_id: refId, + })), + rejected_matches: rejected.map((m) => ({ + face_id: m.id, + similarity: m.similarity, + distance: m.distance, + reference_face_id: refId, + })), + }) // Update original selected faces to current state const newOriginal: Record = {} @@ -624,7 +643,7 @@ export default function AutoMatch() { }) setOriginalSelectedFaces(prev => ({ ...prev, ...newOriginal })) - showToast(`Saved ${faceIds.length} match(es)`, 'success') + showToast(`Saved ${accepted.length} match(es)`, 'success') } catch (error) { console.error('Save failed:', error) showToast('Failed to save matches. Please try again.', 'error') diff --git a/backend/api/faces.py b/backend/api/faces.py index 76c52f7..877d326 100644 --- a/backend/api/faces.py +++ b/backend/api/faces.py @@ -49,6 +49,7 @@ from backend.services.face_service import ( find_similar_faces, get_auto_match_people_list, list_unidentified_faces, + record_match_decisions, ) from backend.services.face_service import ( get_auto_match_person_matches as get_person_matches_service, @@ -612,7 +613,7 @@ def auto_match_faces( """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) + 1. Gets all identified people (one face per person, best quality >= MIN_AUTO_MATCH_REFERENCE_QUALITY) 2. For each person, finds similar unidentified faces (confidence >= 40%) 3. Returns matches grouped by person, sorted by person name @@ -620,7 +621,7 @@ def auto_match_faces( - Only processes persons with frontal or tilted reference faces (not profile) - Only processes persons with reference face quality > 50% (quality_score > 0.5) - Only matches with frontal or tilted unidentified faces (not profile) - - Only auto-accepts matches with similarity >= threshold + - Only auto-accepts matches with similarity >= threshold (default 85%) - Only auto-accepts faces with quality > 50% (quality_score > 0.5) """ from backend.db.models import Person, Photo @@ -649,6 +650,7 @@ def auto_match_faces( # 3. Quality must be > 50% (quality_score > 0.5) qualifying_faces = [] + accept_log_items = [] for face, distance, confidence_pct in similar_faces: # Check similarity threshold if confidence_pct < request.auto_accept_threshold: @@ -662,6 +664,14 @@ def auto_match_faces( continue qualifying_faces.append(face.id) + accept_log_items.append( + { + "face_id": face.id, + "similarity": float(confidence_pct), + "distance": float(distance), + "reference_face_id": reference_face_id, + } + ) # Auto-accept qualifying faces if qualifying_faces: @@ -670,6 +680,16 @@ def auto_match_faces( db, person_id, qualifying_faces ) auto_accepted_faces += identified_count + if identified_count: + record_match_decisions( + db, + decision="accept", + source="auto_accept", + person_id=person_id, + items=accept_log_items, + user_id=None, + commit=True, + ) except Exception as e: import logging logger = logging.getLogger(__name__) diff --git a/backend/api/people.py b/backend/api/people.py index 1915232..2ce0d85 100644 --- a/backend/api/people.py +++ b/backend/api/people.py @@ -21,7 +21,7 @@ from backend.schemas.people import ( PersonUpdateRequest, PersonWithFacesResponse, ) -from backend.services.face_service import accept_auto_match_matches +from backend.services.face_service import accept_auto_match_matches, record_match_decisions router = APIRouter(prefix="/people", tags=["people"]) @@ -278,9 +278,63 @@ def accept_matches( user_id = current_user["user_id"] try: - identified_count, updated_count = accept_auto_match_matches( - db, person_id, request.face_ids, user_id=user_id - ) + person = db.query(Person).filter(Person.id == person_id).first() + if not person: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Person {person_id} not found", + ) + + if request.face_ids: + accept_auto_match_matches( + db, person_id, request.face_ids, user_id=user_id + ) + + # Log accepts (with optional scores from client) + score_by_face = { + item.face_id: item for item in (request.accepted_matches or []) + } + accept_items = [] + for face_id in request.face_ids: + scored = score_by_face.get(face_id) + accept_items.append( + { + "face_id": face_id, + "similarity": scored.similarity if scored else None, + "distance": scored.distance if scored else None, + "reference_face_id": scored.reference_face_id if scored else None, + } + ) + if accept_items: + record_match_decisions( + db, + decision="accept", + source="auto_match", + person_id=person_id, + items=accept_items, + user_id=user_id, + commit=True, + ) + + if request.rejected_matches: + reject_items = [ + { + "face_id": item.face_id, + "similarity": item.similarity, + "distance": item.distance, + "reference_face_id": item.reference_face_id, + } + for item in request.rejected_matches + ] + record_match_decisions( + db, + decision="reject", + source="auto_match", + person_id=person_id, + items=reject_items, + user_id=user_id, + commit=True, + ) except ValueError as e: if "not found" in str(e).lower(): raise HTTPException( diff --git a/backend/app.py b/backend/app.py index 4414fcd..167c82e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -406,6 +406,51 @@ def ensure_photo_person_linkage_table(inspector) -> None: print("✅ Created photo_person_linkage table") +def ensure_match_decisions_table(inspector) -> None: + """Ensure match_decisions table exists for Auto-Match accept/reject logging.""" + if "match_decisions" in inspector.get_table_names(): + print("ℹ️ match_decisions table already exists") + return + + print("🔄 Creating match_decisions table...") + with engine.connect() as connection: + with connection.begin(): + connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS match_decisions ( + id SERIAL PRIMARY KEY, + decision TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'auto_match', + person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE, + face_id INTEGER NOT NULL REFERENCES faces(id) ON DELETE CASCADE, + reference_face_id INTEGER REFERENCES faces(id) ON DELETE SET NULL, + similarity NUMERIC, + distance NUMERIC, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + for idx_name, idx_cols in [ + ("idx_match_decisions_decision", "decision"), + ("idx_match_decisions_source", "source"), + ("idx_match_decisions_person", "person_id"), + ("idx_match_decisions_face", "face_id"), + ("idx_match_decisions_created", "created_at"), + ("idx_match_decisions_person_face", "person_id, face_id"), + ("idx_match_decisions_decision_created", "decision, created_at"), + ]: + try: + connection.execute( + text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON match_decisions({idx_cols})") + ) + except Exception: + pass + print("✅ Created match_decisions table") + + def ensure_people_contact_columns(inspector) -> None: """Ensure people table has optional email/phone contact columns.""" if "people" not in inspector.get_table_names(): @@ -756,6 +801,7 @@ async def lifespan(app: FastAPI): ensure_face_excluded_column(inspector) ensure_role_permissions_table(inspector) ensure_people_contact_columns(inspector) + ensure_match_decisions_table(inspector) # Setup auth database tables for both frontends (viewer and admin) if auth_engine is not None: diff --git a/backend/config.py b/backend/config.py index 2cf11e5..4d2d1eb 100644 --- a/backend/config.py +++ b/backend/config.py @@ -31,4 +31,13 @@ CONFIDENCE_CALIBRATION_METHOD = "empirical" # "empirical", "linear", or "sigmoi # Faces smaller than this are excluded from auto-match to avoid generic encodings MIN_AUTO_MATCH_FACE_SIZE_RATIO = 0.005 # 0.5% of image area +# Auto-match reference / accept gates (Phase 2 accuracy) +# Reference faces below this quality are not used for matching +MIN_AUTO_MATCH_REFERENCE_QUALITY = 0.5 # was 0.3 — prefer clearer refs +# Default Auto-Accept threshold (%) — align UI copy (~85%) with code +DEFAULT_AUTO_ACCEPT_THRESHOLD = 85.0 +# Browse vs Run tolerances (UI defaults; lower = stricter) +DEFAULT_BROWSE_TOLERANCE = 0.5 +DEFAULT_RUN_TOLERANCE = 0.4 + diff --git a/backend/db/models.py b/backend/db/models.py index dc4ab5a..55842f0 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -291,3 +291,28 @@ class RolePermission(Base): Index("idx_role_permissions_role_feature", "role", "feature_key"), ) + +class MatchDecision(Base): + """Admin accept/reject of an Auto-Match (or Identify) suggestion. + + Used to retune thresholds (Phase 3) from real JRCC decisions — no PII beyond IDs. + """ + + __tablename__ = "match_decisions" + + id = Column(Integer, primary_key=True, autoincrement=True) + decision = Column(Text, nullable=False, index=True) # accept | reject + source = Column(Text, nullable=False, default="auto_match", index=True) # auto_match | identify | auto_accept + person_id = Column(Integer, ForeignKey("people.id"), nullable=False, index=True) + face_id = Column(Integer, ForeignKey("faces.id"), nullable=False, index=True) + reference_face_id = Column(Integer, ForeignKey("faces.id"), nullable=True, index=True) + similarity = Column(Numeric, nullable=True) # calibrated confidence 0–100 when known + distance = Column(Numeric, nullable=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + __table_args__ = ( + Index("idx_match_decisions_person_face", "person_id", "face_id"), + Index("idx_match_decisions_decision_created", "decision", "created_at"), + ) + diff --git a/backend/schemas/faces.py b/backend/schemas/faces.py index 53773be..aabc6a2 100644 --- a/backend/schemas/faces.py +++ b/backend/schemas/faces.py @@ -218,7 +218,12 @@ class AutoMatchRequest(BaseModel): tolerance: float = Field(0.5, 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%)") + auto_accept_threshold: float = Field( + 85.0, + ge=0.0, + le=100.0, + description="Similarity threshold for auto-acceptance (0-100%)", + ) use_distance_based_thresholds: bool = Field(False, description="Use distance-based confidence thresholds (stricter for borderline distances)") @@ -303,12 +308,31 @@ class AutoMatchResponse(BaseModel): skipped_matches: int = Field(0, description="Number of matches skipped (didn't meet criteria)") +class MatchDecisionScore(BaseModel): + """Optional scores for an accept/reject decision log entry.""" + + model_config = ConfigDict(protected_namespaces=()) + + face_id: int + similarity: float | None = Field(None, description="Calibrated confidence 0–100 when known") + distance: float | None = None + reference_face_id: int | None = None + + class AcceptMatchesRequest(BaseModel): """Request to accept matches for a person.""" model_config = ConfigDict(protected_namespaces=()) face_ids: list[int] = Field(..., min_items=0, description="Face IDs to identify with this person") + accepted_matches: list[MatchDecisionScore] | None = Field( + None, + description="Optional scores for accepted faces (for match_decisions log)", + ) + rejected_matches: list[MatchDecisionScore] | None = Field( + None, + description="Faces reviewed but not accepted (logged as reject)", + ) class MaintenanceFaceItem(BaseModel): diff --git a/backend/services/face_service.py b/backend/services/face_service.py index 6252692..7b9b728 100644 --- a/backend/services/face_service.py +++ b/backend/services/face_service.py @@ -34,11 +34,12 @@ from backend.config import ( DEFAULT_FACE_TOLERANCE, MAX_FACE_SIZE, MIN_AUTO_MATCH_FACE_SIZE_RATIO, + MIN_AUTO_MATCH_REFERENCE_QUALITY, MIN_FACE_CONFIDENCE, MIN_FACE_SIZE, USE_CALIBRATED_CONFIDENCE, ) -from backend.db.models import Face, Person, Photo, PhotoTagLinkage, Tag +from backend.db.models import Face, MatchDecision, Person, Photo, PhotoTagLinkage, Tag from src.utils.exif_utils import EXIFOrientationHandler from src.utils.pose_detection import RETINAFACE_AVAILABLE, PoseDetector @@ -2260,6 +2261,118 @@ def calculate_batch_similarities( return pairs +def _auto_match_pose_order(): + """SQLAlchemy CASE: frontal first, then tilted, then profile (last).""" + return case( + (func.lower(Face.pose_mode).like("%profile%"), 2), + (func.lower(Face.pose_mode).like("%tilted%"), 1), + (func.lower(Face.pose_mode).like("frontal%"), 0), + else_=2, + ) + + +def query_auto_match_reference_faces( + db: Session, + *, + min_quality: float | None = None, + person_id: int | None = None, +) -> List[Face]: + """Identified faces eligible as Auto-Match references, ordered best-first. + + Order: person_id, quality DESC, pose (frontal → tilted → profile). + Caller groups to one face per person (first row wins). + """ + quality_floor = ( + MIN_AUTO_MATCH_REFERENCE_QUALITY if min_quality is None else min_quality + ) + pose_order = _auto_match_pose_order() + query = ( + db.query(Face) + .join(Photo, Face.photo_id == Photo.id) + .filter(Face.person_id.isnot(None)) + .filter(Face.quality_score >= quality_floor) + ) + if person_id is not None: + query = query.filter(Face.person_id == person_id) + return query.order_by(Face.person_id, Face.quality_score.desc(), pose_order.asc()).all() + + +def pick_best_reference_face_per_person( + identified_faces: List[Face], +) -> Dict[int, Face]: + """First face per person_id (assumes list already ordered best-first).""" + person_faces: Dict[int, Face] = {} + for face in identified_faces: + if face.person_id is not None and face.person_id not in person_faces: + person_faces[face.person_id] = face + return person_faces + + +def record_match_decision( + db: Session, + *, + decision: str, + source: str, + person_id: int, + face_id: int, + reference_face_id: int | None = None, + similarity: float | None = None, + distance: float | None = None, + user_id: int | None = None, + commit: bool = False, +) -> MatchDecision: + """Append one accept/reject row for later threshold calibration.""" + row = MatchDecision( + decision=decision, + source=source, + person_id=person_id, + face_id=face_id, + reference_face_id=reference_face_id, + similarity=similarity, + distance=distance, + user_id=user_id, + ) + db.add(row) + if commit: + db.commit() + db.refresh(row) + return row + + +def record_match_decisions( + db: Session, + *, + decision: str, + source: str, + person_id: int, + items: List[dict], + user_id: int | None = None, + commit: bool = True, +) -> int: + """Bulk-log match decisions. Each item: face_id + optional similarity/distance/reference_face_id.""" + count = 0 + for item in items: + face_id = item.get("face_id") + if face_id is None: + continue + record_match_decision( + db, + decision=decision, + source=source, + person_id=person_id, + face_id=int(face_id), + reference_face_id=item.get("reference_face_id"), + similarity=item.get("similarity"), + distance=item.get("distance"), + user_id=user_id, + commit=False, + ) + count += 1 + if commit and count: + db.commit() + return count + + def find_auto_match_matches( db: Session, tolerance: float = 0.5, @@ -2269,7 +2382,7 @@ def find_auto_match_matches( """Find auto-match matches for all identified people, matching desktop logic exactly. Desktop flow (from auto_match_panel.py _start_auto_match): - 1. Get all identified faces (one per person, best quality >= 0.3) + 1. Get all identified faces (one per person, best quality >= MIN_AUTO_MATCH_REFERENCE_QUALITY) 2. Group by person and get best quality face per person 3. For each person, find similar unidentified faces using _get_filtered_similar_faces 4. Return matches grouped by person @@ -2285,30 +2398,7 @@ def find_auto_match_matches( if tolerance is None: tolerance = DEFAULT_FACE_TOLERANCE - # Get all identified faces (one per person) to use as reference faces - # Desktop query: - # SELECT f.id, f.person_id, f.photo_id, f.location, p.filename, f.quality_score, - # f.face_confidence, f.detector_backend, f.model_name - # FROM faces f - # JOIN photos p ON f.photo_id = p.id - # WHERE f.person_id IS NOT NULL AND f.quality_score >= 0.3 - # ORDER BY f.person_id, f.quality_score DESC, pose_mode (frontal first, then tilted, then profile) - # Add pose mode ordering: frontal first, then tilted, then profile last - pose_order = case( - (func.lower(Face.pose_mode).like('%profile%'), 2), # Profile = 2 (last) - (func.lower(Face.pose_mode).like('%tilted%'), 1), # Tilted = 1 (second) - (func.lower(Face.pose_mode).like('frontal%'), 0), # Frontal = 0 (first) - else_=2 # Default to last for unknown poses - ) - - identified_faces: List[Face] = ( - db.query(Face) - .join(Photo, Face.photo_id == Photo.id) - .filter(Face.person_id.isnot(None)) - .filter(Face.quality_score >= 0.3) - .order_by(Face.person_id, Face.quality_score.desc(), pose_order.asc()) - .all() - ) + identified_faces = query_auto_match_reference_faces(db) if not identified_faces: return [] @@ -2323,12 +2413,7 @@ def find_auto_match_matches( if not identified_faces: return [] - # Group by person and get the best quality face per person (matching desktop) - person_faces: Dict[int, Face] = {} - for face in identified_faces: - person_id = face.person_id - if person_id not in person_faces: - person_faces[person_id] = face + person_faces = pick_best_reference_face_per_person(identified_faces) # Convert to ordered list to ensure consistent ordering # Desktop sorts by person name for consistent, user-friendly ordering @@ -2417,23 +2502,7 @@ def get_auto_match_people_list( if unidentified_count == 0: return [] - # Get all identified faces (one per person) to use as reference faces - # Same logic as find_auto_match_matches but without finding matches - pose_order = case( - (func.lower(Face.pose_mode).like('%profile%'), 2), # Profile = 2 (last) - (func.lower(Face.pose_mode).like('%tilted%'), 1), # Tilted = 1 (second) - (func.lower(Face.pose_mode).like('frontal%'), 0), # Frontal = 0 (first) - else_=2 # Default to last for unknown poses - ) - - identified_faces: List[Face] = ( - db.query(Face) - .join(Photo, Face.photo_id == Photo.id) - .filter(Face.person_id.isnot(None)) - .filter(Face.quality_score >= 0.3) - .order_by(Face.person_id, Face.quality_score.desc(), pose_order.asc()) - .all() - ) + identified_faces = query_auto_match_reference_faces(db) if not identified_faces: return [] @@ -2448,12 +2517,7 @@ def get_auto_match_people_list( if not identified_faces: return [] - # Group by person and get the best quality face per person - person_faces: Dict[int, Face] = {} - for face in identified_faces: - person_id = face.person_id - if person_id not in person_faces: - person_faces[person_id] = face + person_faces = pick_best_reference_face_per_person(identified_faces) # Convert to ordered list with person names person_faces_list = [] @@ -2503,17 +2567,10 @@ def get_auto_match_person_matches( Returns: List of (face, distance, confidence_pct) tuples """ - from backend.db.models import Face - - # Get reference face for this person (best quality >= 0.3) - reference_face = ( - db.query(Face) - .filter(Face.person_id == person_id) - .filter(Face.quality_score >= 0.3) - .order_by(Face.quality_score.desc()) - .first() - ) - + refs = query_auto_match_reference_faces(db, person_id=person_id) + if not refs: + return [] + reference_face = pick_best_reference_face_per_person(refs).get(person_id) if not reference_face: return [] diff --git a/docs/FACE_MATCH_ERROR_TAGGING.md b/docs/FACE_MATCH_ERROR_TAGGING.md index 5be1012..4e1a711 100644 --- a/docs/FACE_MATCH_ERROR_TAGGING.md +++ b/docs/FACE_MATCH_ERROR_TAGGING.md @@ -34,6 +34,8 @@ date | source (auto-match|identify) | face_id | suggested_person_id | true_perso ## After ~100 rows -Summarize counts by tag. Feed into Phase 2–3 (stricter Auto-Match thresholds, -confidence recalibration). Keep the sheet private; only aggregates belong in -docs/PRs. +Summarize counts by tag. Feed into Phase 3 (confidence recalibration). Keep the +sheet private; only aggregates belong in docs/PRs. + +Phase 2 also logs admin accept/reject (and auto-accept) into `match_decisions` +for the same recalibration work — use both the manual sheet and that table. diff --git a/tests/test_auto_match_phase2.py b/tests/test_auto_match_phase2.py new file mode 100644 index 0000000..0bfc2c0 --- /dev/null +++ b/tests/test_auto_match_phase2.py @@ -0,0 +1,109 @@ +"""Unit tests for Auto-Match Phase 2 helpers (no DeepFace / DB required).""" + +from __future__ import annotations + +from types import SimpleNamespace + +from backend.config import ( + DEFAULT_AUTO_ACCEPT_THRESHOLD, + DEFAULT_BROWSE_TOLERANCE, + DEFAULT_RUN_TOLERANCE, + MIN_AUTO_MATCH_REFERENCE_QUALITY, +) +from backend.services.face_service import ( + pick_best_reference_face_per_person, + record_match_decisions, +) + + +def _face(person_id: int | None, quality: float, face_id: int = 1): + return SimpleNamespace(id=face_id, person_id=person_id, quality_score=quality) + + +class TestPhase2ConfigDefaults: + def test_reference_quality_stricter_than_legacy(self): + assert MIN_AUTO_MATCH_REFERENCE_QUALITY >= 0.5 + + def test_auto_accept_aligns_with_ui_copy(self): + assert DEFAULT_AUTO_ACCEPT_THRESHOLD >= 85.0 + + def test_run_tolerance_stricter_than_browse(self): + assert DEFAULT_RUN_TOLERANCE < DEFAULT_BROWSE_TOLERANCE + assert DEFAULT_BROWSE_TOLERANCE <= 0.5 + + +class TestPickBestReferenceFacePerPerson: + def test_keeps_first_face_per_person(self): + faces = [ + _face(1, 0.9, face_id=10), + _face(1, 0.8, face_id=11), + _face(2, 0.7, face_id=20), + ] + picked = pick_best_reference_face_per_person(faces) + assert set(picked.keys()) == {1, 2} + assert picked[1].id == 10 + assert picked[2].id == 20 + + def test_skips_unidentified(self): + faces = [_face(None, 0.9, face_id=1), _face(3, 0.6, face_id=3)] + picked = pick_best_reference_face_per_person(faces) + assert list(picked.keys()) == [3] + assert picked[3].id == 3 + + def test_empty(self): + assert pick_best_reference_face_per_person([]) == {} + + +class TestRecordMatchDecisions: + def test_bulk_adds_and_skips_missing_face_id(self): + added: list = [] + + class FakeSession: + def add(self, row): + added.append(row) + + def commit(self): + pass + + count = record_match_decisions( + FakeSession(), # type: ignore[arg-type] + decision="accept", + source="auto_match", + person_id=42, + items=[ + {"face_id": 1, "similarity": 91.0, "distance": 0.2, "reference_face_id": 9}, + {"similarity": 50.0}, # missing face_id — skipped + {"face_id": 2, "similarity": 88.0}, + ], + user_id=7, + commit=True, + ) + assert count == 2 + assert len(added) == 2 + assert added[0].decision == "accept" + assert added[0].person_id == 42 + assert added[0].face_id == 1 + assert float(added[0].similarity) == 91.0 + assert added[0].user_id == 7 + assert added[1].face_id == 2 + + def test_reject_source(self): + added: list = [] + + class FakeSession: + def add(self, row): + added.append(row) + + def commit(self): + pass + + record_match_decisions( + FakeSession(), # type: ignore[arg-type] + decision="reject", + source="auto_match", + person_id=1, + items=[{"face_id": 99, "similarity": 72.0}], + commit=True, + ) + assert added[0].decision == "reject" + assert added[0].source == "auto_match"