| """Renderers for a FUT-HEROS analysis. |
| |
| render() -> clean analysis video: player segmentation masks (any number, bright on |
| possession), green goal mask, smooth ball trail, red goal-spot marker. |
| No HUD text, no pose card baked in (those are separate UI cards). |
| pose_card_img() -> the standalone Pose-Detection card (gray silhouette + black skeleton |
| on white), for display beside the video. |
| """ |
| from __future__ import annotations |
|
|
| import os |
| import shutil |
| import subprocess |
|
|
| import cv2 |
| import numpy as np |
|
|
| from .pipeline import POSE_EDGES, L_ANKLE, R_ANKLE, AnalysisResult |
|
|
| |
| PAL = [(200, 90, 0), (0, 130, 255), (60, 180, 75), (180, 60, 200), (0, 200, 200), (120, 120, 0)] |
| PAL_HI = [(255, 170, 40), (40, 200, 255), (110, 240, 130), (230, 120, 250), (60, 255, 255), (200, 200, 60)] |
|
|
|
|
| def _mask_full(pmasks, key, f, W, H): |
| m = pmasks if key == "goal" else (pmasks[key][f] if key in pmasks else None) |
| if m is None or not m.any(): |
| return None |
| return cv2.resize(m.astype(np.uint8), (W, H), interpolation=cv2.INTER_NEAREST).astype(bool) |
|
|
|
|
| def _blend(fr, mask, color, alpha): |
| fr[mask] = (alpha * np.array(color, np.float32) + (1 - alpha) * fr[mask]).astype(np.uint8) |
|
|
|
|
| def render(clip: str, tracks: dict, res: AnalysisResult, out_path: str, truth: dict | None = None) -> str: |
| W, H, n, fps = tracks["W"], tracks["H"], tracks["n"], tracks.get("fps") or 30.0 |
| xy = res.xy; holder = res.holder; pids = list(tracks["players"]) |
| gf = int(res.goal_time * fps) if res.goal_time is not None else None |
| goal = tracks["goal"]; pmasks = tracks.get("pmasks", {}); gmask = tracks.get("goal_mask") |
| |
| |
| os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) |
| cap = cv2.VideoCapture(clip) |
| ff = subprocess.Popen( |
| ["ffmpeg", "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "bgr24", |
| "-s", f"{W}x{H}", "-r", f"{fps}", "-i", "-", "-an", "-c:v", "libx264", |
| "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", |
| "-movflags", "+faststart", out_path], |
| stdin=subprocess.PIPE) |
| tf = int(0.6 * fps); f = 0 |
| while True: |
| ok, fr = cap.read() |
| if not ok: |
| break |
| |
| gm = _mask_full(gmask, "goal", f, W, H) if gmask is not None else None |
| if gm is not None: |
| _blend(fr, gm, (0, 200, 0), 0.40) |
| elif goal is not None: |
| cv2.rectangle(fr, (int(goal[0]), int(goal[1])), (int(goal[2]), int(goal[3])), (0, 200, 0), 2) |
| |
| for i, pid in enumerate(pids): |
| has = holder[f] == pid |
| col = PAL[i % len(PAL)] |
| label = f"P{i+1}" + (" BALL" if has else "") |
| pmf = _mask_full(pmasks, pid, f, W, H) |
| if pmf is not None: |
| _blend(fr, pmf, PAL_HI[i % len(PAL_HI)] if has else col, 0.65 if has else 0.42) |
| ys, xs = np.where(pmf) |
| if len(xs): |
| lx, ly = int(np.median(xs)) - 14, max(18, int(ys.min()) - 8) |
| cv2.putText(fr, label, (lx, ly), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 3, cv2.LINE_AA) |
| cv2.putText(fr, label, (lx, ly), cv2.FONT_HERSHEY_SIMPLEX, 0.6, col, 2, cv2.LINE_AA) |
| elif f in tracks["players"][pid]: |
| cx, cy, w, h = tracks["players"][pid][f] |
| cv2.rectangle(fr, (int(cx-w/2), int(cy-h/2)), (int(cx+w/2), int(cy+h/2)), col, 2) |
| cv2.putText(fr, label, (int(cx-w/2), int(cy-h/2)-8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, col, 2, cv2.LINE_AA) |
| |
| pts = [(ff, xy[ff, 0], xy[ff, 1]) for ff in range(max(0, f-tf), f+1) if not np.isnan(xy[ff, 0])] |
| for a, c in zip(pts, pts[1:]): |
| cv2.line(fr, (int(a[1]), int(a[2])), (int(c[1]), int(c[2])), (0, 230, 255), 3, cv2.LINE_AA) |
| |
| if res.goal == "goal" and gf is not None and goal is not None and f >= gf: |
| gx, gy = (int(xy[gf, 0]), int(xy[gf, 1])) if not np.isnan(xy[gf, 0]) else (int((goal[0]+goal[2])/2), int((goal[1]+goal[3])/2)) |
| cv2.circle(fr, (gx, gy), 12, (0, 0, 255), 3, cv2.LINE_AA) |
| if fr.shape[1] != W or fr.shape[0] != H: |
| fr = cv2.resize(fr, (W, H)) |
| ff.stdin.write(fr.tobytes()); f += 1 |
| cap.release(); ff.stdin.close(); ff.wait() |
| return out_path |
|
|
|
|
| def pose_card_img(clip: str, tracks: dict, res: AnalysisResult, out_png: str, card_h: int = 640) -> str | None: |
| """Standalone 'Pose Detection' card: dark-grey silhouette (from the shooter's mask) + |
| black MediaPipe skeleton on white. None if no pose was resolved.""" |
| if res.pose_pts is None or res.kbox is None or res.kick_frame is None: |
| return None |
| x0, y0, x1, y1 = res.kbox; w, h = x1 - x0, y1 - y0 |
| if w < 5 or h < 5: |
| return None |
| canvas = np.full((h, w, 3), 245, np.uint8) |
| pm = tracks.get("pmasks", {}).get(res.shooter) |
| if pm is not None: |
| m = cv2.resize(pm[res.kick_frame].astype(np.uint8), (tracks["W"], tracks["H"]), cv2.INTER_NEAREST).astype(bool) |
| sub = m[y0:y1, x0:x1] |
| if sub.shape == (h, w): |
| canvas[sub] = (90, 90, 90) |
| lp = res.pose_pts - np.array([x0, y0]) |
| for a, b in POSE_EDGES: |
| if a < len(lp) and b < len(lp): |
| cv2.line(canvas, (int(lp[a][0]), int(lp[a][1])), (int(lp[b][0]), int(lp[b][1])), (25, 25, 25), 2, cv2.LINE_AA) |
| for p in lp: |
| cv2.circle(canvas, (int(p[0]), int(p[1])), 3, (25, 25, 25), -1) |
| idx = L_ANKLE if res.foot == "L" else R_ANKLE if res.foot == "R" else None |
| if idx is not None and idx < len(lp): |
| cv2.circle(canvas, (int(lp[idx][0]), int(lp[idx][1])), 8, (0, 0, 255), 2, cv2.LINE_AA) |
| sc = card_h / h |
| card = cv2.resize(canvas, (max(1, int(w * sc)), card_h)) |
| os.makedirs(os.path.dirname(out_png) or ".", exist_ok=True) |
| cv2.imwrite(out_png, card) |
| return out_png |
|
|