Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |