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()