dreamlessx commited on
Commit
92264a1
·
verified ·
1 Parent(s): 30cc2b8

Update landmarkdiff/morphometry.py to v0.3.2

Browse files
Files changed (1) hide show
  1. landmarkdiff/morphometry.py +342 -0
landmarkdiff/morphometry.py ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Nasal morphometry and facial symmetry evaluation.
2
+
3
+ Geometric evaluation metrics derived from Varghaei et al. (2025),
4
+ adapted for evaluating surgical prediction outputs.
5
+
6
+ Computes five nasal ratios plus bilateral facial symmetry from
7
+ MediaPipe 478-point landmarks, enabling interpretable clinical
8
+ quality assessment beyond perceptual metrics (LPIPS, FID).
9
+
10
+ Usage::
11
+
12
+ from landmarkdiff.morphometry import NasalMorphometry, FacialSymmetry
13
+
14
+ morph = NasalMorphometry()
15
+ ratios = morph.compute(landmarks_478)
16
+
17
+ sym = FacialSymmetry()
18
+ score = sym.compute(landmarks_478)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from dataclasses import dataclass
25
+
26
+ import numpy as np
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # MediaPipe landmark indices (478-point mesh)
31
+ # Reference: https://github.com/google/mediapipe/blob/master/mediapipe/modules/face_geometry/data/canonical_face_model_uv_visualization.png
32
+ NOSE_TIP = 1
33
+ LEFT_NOSTRIL = 98
34
+ RIGHT_NOSTRIL = 327
35
+ LEFT_INNER_EYE = 133
36
+ RIGHT_INNER_EYE = 362
37
+ LEFT_OUTER_EYE = 33
38
+ RIGHT_OUTER_EYE = 263
39
+ LEFT_CHEEK = 234
40
+ RIGHT_CHEEK = 454
41
+ CHIN = 152
42
+ FOREHEAD = 10
43
+ GLABELLA = 168
44
+
45
+
46
+ @dataclass
47
+ class NasalRatios:
48
+ """Five nasal morphometric ratios from Varghaei et al. (2025).
49
+
50
+ Attributes:
51
+ alar_intercanthal: Alar width / intercanthal distance.
52
+ Ideal ~1.0 (nose width equals eye spacing).
53
+ alar_face_width: Alar width / face width.
54
+ Ideal ~0.20 (nose is 1/5 of face width).
55
+ nose_length_face_height: Nose length / face height.
56
+ Proportional measure of nose vertical extent.
57
+ tip_midline_deviation: Horizontal offset of nose tip from
58
+ facial midline, normalized by face width. Lower is better.
59
+ nostril_vertical_asymmetry: Vertical height difference between
60
+ nostrils, normalized by face height. Lower is better.
61
+ """
62
+
63
+ alar_intercanthal: float = 0.0
64
+ alar_face_width: float = 0.0
65
+ nose_length_face_height: float = 0.0
66
+ tip_midline_deviation: float = 0.0
67
+ nostril_vertical_asymmetry: float = 0.0
68
+
69
+ def improvement_score(self, reference: NasalRatios) -> dict[str, bool]:
70
+ """Check which ratios improved relative to reference (pre-op).
71
+
72
+ A ratio 'improved' if the prediction moved it closer to the
73
+ anthropometric ideal compared to the reference.
74
+ """
75
+ ideals = {
76
+ "alar_intercanthal": 1.0,
77
+ "alar_face_width": 0.20,
78
+ }
79
+ results = {}
80
+ for name, ideal in ideals.items():
81
+ pred_val = getattr(self, name)
82
+ ref_val = getattr(reference, name)
83
+ results[name] = abs(pred_val - ideal) < abs(ref_val - ideal)
84
+
85
+ # For deviation/asymmetry, lower is always better
86
+ results["tip_midline_deviation"] = (
87
+ self.tip_midline_deviation < reference.tip_midline_deviation
88
+ )
89
+ results["nostril_vertical_asymmetry"] = (
90
+ self.nostril_vertical_asymmetry < reference.nostril_vertical_asymmetry
91
+ )
92
+ return results
93
+
94
+ def to_dict(self) -> dict[str, float]:
95
+ return {
96
+ "alar_intercanthal": self.alar_intercanthal,
97
+ "alar_face_width": self.alar_face_width,
98
+ "nose_length_face_height": self.nose_length_face_height,
99
+ "tip_midline_deviation": self.tip_midline_deviation,
100
+ "nostril_vertical_asymmetry": self.nostril_vertical_asymmetry,
101
+ }
102
+
103
+
104
+ class NasalMorphometry:
105
+ """Compute nasal morphometric ratios from MediaPipe landmarks.
106
+
107
+ Five geometric features following Varghaei et al. (2025):
108
+ 1. Alar width / intercanthal distance (ideal ~1.0)
109
+ 2. Alar width / face width (ideal ~0.20)
110
+ 3. Nose length / face height
111
+ 4. Tip midline deviation (normalized)
112
+ 5. Nostril vertical asymmetry (normalized)
113
+ """
114
+
115
+ def compute(self, landmarks: np.ndarray) -> NasalRatios:
116
+ """Compute all five nasal ratios.
117
+
118
+ Args:
119
+ landmarks: (N, 2) or (N, 3) array of MediaPipe landmarks.
120
+ Must have at least 478 points. Uses only x, y.
121
+
122
+ Returns:
123
+ NasalRatios dataclass with computed values.
124
+ """
125
+ pts = landmarks[:, :2] # use only x, y
126
+
127
+ # Key points
128
+ nose_tip = pts[NOSE_TIP]
129
+ left_nostril = pts[LEFT_NOSTRIL]
130
+ right_nostril = pts[RIGHT_NOSTRIL]
131
+ left_inner_eye = pts[LEFT_INNER_EYE]
132
+ right_inner_eye = pts[RIGHT_INNER_EYE]
133
+ left_cheek = pts[LEFT_CHEEK]
134
+ right_cheek = pts[RIGHT_CHEEK]
135
+ forehead = pts[FOREHEAD]
136
+ chin = pts[CHIN]
137
+ glabella = pts[GLABELLA]
138
+
139
+ # Distances (cast to float for mypy compatibility)
140
+ alar_width: float = float(np.linalg.norm(left_nostril - right_nostril))
141
+ intercanthal: float = max(float(np.linalg.norm(left_inner_eye - right_inner_eye)), 1e-6)
142
+ face_width: float = max(float(np.linalg.norm(left_cheek - right_cheek)), 1e-6)
143
+ face_height: float = max(float(np.linalg.norm(forehead - chin)), 1e-6)
144
+ nose_length: float = float(np.linalg.norm(glabella - nose_tip))
145
+
146
+ # Facial midline (between outer eye corners)
147
+ midline_x = (pts[LEFT_OUTER_EYE][0] + pts[RIGHT_OUTER_EYE][0]) / 2
148
+
149
+ # Ratios
150
+ alar_intercanthal = float(alar_width / intercanthal)
151
+ alar_face = float(alar_width / face_width)
152
+ nose_face = float(nose_length / face_height)
153
+ tip_deviation = float(abs(nose_tip[0] - midline_x) / face_width)
154
+ nostril_asymmetry = float(abs(left_nostril[1] - right_nostril[1]) / face_height)
155
+
156
+ return NasalRatios(
157
+ alar_intercanthal=alar_intercanthal,
158
+ alar_face_width=alar_face,
159
+ nose_length_face_height=nose_face,
160
+ tip_midline_deviation=tip_deviation,
161
+ nostril_vertical_asymmetry=nostril_asymmetry,
162
+ )
163
+
164
+ def compute_from_image(self, image: np.ndarray) -> NasalRatios | None:
165
+ """Extract landmarks from image and compute ratios.
166
+
167
+ Args:
168
+ image: BGR uint8 image (H, W, 3).
169
+
170
+ Returns:
171
+ NasalRatios or None if landmark detection fails.
172
+ """
173
+ try:
174
+ import mediapipe as mp
175
+ except ImportError:
176
+ logger.warning("mediapipe required for landmark extraction")
177
+ return None
178
+
179
+ with mp.solutions.face_mesh.FaceMesh(
180
+ static_image_mode=True,
181
+ max_num_faces=1,
182
+ refine_landmarks=True,
183
+ min_detection_confidence=0.5,
184
+ ) as face_mesh:
185
+ import cv2
186
+
187
+ rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
188
+ results = face_mesh.process(rgb)
189
+
190
+ if not results.multi_face_landmarks:
191
+ return None
192
+
193
+ h, w = image.shape[:2]
194
+ face = results.multi_face_landmarks[0]
195
+ landmarks = np.array([(lm.x * w, lm.y * h) for lm in face.landmark])
196
+ return self.compute(landmarks)
197
+
198
+
199
+ class FacialSymmetry:
200
+ """Bilateral facial symmetry scoring.
201
+
202
+ Measures deviation from perfect bilateral symmetry by reflecting
203
+ left-side landmarks across the facial midline and computing
204
+ distances to nearest right-side counterparts.
205
+
206
+ Lower scores indicate greater symmetry.
207
+ """
208
+
209
+ def compute(
210
+ self,
211
+ landmarks: np.ndarray,
212
+ left_eye_idx: int = LEFT_OUTER_EYE,
213
+ right_eye_idx: int = RIGHT_OUTER_EYE,
214
+ ) -> float:
215
+ """Compute bilateral symmetry error.
216
+
217
+ Args:
218
+ landmarks: (N, 2) or (N, 3) array. Uses only x, y.
219
+ left_eye_idx: Landmark index for left outer eye corner.
220
+ right_eye_idx: Landmark index for right outer eye corner.
221
+
222
+ Returns:
223
+ Mean symmetry error (lower = more symmetric).
224
+ Normalized by inter-ocular distance.
225
+ """
226
+ pts = landmarks[:, :2].copy()
227
+
228
+ # Midline from eye corners
229
+ midline_x = (pts[left_eye_idx][0] + pts[right_eye_idx][0]) / 2
230
+ iod = abs(pts[left_eye_idx][0] - pts[right_eye_idx][0])
231
+ if iod < 1e-6:
232
+ return 0.0
233
+
234
+ # Partition into left and right
235
+ left_mask = pts[:, 0] < midline_x
236
+ right_mask = pts[:, 0] > midline_x
237
+
238
+ left_pts = pts[left_mask]
239
+ right_pts = pts[right_mask]
240
+
241
+ if len(left_pts) == 0 or len(right_pts) == 0:
242
+ return 0.0
243
+
244
+ # Reflect left across midline
245
+ reflected = left_pts.copy()
246
+ reflected[:, 0] = 2 * midline_x - reflected[:, 0]
247
+
248
+ # KDTree nearest-neighbor matching
249
+ try:
250
+ from scipy.spatial import KDTree
251
+
252
+ tree = KDTree(right_pts)
253
+ distances, _ = tree.query(reflected)
254
+ return float(np.mean(distances) / iod)
255
+ except ImportError:
256
+ # Fallback: brute force
257
+ total = 0.0
258
+ for pt in reflected:
259
+ dists = np.linalg.norm(right_pts - pt, axis=1)
260
+ total += np.min(dists)
261
+ return float(total / (len(reflected) * iod))
262
+
263
+ def compute_from_image(self, image: np.ndarray) -> float | None:
264
+ """Extract landmarks from image and compute symmetry.
265
+
266
+ Args:
267
+ image: BGR uint8 image (H, W, 3).
268
+
269
+ Returns:
270
+ Symmetry error or None if detection fails.
271
+ """
272
+ try:
273
+ import mediapipe as mp
274
+ except ImportError:
275
+ logger.warning("mediapipe required for landmark extraction")
276
+ return None
277
+
278
+ with mp.solutions.face_mesh.FaceMesh(
279
+ static_image_mode=True,
280
+ max_num_faces=1,
281
+ refine_landmarks=True,
282
+ min_detection_confidence=0.5,
283
+ ) as face_mesh:
284
+ import cv2
285
+
286
+ rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
287
+ results = face_mesh.process(rgb)
288
+
289
+ if not results.multi_face_landmarks:
290
+ return None
291
+
292
+ h, w = image.shape[:2]
293
+ face = results.multi_face_landmarks[0]
294
+ landmarks = np.array([(lm.x * w, lm.y * h) for lm in face.landmark])
295
+ return self.compute(landmarks)
296
+
297
+
298
+ def compare_morphometry(
299
+ pred_image: np.ndarray,
300
+ input_image: np.ndarray,
301
+ procedure: str = "rhinoplasty",
302
+ ) -> dict:
303
+ """Compare morphometric quality between prediction and input.
304
+
305
+ Computes nasal ratios and symmetry for both images and reports
306
+ which metrics improved. Useful for evaluating whether the predicted
307
+ surgical output shows clinically meaningful improvement.
308
+
309
+ Args:
310
+ pred_image: Predicted output (BGR uint8).
311
+ input_image: Original input (BGR uint8).
312
+ procedure: Procedure type (affects which metrics are relevant).
313
+
314
+ Returns:
315
+ Dict with 'input_ratios', 'pred_ratios', 'improvements',
316
+ 'input_symmetry', 'pred_symmetry', 'symmetry_improved'.
317
+ """
318
+ morph = NasalMorphometry()
319
+ sym = FacialSymmetry()
320
+
321
+ input_ratios = morph.compute_from_image(input_image)
322
+ pred_ratios = morph.compute_from_image(pred_image)
323
+ input_sym = sym.compute_from_image(input_image)
324
+ pred_sym = sym.compute_from_image(pred_image)
325
+
326
+ result: dict = {
327
+ "procedure": procedure,
328
+ "input_ratios": input_ratios.to_dict() if input_ratios else None,
329
+ "pred_ratios": pred_ratios.to_dict() if pred_ratios else None,
330
+ "input_symmetry": input_sym,
331
+ "pred_symmetry": pred_sym,
332
+ "symmetry_improved": (
333
+ pred_sym < input_sym if pred_sym is not None and input_sym is not None else None
334
+ ),
335
+ }
336
+
337
+ if input_ratios and pred_ratios:
338
+ result["improvements"] = pred_ratios.improvement_score(input_ratios)
339
+ else:
340
+ result["improvements"] = None
341
+
342
+ return result