Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ai-vision-service/face-recognition/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ class AppSettings(BaseSettings):
Lower values produce tighter, more homogeneous clusters."""

# --- Quality filtering ---
min_face_size_pixels: int = 0
"""Minimum face size in pixels. The longest side of the detected bounding box (width or height)
must be strictly greater than this value. Set to ``0`` to disable (default)."""

blur_threshold: float = 0.5
"""Laplacian variance threshold for blur detection.
Face crops with a variance below this value are discarded before embedding."""
Expand Down Expand Up @@ -134,6 +138,7 @@ def to_diagnostics_payload(self) -> dict[str, str]:
"detector_backend": str(self.detector_backend),
"match_threshold": str(self.match_threshold),
"max_faces_per_photo": str(self.max_faces_per_photo),
"min_face_size_pixels": str(self.min_face_size_pixels),
"model_name": str(self.model_name),
"rescan_iou_threshold": str(self.rescan_iou_threshold),
"thread_pool_size": str(self.thread_pool_size),
Expand Down
20 changes: 18 additions & 2 deletions ai-vision-service/face-recognition/app/detection/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,13 @@ def __init__(
detection_threshold: float = 0.5,
blur_threshold: float = 100.0,
detector_backend: str = "retinaface",
min_face_size_pixels: int = 0,
) -> None:
self._model_name = model_name
self._detection_threshold = detection_threshold
self._blur_threshold = blur_threshold
self._detector_backend = detector_backend
self._min_face_size_pixels = min_face_size_pixels
self._loaded: bool = False
self._lock = threading.Lock()

Expand Down Expand Up @@ -172,6 +174,7 @@ def _detect_array(self, img: Any) -> list[DetectedFace]:

results: list[DetectedFace] = []
filtered_by_confidence = 0
filtered_by_size = 0
filtered_by_blur = 0

for face in raw_faces:
Expand All @@ -186,6 +189,18 @@ def _detect_array(self, img: Any) -> list[DetectedFace]:
x2 = x1 + float(area["w"])
y2 = y1 + float(area["h"])

# Size filter: exclude faces whose longest side does not exceed the minimum.
if self._min_face_size_pixels > 0:
longest_side = max(float(area["w"]), float(area["h"]))
if longest_side <= self._min_face_size_pixels:
filtered_by_size += 1
logger.info(
"Filtered small face: longest_side=%.1f <= min_face_size_pixels=%d",
longest_side,
self._min_face_size_pixels,
)
continue

# Compute Laplacian variance on the face crop region.
# This sharpness score is always computed and sent to Lychee for filtering/tuning.
px1 = max(0, int(x1))
Expand Down Expand Up @@ -232,11 +247,12 @@ def _detect_array(self, img: Any) -> list[DetectedFace]:
results.sort(key=lambda f: f.confidence, reverse=True)

# Log summary
if filtered_by_confidence > 0 or filtered_by_blur > 0:
if filtered_by_confidence > 0 or filtered_by_size > 0 or filtered_by_blur > 0:
logger.info(
"Face detection: %d face(s) passed filters (filtered %d by confidence, %d by blur)",
"Face detection: %d face(s) passed filters (filtered %d by confidence, %d by size, %d by blur)",
len(results),
filtered_by_confidence,
filtered_by_size,
filtered_by_blur,
)
else:
Expand Down
1 change: 1 addition & 0 deletions ai-vision-service/face-recognition/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ async def _default_lifespan(app: FastAPI) -> AsyncGenerator[None]:
detection_threshold=settings.detection_threshold,
blur_threshold=settings.blur_threshold,
detector_backend=settings.detector_backend,
min_face_size_pixels=settings.min_face_size_pixels,
)
detector.load()
logger.info("DeepFace model '%s' (backend: %s) loaded successfully", settings.model_name, settings.detector_backend)
Expand Down
58 changes: 57 additions & 1 deletion ai-vision-service/face-recognition/tests/test_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def _detector_with_faces(
faces: list[Any],
*,
blur_threshold: float = 0.0,
min_face_size_pixels: int = 0,
) -> Generator[FaceDetector]:
"""Yield a loaded FaceDetector whose DeepFace.represent returns *faces*.

