From acbab58c3aee1aadb8439ed0b3e1dc92a17efb23 Mon Sep 17 00:00:00 2001 From: daehyeong2 Date: Tue, 23 Jun 2026 09:38:52 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=20=EB=B3=B8=EC=9D=B8=20=EC=96=BC=EA=B5=B4=20=EA=B0=90=EC=A7=80?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- app/services/ai/identity.py | 19 +++++++++++++++---- privacy_blur/__init__.py | 4 ++++ privacy_blur/errors.py | 8 ++++++++ privacy_blur/runtime.py | 28 +++++++++++++++++++++------- tests/test_deployment_privacy.py | 3 ++- tests/test_reference_face_service.py | 20 +++++++++++++++++--- 7 files changed, 68 insertions(+), 16 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3e9bcf6..ba0de37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ RUN AI_PRIVACY_ENABLED=true \ AI_PRIVACY_USE_HALF=false \ AI_PRIVACY_ME_IMAGE_PATH=me.jpeg \ python -c \ - "from app.services.ai.filter import close_privacy_filter, initialize_privacy_runtime; initialize_privacy_runtime(); close_privacy_filter()" + "from pathlib import Path; from app.services.ai.filter import close_privacy_filter, initialize_privacy_runtime, prepare_identity_update; initialize_privacy_runtime(); prepare_identity_update(Path('me.jpeg')); close_privacy_filter()" EXPOSE 8000 EXPOSE 50000-60000/udp diff --git a/app/services/ai/identity.py b/app/services/ai/identity.py index 15413bd..e7521b7 100644 --- a/app/services/ai/identity.py +++ b/app/services/ai/identity.py @@ -43,7 +43,12 @@ def register_reference_face(image_data: bytes) -> ReferenceFaceInfo: prepared_update = filter_module.prepare_identity_update( candidate_path ) - except _identity_validation_errors() as exc: + except _invalid_reference_image_error() as exc: + raise BadRequestError( + "유효하지 않은 이미지 파일입니다.", + details={"reason": "invalid_image"}, + ) from exc + except _face_not_detected_error() as exc: raise BadRequestError( "업로드한 이미지에서 얼굴을 감지할 수 없습니다.", details={"reason": "face_not_detected"}, @@ -107,10 +112,16 @@ def delete_reference_face() -> None: logger.info("Deleted reference face") -def _identity_validation_errors() -> tuple[type[Exception], ...]: - from privacy_blur.errors import PrivacyBlurNotReadyError +def _invalid_reference_image_error() -> type[Exception]: + from privacy_blur.errors import InvalidReferenceImageError + + return InvalidReferenceImageError + + +def _face_not_detected_error() -> type[Exception]: + from privacy_blur.errors import ReferenceFaceNotDetectedError - return (PrivacyBlurNotReadyError, RuntimeError, ValueError) + return ReferenceFaceNotDetectedError def _validate_image(image_data: bytes) -> None: diff --git a/privacy_blur/__init__.py b/privacy_blur/__init__.py index e28da68..b1f9101 100644 --- a/privacy_blur/__init__.py +++ b/privacy_blur/__init__.py @@ -13,17 +13,21 @@ ) from .config import PrivacyBlurConfig from .errors import ( + InvalidReferenceImageError, PrivacyBlurError, PrivacyBlurNotReadyError, + ReferenceFaceNotDetectedError, UnsupportedFrameTypeError, ) __all__ = [ "FacePrivacyEngine", "FacePrivacyFilter", + "InvalidReferenceImageError", "PrivacyBlurConfig", "PrivacyBlurError", "PrivacyBlurNotReadyError", + "ReferenceFaceNotDetectedError", "UnsupportedFrameTypeError", "configure", "get_default_config", diff --git a/privacy_blur/errors.py b/privacy_blur/errors.py index bdaa81f..c93a69f 100644 --- a/privacy_blur/errors.py +++ b/privacy_blur/errors.py @@ -9,5 +9,13 @@ class PrivacyBlurNotReadyError(PrivacyBlurError): """Raised when models or runtime dependencies are missing.""" +class InvalidReferenceImageError(PrivacyBlurError): + """Raised when a reference image cannot be decoded.""" + + +class ReferenceFaceNotDetectedError(PrivacyBlurError): + """Raised when a reference image does not contain a detectable face.""" + + class UnsupportedFrameTypeError(PrivacyBlurError): """Raised when a frame type cannot be processed.""" diff --git a/privacy_blur/runtime.py b/privacy_blur/runtime.py index 2f9d328..84045a0 100644 --- a/privacy_blur/runtime.py +++ b/privacy_blur/runtime.py @@ -21,7 +21,12 @@ from ultralytics import YOLO from .config import PrivacyBlurConfig -from .errors import PrivacyBlurNotReadyError +from .errors import ( + InvalidReferenceImageError, + PrivacyBlurNotReadyError, + ReferenceFaceNotDetectedError, +) +from .face_detection import detect_best_yunet_face if TYPE_CHECKING: from .core import FacePrivacyEngine, FaceTrack @@ -234,18 +239,27 @@ def _create_ort_session(self, path: Path, kind: str) -> ort.InferenceSession: def _load_reference_feature(self) -> torch.Tensor: reference = cv2.imread(str(self.config.me_image_path)) if reference is None: - raise PrivacyBlurNotReadyError( + raise InvalidReferenceImageError( f"Failed to read reference image: {self.config.me_image_path}" ) - detections = self._predict_yolo([reference])[0] - if not detections: - raise PrivacyBlurNotReadyError( + + detector = cv2.FaceDetectorYN.create( + str(self.config.face_detection_model_path), + "", + (self.config.identity_detector_size,) * 2, + self.config.identity_detector_confidence, + 0.3, + 5000, + ) + face = detect_best_yunet_face(detector, reference) + if face is None: + raise ReferenceFaceNotDetectedError( f"No face found in reference image: {self.config.me_image_path}" ) - box, _ = max(detections, key=lambda item: item[0][2] * item[0][3] * item[1]) + box = face[:4] embeddings, valid = self._face_embeddings([(reference, box)]) if not bool(valid[0]): - raise PrivacyBlurNotReadyError( + raise ReferenceFaceNotDetectedError( f"YuNet could not align the face in reference image: {self.config.me_image_path}" ) return embeddings[0].detach() diff --git a/tests/test_deployment_privacy.py b/tests/test_deployment_privacy.py index b1c86ed..497152c 100644 --- a/tests/test_deployment_privacy.py +++ b/tests/test_deployment_privacy.py @@ -16,7 +16,8 @@ def test_production_compose_forces_privacy_filter_enabled(): def test_docker_image_build_validates_privacy_filter_initialization(): dockerfile = (PROJECT_ROOT / "Dockerfile").read_text() - assert "initialize_privacy_runtime; initialize_privacy_runtime()" in dockerfile + assert "initialize_privacy_runtime()" in dockerfile + assert "prepare_identity_update(Path('me.jpeg'))" in dockerfile def test_docker_image_installs_matching_cuda_torchvision(): diff --git a/tests/test_reference_face_service.py b/tests/test_reference_face_service.py index cd37069..7903217 100644 --- a/tests/test_reference_face_service.py +++ b/tests/test_reference_face_service.py @@ -80,12 +80,12 @@ def test_register_uses_candidate_then_publishes_final_path(running_runtime): def test_invalid_replacement_preserves_existing_reference(running_runtime): - from privacy_blur.errors import PrivacyBlurNotReadyError + from privacy_blur.errors import ReferenceFaceNotDetectedError REFERENCE_FACE_DIR.mkdir(parents=True, exist_ok=True) REFERENCE_FACE_PATH.write_bytes(b"old valid image") - running_runtime["runtime"].prepare_identity.side_effect = PrivacyBlurNotReadyError( - "No face found" + running_runtime["runtime"].prepare_identity.side_effect = ( + ReferenceFaceNotDetectedError("No face found") ) with pytest.raises(BadRequestError, match="얼굴을 감지할 수 없습니다"): @@ -96,6 +96,20 @@ def test_invalid_replacement_preserves_existing_reference(running_runtime): running_runtime["filter"].replace_config.assert_not_called() +def test_runtime_failure_is_not_reported_as_face_not_detected(running_runtime): + REFERENCE_FACE_DIR.mkdir(parents=True, exist_ok=True) + REFERENCE_FACE_PATH.write_bytes(b"old valid image") + running_runtime["runtime"].prepare_identity.side_effect = RuntimeError( + "CUDA execution failed" + ) + + with pytest.raises(RuntimeError, match="CUDA execution failed"): + register_reference_face(b"replacement image") + + assert REFERENCE_FACE_PATH.read_bytes() == b"old valid image" + running_runtime["runtime"].apply_identity.assert_not_called() + + def test_register_without_runtime_validates_and_saves_file(): image_data = _create_test_jpeg() with patch("app.services.ai.identity._validate_image") as validate: