picpocket / test /test_facial_analysis.py
chawin.chen
fix: 五官辅助分析失败不影响主评分并兼容旧版 App
3558500
Raw History Blame Contribute Delete
8.45 kB
"""隔离验证五官评分:不导入业务 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()