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.
- landmarkdiff/__init__.py +40 -0
- landmarkdiff/__main__.py +207 -0
- landmarkdiff/api_client.py +316 -0
- landmarkdiff/arcface_torch.py +583 -0
- landmarkdiff/audit.py +338 -0
- landmarkdiff/augmentation.py +293 -0
- landmarkdiff/benchmark.py +213 -0
- landmarkdiff/checkpoint_manager.py +361 -0
- landmarkdiff/cli.py +252 -0
- landmarkdiff/clinical.py +223 -0
- landmarkdiff/conditioning.py +278 -0
- landmarkdiff/config.py +358 -0
- landmarkdiff/curriculum.py +191 -0
- landmarkdiff/data.py +405 -0
- landmarkdiff/data_version.py +301 -0
- landmarkdiff/displacement_model.py +745 -0
- landmarkdiff/ensemble.py +330 -0
- landmarkdiff/evaluation.py +415 -0
- landmarkdiff/experiment_tracker.py +231 -0
- landmarkdiff/face_verifier.py +947 -0
- landmarkdiff/fid.py +244 -0
- landmarkdiff/hyperparam.py +347 -0
- landmarkdiff/inference.py +754 -0
- landmarkdiff/landmarks.py +432 -0
- landmarkdiff/log.py +90 -0
- landmarkdiff/losses.py +348 -0
- landmarkdiff/manipulation.py +651 -0
- landmarkdiff/masking.py +316 -0
- landmarkdiff/metrics_agg.py +313 -0
- landmarkdiff/metrics_viz.py +464 -0
- landmarkdiff/model_registry.py +362 -0
- landmarkdiff/morphometry.py +342 -0
- landmarkdiff/postprocess.py +600 -0
- landmarkdiff/py.typed +0 -0
- landmarkdiff/safety.py +395 -0
- landmarkdiff/synthetic/__init__.py +23 -0
- landmarkdiff/synthetic/augmentation.py +188 -0
- landmarkdiff/synthetic/pair_generator.py +208 -0
- landmarkdiff/synthetic/tps_warp.py +273 -0
- landmarkdiff/validation.py +324 -0
- landmarkdiff-0.2.3.dist-info/METADATA +1173 -0
- landmarkdiff-0.2.3.dist-info/RECORD +46 -0
- landmarkdiff-0.2.3.dist-info/WHEEL +5 -0
- landmarkdiff-0.2.3.dist-info/entry_points.txt +2 -0
- landmarkdiff-0.2.3.dist-info/licenses/LICENSE +21 -0
- 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
|