+
{error}
)}
@@ -76,7 +78,7 @@ export default function Login() {
Username
@@ -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"
/>
Password
@@ -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"
/>
setShowPassword((prev) => !prev)}
- className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700 focus:outline-none"
+ className="absolute inset-y-0 right-2 flex items-center text-muted-foreground hover:text-foreground focus:outline-none"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? '🙈' : '👁️'}
@@ -120,7 +122,7 @@ export default function Login() {
{loading ? 'Logging in...' : 'Login'}
@@ -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 == []