naddaa's picture
Initial commit: facial symmetry analysis with MediaPipe Face Mesh
f3841e1
Raw
History Blame Contribute Delete
7.72 kB
"""
Visualization utilities for facial landmark and symmetry analysis.
"""
from __future__ import annotations
import cv2
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.figure import Figure
from src.landmarks.detector import LandmarkResult, LANDMARK_GROUPS, BILATERAL_PAIRS
from src.symmetry.analyzer import SymmetryResult
# Color palette (BGR for cv2, RGB for matplotlib)
COLORS_BGR = {
"left_eye": (86, 180, 233),
"right_eye": (86, 180, 233),
"left_eyebrow": (230, 159, 0),
"right_eyebrow": (230, 159, 0),
"nose_tip": (0, 158, 115),
"nose_bridge": (0, 158, 115),
"upper_lip": (204, 121, 167),
"lower_lip": (204, 121, 167),
"jawline": (160, 160, 160),
"midline": (255, 255, 255),
}
def draw_landmarks(
image: np.ndarray,
result: LandmarkResult,
radius: int = 2,
draw_connections: bool = True,
alpha: float = 0.7,
) -> np.ndarray:
"""Draw 468 facial landmarks on the image.
Args:
image: RGB uint8 image.
result: LandmarkResult from FaceLandmarkDetector.
radius: Dot radius in pixels.
draw_connections: Draw group contour lines.
alpha: Blend factor for overlay.
Returns:
RGB uint8 annotated image.
"""
overlay = image.copy()
bgr = cv2.cvtColor(overlay, cv2.COLOR_RGB2BGR)
# Draw all landmarks as small dots (gray)
for pt in result.landmarks:
cv2.circle(bgr, (int(pt[0]), int(pt[1])), radius, (180, 180, 180), -1)
# Draw group landmarks with distinct colors
for group_name, pts in result.groups.items():
color = COLORS_BGR.get(group_name, (255, 255, 255))
rgb_color = (color[2], color[1], color[0]) # BGR → RGB for drawing on RGB img
for pt in pts:
cv2.circle(bgr, (int(pt[0]), int(pt[1])), radius + 1, color, -1)
if draw_connections and len(pts) > 1:
for i in range(len(pts) - 1):
p1 = (int(pts[i][0]), int(pts[i][1]))
p2 = (int(pts[i+1][0]), int(pts[i+1][1]))
cv2.line(bgr, p1, p2, color, 1, cv2.LINE_AA)
# Midline
mx = int(result.midline_x)
h = image.shape[0]
cv2.line(bgr, (mx, 0), (mx, h), (200, 200, 50), 1, cv2.LINE_AA)
result_rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
return cv2.addWeighted(image, 1 - alpha, result_rgb, alpha, 0)
def draw_symmetry_overlay(
image: np.ndarray,
lm_result: LandmarkResult,
sym_result: SymmetryResult,
show_pairs: bool = True,
) -> np.ndarray:
"""Draw bilateral landmark pairs color-coded by asymmetry magnitude.
Green = low asymmetry, red = high asymmetry (relative to median).
Args:
image: RGB uint8 image.
lm_result: LandmarkResult.
sym_result: SymmetryResult.
show_pairs: Draw connecting lines between bilateral pairs.
Returns:
Annotated RGB image.
"""
bgr = cv2.cvtColor(image.copy(), cv2.COLOR_RGB2BGR)
pts = lm_result.landmarks
midline_x = lm_result.midline_x
distances = sym_result.pair_distances_px
# Normalize distances for color mapping
d_min, d_max = distances.min(), max(distances.max(), 1.0)
for i, (left_idx, right_idx) in enumerate(BILATERAL_PAIRS):
left_pt = pts[left_idx].astype(int)
right_pt = pts[right_idx].astype(int)
# Color: green (symmetric) → red (asymmetric)
t = (distances[i] - d_min) / (d_max - d_min)
color = (int(t * 255), int((1 - t) * 200), 30) # BGR
cv2.circle(bgr, tuple(left_pt), 4, color, -1)
cv2.circle(bgr, tuple(right_pt), 4, color, -1)
if show_pairs:
cv2.line(bgr, tuple(left_pt), tuple(right_pt), color, 1, cv2.LINE_AA)
# Midline
cv2.line(bgr, (int(midline_x), 0), (int(midline_x), image.shape[0]),
(80, 200, 200), 1, cv2.LINE_AA)
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
def draw_bilateral_comparison(
image: np.ndarray,
lm_result: LandmarkResult,
) -> Figure:
"""Create a side-by-side left/right half comparison figure.
Flips the left half and places it next to the right half to visually
demonstrate what a perfectly symmetric version would look like.
Returns:
Matplotlib Figure (3 panels: original | left-mirrored | right-mirrored)
"""
h, w = image.shape[:2]
mx = int(lm_result.midline_x)
# Left half, mirrored (reflects left face to fill right side)
left_half = image[:, :mx]
left_mirror = np.fliplr(left_half)
# Pad or crop to match right half width
right_w = w - mx
if left_mirror.shape[1] >= right_w:
left_mirror = left_mirror[:, :right_w]
else:
pad = right_w - left_mirror.shape[1]
left_mirror = np.pad(left_mirror, ((0,0),(0,pad),(0,0)))
left_full = np.concatenate([np.fliplr(left_mirror), left_mirror], axis=1)
left_full = left_full[:, :w]
# Right half, mirrored
right_half = image[:, mx:]
right_mirror = np.fliplr(right_half)
left_w = mx
if right_mirror.shape[1] >= left_w:
right_mirror = right_mirror[:, :left_w]
else:
pad = left_w - right_mirror.shape[1]
right_mirror = np.pad(right_mirror, ((0,0),(0,pad),(0,0)))
right_full = np.concatenate([right_mirror, np.fliplr(right_mirror)], axis=1)
right_full = right_full[:, :w]
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
axes[0].imshow(image); axes[0].set_title('Original', fontsize=12)
axes[1].imshow(left_full); axes[1].set_title('Left×2 (mirrored)', fontsize=12)
axes[2].imshow(right_full); axes[2].set_title('Right×2 (mirrored)', fontsize=12)
for ax in axes:
ax.axis('off')
plt.suptitle('Bilateral Symmetry Comparison', fontsize=14, fontweight='bold')
plt.tight_layout()
return fig
def plot_asymmetry_radar(sym_result: SymmetryResult) -> Figure:
"""Radar / spider chart of per-region symmetry scores."""
regions = list(sym_result.region_scores.keys())
scores = [sym_result.region_scores[r] for r in regions]
n = len(regions)
angles = np.linspace(0, 2 * np.pi, n, endpoint=False).tolist()
angles += angles[:1]
scores += scores[:1]
fig, ax = plt.subplots(figsize=(6, 6), subplot_kw=dict(polar=True))
ax.plot(angles, scores, 'o-', linewidth=2, color='royalblue')
ax.fill(angles, scores, alpha=0.25, color='royalblue')
ax.set_xticks(angles[:-1])
ax.set_xticklabels(regions, fontsize=11)
ax.set_ylim(0, 1)
ax.set_yticks([0.2, 0.4, 0.6, 0.8, 1.0])
ax.set_yticklabels(['0.2', '0.4', '0.6', '0.8', '1.0'], fontsize=8)
ax.set_title('Per-Region Symmetry Scores', fontsize=13, pad=20)
ax.grid(True, alpha=0.3)
return fig
def plot_asymmetry_bar(sym_result: SymmetryResult) -> Figure:
"""Horizontal bar chart of bilateral pair asymmetries (% IOD)."""
iod = sym_result.inter_ocular_distance
pairs_pct = sym_result.pair_distances_px / iod * 100
labels = sym_result.pair_labels
colors = ['#e74c3c' if v > 5 else '#2ecc71' for v in pairs_pct]
fig, ax = plt.subplots(figsize=(9, max(4, len(labels) * 0.5)))
y = np.arange(len(labels))
ax.barh(y, pairs_pct, color=colors, edgecolor='white', linewidth=0.5)
ax.set_yticks(y)
ax.set_yticklabels(labels, fontsize=10)
ax.set_xlabel('Asymmetry (% of inter-ocular distance)', fontsize=11)
ax.set_title('Bilateral Pair Asymmetry', fontsize=13)
ax.axvline(5, color='orange', linestyle='--', lw=1.5, label='5% IOD threshold')
ax.legend(fontsize=9)
ax.grid(axis='x', alpha=0.3)
plt.tight_layout()
return fig