dreamlessx commited on
Commit
544c445
·
verified ·
1 Parent(s): 871693c

Update landmarkdiff/manipulation.py to v0.3.2

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