landmarkdiff 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. landmarkdiff/__init__.py +40 -0
  2. landmarkdiff/__main__.py +207 -0
  3. landmarkdiff/api_client.py +316 -0
  4. landmarkdiff/arcface_torch.py +583 -0
  5. landmarkdiff/audit.py +338 -0
  6. landmarkdiff/augmentation.py +293 -0
  7. landmarkdiff/benchmark.py +213 -0
  8. landmarkdiff/checkpoint_manager.py +361 -0
  9. landmarkdiff/cli.py +252 -0
  10. landmarkdiff/clinical.py +223 -0
  11. landmarkdiff/conditioning.py +278 -0
  12. landmarkdiff/config.py +358 -0
  13. landmarkdiff/curriculum.py +191 -0
  14. landmarkdiff/data.py +405 -0
  15. landmarkdiff/data_version.py +301 -0
  16. landmarkdiff/displacement_model.py +745 -0
  17. landmarkdiff/ensemble.py +330 -0
  18. landmarkdiff/evaluation.py +415 -0
  19. landmarkdiff/experiment_tracker.py +231 -0
  20. landmarkdiff/face_verifier.py +947 -0
  21. landmarkdiff/fid.py +244 -0
  22. landmarkdiff/hyperparam.py +347 -0
  23. landmarkdiff/inference.py +754 -0
  24. landmarkdiff/landmarks.py +432 -0
  25. landmarkdiff/log.py +90 -0
  26. landmarkdiff/losses.py +348 -0
  27. landmarkdiff/manipulation.py +651 -0
  28. landmarkdiff/masking.py +316 -0
  29. landmarkdiff/metrics_agg.py +313 -0
  30. landmarkdiff/metrics_viz.py +464 -0
  31. landmarkdiff/model_registry.py +362 -0
  32. landmarkdiff/morphometry.py +342 -0
  33. landmarkdiff/postprocess.py +600 -0
  34. landmarkdiff/py.typed +0 -0
  35. landmarkdiff/safety.py +395 -0
  36. landmarkdiff/synthetic/__init__.py +23 -0
  37. landmarkdiff/synthetic/augmentation.py +188 -0
  38. landmarkdiff/synthetic/pair_generator.py +208 -0
  39. landmarkdiff/synthetic/tps_warp.py +273 -0
  40. landmarkdiff/validation.py +324 -0
  41. landmarkdiff-0.2.3.dist-info/METADATA +1173 -0
  42. landmarkdiff-0.2.3.dist-info/RECORD +46 -0
  43. landmarkdiff-0.2.3.dist-info/WHEEL +5 -0
  44. landmarkdiff-0.2.3.dist-info/entry_points.txt +2 -0
  45. landmarkdiff-0.2.3.dist-info/licenses/LICENSE +21 -0
  46. landmarkdiff-0.2.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,362 @@
