Merge pull request 'Phase 1: fix face quality sharpness scoring' (#89) from fix/face-quality-sharpness-phase1 into master
CI / skip-ci-check (push) Successful in 30s
CI / python-lint (push) Successful in 31s
CI / docker-ci (push) Successful in 32s
CI / secret-scan (push) Successful in 35s
CI / e2e (push) Successful in 2m37s
CI / viewer-unit (push) Successful in 2m55s
CI / admin-unit (push) Successful in 3m7s

This commit was merged in pull request #89.
This commit is contained in:
2026-08-05 10:07:58 -05:00
4 changed files with 200 additions and 13 deletions
+9
View File
@@ -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`
+35 -13
View File
@@ -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 050.
# 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)
+39
View File
@@ -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 23 (stricter Auto-Match thresholds,
confidence recalibration). Keep the sheet private; only aggregates belong in
docs/PRs.
+117
View File
@@ -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