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,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