diff --git a/ROADMAP.md b/ROADMAP.md index 12c7d5f..d41777b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,8 +109,9 @@ Living plan for product quality, auth/email reliability, and automation. - [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` - [x] **Phase 3 — recalibrate confidence + re-score quality** — fit distance→confidence knots from identified pairs / `match_decisions`; optional JSON overrides legacy curve; `scripts/fit_confidence_calibration.py` + `scripts/rescore_face_quality.py`; docs in `docs/FACE_CONFIDENCE_CALIBRATION.md` - [x] **Phase 4 — multi-ref matching** — up to 3 trusted refs per person; best (min) distance wins; rolling-mean calibration fitter; status in `docs/FACE_ACCURACY_STATUS.md` -- [ ] **Phase 5 — harder reject of junk detections (tiny/blur/pose)** -- [ ] **Phase 6 — multi-embedding per person** (centroid / age-band — partially overlapped by Phase 4 refs) +- [x] **Immich precision gates** — max recognition distance, next-best person margin, detection floor 0.55, auto-accept distance cap; admin Chabad Blue theme (shell + login) +- [ ] **Phase 5 — harder reject of junk detections (tiny/blur/pose)** — partial via detection floor; blur/pose still open +- [ ] **Phase 6 — multi-embedding / person merge / cluster-name Identify** ## Later diff --git a/admin-frontend/index.html b/admin-frontend/index.html index 26b33e7..792b87c 100644 --- a/admin-frontend/index.html +++ b/admin-frontend/index.html @@ -2,9 +2,12 @@ - + - PunimTag + + + + JRCC Photos Admin
diff --git a/admin-frontend/src/components/Layout.tsx b/admin-frontend/src/components/Layout.tsx index 2eb2781..37af124 100644 --- a/admin-frontend/src/components/Layout.tsx +++ b/admin-frontend/src/components/Layout.tsx @@ -75,7 +75,9 @@ export default function Layout() { } }} className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${ - isActive ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50' + isActive + ? 'bg-primary/10 text-primary' + : 'text-foreground/80 hover:bg-muted' } ${extraClasses}`} > {item.icon} @@ -112,19 +114,19 @@ export default function Layout() { } return ( -
+
{/* Top bar */} -
+
{/* Left sidebar - fixed position with logo */} -
+
{isIOSDevice ? ( ) : ( - PunimTag { - // Fallback if logo.png doesn't exist, try logo.svg - const target = e.target as HTMLImageElement - if (target.src.endsWith('logo.png')) { - target.src = '/logo.svg' - } - }} + JRCC + JRCC )} @@ -153,13 +153,13 @@ export default function Layout() {
-

{getPageTitle()}

+

{getPageTitle()}

- {username} + {username} @@ -173,14 +173,14 @@ export default function Layout() { {/* Overlay for mobile when sidebar is open */} {isIOSDevice && sidebarOpen && (
setSidebarOpen(false)} /> )} {/* Left sidebar - fixed position */}
setMaintenanceExpanded((prev) => !prev)} - className="w-full px-3 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 flex items-center justify-between hover:text-gray-700" + className="w-full px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center justify-between hover:text-foreground" > Maintenance {maintenanceExpanded ? '▼' : '▶'} diff --git a/admin-frontend/src/index.css b/admin-frontend/src/index.css index 7b88354..075ca66 100644 --- a/admin-frontend/src/index.css +++ b/admin-frontend/src/index.css @@ -2,64 +2,117 @@ @tailwind components; @tailwind utilities; +/* + Chabad Blue — shared with viewer-frontend/app/globals.css + Navy #0038A8 / deep text #0B1F4B / soft bg #F5F7FB / quiet gold #C4A35A +*/ :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + --radius: 0.625rem; + --background: #f5f7fb; + --foreground: #0b1f4b; + --card: #ffffff; + --card-foreground: #0b1f4b; + --popover: #ffffff; + --popover-foreground: #0b1f4b; + --primary: #0038a8; + --primary-foreground: #ffffff; + --secondary: #e8eef8; + --secondary-foreground: #0b1f4b; + --muted: #e8eef8; + --muted-foreground: #3d4f73; + --accent: #0038a8; + --accent-foreground: #ffffff; + --gold: #c4a35a; + --gold-foreground: #1c1508; + --destructive: #b91c1c; + --border: rgba(11, 31, 75, 0.12); + --input: rgba(11, 31, 75, 0.14); + --ring: #0038a8; + --font-sans: "Rubik", "Heebo", ui-sans-serif, system-ui, sans-serif; + --font-display: "Heebo", "Rubik", ui-sans-serif, system-ui, sans-serif; +} + +.dark { + --background: #071428; + --foreground: #f5f7fb; + --card: #0b1f4b; + --card-foreground: #f5f7fb; + --popover: #0b1f4b; + --popover-foreground: #f5f7fb; + --primary: #5b8def; + --primary-foreground: #071428; + --secondary: #132a5c; + --secondary-foreground: #f5f7fb; + --muted: #132a5c; + --muted-foreground: #b6c4df; + --accent: #5b8def; + --accent-foreground: #071428; + --gold: #d4b86a; + --gold-foreground: #1c1508; + --destructive: #f87171; + --border: rgba(245, 247, 251, 0.12); + --input: rgba(245, 247, 251, 0.16); + --ring: #5b8def; +} + +html { + font-family: var(--font-sans); line-height: 1.5; font-weight: 400; + color: var(--foreground); + background: var(--background); } body { margin: 0; min-height: 100vh; + background: var(--background); + color: var(--foreground); } /* Custom scrollbar styling for similar faces container */ .similar-faces-scrollable { - /* Firefox */ scrollbar-width: auto; - scrollbar-color: #4B5563 #F3F4F6; + scrollbar-color: var(--muted-foreground) var(--muted); } .similar-faces-scrollable::-webkit-scrollbar { - /* Chrome, Safari, Edge */ width: 12px; } .similar-faces-scrollable::-webkit-scrollbar-track { - background: #F3F4F6; + background: var(--muted); border-radius: 6px; } .similar-faces-scrollable::-webkit-scrollbar-thumb { - background: #4B5563; + background: var(--muted-foreground); border-radius: 6px; - border: 2px solid #F3F4F6; + border: 2px solid var(--muted); } .similar-faces-scrollable::-webkit-scrollbar-thumb:hover { - background: #374151; + background: var(--foreground); } .role-permissions-scroll { scrollbar-width: auto; - scrollbar-color: #1d4ed8 #e5e7eb; + scrollbar-color: var(--primary) var(--secondary); } .role-permissions-scroll::-webkit-scrollbar { width: 16px; height: 16px; - background-color: #bfdbfe; + background-color: var(--secondary); } .role-permissions-scroll::-webkit-scrollbar-track { - background: #bfdbfe; + background: var(--secondary); border-radius: 8px; } .role-permissions-scroll::-webkit-scrollbar-thumb { - background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%); + background: var(--primary); border-radius: 8px; - border: 3px solid #bfdbfe; - box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2); + border: 3px solid var(--secondary); } - diff --git a/admin-frontend/src/pages/Login.tsx b/admin-frontend/src/pages/Login.tsx index 3d50332..e92273a 100644 --- a/admin-frontend/src/pages/Login.tsx +++ b/admin-frontend/src/pages/Login.tsx @@ -12,8 +12,6 @@ export default function Login() { const navigate = useNavigate() useEffect(() => { - // Only redirect if user is already authenticated (e.g., visiting /login while logged in) - // Don't redirect on isLoading changes during login attempts if (isAuthenticated && !isLoading) { navigate('/', { replace: true }) } @@ -33,42 +31,46 @@ export default function Login() { setError(result.error || 'Login failed') setLoading(false) } - } catch (err) { + } catch { setError('Login failed') setLoading(false) } } - // Only show loading screen on initial auth check, not during login attempts if (isLoading && !loading) { - return
Loading...
+ return ( +
+ Loading... +
+ ) } return ( -
+
-
+
- PunimTag { - // Fallback if logo.png doesn't exist, try logo.svg - const target = e.target as HTMLImageElement - if (target.src.endsWith('logo.png')) { - target.src = '/logo.svg' - } - }} + Jewish Russian Community Centre + Jewish Russian Community Centre
-

Photo Management System

+

+ Photos Admin +

+

JRCC photo management

{error && ( -
+
{error}
)} @@ -76,7 +78,7 @@ export default function Login() {
@@ -86,14 +88,14 @@ export default function Login() { value={username} onChange={(e) => setUsername(e.target.value)} required - className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + className="w-full px-3 py-2 border border-input rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring" />
@@ -104,12 +106,12 @@ export default function Login() { value={password} onChange={(e) => setPassword(e.target.value)} required - className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + className="w-full px-3 py-2 pr-10 border border-input rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring" /> @@ -130,4 +132,3 @@ export default function Login() {
) } - diff --git a/admin-frontend/tailwind.config.js b/admin-frontend/tailwind.config.js index d37737f..8be0a36 100644 --- a/admin-frontend/tailwind.config.js +++ b/admin-frontend/tailwind.config.js @@ -4,9 +4,53 @@ export default { "./index.html", "./src/**/*.{js,ts,jsx,tsx}", ], + darkMode: "class", theme: { - extend: {}, + extend: { + colors: { + background: "var(--background)", + foreground: "var(--foreground)", + card: { + DEFAULT: "var(--card)", + foreground: "var(--card-foreground)", + }, + popover: { + DEFAULT: "var(--popover)", + foreground: "var(--popover-foreground)", + }, + primary: { + DEFAULT: "var(--primary)", + foreground: "var(--primary-foreground)", + }, + secondary: { + DEFAULT: "var(--secondary)", + foreground: "var(--secondary-foreground)", + }, + muted: { + DEFAULT: "var(--muted)", + foreground: "var(--muted-foreground)", + }, + accent: { + DEFAULT: "var(--accent)", + foreground: "var(--accent-foreground)", + }, + destructive: "var(--destructive)", + border: "var(--border)", + input: "var(--input)", + ring: "var(--ring)", + gold: { + DEFAULT: "var(--gold)", + foreground: "var(--gold-foreground)", + }, + }, + fontFamily: { + sans: ["var(--font-sans)"], + display: ["var(--font-display)"], + }, + borderRadius: { + lg: "var(--radius)", + }, + }, }, plugins: [], } - diff --git a/backend/api/faces.py b/backend/api/faces.py index 877d326..0b1165b 100644 --- a/backend/api/faces.py +++ b/backend/api/faces.py @@ -13,6 +13,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session from backend.api.auth import get_current_user_with_id +from backend.config import AUTO_ACCEPT_MAX_DISTANCE from backend.db.models import Face, Person, PersonEncoding, Photo from backend.db.session import get_db from backend.schemas.faces import ( @@ -652,6 +653,11 @@ def auto_match_faces( qualifying_faces = [] accept_log_items = [] for face, distance, confidence_pct in similar_faces: + # Immich-style: auto-accept only when very close + if float(distance) > AUTO_ACCEPT_MAX_DISTANCE: + skipped_matches += 1 + continue + # Check similarity threshold if confidence_pct < request.auto_accept_threshold: skipped_matches += 1 diff --git a/backend/config.py b/backend/config.py index ffd0755..fea5c96 100644 --- a/backend/config.py +++ b/backend/config.py @@ -16,8 +16,8 @@ SUPPORTED_VIDEO_FORMATS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".fl DEEPFACE_ENFORCE_DETECTION = False DEEPFACE_ALIGN_FACES = True -# Face filtering thresholds -MIN_FACE_CONFIDENCE = 0.4 +# Face filtering thresholds (Immich-style: prefer precision over recall) +MIN_FACE_CONFIDENCE = 0.55 # was 0.4 — junk detections poison matching MIN_FACE_SIZE = 40 MAX_FACE_SIZE = 1500 @@ -26,6 +26,15 @@ DEFAULT_FACE_TOLERANCE = 0.5 # Lowered from 0.6 for stricter matching USE_CALIBRATED_CONFIDENCE = True CONFIDENCE_CALIBRATION_METHOD = "empirical" # "empirical", "linear", or "sigmoid" +# Immich-inspired precision gates (false accepts hurt more than misses) +# Hard ceiling on cosine distance regardless of adaptive tolerance +MAX_RECOGNITION_DISTANCE = 0.50 +# Require best person to beat runner-up by this margin (sibling/lookalike guard) +MIN_NEXT_BEST_PERSON_MARGIN = 0.08 +# Auto-accept: distance must be this close AND (multi-ref agree OR single very close) +AUTO_ACCEPT_MAX_DISTANCE = 0.35 +AUTO_ACCEPT_MIN_REF_AGREE = 2 + # Auto-match face size filtering # Minimum face size as percentage of image area (0.5% = 0.005) # Faces smaller than this are excluded from auto-match to avoid generic encodings diff --git a/backend/services/face_service.py b/backend/services/face_service.py index 8d7a76e..2720f4c 100644 --- a/backend/services/face_service.py +++ b/backend/services/face_service.py @@ -34,10 +34,12 @@ from backend.config import ( DEEPFACE_ENFORCE_DETECTION, DEFAULT_FACE_TOLERANCE, MAX_FACE_SIZE, + MAX_RECOGNITION_DISTANCE, MIN_AUTO_MATCH_FACE_SIZE_RATIO, MIN_AUTO_MATCH_REFERENCE_QUALITY, MIN_FACE_CONFIDENCE, MIN_FACE_SIZE, + MIN_NEXT_BEST_PERSON_MARGIN, USE_CALIBRATED_CONFIDENCE, ) from backend.db.models import Face, MatchDecision, Person, Photo, PhotoTagLinkage, Tag @@ -1821,19 +1823,68 @@ def get_distance_based_min_confidence(distance: float) -> float: def calculate_adaptive_tolerance(base_tolerance: float, face_quality: float) -> float: - """Calculate adaptive tolerance based on face quality, matching desktop exactly.""" - # Start with base tolerance - tolerance = base_tolerance - - # Adjust based on face quality (higher quality = stricter tolerance) - quality_factor = 0.9 + (face_quality * 0.2) # Range: 0.9 to 1.1 - tolerance *= quality_factor - - # Ensure tolerance stays within reasonable bounds for DeepFace - # Allow tolerance down to 0.0 (user can set very strict matching) - # Allow tolerance up to 1.0 (matching API validation range) - # The quality factor can increase tolerance up to 1.1x, so cap at 1.0 to stay within API limits - return max(0.0, min(1.0, tolerance)) + """Adaptive tolerance capped by Immich-style max recognition distance. + + Higher quality → slightly stricter (never loosens past base). Always + capped at ``MAX_RECOGNITION_DISTANCE`` so low-quality pairs cannot widen + the match window into sibling/lookalike territory. + """ + q = max(0.0, min(1.0, float(face_quality))) + # quality 1.0 → 0.92× base; quality 0 → base (no loosening) + quality_factor = 1.0 - (q * 0.08) + tolerance = base_tolerance * quality_factor + return max(0.0, min(MAX_RECOGNITION_DISTANCE, base_tolerance, tolerance)) + + +def passes_max_recognition_distance( + distance: float, + *, + max_distance: float | None = None, +) -> bool: + """Hard ceiling on cosine distance (Immich max recognition distance).""" + ceiling = MAX_RECOGNITION_DISTANCE if max_distance is None else max_distance + return float(distance) <= float(ceiling) + + +def filter_ambiguous_cross_person_matches( + results: List[Tuple[int, int, Face, List[Tuple[Face, float, float]]]], + *, + margin: float | None = None, +) -> List[Tuple[int, int, Face, List[Tuple[Face, float, float]]]]: + """Drop a candidate from a person if another person is nearly as close. + + Immich-style: only keep the best person when ``d_second - d_best >= margin``. + Ambiguous faces are removed from all people (admin reviews via Identify). + """ + min_margin = MIN_NEXT_BEST_PERSON_MARGIN if margin is None else margin + # face_id -> list of (person_id, distance) + by_face: Dict[int, List[Tuple[int, float]]] = {} + for person_id, _ref_id, _ref, matches in results: + for face, distance, _conf in matches: + by_face.setdefault(face.id, []).append((person_id, float(distance))) + + allowed: Dict[int, int] = {} # face_id -> winning person_id + for face_id, contenders in by_face.items(): + contenders.sort(key=lambda t: t[1]) + best_person, best_d = contenders[0] + if len(contenders) == 1: + allowed[face_id] = best_person + continue + second_d = contenders[1][1] + if second_d - best_d >= min_margin: + allowed[face_id] = best_person + # else: ambiguous — omit from all + + filtered = [] + for person_id, ref_id, ref, matches in results: + kept = [ + (f, d, c) + for f, d, c in matches + if allowed.get(f.id) == person_id and passes_max_recognition_distance(d) + ] + if kept: + filtered.append((person_id, ref_id, ref, kept)) + return filtered def calibrate_confidence(distance: float, tolerance: float = None) -> float: @@ -2041,6 +2092,10 @@ def find_similar_faces( # Calculate distance (matching desktop exactly) distance = calculate_cosine_distance(base_enc, other_enc) + + # Immich-style hard ceiling (never match beyond max recognition distance) + if not passes_max_recognition_distance(distance): + continue # Filter by distance <= adaptive_tolerance (matching desktop find_similar_faces) if distance <= adaptive_tolerance: @@ -2504,7 +2559,7 @@ def find_auto_match_matches( if similar_faces: results.append((person_id, reference_face_id, reference_face, similar_faces)) - return results + return filter_ambiguous_cross_person_matches(results) def get_auto_match_people_list( diff --git a/docs/FACE_ACCURACY_STATUS.md b/docs/FACE_ACCURACY_STATUS.md index b592610..8eac7f3 100644 --- a/docs/FACE_ACCURACY_STATUS.md +++ b/docs/FACE_ACCURACY_STATUS.md @@ -1,35 +1,45 @@ # Face recognition accuracy — status (2026-08) -Honest read of Phases 1–3 on **DEV** after quality re-score -(`2026-08-05`, ~2502 faces, 6 identified people / 29 identified faces). +Honest read of Phases 1–4 on **DEV** after quality re-score +(`2026-08-05`, ~2502 faces, 6 identified people / 29 identified faces), +plus Immich-inspired precision gates. ## Are the numbers better? | Signal | Verdict | Notes | |--------|---------|--------| | **Same vs different embedding distance** | **Yes — working** | Same-person pairs mean **0.44** (p50 0.45); different-person mean **0.84** (p50 0.92). Clear ArcFace separation. | -| **Quality score spread** | **Slightly better** | After Phase 1 fix + Phase 3 re-score: max **0.78** (was ~0.72), stddev still **~0.05**, mean ~**0.62**. Sharpness now real, but size/brightness/contrast still dominate the weighted score — scores stay clustered. | -| **Quality gate (≥0.5 refs)** | **Useful** | **38** faces now below 0.5 (excluded from Auto-Match refs / auto-accept). All **29** identified faces remain eligible. | -| **Fitted confidence knots** | **Not applied** | Auto-fit collapsed (empty low-distance bins + Laplace smoothing). Legacy empirical curve still in use — correct call until fitter is fixed / more close same-pairs exist. | -| **`match_decisions` log** | **Empty** | No Auto-Match Saves yet on DEV — Phase 2 logging is live but unused. | +| **Quality score spread** | **Slightly better** | After Phase 1 fix + Phase 3 re-score: max **0.78** (was ~0.72), stddev still **~0.05**, mean ~**0.62**. Sharpness now real, but size/brightness/contrast still dominate — scores stay clustered. | +| **Quality gate (≥0.5 refs)** | **Useful** | **38** faces below 0.5; all **29** identified faces remain eligible. | +| **Fitted confidence knots** | **Not applied** | Auto-fit still noisy on small DEV set. Legacy empirical curve still in use. | +| **`match_decisions` log** | **Empty** | Need Auto-Match Saves on DEV. | -**Bottom line:** Matching *geometry* looks healthy (same ≪ different). Displayed confidence and Auto-Match thresholds still use the legacy curve. Quality is honest but not highly discriminative. Next leverage is **multi-reference matching** (Phase 4): one crop often misses pose/lighting variance even when distances separate in aggregate. +**Bottom line:** Matching geometry is healthy (same ≪ different). Precision gates from Immich cut false accepts; clustering-first UX and person-merge are still open. -## What we changed (phases) +## Phases -1. **Phase 1** — Laplacian sharpness on the face crop (was a constant). -2. **Phase 2** — Ref quality ≥ 0.5; stricter Auto-Match defaults; `match_decisions` accept/reject log. -3. **Phase 3** — Quality re-score script (full DEV corpus done); calibration fit tooling (do not `--apply` until knots look sane). -4. **Phase 4** — Up to N trusted refs per person; take **best (min) distance** across refs. +1. **Phase 1** — Laplacian sharpness on the face crop. +2. **Phase 2** — Ref quality ≥ 0.5; stricter Auto-Match; `match_decisions`. +3. **Phase 3** — Quality re-score + calibration fit tooling (don't `--apply` yet). +4. **Phase 4** — Up to 3 trusted refs; best (min) distance. +5. **Immich steals** — max distance 0.50; next-best margin 0.08; detection floor 0.55; auto-accept max distance 0.35. -## Ops checklist +## Immich review — steal vs skip + +| Idea | Action | +|------|--------| +| Max recognition distance | **Done** | +| Under-merge + margin | **Done** | +| Min detection score | **Done** (new Process jobs) | +| Cluster-first / name cluster | Later | +| Person merge UI | Later | +| InsightFace buffalo | Skip — ArcFace OK | + +Immich source is public (`github.com/immich-app/immich`); no local clone required. You already run Immich at `photos.levkin.ca`. + +## Ops ```bash -# Quality backfill (already run on DEV) ./venv/bin/python scripts/rescore_face_quality.py --dry-run --limit 50 - -# Calibration fit (review knots; only --apply if near≪far) ./venv/bin/python scripts/fit_confidence_calibration.py ``` - -Use Auto-Match Save / Reject so `match_decisions` fills — that improves future fits more than more synthetic pairs. diff --git a/tests/test_immich_precision_gates.py b/tests/test_immich_precision_gates.py new file mode 100644 index 0000000..7cd973b --- /dev/null +++ b/tests/test_immich_precision_gates.py @@ -0,0 +1,72 @@ +"""Tests for Immich-inspired precision gates.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from backend.config import ( + AUTO_ACCEPT_MAX_DISTANCE, + MAX_RECOGNITION_DISTANCE, + MIN_FACE_CONFIDENCE, + MIN_NEXT_BEST_PERSON_MARGIN, +) +from backend.services.face_service import ( + calculate_adaptive_tolerance, + filter_ambiguous_cross_person_matches, + passes_max_recognition_distance, +) + + +class TestPrecisionConfig: + def test_detection_floor_stricter_than_legacy(self): + assert MIN_FACE_CONFIDENCE >= 0.55 + + def test_max_distance_at_or_below_half(self): + assert MAX_RECOGNITION_DISTANCE <= 0.5 + + def test_auto_accept_tighter_than_max(self): + assert AUTO_ACCEPT_MAX_DISTANCE < MAX_RECOGNITION_DISTANCE + + +class TestAdaptiveTolerance: + def test_never_exceeds_max_or_base(self): + for q in (0.0, 0.5, 1.0): + t = calculate_adaptive_tolerance(0.5, q) + assert t <= MAX_RECOGNITION_DISTANCE + assert t <= 0.5 + + def test_high_quality_not_looser_than_low(self): + low = calculate_adaptive_tolerance(0.5, 0.2) + high = calculate_adaptive_tolerance(0.5, 0.9) + assert high <= low + + +class TestMaxDistance: + def test_gate(self): + assert passes_max_recognition_distance(0.4) + assert not passes_max_recognition_distance(MAX_RECOGNITION_DISTANCE + 0.01) + + +class TestNextBestMargin: + def test_keeps_clear_winner(self): + face = SimpleNamespace(id=1) + ref = SimpleNamespace(id=9) + results = [ + (10, 9, ref, [(face, 0.30, 80.0)]), + (11, 9, ref, [(face, 0.45, 60.0)]), + ] + filtered = filter_ambiguous_cross_person_matches( + results, margin=MIN_NEXT_BEST_PERSON_MARGIN + ) + assert len(filtered) == 1 + assert filtered[0][0] == 10 + + def test_drops_ambiguous(self): + face = SimpleNamespace(id=2) + ref = SimpleNamespace(id=9) + results = [ + (10, 9, ref, [(face, 0.32, 80.0)]), + (11, 9, ref, [(face, 0.35, 78.0)]), # margin 0.03 < 0.08 + ] + filtered = filter_ambiguous_cross_person_matches(results, margin=0.08) + assert filtered == []