From d807df4057401ff3d944eab7c8dd59f205d486d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 13:22:51 +0000 Subject: [PATCH 1/2] feat: add min_face_size_pixels setting to filter small faces before laplacian check Agent-Logs-Url: https://github.com/LycheeOrg/Lychee/sessions/807c8165-99e0-4c56-b5df-e3c6bc7dd27f Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com> --- .../face-recognition/app/config.py | 5 ++ .../app/detection/detector.py | 20 ++++++- .../face-recognition/app/main.py | 1 + .../face-recognition/tests/test_detection.py | 58 ++++++++++++++++++- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/ai-vision-service/face-recognition/app/config.py b/ai-vision-service/face-recognition/app/config.py index 58793813a58..932a730bf7a 100644 --- a/ai-vision-service/face-recognition/app/config.py +++ b/ai-vision-service/face-recognition/app/config.py @@ -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.""" @@ -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), diff --git a/ai-vision-service/face-recognition/app/detection/detector.py b/ai-vision-service/face-recognition/app/detection/detector.py index b33a2ea4760..15270bee860 100644 --- a/ai-vision-service/face-recognition/app/detection/detector.py +++ b/ai-vision-service/face-recognition/app/detection/detector.py @@ -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() @@ -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: @@ -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)) @@ -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: diff --git a/ai-vision-service/face-recognition/app/main.py b/ai-vision-service/face-recognition/app/main.py index a337f2ca7f2..1fbb68e6dde 100644 --- a/ai-vision-service/face-recognition/app/main.py +++ b/ai-vision-service/face-recognition/app/main.py @@ -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) diff --git a/ai-vision-service/face-recognition/tests/test_detection.py b/ai-vision-service/face-recognition/tests/test_detection.py index 1622391db8a..5db2d764d56 100644 --- a/ai-vision-service/face-recognition/tests/test_detection.py +++ b/ai-vision-service/face-recognition/tests/test_detection.py @@ -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*. @@ -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 @@ -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 == [] From 13ce07de168fee673504ed558e75c7f8d77548f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 13:23:45 +0000 Subject: [PATCH 2/2] style: fix double-space in min_face_size_pixels docstring Agent-Logs-Url: https://github.com/LycheeOrg/Lychee/sessions/807c8165-99e0-4c56-b5df-e3c6bc7dd27f Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com> --- ai-vision-service/face-recognition/app/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ai-vision-service/face-recognition/app/config.py b/ai-vision-service/face-recognition/app/config.py index 932a730bf7a..306e62127ba 100644 --- a/ai-vision-service/face-recognition/app/config.py +++ b/ai-vision-service/face-recognition/app/config.py @@ -101,8 +101,8 @@ class AppSettings(BaseSettings): # --- 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).""" + """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.