kicky-ai / futheros /render.py
dcrey7's picture
Build detector once at import (fix per-call model rebuild) + faststart mp4
25a548e
Raw
History Blame Contribute Delete
6.26 kB
"""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
# player palette (cycles for any number of players)
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")
# Stream annotated raw frames straight into ffmpeg via stdin. Avoids cv2.VideoWriter (doesn't
# finalize off the main thread) AND per-frame JPEG files (slow IO) -> fast + thread-robust.
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
# goal segmentation (green)
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)
# player segmentation masks (any number), brighter on possession, with P# labels
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)
# ball trail
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)
# goal-spot marker (when scored)
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