Phase 1: fix face quality sharpness scoring #89
@@ -103,6 +103,15 @@ Living plan for product quality, auth/email reliability, and automation.
|
||||
- [x] **Favorite sign-in copy** — logged-out heart opens favorites messaging (not report) via `SignInRequiredDialog`
|
||||
- [x] **Text contrast** — replace misplaced `text-secondary` (pale wash token) with `text-foreground` / `text-primary` on labels, auth dialogs, menus
|
||||
|
||||
### 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**
|
||||
- [ ] **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)**
|
||||
- [ ] **Phase 6 — multi-embedding per person**
|
||||
|
||||
## Later
|
||||
|
||||
- [x] Proper DEV deploy (`next start` + CI image) instead of long-lived `next dev` — `scripts/deploy-viewer.sh`, `ecosystem.config.js.example` uses `next start -p 3001`
|
||||
|
||||
@@ -110,6 +110,31 @@ def _pre_warm_deepface(
|
||||
# Don't raise - let it load on first photo instead
|
||||
|
||||
|
||||
def face_laplacian_variance(gray_face: np.ndarray) -> float:
|
||||
"""Variance of the Laplacian of a grayscale face crop (sharpness proxy).
|
||||
|
||||
Higher values → sharper edges. Pure-numpy so tests can run without OpenCV.
|
||||
"""
|
||||
gray = np.asarray(gray_face, dtype=np.float32)
|
||||
if gray.ndim != 2 or gray.size == 0:
|
||||
return 0.0
|
||||
kernel = np.array([[0.0, -1.0, 0.0], [-1.0, 4.0, -1.0], [0.0, -1.0, 0.0]], dtype=np.float32)
|
||||
# Manual 3x3 convolution (valid region) — matches cv2.Laplacian intent
|
||||
padded = np.pad(gray, 1, mode="edge")
|
||||
conv = (
|
||||
kernel[0, 0] * padded[0:-2, 0:-2]
|
||||
+ kernel[0, 1] * padded[0:-2, 1:-1]
|
||||
+ kernel[0, 2] * padded[0:-2, 2:]
|
||||
+ kernel[1, 0] * padded[1:-1, 0:-2]
|
||||
+ kernel[1, 1] * padded[1:-1, 1:-1]
|
||||
+ kernel[1, 2] * padded[1:-1, 2:]
|
||||
+ kernel[2, 0] * padded[2:, 0:-2]
|
||||
+ kernel[2, 1] * padded[2:, 1:-1]
|
||||
+ kernel[2, 2] * padded[2:, 2:]
|
||||
)
|
||||
return float(np.var(conv))
|
||||
|
||||
|
||||
def calculate_face_quality_score(
|
||||
image_np: np.ndarray,
|
||||
face_location: dict,
|
||||
@@ -118,8 +143,10 @@ def calculate_face_quality_score(
|
||||
) -> int:
|
||||
"""Calculate face quality score (0-100).
|
||||
|
||||
This matches the desktop version logic exactly from src/core/face_processing.py _calculate_face_quality_score()
|
||||
Returns 0-100 (will be converted to 0.0-1.0 for database storage).
|
||||
Returns 0-100 (stored as 0.0-1.0 in the database after /100).
|
||||
|
||||
Sharpness uses Laplacian variance of the face crop (not the kernel
|
||||
constant — that desktop bug made sharpness ≈ 0 for every face).
|
||||
|
||||
Args:
|
||||
image_np: Image as numpy array
|
||||
@@ -159,15 +186,11 @@ def calculate_face_quality_score(
|
||||
else:
|
||||
gray_face = face_region
|
||||
|
||||
# Calculate sharpness (Laplacian variance)
|
||||
# Match desktop version exactly (including the bug for consistency)
|
||||
# Desktop calculates var of kernel array itself, not the convolved result
|
||||
laplacian_var = np.var(np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]]).astype(np.float32))
|
||||
if laplacian_var > 0:
|
||||
sharpness = np.var(np.array([[0, -1, 0], [-1, 4, -1], [0, -1, 0]]).astype(np.float32))
|
||||
else:
|
||||
sharpness = 0.0
|
||||
sharpness_score = min(1.0, sharpness / 1000.0) # Normalize sharpness
|
||||
# Sharpness: Laplacian variance of the face pixels
|
||||
sharpness = face_laplacian_variance(gray_face)
|
||||
# Sharp event crops often land in the thousands; blurry ones near 0–50.
|
||||
# Scale so mid-blur (hundreds) doesn't saturate at 1.0.
|
||||
sharpness_score = min(1.0, sharpness / 5000.0)
|
||||
|
||||
# Calculate brightness and contrast
|
||||
mean_brightness = np.mean(gray_face)
|
||||
@@ -188,7 +211,7 @@ def calculate_face_quality_score(
|
||||
position_y_score = 1.0 - abs(center_y - img_height / 2) / (img_height / 2)
|
||||
position_score = (position_x_score + position_y_score) / 2.0
|
||||
|
||||
# Weighted combination of all factors (matches desktop exactly)
|
||||
# Weighted combination of all factors
|
||||
quality_score = (
|
||||
size_score * 0.25 +
|
||||
sharpness_score * 0.25 +
|
||||
@@ -198,7 +221,6 @@ def calculate_face_quality_score(
|
||||
position_score * 0.10
|
||||
)
|
||||
|
||||
# Desktop returns 0.0-1.0, we need 0-100 for database
|
||||
quality_score = max(0.0, min(1.0, quality_score))
|
||||
return int(quality_score * 100)
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Face match error tagging (Phase 1)
|
||||
|
||||
Lightweight checklist for sampling Auto-Match / Identify false positives and
|
||||
false negatives. Goal: ~100 labeled misses so later threshold/model work is
|
||||
driven by JRCC data, not vibes.
|
||||
|
||||
## How to sample
|
||||
|
||||
1. Open Admin → **Auto-Match** and **Identify** on DEV (or PROD copy).
|
||||
2. For each wrong suggestion or missed true pair, note one row below (or in a
|
||||
private spreadsheet — do **not** commit PII/photos to git).
|
||||
3. Prefer diversity: kids, adults, glasses, side light, crowd crops, siblings.
|
||||
|
||||
## Tag vocabulary
|
||||
|
||||
| Tag | Meaning |
|
||||
|-----|---------|
|
||||
| `fp_wrong_person` | Suggested identity is a different person |
|
||||
| `fp_near_relative` | Sibling / parent lookalike |
|
||||
| `fn_missed_same` | Same person present but not suggested |
|
||||
| `blur_crop` | Face too soft / motion blur |
|
||||
| `tiny_crop` | Face very small in frame |
|
||||
| `profile_pose` | Strong yaw / profile |
|
||||
| `occlusion` | Hand, mic, hat covering face |
|
||||
| `lighting` | Extreme back/side light |
|
||||
| `child_adult` | Age band confusion |
|
||||
| `other` | Free-text in notes |
|
||||
|
||||
## Row template (copy)
|
||||
|
||||
```
|
||||
date | source (auto-match|identify) | face_id | suggested_person_id | true_person_id | tags | notes
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Unit tests for face quality scoring (no DeepFace / DB required)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.services.face_service import calculate_face_quality_score
|
||||
|
||||
|
||||
def _centered_face_image(
|
||||
face_pixels: np.ndarray,
|
||||
canvas: int = 200,
|
||||
) -> tuple[np.ndarray, dict]:
|
||||
"""Place a square face crop in the center of a canvas."""
|
||||
h, w = face_pixels.shape[:2]
|
||||
assert h == w
|
||||
img = np.full((canvas, canvas, 3), 128, dtype=np.uint8)
|
||||
x = (canvas - w) // 2
|
||||
y = (canvas - h) // 2
|
||||
img[y : y + h, x : x + w] = face_pixels
|
||||
return img, {"x": x, "y": y, "w": w, "h": h}
|
||||
|
||||
|
||||
def _sharp_face(size: int = 120) -> np.ndarray:
|
||||
"""High-frequency checkerboard — should score as sharp."""
|
||||
face = np.zeros((size, size, 3), dtype=np.uint8)
|
||||
# Mid brightness overall, strong local contrast
|
||||
face[:, :] = 110
|
||||
for i in range(0, size, 4):
|
||||
for j in range(0, size, 4):
|
||||
if (i // 4 + j // 4) % 2 == 0:
|
||||
face[i : i + 2, j : j + 2] = 200
|
||||
else:
|
||||
face[i : i + 2, j : j + 2] = 40
|
||||
return face
|
||||
|
||||
|
||||
def _blurry_face(size: int = 120) -> np.ndarray:
|
||||
"""Box-blurred version of the sharp face."""
|
||||
sharp = _sharp_face(size).astype(np.float32)
|
||||
k = 15
|
||||
pad = k // 2
|
||||
padded = np.pad(sharp, ((pad, pad), (pad, pad), (0, 0)), mode="edge")
|
||||
blurred = np.zeros_like(sharp)
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
blurred[y, x] = padded[y : y + k, x : x + k].mean(axis=(0, 1))
|
||||
return np.clip(blurred, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def _match_mean_std(source: np.ndarray, reference: np.ndarray) -> np.ndarray:
|
||||
"""Rescale source grayscale so mean/std match reference (isolates sharpness)."""
|
||||
src_gray = source.astype(np.float32).mean(axis=2)
|
||||
ref_gray = reference.astype(np.float32).mean(axis=2)
|
||||
src_std = float(src_gray.std()) or 1.0
|
||||
ref_std = float(ref_gray.std()) or 1.0
|
||||
scaled = (src_gray - src_gray.mean()) * (ref_std / src_std) + ref_gray.mean()
|
||||
scaled = np.clip(scaled, 0, 255).astype(np.uint8)
|
||||
return np.stack([scaled, scaled, scaled], axis=-1)
|
||||
|
||||
|
||||
class TestCalculateFaceQualityScore:
|
||||
def test_empty_region_returns_zero(self):
|
||||
img = np.zeros((50, 50, 3), dtype=np.uint8)
|
||||
# Location outside the image → empty crop
|
||||
score = calculate_face_quality_score(
|
||||
img, {"x": 100, "y": 100, "w": 20, "h": 20}, 50, 50
|
||||
)
|
||||
assert score == 0
|
||||
|
||||
def test_sharp_face_scores_higher_than_blurry_when_contrast_matched(self):
|
||||
"""Sharpness must use Laplacian on the face crop, not the kernel constant.
|
||||
|
||||
Brightness/contrast/size/aspect/position are matched so the only remaining
|
||||
25% weight that can separate the scores is sharpness.
|
||||
"""
|
||||
sharp = _sharp_face()
|
||||
blurry = _match_mean_std(_blurry_face(), sharp)
|
||||
|
||||
sharp_img, sharp_loc = _centered_face_image(sharp)
|
||||
blur_img, blur_loc = _centered_face_image(blurry)
|
||||
|
||||
sharp_score = calculate_face_quality_score(
|
||||
sharp_img, sharp_loc, sharp_img.shape[1], sharp_img.shape[0]
|
||||
)
|
||||
blur_score = calculate_face_quality_score(
|
||||
blur_img, blur_loc, blur_img.shape[1], blur_img.shape[0]
|
||||
)
|
||||
|
||||
assert 0 <= blur_score <= 100
|
||||
assert 0 <= sharp_score <= 100
|
||||
assert sharp_score > blur_score + 8, (
|
||||
f"expected sharp ({sharp_score}) well above blurry ({blur_score}); "
|
||||
"Laplacian may still be computed on the kernel instead of the face"
|
||||
)
|
||||
|
||||
def test_face_laplacian_variance_is_higher_for_sharp_crop(self):
|
||||
from backend.services.face_service import face_laplacian_variance
|
||||
|
||||
sharp = _sharp_face().mean(axis=2)
|
||||
blurry = _blurry_face().mean(axis=2)
|
||||
assert face_laplacian_variance(sharp) > face_laplacian_variance(blurry) * 5
|
||||
|
||||
def test_tiny_face_scores_lower_than_large(self):
|
||||
# Same mid-gray content; size_score dominates the difference
|
||||
tiny = np.full((30, 30, 3), 128, dtype=np.uint8)
|
||||
large = np.full((120, 120, 3), 128, dtype=np.uint8)
|
||||
tiny_img, tiny_loc = _centered_face_image(tiny, canvas=200)
|
||||
large_img, large_loc = _centered_face_image(large, canvas=200)
|
||||
|
||||
tiny_score = calculate_face_quality_score(
|
||||
tiny_img, tiny_loc, tiny_img.shape[1], tiny_img.shape[0]
|
||||
)
|
||||
large_score = calculate_face_quality_score(
|
||||
large_img, large_loc, large_img.shape[1], large_img.shape[0]
|
||||
)
|
||||
assert large_score > tiny_score
|
||||
Reference in New Issue
Block a user