1
+ """Model registry for checkpoint discovery and management.
2
+
3
+ Provides a unified interface for finding, loading, and comparing model
4
+ checkpoints across local directories and remote sources.
5
+
6
+ Usage:
7
+ from landmarkdiff.model_registry import ModelRegistry
8
+
9
+ registry = ModelRegistry("checkpoints/")
10
+
11
+ # Discover all checkpoints
12
+ models = registry.list_models()
13
+
14
+ # Get best checkpoint by metric
15
+ best = registry.get_best("loss")
16
+
17
+ # Load a specific checkpoint
18
+ state = registry.load("checkpoint-5000")
19
+
20
+ # Compare multiple checkpoints
21
+ comparison = registry.compare(["checkpoint-1000", "checkpoint-5000"])
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ import torch
32
+
33
+
34
+ @dataclass
35
+ class ModelEntry:
36
+ """Metadata for a registered model checkpoint."""
37
+
38
+ name: str
39
+ path: Path
40
+ step: int = 0
41
+ phase: str = ""
42
+ metrics: dict[str, float] = field(default_factory=dict)
43
+ size_mb: float = 0.0
44
+ has_ema: bool = False
45
+ has_training_state: bool = False
46
+
47
+ @property
48
+ def inference_path(self) -> Path | None:
49
+ """Path to inference-ready weights (EMA preferred)."""
50
+ ema_dir = self.path / "controlnet_ema"
51
+ if ema_dir.exists():
52
+ return ema_dir
53
+ # Fallback to training state
54
+ state_path = self.path / "training_state.pt"
55
+ if state_path.exists():
56
+ return state_path
57
+ return None
58
+
59
+
60
+ class ModelRegistry:
61
+ """Central registry for discovering and managing model checkpoints.
62
+
63
+ Args:
64
+ checkpoint_dirs: One or more directories to scan for checkpoints.
65
+ scan_on_init: Whether to scan directories immediately on creation.
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ *checkpoint_dirs: str | Path,
71
+ scan_on_init: bool = True,
72
+ ) -> None:
73
+ self.checkpoint_dirs = [Path(d) for d in checkpoint_dirs]
74
+ self._models: dict[str, ModelEntry] = {}
75
+
76
+ if scan_on_init:
77
+ self.scan()
78
+
79
+ def scan(self) -> int:
80
+ """Scan checkpoint directories and register all found models.
81
+
82
+ Returns:
83
+ Number of models found.
84
+ """
85
+ self._models.clear()
86
+ for base_dir in self.checkpoint_dirs:
87
+ if not base_dir.exists():
88
+ continue
89
+ self._scan_directory(base_dir)
90
+ return len(self._models)
91
+
92
+ def _scan_directory(self, base_dir: Path) -> None:
93
+ """Scan a single directory for checkpoint subdirectories."""
94
+ # Look for checkpoint-* directories
95
+ for ckpt_dir in sorted(base_dir.glob("checkpoint-*")):
96
+ if not ckpt_dir.is_dir():
97
+ continue
98
+ entry = self._load_entry(ckpt_dir)
99
+ if entry is not None:
100
+ self._models[entry.name] = entry
101
+
102
+ # Also check for "final" and "best" directories/symlinks
103
+ for special in ["final", "best", "latest"]:
104
+ special_dir = base_dir / special
105
+ if special_dir.exists() and special_dir.is_dir():
106
+ entry = self._load_entry(special_dir)
107
+ if entry is not None:
108
+ entry.name = f"{base_dir.name}/{special}"
109
+ self._models[entry.name] = entry
110
+
111
+ def _load_entry(self, ckpt_dir: Path) -> ModelEntry | None:
112
+ """Load metadata for a single checkpoint directory."""
113
+ has_training = (ckpt_dir / "training_state.pt").exists()
114
+ has_ema = (ckpt_dir / "controlnet_ema").exists()
115
+
116
+ if not has_training and not has_ema:
117
+ return None
118
+
119
+ # Try to load metadata.json (from CheckpointManager)
120
+ meta_path = ckpt_dir / "metadata.json"
121
+ if meta_path.exists():
122
+ with open(meta_path) as f:
123
+ meta = json.load(f)
124
+ return ModelEntry(
125
+ name=ckpt_dir.name,
126
+ path=ckpt_dir,
127
+ step=meta.get("step", 0),
128
+ phase=meta.get("phase", ""),
129
+ metrics=meta.get("metrics", {}),
130
+ size_mb=meta.get("size_mb", 0.0),
131
+ has_ema=has_ema,
132
+ has_training_state=has_training,
133
+ )
134
+
135
+ # Fallback: extract step from directory name
136
+ step = 0
137
+ parts = ckpt_dir.name.split("-")
138
+ if len(parts) >= 2 and parts[-1].isdigit():
139
+ step = int(parts[-1])
140
+
141
+ # Compute size
142
+ size_mb = sum(f.stat().st_size for f in ckpt_dir.rglob("*") if f.is_file()) / (1024 * 1024)
143
+
144
+ return ModelEntry(
145
+ name=ckpt_dir.name,
146
+ path=ckpt_dir,
147
+ step=step,
148
+ size_mb=round(size_mb, 1),
149
+ has_ema=has_ema,
150
+ has_training_state=has_training,
151
+ )
152
+
153
+ # ------------------------------------------------------------------
154
+ # Queries
155
+ # ------------------------------------------------------------------
156
+
157
+ def list_models(self, sort_by: str = "step") -> list[ModelEntry]:
158
+ """List all registered models.
159
+
160
+ Args:
161
+ sort_by: Sort key — "step", "name", or a metric name.
162
+
163
+ Returns:
164
+ Sorted list of ModelEntry objects.
165
+ """
166
+ models = list(self._models.values())
167
+ if sort_by == "step":
168
+ models.sort(key=lambda m: m.step)
169
+ elif sort_by == "name":
170
+ models.sort(key=lambda m: m.name)
171
+ else:
172
+ # Sort by metric value
173
+ models.sort(
174
+ key=lambda m: m.metrics.get(sort_by, float("inf")),
175
+ )
176
+ return models
177
+
178
+ def get(self, name: str) -> ModelEntry | None:
179
+ """Get a model entry by name."""
180
+ return self._models.get(name)
181
+
182
+ def get_best(
183
+ self,
184
+ metric: str = "loss",
185
+ lower_is_better: bool = True,
186
+ ) -> ModelEntry | None:
187
+ """Get the best model by a specific metric.
188
+
189
+ Args:
190
+ metric: Metric name to rank by.
191
+ lower_is_better: If True, lower values are better.
192
+
193
+ Returns:
194
+ Best ModelEntry, or None if no models have the metric.
195
+ """
196
+ candidates = [m for m in self._models.values() if metric in m.metrics]
197
+ if not candidates:
198
+ return None
199
+
200
+ return (
201
+ min(candidates, key=lambda m: m.metrics[metric])
202
+ if lower_is_better
203
+ else max(candidates, key=lambda m: m.metrics[metric])
204
+ )
205
+
206
+ def get_by_step(self, step: int) -> ModelEntry | None:
207
+ """Get a model by its training step."""
208
+ for model in self._models.values():
209
+ if model.step == step:
210
+ return model
211
+ return None
212
+
213
+ # ------------------------------------------------------------------
214
+ # Loading
215
+ # ------------------------------------------------------------------
216
+
217
+ def load(
218
+ self,
219
+ name: str,
220
+ map_location: str = "cpu",
221
+ ) -> dict[str, Any]:
222
+ """Load training state from a checkpoint.
223
+
224
+ Args:
225
+ name: Checkpoint name (e.g. "checkpoint-5000").
226
+ map_location: Device to load tensors to.
227
+
228
+ Returns:
229
+ State dict containing controlnet, ema_controlnet, optimizer, etc.
230
+
231
+ Raises:
232
+ KeyError: If checkpoint not found.
233
+ FileNotFoundError: If training_state.pt missing.
234
+ """
235
+ entry = self._models.get(name)
236
+ if entry is None:
237
+ raise KeyError(f"Checkpoint '{name}' not found in registry")
238
+
239
+ state_path = entry.path / "training_state.pt"
240
+ if not state_path.exists():
241
+ raise FileNotFoundError(f"No training_state.pt in {entry.path}")
242
+
243
+ return torch.load(state_path, map_location=map_location, weights_only=True)
244
+
245
+ def load_controlnet(
246
+ self,
247
+ name: str,
248
+ use_ema: bool = True,
249
+ ) -> Any:
250
+ """Load a ControlNet model from checkpoint.
251
+
252
+ Args:
253
+ name: Checkpoint name.
254
+ use_ema: If True, load EMA weights (preferred for inference).
255
+
256
+ Returns:
257
+ ControlNetModel instance.
258
+ """
259
+ from diffusers import ControlNetModel
260
+
261
+ entry = self._models.get(name)
262
+ if entry is None:
263
+ raise KeyError(f"Checkpoint '{name}' not found in registry")
264
+
265
+ if use_ema and entry.has_ema:
266
+ return ControlNetModel.from_pretrained(str(entry.path / "controlnet_ema"))
267
+
268
+ # Fallback: load from training state
269
+ state = self.load(name)
270
+ model = ControlNetModel.from_pretrained(
271
+ "lllyasviel/control_v11p_sd15_openpose",
272
+ subfolder="diffusion_sd15",
273
+ )
274
+ key = "ema_controlnet" if use_ema else "controlnet"
275
+ model.load_state_dict(state[key])
276
+ return model
277
+
278
+ # ------------------------------------------------------------------
279
+ # Comparison
280
+ # ------------------------------------------------------------------
281
+
282
+ def compare(
283
+ self,
284
+ names: list[str],
285
+ metrics: list[str] | None = None,
286
+ ) -> dict[str, Any]:
287
+ """Compare multiple checkpoints side-by-side.
288
+
289
+ Args:
290
+ names: List of checkpoint names to compare.
291
+ metrics: Specific metrics to include. None = all available.
292
+
293
+ Returns:
294
+ Dict with comparison table data.
295
+ """
296
+ entries = []
297
+ for name in names:
298
+ entry = self._models.get(name)
299
+ if entry is not None:
300
+ entries.append(entry)
301
+
302
+ if not entries:
303
+ return {"error": "No valid checkpoints found"}
304
+
305
+ # Collect all available metrics
306
+ if metrics is None:
307
+ all_metrics: set[str] = set()
308
+ for e in entries:
309
+ all_metrics.update(e.metrics.keys())
310
+ metrics = sorted(all_metrics)
311
+
312
+ rows = []
313
+ for e in entries:
314
+ row = {
315
+ "name": e.name,
316
+ "step": e.step,
317
+ "phase": e.phase,
318
+ "size_mb": e.size_mb,
319
+ }
320
+ for m in metrics:
321
+ row[m] = e.metrics.get(m)
322
+ rows.append(row)
323
+
324
+ return {
325
+ "metrics": metrics,
326
+ "rows": rows,
327
+ "count": len(rows),
328
+ }
329
+
330
+ # ------------------------------------------------------------------
331
+ # Summary
332
+ # ------------------------------------------------------------------
333
+
334
+ def summary(self) -> str:
335
+ """Return a human-readable summary."""
336
+ models = self.list_models()
337
+ if not models:
338
+ return "No models registered."
339
+
340
+ total_size = sum(m.size_mb for m in models)
341
+ lines = [
342
+ f"Model Registry: {len(models)} checkpoints ({total_size:.0f} MB)",
343
+ f" Steps: {models[0].step} — {models[-1].step}",
344
+ ]
345
+
346
+ # Show metrics ranges
347
+ all_metrics: set[str] = set()
348
+ for m in models:
349
+ all_metrics.update(m.metrics.keys())
350
+
351
+ for metric in sorted(all_metrics):
352
+ values = [m.metrics[metric] for m in models if metric in m.metrics]
353
+ if values:
354
+ lines.append(f" {metric}: {min(values):.4f} — {max(values):.4f}")
355
+
356
+ return "\n".join(lines)
357
+
358
+ def __len__(self) -> int:
359
+ return len(self._models)
360
+
361
+ def __contains__(self, name: str) -> bool:
362
+ return name in self._models
@@ -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