Merge pull request 'Phase 2: trusted Auto-Match refs + accept/reject log' (#90) from feature/face-accuracy-phase2-automatch into master
CI / skip-ci-check (push) Successful in 30s
CI / docker-ci (push) Successful in 32s
CI / python-lint (push) Successful in 33s
CI / secret-scan (push) Successful in 41s
CI / viewer-unit (push) Successful in 1m46s
CI / e2e (push) Failing after 1m56s
CI / admin-unit (push) Successful in 2m2s
CI / skip-ci-check (push) Successful in 30s
CI / docker-ci (push) Successful in 32s
CI / python-lint (push) Successful in 33s
CI / secret-scan (push) Successful in 41s
CI / viewer-unit (push) Successful in 1m46s
CI / e2e (push) Failing after 1m56s
CI / admin-unit (push) Successful in 2m2s
This commit was merged in pull request #90.
This commit is contained in:
+1
-1
@@ -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)**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -73,8 +73,29 @@ export const peopleApi = {
|
||||
const res = await apiClient.get<PersonVideosResponse>(`/api/v1/people/${personId}/videos`)
|
||||
return res.data
|
||||
},
|
||||
acceptMatches: async (personId: number, faceIds: number[]): Promise<IdentifyFaceResponse> => {
|
||||
const res = await apiClient.post<IdentifyFaceResponse>(`/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<IdentifyFaceResponse> => {
|
||||
const payload = Array.isArray(body) ? { face_ids: body } : body
|
||||
const res = await apiClient.post<IdentifyFaceResponse>(
|
||||
`/api/v1/people/${personId}/accept-matches`,
|
||||
payload,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
delete: async (personId: number): Promise<void> => {
|
||||
|
||||
@@ -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<AutoMatchPersonSummary[]>([])
|
||||
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonSummary[]>([])
|
||||
@@ -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<number, boolean> = {}
|
||||
@@ -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')
|
||||
|
||||
+22
-2
@@ -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__)
|
||||
|
||||
+58
-4
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user