File size: 8,453 Bytes
29fdc13 6b88aa3 29fdc13 6b88aa3 29fdc13 3558500 6b88aa3 29fdc13 6b88aa3 29fdc13 6b88aa3 29fdc13 6b88aa3 29fdc13 6b88aa3 29fdc13 3558500 29fdc13 6b88aa3 29fdc13 6b88aa3 29fdc13 6b88aa3 29fdc13 6b88aa3 29fdc13 6b88aa3 29fdc13 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | """隔离验证五官评分:不导入业务 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<String, Float> 不接受 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()
|