diff --git a/features/video-studio/processing-service/showcase.py b/features/video-studio/processing-service/showcase.py new file mode 100644 index 000000000..9226d9be8 --- /dev/null +++ b/features/video-studio/processing-service/showcase.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Visual showcase of GimpMaskModifier on different face shapes. + +Generates synthetic face geometry (mimicking InsightFace buffalo_l output) +for six face archetypes, applies the procedural mask, and saves a grid. + +Usage: python showcase.py +Output: showcase_output.png +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "src")) + +import math + +import cv2 +import numpy as np + +from modifiers.mask import GimpMaskModifier +from models.types import FaceRegion + + +# --------------------------------------------------------------------------- +# Synthetic face generator +# --------------------------------------------------------------------------- + +def make_face_oval_landmarks( + cx: float, cy: float, + rx: float, ry: float, + angle_deg: float = 0.0, + n: int = 106, + noise: float = 3.0, + seed: int = 0, +) -> list[tuple[float, float]]: + """Generate 106 landmark points on a rotated + noisy ellipse.""" + rng = np.random.default_rng(seed) + angles = np.linspace(0, 2 * math.pi, n, endpoint=False) + a_rad = math.radians(angle_deg) + cos_a, sin_a = math.cos(a_rad), math.sin(a_rad) + pts = [] + for theta in angles: + lx = rx * math.cos(theta) + rng.normal(0, noise) + ly = ry * math.sin(theta) + rng.normal(0, noise) + x = cx + cos_a * lx - sin_a * ly + y = cy + sin_a * lx + cos_a * ly + pts.append((float(x), float(y))) + return pts + + +def make_kps(cx, cy, rx, ry, angle_deg=0.0): + """Synthesise 5 InsightFace keypoints from face parameters.""" + a = math.radians(angle_deg) + eye_y_off = -ry * 0.20 + mouth_y_off = ry * 0.38 + mouth_x_off = rx * 0.32 + + def rot(dx, dy): + return (cx + math.cos(a)*dx - math.sin(a)*dy, + cy + math.sin(a)*dx + math.cos(a)*dy) + + le = rot(-rx * 0.32, eye_y_off) + re = rot( rx * 0.32, eye_y_off) + nos = rot(0, -ry * 0.02) + lm = rot(-mouth_x_off, mouth_y_off) + rm = rot( mouth_x_off, mouth_y_off) + return [le, re, nos, lm, rm] + + +def make_bbox(cx, cy, rx, ry, angle_deg=0.0, pad=1.05): + x1 = int(cx - rx * pad) + y1 = int(cy - ry * pad) + x2 = int(cx + rx * pad) + y2 = int(cy + ry * pad) + return (x1, y1, x2, y2) + + +def skin_background(h: int, w: int, seed: int = 42) -> np.ndarray: + """A realistic skin-tone gradient background for each portrait.""" + rng = np.random.default_rng(seed) + base_b = int(rng.integers(120, 160)) + base_g = int(rng.integers(140, 190)) + base_r = int(rng.integers(160, 210)) + + img = np.zeros((h, w, 3), dtype=np.float32) + for y in range(h): + t = y / h + img[y, :] = ( + base_b * (1 - t * 0.15), + base_g * (1 - t * 0.10), + base_r * (1 - t * 0.08), + ) + + # Add some skin texture noise + noise = rng.normal(0, 6, (h, w, 3)) + img = np.clip(img + noise, 0, 255).astype(np.uint8) + return img + + +# --------------------------------------------------------------------------- +# Face archetypes +# --------------------------------------------------------------------------- + +ARCHETYPES = [ + { + "label": "Round", + "rx": 100, "ry": 100, + "angle": 0.0, + "desc": "Equal width/height", + "seed": 1, + }, + { + "label": "Long & Narrow", + "rx": 70, "ry": 130, + "angle": 0.0, + "desc": "Tall face, narrow jaw", + "seed": 2, + }, + { + "label": "Wide & Short", + "rx": 130, "ry": 80, + "angle": 0.0, + "desc": "Broad face, short height", + "seed": 3, + }, + { + "label": "Tilted Left", + "rx": 95, "ry": 115, + "angle": -18.0, + "desc": "18° head tilt", + "seed": 4, + }, + { + "label": "Tilted Right", + "rx": 95, "ry": 115, + "angle": 18.0, + "desc": "18° head tilt", + "seed": 5, + }, + { + "label": "Oval (Classic)", + "rx": 85, "ry": 115, + "angle": 0.0, + "desc": "Standard oval proportion", + "seed": 6, + }, +] + +CELL_W = 340 +LABEL_H = 54 # label bar below each frame +CELL_H = 420 # frame-only height +COLS = 3 +ROWS = math.ceil(len(ARCHETYPES) / COLS) +PAD = 20 +HEADER_H = 60 +FOOTER_H = 50 + +SLOT_H = CELL_H + LABEL_H # full height of one grid slot + +TOTAL_W = COLS * CELL_W + (COLS + 1) * PAD +TOTAL_H = ROWS * SLOT_H + (ROWS + 1) * PAD + HEADER_H + FOOTER_H + +FONT = cv2.FONT_HERSHEY_DUPLEX +FONT_SM = cv2.FONT_HERSHEY_SIMPLEX +GOLD = (40, 180, 230) +WHITE = (255, 255, 255) +DARK = (30, 30, 30) +BG_CANVAS = (20, 18, 18) + + +def draw_cell(archetype: dict, mask: GimpMaskModifier) -> np.ndarray: + cx, cy = CELL_W // 2, CELL_H // 2 - 20 + rx, ry = archetype["rx"], archetype["ry"] + angle = archetype["angle"] + seed = archetype["seed"] + + frame = skin_background(CELL_H, CELL_W, seed=seed) + + kps = make_kps(cx, cy, rx, ry, angle) + lm106 = make_face_oval_landmarks(cx, cy, rx, ry, angle, noise=2.5, seed=seed) + bbox = make_bbox(cx, cy, rx, ry, angle) + + face = FaceRegion(bbox=bbox, confidence=0.97, keypoints=kps, landmarks_106=lm106) + frame = mask.apply(frame, face) + + # Label bar at bottom + bar_h = 54 + bar = np.full((bar_h, CELL_W, 3), (12, 10, 10), dtype=np.uint8) + cv2.putText(bar, archetype["label"], (10, 26), + FONT, 0.65, GOLD, 1, cv2.LINE_AA) + cv2.putText(bar, archetype["desc"], (10, 46), + FONT_SM, 0.42, (180, 180, 180), 1, cv2.LINE_AA) + + return np.vstack([frame, bar]) + + +def render_grid(mask: GimpMaskModifier) -> np.ndarray: + canvas = np.full((TOTAL_H, TOTAL_W, 3), BG_CANVAS, dtype=np.uint8) + + # Header + cv2.putText(canvas, "GimpMaskModifier — Procedural Face-Shape Adaptation", + (PAD, 42), FONT, 0.70, GOLD, 1, cv2.LINE_AA) + cv2.line(canvas, (PAD, HEADER_H - 6), (TOTAL_W - PAD, HEADER_H - 6), + (60, 55, 55), 1) + + for i, arch in enumerate(ARCHETYPES): + col = i % COLS + row = i // COLS + x0 = PAD + col * (CELL_W + PAD) + y0 = HEADER_H + PAD + row * (SLOT_H + PAD) + + cell = draw_cell(arch, mask) + canvas[y0: y0 + cell.shape[0], x0: x0 + cell.shape[1]] = cell + + # Cell border + ch, cw = cell.shape[:2] + cv2.rectangle(canvas, (x0 - 1, y0 - 1), (x0 + cw, y0 + ch), (70, 60, 60), 1) + + # Footer + fy = TOTAL_H - FOOTER_H + 24 + cv2.putText(canvas, + "Shape source: InsightFace 106-pt convex hull | " + "Eyes: landmark-proximity cluster | " + "Mouth: 5-kps", + (PAD, fy), FONT_SM, 0.38, (120, 120, 120), 1, cv2.LINE_AA) + + return canvas + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + mask = GimpMaskModifier() + + grid = render_grid(mask) + + out_path = Path(__file__).parent / "showcase_output.png" + cv2.imwrite(str(out_path), grid) + print(f"Saved: {out_path} ({grid.shape[1]}×{grid.shape[0]})") diff --git a/features/video-studio/processing-service/showcase_output.png b/features/video-studio/processing-service/showcase_output.png new file mode 100644 index 000000000..4df9e06ab Binary files /dev/null and b/features/video-studio/processing-service/showcase_output.png differ