"""隔离验证五官评分:不导入业务 config,不启动服务,不加载真实 AI 模型。""" import importlib.util import json from pathlib import Path import sys import types import unittest from unittest.mock import Mock, patch import cv2 # 在临时 sys.modules 补丁外加载,避免重复初始化原生扩展。 import numpy as np ROOT = Path(__file__).resolve().parents[1] def load_analyzer(available: bool = True, legacy_api: bool = True) -> tuple[types.ModuleType, Mock]: config = types.ModuleType("config") config.logger = Mock() config.DLIB_AVAILABLE = available config.FACIAL_FUSION_ALPHA = 0.35 config.FACIAL_SPREAD_FACTOR = 2.5 mediapipe = types.ModuleType("mediapipe") mediapipe.__version__ = "0.10.21" if legacy_api else "0.10.31" factory = Mock() if legacy_api: mediapipe.solutions = types.SimpleNamespace( face_mesh=types.SimpleNamespace(FaceMesh=factory) ) spec = importlib.util.spec_from_file_location("isolated_facial_analyzer", ROOT / "facial_analyzer.py") module = importlib.util.module_from_spec(spec) with patch.dict(sys.modules, {"config": config, "mediapipe": mediapipe}): spec.loader.exec_module(module) return module, factory def sample_points() -> list[tuple[int, int]]: """手工几何夹具,用于验证评分会响应五官形状变化。""" jaw = [(int(110 - 80 * np.cos(t)), int(90 + 110 * np.sin(t))) for t in np.linspace(0, np.pi, 17)] brows = [(50, 65), (60, 60), (70, 58), (80, 60), (90, 65), (130, 65), (140, 60), (150, 58), (160, 60), (170, 65)] nose = [(110, 80), (110, 95), (110, 110), (110, 125), (95, 125), (102, 130), (110, 132), (118, 130), (125, 125)] eyes = [(55, 85), (63, 80), (77, 80), (85, 85), (77, 90), (63, 90), (135, 85), (143, 80), (157, 80), (165, 85), (157, 90), (143, 90)] mouth = [(80, 155), (90, 150), (100, 148), (110, 147), (120, 148), (130, 150), (140, 155), (130, 161), (120, 164), (110, 165), (100, 164), (90, 161), (85, 155), (100, 152), (110, 151), (120, 152), (135, 155), (120, 159), (110, 160), (100, 159)] return jaw + brows + nose + eyes + mouth class FacialAnalysisTest(unittest.TestCase): def setUp(self) -> None: self.module, self.factory = load_analyzer() self.analyzer = self.module.FacialFeatureAnalyzer() self.image = np.zeros((224, 224, 3), dtype=np.uint8) self.box = [0, 0, 224, 224] def assert_unavailable(self, result: dict, reason: str) -> None: self.assertEqual("unavailable", result["analysis_method"]) self.assertFalse(result["scores_available"]) self.assertFalse(result["is_estimated"]) self.assertEqual(reason, result["fallback_reason"]) # Android 的 Map 不接受 null 元素,缺失时应省略整个辅助对象。 for key in ("facial_features", "harmony_score", "overall_facial_score", "radar_analysis", "beauty_timeline"): self.assertNotIn(key, result) self.assertEqual(result, json.loads(json.dumps(result, allow_nan=False))) def test_legacy_api_initializes_detector(self) -> None: self.factory.assert_called_once_with( static_image_mode=True, max_num_faces=1, refine_landmarks=True, min_detection_confidence=0.5, min_tracking_confidence=0.5, ) self.assertEqual({"ready": True, "mediapipe_version": "0.10.21", "initialization_error": None}, self.analyzer.get_status()) def test_importable_new_api_is_not_reported_ready(self) -> None: module, factory = load_analyzer(legacy_api=False) analyzer = module.FacialFeatureAnalyzer() factory.assert_not_called() self.assertFalse(analyzer.get_status()["ready"]) self.assertEqual("0.10.31", analyzer.get_status()["mediapipe_version"]) result = analyzer.analyze_facial_features(self.image, self.box, beauty_score=80) self.assert_unavailable(result, "incompatible_mediapipe_api") def test_missing_dependency_is_reported(self) -> None: module, _ = load_analyzer(available=False) analyzer = module.FacialFeatureAnalyzer() self.assertFalse(analyzer.get_status()["ready"]) self.assertIsNone(analyzer.get_status()["mediapipe_version"]) self.assert_unavailable(analyzer.analyze_facial_features(self.image, self.box, beauty_score=80), "mediapipe_unavailable") def test_model_initialization_failure_is_reported(self) -> None: self.factory.side_effect = RuntimeError("missing model asset") analyzer = self.module.FacialFeatureAnalyzer() self.assertFalse(analyzer.get_status()["ready"]) self.assertEqual("model_initialization_failed", analyzer.get_status()["initialization_error"]) self.assert_unavailable(analyzer.analyze_facial_features(self.image, self.box, beauty_score=80), "model_initialization_failed") def test_no_face_is_a_per_image_fallback(self) -> None: self.analyzer.face_mesh.process.return_value = types.SimpleNamespace(multi_face_landmarks=[]) result = self.analyzer.analyze_facial_features(self.image, self.box, beauty_score=80) self.assert_unavailable(result, "no_landmarks_detected") self.assertTrue(self.analyzer.get_status()["ready"]) self.module.logger.warning.assert_not_called() def test_inference_exception_is_reported(self) -> None: self.analyzer.face_mesh.process.side_effect = RuntimeError("inference failed") with patch.object(self.module.traceback, "print_exc"): result = self.analyzer.analyze_facial_features(self.image, self.box, beauty_score=80) self.assert_unavailable(result, "landmark_detection_failed") def test_geometry_exception_is_reported(self) -> None: with patch.object(self.analyzer, "_get_face_measurements", side_effect=RuntimeError("bad landmarks")), \ patch.object(self.module.traceback, "print_exc"): result = self.analyzer._analyze_features_from_landmarks(sample_points(), self.image.shape, beauty_score=80) self.assert_unavailable(result, "landmark_scoring_failed") def test_detected_geometry_produces_distinct_scores_and_changes_with_eyes(self) -> None: landmark = types.SimpleNamespace(y=0.1) detected = types.SimpleNamespace(landmark=[landmark] * 478) self.analyzer.face_mesh.process.return_value = types.SimpleNamespace(multi_face_landmarks=[detected]) points = sample_points() with patch.object(self.analyzer, "_convert_mediapipe_to_dlib_format", return_value=points): result = self.analyzer.analyze_facial_features(self.image, self.box, beauty_score=80) self.assertEqual("mediapipe_landmarks", result["analysis_method"]) self.assertTrue(result["scores_available"]) self.assertFalse(result["is_estimated"]) self.assertIsNone(result["fallback_reason"]) self.assertGreater(len(set(result["facial_features"].values())), 1) for index in (37, 38, 43, 44): points[index] = (points[index][0], 83) for index in (40, 41, 46, 47): points[index] = (points[index][0], 87) changed = self.analyzer._analyze_features_from_landmarks(points, self.image.shape, beauty_score=80) self.assertNotEqual(result["facial_features"]["eyes"], changed["facial_features"]["eyes"]) def test_failed_detection_never_derives_scores_from_overall_beauty(self) -> None: self.analyzer.face_mesh.process.return_value = types.SimpleNamespace(multi_face_landmarks=[]) for score in (7.287, 72.87, 8, 80): with self.subTest(score=score): result = self.analyzer.analyze_facial_features(self.image, self.box, beauty_score=score) self.assert_unavailable(result, "no_landmarks_detected") def test_individual_scoring_exception_does_not_become_a_default_score(self) -> None: with patch.object(self.analyzer, "_gaussian_score", side_effect=ValueError("invalid geometry")): result = self.analyzer._analyze_features_from_landmarks(sample_points(), self.image.shape, beauty_score=72.87) self.assert_unavailable(result, "landmark_scoring_failed") if __name__ == "__main__": unittest.main()