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,651 @@
|
|
|
1
|
+
"""Landmark manipulation via Gaussian RBF deformation.
|
|
2
|
+
|
|
3
|
+
v1/v2 uses relative sliders (0-100 intensity).
|
|
4
|
+
mm inputs only in v3+ with FLAME calibrated metric space.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from landmarkdiff.landmarks import FaceLandmarks
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from landmarkdiff.clinical import ClinicalFlags
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class DeformationHandle:
|
|
25
|
+
"""Single deformation control point."""
|
|
26
|
+
|
|
27
|
+
landmark_index: int
|
|
28
|
+
displacement: np.ndarray # (2,) or (3,) pixel displacement
|
|
29
|
+
influence_radius: float # Gaussian RBF radius in pixels
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Procedure-specific landmark indices from the technical specification
|
|
33
|
+
PROCEDURE_LANDMARKS: dict[str, list[int]] = {
|
|
34
|
+
"rhinoplasty": [
|
|
35
|
+
1,
|
|
36
|
+
2,
|
|
37
|
+
4,
|
|
38
|
+
5,
|
|
39
|
+
6,
|
|
40
|
+
19,
|
|
41
|
+
94,
|
|
42
|
+
141,
|
|
43
|
+
168,
|
|
44
|
+
195,
|
|
45
|
+
197,
|
|
46
|
+
236,
|
|
47
|
+
240,
|
|
48
|
+
274,
|
|
49
|
+
275,
|
|
50
|
+
278,
|
|
51
|
+
279,
|
|
52
|
+
294,
|
|
53
|
+
326,
|
|
54
|
+
327,
|
|
55
|
+
360,
|
|
56
|
+
363,
|
|
57
|
+
370,
|
|
58
|
+
456,
|
|
59
|
+
460,
|
|
60
|
+
],
|
|
61
|
+
"blepharoplasty": [
|
|
62
|
+
33,
|
|
63
|
+
7,
|
|
64
|
+
163,
|
|
65
|
+
144,
|
|
66
|
+
145,
|
|
67
|
+
153,
|
|
68
|
+
154,
|
|
69
|
+
155,
|
|
70
|
+
157,
|
|
71
|
+
158,
|
|
72
|
+
159,
|
|
73
|
+
160,
|
|
74
|
+
161,
|
|
75
|
+
246,
|
|
76
|
+
362,
|
|
77
|
+
382,
|
|
78
|
+
381,
|
|
79
|
+
380,
|
|
80
|
+
374,
|
|
81
|
+
373,
|
|
82
|
+
390,
|
|
83
|
+
249,
|
|
84
|
+
263,
|
|
85
|
+
466,
|
|
86
|
+
388,
|
|
87
|
+
387,
|
|
88
|
+
386,
|
|
89
|
+
385,
|
|
90
|
+
384,
|
|
91
|
+
398,
|
|
92
|
+
],
|
|
93
|
+
"rhytidectomy": [
|
|
94
|
+
10,
|
|
95
|
+
21,
|
|
96
|
+
54,
|
|
97
|
+
58,
|
|
98
|
+
67,
|
|
99
|
+
93,
|
|
100
|
+
103,
|
|
101
|
+
109,
|
|
102
|
+
127,
|
|
103
|
+
132,
|
|
104
|
+
136,
|
|
105
|
+
150,
|
|
106
|
+
162,
|
|
107
|
+
172,
|
|
108
|
+
176,
|
|
109
|
+
187,
|
|
110
|
+
207,
|
|
111
|
+
213,
|
|
112
|
+
234,
|
|
113
|
+
284,
|
|
114
|
+
297,
|
|
115
|
+
323,
|
|
116
|
+
332,
|
|
117
|
+
338,
|
|
118
|
+
356,
|
|
119
|
+
361,
|
|
120
|
+
365,
|
|
121
|
+
379,
|
|
122
|
+
389,
|
|
123
|
+
397,
|
|
124
|
+
400,
|
|
125
|
+
427,
|
|
126
|
+
454,
|
|
127
|
+
],
|
|
128
|
+
"orthognathic": [
|
|
129
|
+
0,
|
|
130
|
+
17,
|
|
131
|
+
18,
|
|
132
|
+
36,
|
|
133
|
+
37,
|
|
134
|
+
39,
|
|
135
|
+
40,
|
|
136
|
+
57,
|
|
137
|
+
61,
|
|
138
|
+
78,
|
|
139
|
+
80,
|
|
140
|
+
81,
|
|
141
|
+
82,
|
|
142
|
+
84,
|
|
143
|
+
87,
|
|
144
|
+
88,
|
|
145
|
+
91,
|
|
146
|
+
95,
|
|
147
|
+
146,
|
|
148
|
+
167,
|
|
149
|
+
169,
|
|
150
|
+
170,
|
|
151
|
+
175,
|
|
152
|
+
181,
|
|
153
|
+
191,
|
|
154
|
+
200,
|
|
155
|
+
201,
|
|
156
|
+
202,
|
|
157
|
+
204,
|
|
158
|
+
208,
|
|
159
|
+
211,
|
|
160
|
+
212,
|
|
161
|
+
214,
|
|
162
|
+
269,
|
|
163
|
+
270,
|
|
164
|
+
291,
|
|
165
|
+
311,
|
|
166
|
+
312,
|
|
167
|
+
317,
|
|
168
|
+
321,
|
|
169
|
+
324,
|
|
170
|
+
325,
|
|
171
|
+
375,
|
|
172
|
+
396,
|
|
173
|
+
405,
|
|
174
|
+
407,
|
|
175
|
+
415,
|
|
176
|
+
],
|
|
177
|
+
"brow_lift": [
|
|
178
|
+
70,
|
|
179
|
+
63,
|
|
180
|
+
105,
|
|
181
|
+
66,
|
|
182
|
+
107, # left brow
|
|
183
|
+
300,
|
|
184
|
+
293,
|
|
185
|
+
334,
|
|
186
|
+
296,
|
|
187
|
+
336, # right brow
|
|
188
|
+
9,
|
|
189
|
+
8,
|
|
190
|
+
10,
|
|
191
|
+
109,
|
|
192
|
+
67,
|
|
193
|
+
103,
|
|
194
|
+
338,
|
|
195
|
+
297,
|
|
196
|
+
332, # forehead/upper face
|
|
197
|
+
],
|
|
198
|
+
"mentoplasty": [
|
|
199
|
+
148,
|
|
200
|
+
149,
|
|
201
|
+
150,
|
|
202
|
+
152,
|
|
203
|
+
171,
|
|
204
|
+
175,
|
|
205
|
+
176,
|
|
206
|
+
377,
|
|
207
|
+
],
|
|
208
|
+
}
|
|
209
|
+
# Default influence radii per procedure (in pixels at 512x512)
|
|
210
|
+
PROCEDURE_RADIUS: dict[str, float] = {
|
|
211
|
+
"rhinoplasty": 30.0,
|
|
212
|
+
"blepharoplasty": 15.0,
|
|
213
|
+
"rhytidectomy": 40.0,
|
|
214
|
+
"orthognathic": 35.0,
|
|
215
|
+
"brow_lift": 25.0,
|
|
216
|
+
"mentoplasty": 25.0,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def gaussian_rbf_deform(
|
|
221
|
+
landmarks: np.ndarray,
|
|
222
|
+
handle: DeformationHandle,
|
|
223
|
+
) -> np.ndarray:
|
|
224
|
+
"""Gaussian RBF deform: delta * exp(-dist^2 / 2r^2). Returns copy."""
|
|
225
|
+
result = landmarks.copy()
|
|
226
|
+
center = landmarks[handle.landmark_index, :2]
|
|
227
|
+
displacement = handle.displacement[:2]
|
|
228
|
+
|
|
229
|
+
distances_sq = np.sum((landmarks[:, :2] - center) ** 2, axis=1)
|
|
230
|
+
weights = np.exp(-distances_sq / (2.0 * handle.influence_radius**2))
|
|
231
|
+
|
|
232
|
+
result[:, 0] += displacement[0] * weights
|
|
233
|
+
result[:, 1] += displacement[1] * weights
|
|
234
|
+
|
|
235
|
+
if landmarks.shape[1] > 2 and len(handle.displacement) > 2:
|
|
236
|
+
result[:, 2] += handle.displacement[2] * weights
|
|
237
|
+
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def apply_procedure_preset(
|
|
242
|
+
face: FaceLandmarks,
|
|
243
|
+
procedure: str,
|
|
244
|
+
intensity: float = 50.0,
|
|
245
|
+
image_size: int = 512,
|
|
246
|
+
clinical_flags: ClinicalFlags | None = None,
|
|
247
|
+
displacement_model_path: str | None = None,
|
|
248
|
+
noise_scale: float = 0.0,
|
|
249
|
+
) -> FaceLandmarks:
|
|
250
|
+
"""Apply a surgical procedure preset to landmarks.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
face: Input face landmarks.
|
|
254
|
+
procedure: Procedure name (rhinoplasty, blepharoplasty, etc.).
|
|
255
|
+
intensity: Relative intensity 0-100 (mild=33, moderate=66, aggressive=100).
|
|
256
|
+
image_size: Reference image size for displacement scaling.
|
|
257
|
+
clinical_flags: Optional clinical condition flags.
|
|
258
|
+
displacement_model_path: Path to a fitted DisplacementModel (.npz).
|
|
259
|
+
When provided, uses data-driven displacements from real surgery pairs
|
|
260
|
+
instead of hand-tuned RBF vectors.
|
|
261
|
+
noise_scale: Variation noise scale for data-driven mode (0=deterministic).
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
New FaceLandmarks with manipulated landmarks.
|
|
265
|
+
"""
|
|
266
|
+
if procedure not in PROCEDURE_LANDMARKS:
|
|
267
|
+
raise ValueError(f"Unknown procedure: {procedure}. Choose from {list(PROCEDURE_LANDMARKS)}")
|
|
268
|
+
|
|
269
|
+
landmarks = face.landmarks.copy()
|
|
270
|
+
scale = intensity / 100.0
|
|
271
|
+
|
|
272
|
+
# Data-driven displacement mode (fall back to RBF if procedure not in model)
|
|
273
|
+
# Map UI intensity (0-100) to displacement model intensity (0-2):
|
|
274
|
+
# 50 -> 1.0x mean displacement, matching inference.py scaling
|
|
275
|
+
if displacement_model_path is not None:
|
|
276
|
+
dm_scale = intensity / 50.0
|
|
277
|
+
try:
|
|
278
|
+
return _apply_data_driven(
|
|
279
|
+
face,
|
|
280
|
+
procedure,
|
|
281
|
+
dm_scale,
|
|
282
|
+
displacement_model_path,
|
|
283
|
+
noise_scale,
|
|
284
|
+
)
|
|
285
|
+
except KeyError:
|
|
286
|
+
logger.warning(
|
|
287
|
+
"Procedure '%s' not in displacement model, falling back to RBF preset",
|
|
288
|
+
procedure,
|
|
289
|
+
)
|
|
290
|
+
# Fall through to RBF-based preset below
|
|
291
|
+
|
|
292
|
+
indices = PROCEDURE_LANDMARKS[procedure]
|
|
293
|
+
radius = PROCEDURE_RADIUS[procedure]
|
|
294
|
+
|
|
295
|
+
# Ehlers-Danlos: wider influence radii for hypermobile tissue
|
|
296
|
+
if clinical_flags and clinical_flags.ehlers_danlos:
|
|
297
|
+
radius *= 1.5
|
|
298
|
+
|
|
299
|
+
# Procedure-specific displacement vectors (normalized to image_size)
|
|
300
|
+
pixel_scale = image_size / 512.0
|
|
301
|
+
handles = _get_procedure_handles(procedure, indices, scale, radius * pixel_scale)
|
|
302
|
+
|
|
303
|
+
# Bell's palsy: remove handles on the affected (paralyzed) side
|
|
304
|
+
if clinical_flags and clinical_flags.bells_palsy:
|
|
305
|
+
from landmarkdiff.clinical import get_bells_palsy_side_indices
|
|
306
|
+
|
|
307
|
+
affected = get_bells_palsy_side_indices(clinical_flags.bells_palsy_side)
|
|
308
|
+
affected_indices = set()
|
|
309
|
+
for region_indices in affected.values():
|
|
310
|
+
affected_indices.update(region_indices)
|
|
311
|
+
handles = [h for h in handles if h.landmark_index not in affected_indices]
|
|
312
|
+
|
|
313
|
+
# Convert to pixel space for deformation
|
|
314
|
+
pixel_landmarks = landmarks.copy()
|
|
315
|
+
pixel_landmarks[:, 0] *= face.image_width
|
|
316
|
+
pixel_landmarks[:, 1] *= face.image_height
|
|
317
|
+
|
|
318
|
+
for handle in handles:
|
|
319
|
+
pixel_landmarks = gaussian_rbf_deform(pixel_landmarks, handle)
|
|
320
|
+
|
|
321
|
+
# Convert back to normalized
|
|
322
|
+
result = pixel_landmarks.copy()
|
|
323
|
+
result[:, 0] /= face.image_width
|
|
324
|
+
result[:, 1] /= face.image_height
|
|
325
|
+
|
|
326
|
+
return FaceLandmarks(
|
|
327
|
+
landmarks=result,
|
|
328
|
+
image_width=face.image_width,
|
|
329
|
+
image_height=face.image_height,
|
|
330
|
+
confidence=face.confidence,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _apply_data_driven(
|
|
335
|
+
face: FaceLandmarks,
|
|
336
|
+
procedure: str,
|
|
337
|
+
scale: float,
|
|
338
|
+
model_path: str,
|
|
339
|
+
noise_scale: float = 0.0,
|
|
340
|
+
) -> FaceLandmarks:
|
|
341
|
+
"""Apply data-driven displacements from a fitted DisplacementModel.
|
|
342
|
+
|
|
343
|
+
The model provides mean displacement vectors learned from real surgery pairs,
|
|
344
|
+
applied directly to all 478 landmarks (not just procedure-specific subset).
|
|
345
|
+
"""
|
|
346
|
+
from landmarkdiff.displacement_model import DisplacementModel
|
|
347
|
+
|
|
348
|
+
model = DisplacementModel.load(model_path)
|
|
349
|
+
field = model.get_displacement_field(
|
|
350
|
+
procedure=procedure,
|
|
351
|
+
intensity=scale,
|
|
352
|
+
noise_scale=noise_scale,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# field is (478, 2) in normalized coordinates
|
|
356
|
+
landmarks = face.landmarks.copy()
|
|
357
|
+
n_lm = min(landmarks.shape[0], field.shape[0])
|
|
358
|
+
landmarks[:n_lm, :2] += field[:n_lm]
|
|
359
|
+
|
|
360
|
+
# Clamp to [0, 1]
|
|
361
|
+
landmarks = np.clip(landmarks, 0.0, 1.0)
|
|
362
|
+
|
|
363
|
+
return FaceLandmarks(
|
|
364
|
+
landmarks=landmarks,
|
|
365
|
+
image_width=face.image_width,
|
|
366
|
+
image_height=face.image_height,
|
|
367
|
+
confidence=face.confidence,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _get_procedure_handles(
|
|
372
|
+
procedure: str,
|
|
373
|
+
indices: list[int],
|
|
374
|
+
scale: float,
|
|
375
|
+
radius: float,
|
|
376
|
+
) -> list[DeformationHandle]:
|
|
377
|
+
"""Build deformation handles per procedure. 2D pixel displacements, calibrated at 512x512."""
|
|
378
|
+
handles = []
|
|
379
|
+
|
|
380
|
+
if procedure == "rhinoplasty":
|
|
381
|
+
# --- Alar base narrowing: move nostrils inward (toward midline) ---
|
|
382
|
+
# left nostril -> move RIGHT (+X)
|
|
383
|
+
left_alar = [240, 236, 141, 363, 370]
|
|
384
|
+
for idx in left_alar:
|
|
385
|
+
if idx in indices:
|
|
386
|
+
handles.append(
|
|
387
|
+
DeformationHandle(
|
|
388
|
+
landmark_index=idx,
|
|
389
|
+
displacement=np.array([2.5 * scale, 0.0]),
|
|
390
|
+
influence_radius=radius * 0.6,
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
# right nostril -> move LEFT (-X)
|
|
394
|
+
right_alar = [460, 456, 274, 275, 278, 279]
|
|
395
|
+
for idx in right_alar:
|
|
396
|
+
if idx in indices:
|
|
397
|
+
handles.append(
|
|
398
|
+
DeformationHandle(
|
|
399
|
+
landmark_index=idx,
|
|
400
|
+
displacement=np.array([-2.5 * scale, 0.0]),
|
|
401
|
+
influence_radius=radius * 0.6,
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
# --- Tip refinement: subtle upward rotation + narrowing ---
|
|
406
|
+
tip_indices = [1, 2, 94, 19]
|
|
407
|
+
for idx in tip_indices:
|
|
408
|
+
if idx in indices:
|
|
409
|
+
handles.append(
|
|
410
|
+
DeformationHandle(
|
|
411
|
+
landmark_index=idx,
|
|
412
|
+
displacement=np.array([0.0, -2.0 * scale]),
|
|
413
|
+
influence_radius=radius * 0.5,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# --- Dorsum narrowing: bilateral squeeze of nasal bridge ---
|
|
418
|
+
dorsum_left = [195, 197, 236]
|
|
419
|
+
for idx in dorsum_left:
|
|
420
|
+
if idx in indices:
|
|
421
|
+
handles.append(
|
|
422
|
+
DeformationHandle(
|
|
423
|
+
landmark_index=idx,
|
|
424
|
+
displacement=np.array([1.5 * scale, 0.0]),
|
|
425
|
+
influence_radius=radius * 0.5,
|
|
426
|
+
)
|
|
427
|
+
)
|
|
428
|
+
dorsum_right = [326, 327, 456]
|
|
429
|
+
for idx in dorsum_right:
|
|
430
|
+
if idx in indices:
|
|
431
|
+
handles.append(
|
|
432
|
+
DeformationHandle(
|
|
433
|
+
landmark_index=idx,
|
|
434
|
+
displacement=np.array([-1.5 * scale, 0.0]),
|
|
435
|
+
influence_radius=radius * 0.5,
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
elif procedure == "blepharoplasty":
|
|
440
|
+
# --- Upper lid elevation (primary effect) ---
|
|
441
|
+
upper_lid_left = [159, 160, 161] # central upper lid
|
|
442
|
+
upper_lid_right = [386, 385, 384]
|
|
443
|
+
for idx in upper_lid_left + upper_lid_right:
|
|
444
|
+
if idx in indices:
|
|
445
|
+
handles.append(
|
|
446
|
+
DeformationHandle(
|
|
447
|
+
landmark_index=idx,
|
|
448
|
+
displacement=np.array([0.0, -2.0 * scale]),
|
|
449
|
+
influence_radius=radius,
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
# --- Medial/lateral lid corners: less displacement (tapered) ---
|
|
453
|
+
corner_left = [158, 157, 133, 33]
|
|
454
|
+
corner_right = [387, 388, 362, 263]
|
|
455
|
+
for idx in corner_left + corner_right:
|
|
456
|
+
if idx in indices:
|
|
457
|
+
handles.append(
|
|
458
|
+
DeformationHandle(
|
|
459
|
+
landmark_index=idx,
|
|
460
|
+
displacement=np.array([0.0, -0.8 * scale]),
|
|
461
|
+
influence_radius=radius * 0.7,
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
# --- Subtle lower lid tightening ---
|
|
465
|
+
lower_lid_left = [145, 153, 154]
|
|
466
|
+
lower_lid_right = [374, 380, 381]
|
|
467
|
+
for idx in lower_lid_left + lower_lid_right:
|
|
468
|
+
if idx in indices:
|
|
469
|
+
handles.append(
|
|
470
|
+
DeformationHandle(
|
|
471
|
+
landmark_index=idx,
|
|
472
|
+
displacement=np.array([0.0, 0.5 * scale]),
|
|
473
|
+
influence_radius=radius * 0.5,
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
elif procedure == "rhytidectomy":
|
|
478
|
+
# Different displacement vectors by anatomical sub-region.
|
|
479
|
+
# Jowl area: strongest lift (upward + toward ear)
|
|
480
|
+
jowl_left = [132, 136, 172, 58, 150, 176]
|
|
481
|
+
for idx in jowl_left:
|
|
482
|
+
if idx in indices:
|
|
483
|
+
handles.append(
|
|
484
|
+
DeformationHandle(
|
|
485
|
+
landmark_index=idx,
|
|
486
|
+
displacement=np.array([-2.5 * scale, -3.0 * scale]),
|
|
487
|
+
influence_radius=radius,
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
jowl_right = [361, 365, 397, 288, 379, 400]
|
|
491
|
+
for idx in jowl_right:
|
|
492
|
+
if idx in indices:
|
|
493
|
+
handles.append(
|
|
494
|
+
DeformationHandle(
|
|
495
|
+
landmark_index=idx,
|
|
496
|
+
displacement=np.array([2.5 * scale, -3.0 * scale]),
|
|
497
|
+
influence_radius=radius,
|
|
498
|
+
)
|
|
499
|
+
)
|
|
500
|
+
# Chin/submental: upward only (no lateral)
|
|
501
|
+
chin = [152, 148, 377, 378]
|
|
502
|
+
for idx in chin:
|
|
503
|
+
if idx in indices:
|
|
504
|
+
handles.append(
|
|
505
|
+
DeformationHandle(
|
|
506
|
+
landmark_index=idx,
|
|
507
|
+
displacement=np.array([0.0, -2.0 * scale]),
|
|
508
|
+
influence_radius=radius * 0.8,
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
# Temple/upper face: very mild lift
|
|
512
|
+
temple_left = [10, 21, 54, 67, 103, 109, 162, 127]
|
|
513
|
+
temple_right = [284, 297, 332, 338, 323, 356, 389, 454]
|
|
514
|
+
for idx in temple_left:
|
|
515
|
+
if idx in indices:
|
|
516
|
+
handles.append(
|
|
517
|
+
DeformationHandle(
|
|
518
|
+
landmark_index=idx,
|
|
519
|
+
displacement=np.array([-0.5 * scale, -1.0 * scale]),
|
|
520
|
+
influence_radius=radius * 0.6,
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
for idx in temple_right:
|
|
524
|
+
if idx in indices:
|
|
525
|
+
handles.append(
|
|
526
|
+
DeformationHandle(
|
|
527
|
+
landmark_index=idx,
|
|
528
|
+
displacement=np.array([0.5 * scale, -1.0 * scale]),
|
|
529
|
+
influence_radius=radius * 0.6,
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
elif procedure == "orthognathic":
|
|
534
|
+
# --- Mandible repositioning: move jaw up and forward (visible as upward in 2D) ---
|
|
535
|
+
lower_jaw = [17, 18, 200, 201, 202, 204, 208, 211, 212, 214]
|
|
536
|
+
for idx in lower_jaw:
|
|
537
|
+
if idx in indices:
|
|
538
|
+
handles.append(
|
|
539
|
+
DeformationHandle(
|
|
540
|
+
landmark_index=idx,
|
|
541
|
+
displacement=np.array([0.0, -3.0 * scale]),
|
|
542
|
+
influence_radius=radius,
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
# --- Chin projection: move chin point forward/upward ---
|
|
546
|
+
chin_pts = [175, 170, 169, 167, 396]
|
|
547
|
+
for idx in chin_pts:
|
|
548
|
+
if idx in indices:
|
|
549
|
+
handles.append(
|
|
550
|
+
DeformationHandle(
|
|
551
|
+
landmark_index=idx,
|
|
552
|
+
displacement=np.array([0.0, -2.0 * scale]),
|
|
553
|
+
influence_radius=radius * 0.7,
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
# --- Lateral jaw: bilateral symmetric inward pull for narrowing ---
|
|
557
|
+
jaw_left = [57, 61, 78, 91, 95, 146, 181]
|
|
558
|
+
for idx in jaw_left:
|
|
559
|
+
if idx in indices:
|
|
560
|
+
handles.append(
|
|
561
|
+
DeformationHandle(
|
|
562
|
+
landmark_index=idx,
|
|
563
|
+
displacement=np.array([1.5 * scale, -1.0 * scale]),
|
|
564
|
+
influence_radius=radius * 0.8,
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
jaw_right = [291, 311, 312, 321, 324, 325, 375, 405]
|
|
568
|
+
for idx in jaw_right:
|
|
569
|
+
if idx in indices:
|
|
570
|
+
handles.append(
|
|
571
|
+
DeformationHandle(
|
|
572
|
+
landmark_index=idx,
|
|
573
|
+
displacement=np.array([-1.5 * scale, -1.0 * scale]),
|
|
574
|
+
influence_radius=radius * 0.8,
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
elif procedure == "brow_lift":
|
|
579
|
+
# --- Brow elevation ---
|
|
580
|
+
brow_left = [70, 63, 105, 66, 107]
|
|
581
|
+
brow_right = [300, 293, 334, 296, 336]
|
|
582
|
+
|
|
583
|
+
# Lateral brow often lifted more than medial
|
|
584
|
+
left_weights = [0.7, 0.8, 0.9, 1.0, 1.1]
|
|
585
|
+
for i, idx in enumerate(brow_left):
|
|
586
|
+
if idx in indices:
|
|
587
|
+
handles.append(
|
|
588
|
+
DeformationHandle(
|
|
589
|
+
landmark_index=idx,
|
|
590
|
+
displacement=np.array([0.0, -4.0 * left_weights[i] * scale]),
|
|
591
|
+
influence_radius=radius,
|
|
592
|
+
)
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
right_weights = [0.7, 0.8, 0.9, 1.0, 1.1]
|
|
596
|
+
for i, idx in enumerate(brow_right):
|
|
597
|
+
if idx in indices:
|
|
598
|
+
handles.append(
|
|
599
|
+
DeformationHandle(
|
|
600
|
+
landmark_index=idx,
|
|
601
|
+
displacement=np.array([0.0, -4.0 * right_weights[i] * scale]),
|
|
602
|
+
influence_radius=radius,
|
|
603
|
+
)
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
# --- Forehead smoothing / subtle lift ---
|
|
607
|
+
forehead = [9, 8, 10, 109, 67, 103, 338, 297, 332]
|
|
608
|
+
for idx in forehead:
|
|
609
|
+
if idx in indices:
|
|
610
|
+
handles.append(
|
|
611
|
+
DeformationHandle(
|
|
612
|
+
landmark_index=idx,
|
|
613
|
+
displacement=np.array([0.0, -1.5 * scale]),
|
|
614
|
+
influence_radius=radius * 1.2,
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
elif procedure == "mentoplasty":
|
|
618
|
+
# --- Chin tip advancement: move chin forward (upward in 2D) ---
|
|
619
|
+
chin_tip = [152, 175]
|
|
620
|
+
for idx in chin_tip:
|
|
621
|
+
if idx in indices:
|
|
622
|
+
handles.append(
|
|
623
|
+
DeformationHandle(
|
|
624
|
+
landmark_index=idx,
|
|
625
|
+
displacement=np.array([0.0, -4.0 * scale]),
|
|
626
|
+
influence_radius=radius,
|
|
627
|
+
)
|
|
628
|
+
)
|
|
629
|
+
# --- Lower chin contour: follow tip with softer displacement ---
|
|
630
|
+
lower_contour = [148, 149, 150, 176, 377]
|
|
631
|
+
for idx in lower_contour:
|
|
632
|
+
if idx in indices:
|
|
633
|
+
handles.append(
|
|
634
|
+
DeformationHandle(
|
|
635
|
+
landmark_index=idx,
|
|
636
|
+
displacement=np.array([0.0, -2.5 * scale]),
|
|
637
|
+
influence_radius=radius * 0.8,
|
|
638
|
+
)
|
|
639
|
+
)
|
|
640
|
+
# --- Jaw angles: minimal upward pull for natural transition ---
|
|
641
|
+
jaw_angles = [171, 396]
|
|
642
|
+
for idx in jaw_angles:
|
|
643
|
+
if idx in indices:
|
|
644
|
+
handles.append(
|
|
645
|
+
DeformationHandle(
|
|
646
|
+
landmark_index=idx,
|
|
647
|
+
displacement=np.array([0.0, -1.0 * scale]),
|
|
648
|
+
influence_radius=radius * 0.6,
|
|
649
|
+
)
|
|
650
|
+
)
|
|
651
|
+
return handles
|