dreamlessx commited on
Commit
db489aa
·
verified ·
1 Parent(s): afc1ddc

Update landmarkdiff/evaluation.py to v0.3.2

Browse files
Files changed (1) hide show
  1. landmarkdiff/evaluation.py +128 -11
landmarkdiff/evaluation.py CHANGED
@@ -24,7 +24,7 @@ class EvalMetrics:
24
 
25
  fid: float = 0.0
26
  lpips: float = 0.0
27
- nme: float = 0.0 # Normalized Mean landmark Error
28
  identity_sim: float = 0.0 # ArcFace cosine similarity
29
  ssim: float = 0.0
30
 
@@ -155,7 +155,9 @@ def compute_nme(
155
  Returns:
156
  NME value (lower is better).
157
  """
158
- iod = np.linalg.norm(target_landmarks[left_eye_idx] - target_landmarks[right_eye_idx])
 
 
159
  if iod < 1.0:
160
  iod = 1.0
161
 
@@ -174,7 +176,6 @@ def compute_ssim(
174
  """
175
  try:
176
  from skimage.metrics import structural_similarity
177
-
178
  # Convert to grayscale if color, or compute per-channel
179
  if pred.ndim == 3 and pred.shape[2] == 3:
180
  return float(structural_similarity(pred, target, channel_axis=2, data_range=255))
@@ -194,8 +195,10 @@ def compute_ssim(
194
  C1 = (0.01 * 255) ** 2
195
  C2 = (0.03 * 255) ** 2
196
 
197
- ssim_val = ((2 * mu_p * mu_t + C1) * (2 * sigma_pt + C2)) / (
198
- (mu_p**2 + mu_t**2 + C1) * (sigma_p**2 + sigma_t**2 + C2)
 
 
199
  )
200
  return float(ssim_val)
201
 
@@ -209,7 +212,6 @@ def _get_lpips_fn() -> Any:
209
  global _LPIPS_FN
210
  if _LPIPS_FN is None:
211
  import lpips
212
-
213
  _LPIPS_FN = lpips.LPIPS(net="alex", verbose=False)
214
  _LPIPS_FN.eval()
215
  return _LPIPS_FN
@@ -224,7 +226,7 @@ def compute_lpips(
224
  Returns LPIPS score (lower = more similar).
225
  """
226
  try:
227
- import lpips # noqa: F401
228
  import torch
229
  except ImportError:
230
  return float("nan")
@@ -257,13 +259,12 @@ def compute_fid(
257
  """
258
  try:
259
  from torch_fidelity import calculate_metrics
260
- except ImportError:
261
  raise ImportError(
262
  "torch-fidelity is required for FID. Install with: pip install torch-fidelity"
263
- ) from None
264
 
265
  import torch
266
-
267
  metrics = calculate_metrics(
268
  input1=generated_dir,
269
  input2=real_dir,
@@ -285,7 +286,6 @@ def compute_identity_similarity(
285
  """
286
  try:
287
  from insightface.app import FaceAnalysis
288
-
289
  global _ARCFACE_APP
290
  if _ARCFACE_APP is None:
291
  _ARCFACE_APP = FaceAnalysis(
@@ -315,6 +315,123 @@ def compute_identity_similarity(
315
  return compute_ssim(pred, target)
316
 
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  def evaluate_batch(
319
  predictions: list[np.ndarray],
320
  targets: list[np.ndarray],
 
24
 
25
  fid: float = 0.0
26
  lpips: float = 0.0
27
+ nme: float = 0.0 # Normalized Mean landmark Error
28
  identity_sim: float = 0.0 # ArcFace cosine similarity
29
  ssim: float = 0.0
30
 
 
155
  Returns:
156
  NME value (lower is better).
157
  """
158
+ iod = np.linalg.norm(
159
+ target_landmarks[left_eye_idx] - target_landmarks[right_eye_idx]
160
+ )
161
  if iod < 1.0:
162
  iod = 1.0
163
 
 
176
  """
177
  try:
178
  from skimage.metrics import structural_similarity
 
179
  # Convert to grayscale if color, or compute per-channel
180
  if pred.ndim == 3 and pred.shape[2] == 3:
181
  return float(structural_similarity(pred, target, channel_axis=2, data_range=255))
 
195
  C1 = (0.01 * 255) ** 2
196
  C2 = (0.03 * 255) ** 2
197
 
198
+ ssim_val = (
199
+ (2 * mu_p * mu_t + C1) * (2 * sigma_pt + C2)
200
+ ) / (
201
+ (mu_p ** 2 + mu_t ** 2 + C1) * (sigma_p ** 2 + sigma_t ** 2 + C2)
202
  )
203
  return float(ssim_val)
204
 
 
212
  global _LPIPS_FN
213
  if _LPIPS_FN is None:
214
  import lpips
 
215
  _LPIPS_FN = lpips.LPIPS(net="alex", verbose=False)
216
  _LPIPS_FN.eval()
217
  return _LPIPS_FN
 
226
  Returns LPIPS score (lower = more similar).
227
  """
228
  try:
229
+ import lpips # noqa: F401 — availability check; used in _get_lpips_fn
230
  import torch
231
  except ImportError:
232
  return float("nan")
 
259
  """
260
  try:
261
  from torch_fidelity import calculate_metrics
262
+ except ImportError as e:
263
  raise ImportError(
264
  "torch-fidelity is required for FID. Install with: pip install torch-fidelity"
265
+ ) from e
266
 
267
  import torch
 
268
  metrics = calculate_metrics(
269
  input1=generated_dir,
270
  input2=real_dir,
 
286
  """
287
  try:
288
  from insightface.app import FaceAnalysis
 
289
  global _ARCFACE_APP
290
  if _ARCFACE_APP is None:
291
  _ARCFACE_APP = FaceAnalysis(
 
315
  return compute_ssim(pred, target)
316
 
317
 
318
+ # ------------------------------------------------------------------
319
+ # Geometric nasal ratios (adapted from Varghaei et al., arXiv:2508.13363)
320
+ # ------------------------------------------------------------------
321
+
322
+ # MediaPipe 478-point indices for facial measurements
323
+ _LEFT_ALAR = 129 # left alar (nose wing) outermost point
324
+ _RIGHT_ALAR = 358 # right alar
325
+ _NOSE_TIP = 1 # pronasale
326
+ _NOSE_BRIDGE_TOP = 168 # nasion (bridge root)
327
+ _LEFT_INNER_CANTHUS = 133
328
+ _RIGHT_INNER_CANTHUS = 362
329
+ _LEFT_TRAGION = 234 # left ear (face width proxy)
330
+ _RIGHT_TRAGION = 454 # right ear
331
+ _FOREHEAD = 10 # trichion / upper face
332
+ _CHIN = 152 # menton / lowest chin point
333
+
334
+
335
+ def compute_nasal_ratios(
336
+ landmarks: np.ndarray,
337
+ ) -> dict[str, float]:
338
+ """Compute 5 nasal geometric ratios from MediaPipe 478-point landmarks.
339
+
340
+ Ratios from Varghaei et al. (2025), used clinically to assess
341
+ rhinoplasty outcomes. All ratios are dimensionless.
342
+
343
+ Args:
344
+ landmarks: (478, 2) or (478, 3) landmark pixel coordinates.
345
+
346
+ Returns:
347
+ Dict with keys: alar_face_ratio, nose_face_ratio,
348
+ alar_intercanthal_ratio, tip_deviation, nostril_asymmetry.
349
+ """
350
+ pts = landmarks[:, :2] # use only x,y
351
+
352
+ alar_width = np.linalg.norm(pts[_LEFT_ALAR] - pts[_RIGHT_ALAR])
353
+ face_width = np.linalg.norm(pts[_LEFT_TRAGION] - pts[_RIGHT_TRAGION])
354
+ nose_length = np.linalg.norm(pts[_NOSE_BRIDGE_TOP] - pts[_NOSE_TIP])
355
+ face_height = np.linalg.norm(pts[_FOREHEAD] - pts[_CHIN])
356
+ intercanthal = np.linalg.norm(
357
+ pts[_LEFT_INNER_CANTHUS] - pts[_RIGHT_INNER_CANTHUS]
358
+ )
359
+
360
+ # Midline: midpoint between inner canthi
361
+ midline_x = (pts[_LEFT_INNER_CANTHUS][0] + pts[_RIGHT_INNER_CANTHUS][0]) / 2
362
+ tip_deviation = abs(pts[_NOSE_TIP][0] - midline_x) / (face_width + 1e-8)
363
+
364
+ # Nostril asymmetry: difference in left/right alar-to-tip distances
365
+ left_dist = np.linalg.norm(pts[_LEFT_ALAR] - pts[_NOSE_TIP])
366
+ right_dist = np.linalg.norm(pts[_RIGHT_ALAR] - pts[_NOSE_TIP])
367
+ nostril_asymmetry = abs(left_dist - right_dist) / (alar_width + 1e-8)
368
+
369
+ return {
370
+ "alar_face_ratio": float(alar_width / (face_width + 1e-8)),
371
+ "nose_face_ratio": float(nose_length / (face_height + 1e-8)),
372
+ "alar_intercanthal_ratio": float(alar_width / (intercanthal + 1e-8)),
373
+ "tip_deviation": float(tip_deviation),
374
+ "nostril_asymmetry": float(nostril_asymmetry),
375
+ }
376
+
377
+
378
+ def compute_bilateral_symmetry(
379
+ landmarks: np.ndarray,
380
+ ) -> float:
381
+ """Compute bilateral facial symmetry score from landmarks.
382
+
383
+ Reflects each left-side landmark across the vertical midline and
384
+ measures average displacement from the corresponding right-side point.
385
+ Normalized by inter-ocular distance.
386
+
387
+ Based on KDTree approach from Varghaei et al. (2025).
388
+
389
+ Args:
390
+ landmarks: (478, 2) or (478, 3) landmark pixel coordinates.
391
+
392
+ Returns:
393
+ Symmetry score in [0, 1] where 1 = perfect symmetry.
394
+ """
395
+ pts = landmarks[:, :2]
396
+
397
+ # Midline from forehead to chin
398
+ midline_x = (pts[_LEFT_TRAGION][0] + pts[_RIGHT_TRAGION][0]) / 2
399
+ iod = np.linalg.norm(pts[33] - pts[263]) # inter-ocular distance
400
+ if iod < 1.0:
401
+ iod = 1.0
402
+
403
+ # MediaPipe left-right correspondence pairs (subset of reliable pairs)
404
+ # format: (left_idx, right_idx)
405
+ sym_pairs = [
406
+ (33, 263), # outer canthi
407
+ (133, 362), # inner canthi
408
+ (70, 300), # eyebrow inner
409
+ (105, 334), # eyebrow outer
410
+ (129, 358), # alar
411
+ (61, 291), # mouth corners
412
+ (234, 454), # tragion
413
+ (93, 323), # cheekbone
414
+ (132, 361), # lower eyelid
415
+ (159, 386), # upper eyelid
416
+ (58, 288), # lower lip
417
+ (172, 397), # chin lateral
418
+ (136, 365), # nose lateral
419
+ (48, 278), # nostril
420
+ ]
421
+
422
+ diffs = []
423
+ for left_idx, right_idx in sym_pairs:
424
+ # Reflect left point across midline
425
+ reflected_x = 2 * midline_x - pts[left_idx][0]
426
+ reflected = np.array([reflected_x, pts[left_idx][1]])
427
+ diff = np.linalg.norm(reflected - pts[right_idx]) / iod
428
+ diffs.append(diff)
429
+
430
+ mean_asymmetry = np.mean(diffs)
431
+ # Convert to 0-1 symmetry score (asymmetry of 0 = score of 1)
432
+ return float(np.clip(1.0 - mean_asymmetry, 0.0, 1.0))
433
+
434
+
435
  def evaluate_batch(
436
  predictions: list[np.ndarray],
437
  targets: list[np.ndarray],