Spaces:
Running
Running
Update landmarkdiff/evaluation.py to v0.3.2
Browse files- 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
|
| 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(
|
|
|
|
|
|
|
| 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 = (
|
| 198 |
-
(
|
|
|
|
|
|
|
| 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
|
| 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],
|