Expand All @@ -57,7 +58,7 @@ def _detector_with_faces(
stub_cv2 = MagicMock()
stub_cv2.Laplacian.return_value.var.return_value = 0.0

detector = FaceDetector(detection_threshold=0.5, blur_threshold=blur_threshold)
detector = FaceDetector(detection_threshold=0.5, blur_threshold=blur_threshold, min_face_size_pixels=min_face_size_pixels)
detector._loaded = True # type: ignore[attr-defined]
with patch.dict(sys.modules, {"deepface": stub_deepface_mod, "cv2": stub_cv2}):
yield detector
Expand Down Expand Up @@ -209,3 +210,58 @@ def test_detected_face_laplacian_variance_stored() -> None:
"""laplacian_variance value passed on construction is preserved."""
face = DetectedFace(x=0.0, y=0.0, width=1.0, height=1.0, confidence=0.7, laplacian_variance=123.45)
assert face.laplacian_variance == pytest.approx(123.45)


# ---------------------------------------------------------------------------
# FaceDetector - min_face_size_pixels filtering
# ---------------------------------------------------------------------------


def test_detect_filters_small_face_by_width() -> None:
"""Face whose width is the longest side but not greater than min_face_size_pixels must be excluded."""
face = _make_deepface_face(x=0.0, y=0.0, w=30.0, h=20.0, confidence=0.99, embedding=[0.0] * 512)
fake_img = MagicMock()
fake_img.shape = (100, 100, 3)
with _detector_with_faces([face], min_face_size_pixels=30) as detector:
results = detector._detect_array(fake_img)
assert results == []


def test_detect_filters_small_face_by_height() -> None:
"""Face whose height is the longest side but not greater than min_face_size_pixels must be excluded."""
face = _make_deepface_face(x=0.0, y=0.0, w=20.0, h=30.0, confidence=0.99, embedding=[0.0] * 512)
fake_img = MagicMock()
fake_img.shape = (100, 100, 3)
with _detector_with_faces([face], min_face_size_pixels=30) as detector:
results = detector._detect_array(fake_img)
assert results == []


def test_detect_passes_face_above_min_size() -> None:
"""Face whose longest side strictly exceeds min_face_size_pixels must be kept."""
face = _make_deepface_face(x=0.0, y=0.0, w=31.0, h=20.0, confidence=0.99, embedding=[0.0] * 512)
fake_img = np.zeros((100, 100, 3), dtype=np.uint8)
with _detector_with_faces([face], min_face_size_pixels=30) as detector:
results = detector._detect_array(fake_img)
assert len(results) == 1


def test_detect_min_face_size_disabled_when_zero() -> None:
"""When min_face_size_pixels is 0 (default), all face sizes pass the size filter."""
face = _make_deepface_face(x=0.0, y=0.0, w=5.0, h=5.0, confidence=0.99, embedding=[0.0] * 512)
fake_img = np.zeros((100, 100, 3), dtype=np.uint8)
with _detector_with_faces([face], min_face_size_pixels=0) as detector:
results = detector._detect_array(fake_img)
assert len(results) == 1


def test_detect_size_filter_applied_before_blur_filter() -> None:
"""Size filter must run before laplacian/blur filter: a face that is too small
must be dropped even if the blur threshold is set (i.e. no blur computation needed)."""
face = _make_deepface_face(x=0.0, y=0.0, w=10.0, h=10.0, confidence=0.99, embedding=[0.0] * 512)
fake_img = MagicMock()
fake_img.shape = (100, 100, 3)
# blur_threshold > 0 would normally trigger laplacian computation, but the size filter fires first
with _detector_with_faces([face], min_face_size_pixels=20, blur_threshold=50.0) as detector:
results = detector._detect_array(fake_img)
assert results == []
Loading