Download anim_v2.py from lumos-motion/NextJump-Graphics: direct link, hf CLI and curl.
- Browser
- Download file 102 kB
-
https://huggingface.co/spaces/lumos-motion/NextJump-Graphics/resolve/main/anim_v2.py
- Command line
-
hf download hf://spaces/lumos-motion/NextJump-Graphics/anim_v2.py
-
curl -L -o anim_v2.py https://huggingface.co/spaces/lumos-motion/NextJump-Graphics/resolve/main/anim_v2.py
102 kB
"""
claude_anim_v2.py β broadcast-ready NextJump overlay video from the ONNX export.
Pipeline
1. Pass 1 (analysis): decode the video once, building
- 256x256 model frames for the ONNX counting model, sampled at 60 fps
(the training rate - feeding e.g. 120 fps footage makes it over-count), and
- MoveNet-Thunder keypoints (with crop-region tracking) for framing.
2. Sliding-window ONNX inference -> period length, periodicity, marks, phase.
3. Keypoints -> a smoothed "virtual camera" crop path that keeps the jumper
(and the rope arc) well framed at the target aspect ratio.
4. Pass 2 (render): decode again, crop/zoom each frame along the camera path,
composite broadcast graphics and pipe straight into ffmpeg (audio copied).
Example
python claude_anim_v2.py --video ali.mp4 --format vertical
python claude_anim_v2.py --video 30s_wr_male_new_b_60fps.mp4 \
--format horizontal --title "30s SPEED | WORLD RECORD"
"""
import os
import sys
import math
import time
import shutil
import tempfile
import argparse
import subprocess
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
# scipy is imported lazily: the render worker processes never need it (each import costs memory)
def gaussian_filter1d(*args, **kwargs):
from scipy.ndimage import gaussian_filter1d as f
return f(*args, **kwargs)
def median_filter(*args, **kwargs):
from scipy.ndimage import median_filter as f
return f(*args, **kwargs)
def cpu_count():
"""CPUs this process may actually use (container / affinity limits, e.g. a 2-vCPU Space on a big host)."""
try:
return max(1, len(os.sched_getaffinity(0)))
except AttributeError:
return max(1, os.cpu_count() or 1)
def _running_median(x, size):
"""1-D median filter with edge replication (numpy only; used by the overlay in render workers)."""
if len(x) == 0:
return x
h = size // 2
xp = np.pad(np.asarray(x, float), h, mode='edge')
return np.median(np.lib.stride_tricks.sliding_window_view(xp, size), axis=1)
ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.append(os.path.join(ROOT, 'movenet-pytorch'))
# Output order of checkpoint/onnx/nextjump_all_128.onnx. The graph's output
# *names* are shifted by one (masks were prepended to RepNet.forward's return
# tuple), so always index by position, never by name.
OUT_MASKS, OUT_PERIOD, OUT_PERIODICITY, OUT_MARKS, OUT_EVENT, OUT_FOOT, OUT_PHASE = range(7)
# COCO-17 keypoint indices (MoveNet order)
NOSE, L_SHOULDER, R_SHOULDER, L_HIP, R_HIP, L_ANKLE, R_ANKLE = 0, 5, 6, 11, 12, 15, 16
SKELETON = [(0, 1), (0, 2), (1, 3), (2, 4), (5, 6), (5, 7), (7, 9), (6, 8), (8, 10),
(5, 11), (6, 12), (11, 12), (11, 13), (13, 15), (12, 14), (14, 16)]
# ββ Palette (matches claude_anim.py) ββ
C_BG = (13, 17, 23)
C_ACCENT = (88, 166, 255)
C_FEET = (255, 157, 0)
C_ROPE = (78, 205, 196)
C_GOOD = (63, 185, 80)
C_BAD = (248, 81, 73)
C_TEXT = (230, 237, 243)
C_MUTED = (139, 148, 158)
MODEL_FPS = 60.0 # the counting model was trained on 60 fps clips (see eval.py data dirs)
FORMATS = {'vertical': (1080, 1920), 'horizontal': (1920, 1080)}
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def hex_rgb(h):
h = h.lstrip('#')
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def find_ffmpeg():
# NEXTJUMP_FFMPEG lets you point at a newer build (e.g. one whose NVENC works with your driver)
override = os.environ.get('NEXTJUMP_FFMPEG')
if override and os.path.isfile(override):
return override
exe = shutil.which('ffmpeg')
if exe:
return exe
for cand in (os.path.join(sys.prefix, 'Library', 'bin', 'ffmpeg.exe'),
os.path.join(sys.prefix, 'bin', 'ffmpeg')):
if os.path.isfile(cand):
return cand
raise RuntimeError('ffmpeg not found on PATH or in the active Python environment')
def iter_frames(path, max_frames, mirror):
"""Yield RGB frames from *path* (optionally mirrored), at most *max_frames*."""
cap = cv2.VideoCapture(path)
n = 0
while n < max_frames:
ret, frame = cap.read()
if not ret:
break
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
if mirror:
frame = np.ascontiguousarray(frame[:, ::-1])
yield frame
n += 1
cap.release()
def iter_selected(path, max_frames, mirror, need):
"""Yield (i, BGR frame) only for source frames where need(i) is true. need() is called for every
frame in order. Unneeded frames are only grabbed (decoded but never colour-converted / copied),
~6x cheaper than reading them -- a 120 fps source analysed at 60 fps skips half the work."""
cap = cv2.VideoCapture(path)
try:
for i in range(max_frames):
if not cap.grab():
break
if need(i):
ok, frame = cap.retrieve()
if not ok:
break
if mirror:
frame = np.ascontiguousarray(frame[:, ::-1])
yield i, frame
finally:
cap.release()
def model_input(bgr, size=256):
"""Counting-model input (RGB, size x size). Two-step resize: bilinear to an integer multiple of the
target, then exact-ratio INTER_AREA -- within ~0.5 grey levels of a direct INTER_AREA, ~35% faster."""
h, w = bgr.shape[:2]
kx, ky = max(1, w // size), max(1, h // size)
if kx > 1 or ky > 1:
bgr = cv2.resize(bgr, (size * kx, size * ky), interpolation=cv2.INTER_LINEAR)
return cv2.cvtColor(cv2.resize(bgr, (size, size), interpolation=cv2.INTER_AREA), cv2.COLOR_BGR2RGB)
def schedule(n_frames, src_fps, fps):
"""Source-frame index of every frame of a *fps* timeline over n_frames source frames."""
rs = Resampler(src_fps, fps)
return np.array([i for i in range(n_frames) for _ in range(rs.take(i))], dtype=np.int64)
def _total_frames(path, max_frames):
cap = cv2.VideoCapture(path)
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
cap.release()
return min(total, max_frames) if total > 0 else max_frames
# ββββββββββββββββββββββββββββ pose tracking ββββββββββββββββββββββββββββ
class PoseTracker:
"""MoveNet-Thunder single-person pose with the standard crop-region tracker:
run on the whole (padded) frame until the jumper is found, then re-run on a
square crop around the previous pose for much higher keypoint precision."""
SIZE = 256
def __init__(self, device, conf=0.25):
from movenet.models.model_factory import load_model
self.model = load_model('movenet_thunder', ft_size=64,
model_dir=os.path.join(ROOT, 'movenet-pytorch', '_models'))
self.model = self.model.to(device).eval()
self.device = device
self.conf = conf
self.region = None # (cx, cy, size) in source pixels
def _full_region(self, w, h):
return (w / 2, h / 2, float(max(w, h)))
def crop(self, frame, region):
cx, cy, size = region
s = self.SIZE / size
M = np.float32([[s, 0, self.SIZE / 2 - cx * s], [0, s, self.SIZE / 2 - cy * s]])
return cv2.warpAffine(frame, M, (self.SIZE, self.SIZE), flags=cv2.INTER_AREA if s < 1 else cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
@staticmethod
def _to_frame(kp, region):
cx, cy, size = region
out = np.empty_like(kp)
out[:, 0] = cx - size / 2 + kp[:, 1] * size # x (px)
out[:, 1] = cy - size / 2 + kp[:, 0] * size # y (px)
out[:, 2] = kp[:, 2]
return out
def _infer(self, frame, region):
cx, cy, size = region
crop = self.crop(frame, region)
import torch
with torch.no_grad():
x = torch.from_numpy(crop).float().unsqueeze(0).to(self.device)
kp = self.model(x)[0, 0].cpu().numpy() # (17, 3) = y, x, conf in [0, 1]
out = np.empty_like(kp)
out[:, 0] = cx - size / 2 + kp[:, 1] * size # x (px)
out[:, 1] = cy - size / 2 + kp[:, 0] * size # y (px)
out[:, 2] = kp[:, 2]
return out
def _next_region(self, kp, w, h):
good = kp[:, 2] > self.conf
torso = [L_SHOULDER, R_SHOULDER, L_HIP, R_HIP]
if good.sum() < 5 or not good[torso].any():
return None
hips = [i for i in (L_HIP, R_HIP) if good[i]] or [i for i in torso if good[i]]
cx, cy = kp[hips, 0].mean(), kp[hips, 1].mean()
pts = kp[good, :2]
body_half = max(np.abs(pts[:, 0] - cx).max(), np.abs(pts[:, 1] - cy).max())
torso_pts = kp[[i for i in torso if good[i]], :2]
torso_half = max(np.abs(torso_pts[:, 0] - cx).max(), np.abs(torso_pts[:, 1] - cy).max())
half = max(torso_half * 1.9, body_half * 1.25, 0.08 * max(w, h))
half = min(half, max(w, h) / 2)
return (cx, cy, 2 * half)
def __call__(self, frame, region=None):
if region is not None: # seeded from the selected jumper's detector box: never drifts to someone else
return self._infer(frame, region)
h, w = frame.shape[:2]
region = self.region or self._full_region(w, h)
kp = self._infer(frame, region)
self.region = self._next_region(kp, w, h)
if self.region is None and region != self._full_region(w, h):
# Lost the jumper inside the crop β retry on the full frame.
kp = self._infer(frame, self._full_region(w, h))
self.region = self._next_region(kp, w, h)
return kp
# ββββββββββββββββββββββββββββ jumper selection ββββββββββββββββββββββββββββ
#
# Pose-only tracking follows whoever MoveNet happens to lock on to, which can be a judge. "smart"
# framing instead (1) detects every person a few times per second and links the boxes into tracks,
# (2) probes each candidate by running the counting model on a short clip cropped around them and
# scoring how periodic (= actively jumping) they look, and (3) frames the N most active people.
def square_crop(frame, cx, cy, side, size):
"""Square crop centred on (cx, cy), zero-padded outside the frame, resized to size x size."""
h, w = frame.shape[:2]
half = max(2, int(round(side / 2)))
x0, y0 = int(round(cx)) - half, int(round(cy)) - half
x1, y1 = x0 + 2 * half, y0 + 2 * half
crop = frame[max(0, y0):min(h, y1), max(0, x0):min(w, x1)]
if crop.size == 0:
return np.zeros((size, size, 3), np.uint8)
crop = cv2.copyMakeBorder(crop, max(0, -y0), max(0, y1 - h), max(0, -x0), max(0, x1 - w), cv2.BORDER_CONSTANT, value=0)
return cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA if 2 * half > size else cv2.INTER_LINEAR)
def load_person_detector(spec, device):
"""'coco' -> torchvision Faster R-CNN MobileNetV3 (COCO person class; weights auto-download once).
Otherwise a path to a torch-pickled torchvision detector whose class 1 is a person / jumper
(e.g. NextJump-Tournament's fasterrcnn_model_finetuned.pth)."""
if spec in (None, '', 'coco'):
from torchvision.models.detection import (fasterrcnn_mobilenet_v3_large_fpn,
FasterRCNN_MobileNet_V3_Large_FPN_Weights)
model = fasterrcnn_mobilenet_v3_large_fpn(weights=FasterRCNN_MobileNet_V3_Large_FPN_Weights.COCO_V1,
progress=False)
else:
import torch
model = torch.load(spec, map_location='cpu', weights_only=False)
return model.to(device).eval()
def detect_people(model, frame, device, score_thr=0.6, min_height=0.12, max_side=960):
"""Person boxes (x0, y0, x1, y1) in source pixels; tiny (far-away) people are dropped."""
h, w = frame.shape[:2]
sc = min(1.0, max_side / max(h, w))
img = cv2.resize(frame, (int(w * sc), int(h * sc)), interpolation=cv2.INTER_AREA) if sc < 1 else frame
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # frames arrive as BGR from iter_selected
import torch
with torch.no_grad():
x = torch.from_numpy(np.ascontiguousarray(img)).to(device).permute(2, 0, 1).float() / 255
pred = model([x])[0]
keep = (pred['labels'] == 1) & (pred['scores'] >= score_thr)
boxes = pred['boxes'][keep].cpu().numpy() / sc
scores = pred['scores'][keep].cpu().numpy()
tall = (boxes[:, 3] - boxes[:, 1]) >= min_height * h
return boxes[tall], scores[tall]
def _iou(a, b):
x0 = np.maximum(a[:, None, 0], b[None, :, 0])
y0 = np.maximum(a[:, None, 1], b[None, :, 1])
x1 = np.minimum(a[:, None, 2], b[None, :, 2])
y1 = np.minimum(a[:, None, 3], b[None, :, 3])
inter = np.clip(x1 - x0, 0, None) * np.clip(y1 - y0, 0, None)
area = lambda r: (r[:, 2] - r[:, 0]) * (r[:, 3] - r[:, 1])
return inter / (area(a)[:, None] + area(b)[None, :] - inter + 1e-9)
def build_tracks(dets, max_gap=1.0, min_iou=0.2, min_coverage=0.15):
"""Greedy IoU linking of per-frame detections into person tracks."""
tracks = []
for t, boxes, scores in dets:
used = set()
live = [k for k, tr in enumerate(tracks) if t - tr['t'][-1] <= max_gap]
if live and len(boxes):
iou = _iou(np.array([tracks[k]['b'][-1] for k in live]), boxes)
for a, b in sorted(np.argwhere(iou > min_iou), key=lambda ab: -iou[ab[0], ab[1]]):
if live[a] in used or ('_b', b) in used:
continue
used.update((live[a], ('_b', b)))
tr = tracks[live[a]]
tr['t'].append(t), tr['b'].append(boxes[b]), tr['s'].append(scores[b])
for b in range(len(boxes)):
if ('_b', b) not in used:
tracks.append(dict(t=[t], b=[boxes[b]], s=[scores[b]]))
n_det = max(1, len(dets))
out = []
for tr in tracks:
if len(tr['t']) >= max(3, min_coverage * n_det):
out.append(dict(t=np.array(tr['t']), b=np.array(tr['b']), s=np.array(tr['s'])))
return out
def prefilter_tracks(tracks, duration, keep):
"""Cheap ranking before the (expensive) model probe: people who are on screen for long, are large
(near the camera) and whose box bounces vertically (jumping) come first."""
def cheap(tr):
h = tr['b'][:, 3] - tr['b'][:, 1]
coverage = min(1.0, (tr['t'][-1] - tr['t'][0]) / max(duration, 1e-6)) * len(tr['t']) / max(1, len(tr['t']))
bounce = np.std(np.diff(tr['b'][:, 3])) / max(1.0, np.median(h)) if len(h) > 2 else 0.0
return coverage * np.median(h) * (1 + 10 * bounce)
order = sorted(range(len(tracks)), key=lambda k: -cheap(tracks[k]))
return [tracks[k] for k in order[:keep]]
def track_boxes(tr, times):
"""Track box linearly interpolated (held constant outside its span) at the given times -> (T, 4)."""
times = np.atleast_1d(times)
return np.stack([np.interp(times, tr['t'], tr['b'][:, k]) for k in range(4)], -1)
def probe_windows(duration, win_s, event=None, n=5):
lo, hi = (event[0] + 0.5, event[1]) if event is not None else (0.0, duration)
if hi - lo <= win_s:
return [(max(0.0, lo), max(0.0, lo) + win_s)]
centres = [lo + (hi - lo) * f for f in np.linspace(0.1, 0.9, n)]
return [(c - win_s / 2, c + win_s / 2) for c in centres]
def scan_people(path, max_frames, mirror, src_fps, model_fps, det_fps, detector, device, windows,
probe_side=640, det_side=960, model_sink=None, img_size=256, progress=None):
"""Decode pass: person detections at det_fps, downscaled frames (at model_fps) in the probe windows
and -- with collect_model -- the full-frame counting-model input, so no second pass is needed for it.
Returns dict(dets, probes, scale, model_frames, n_frames, frame_size)."""
total = _total_frames(path, max_frames)
det_rs, pr_rs, mo_rs = Resampler(src_fps, det_fps), Resampler(src_fps, model_fps), Resampler(src_fps, model_fps)
dets, probes, model_frames, scale = [], [[] for _ in windows], model_sink, None
collect_model = model_sink is not None
cur, last = [0, 0, 0], [-1]
def need(i):
t = i / src_fps
nd = det_rs.take(i)
npr = pr_rs.take(i)
nm = mo_rs.take(i) if collect_model else 0
cur[:] = nd, (npr if npr and any(a <= t < b for a, b in windows) else 0), nm
last[0] = i
if i % 50 == 0:
print(f'\rFinding people {i + 1}/{total} ', end='', flush=True)
if progress:
progress(min(1.0, (i + 1) / max(1, total)))
return any(cur)
from concurrent.futures import ThreadPoolExecutor
size, pending = None, []
with ThreadPoolExecutor(1) as det_pool: # detection (GPU, releases the GIL) overlaps decoding
for i, frame in iter_selected(path, max_frames, mirror, need):
nd, npr, nm = cur
size = (frame.shape[1], frame.shape[0])
t = i / src_fps
if nd:
pending.append((t, det_pool.submit(detect_people, detector, frame, device, max_side=det_side)))
if npr:
if scale is None:
scale = min(1.0, probe_side / max(frame.shape[:2]))
small = cv2.resize(frame, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) if scale < 1 else frame
small = cv2.cvtColor(small, cv2.COLOR_BGR2RGB)
for w, (a, b) in enumerate(windows):
if a <= t < b:
probes[w] += [(t, small)] * npr
if nm:
model_frames += [model_input(frame, img_size)] * nm
dets = [(t, *fu.result()) for t, fu in pending]
print(f'\rFound {sum(len(d[1]) for d in dets)} person detections in {len(dets)} frames ')
return dict(dets=dets, probes=probes, scale=scale or 1.0, n_frames=last[0] + 1, frame_size=size,
model_frames=model_frames)
def score_tracks(sess, tracks, probes, scale, batch_size=1, progress=None, gpu=None):
"""Activity per track: counting-model periodicity on a crop around the person in each probe window,
averaged over that person's best half of the windows (so idle time before / after the event, or a
short pause, doesn't hide a jumper; bystanders never look periodic in any window)."""
seq_len = sess.get_inputs()[0].shape[1]
per_window = np.zeros((len(tracks), len(probes)))
jobs = []
for w, frames in enumerate(probes):
if len(frames) < seq_len // 2:
continue
frames = (frames + [frames[-1]] * seq_len)[:seq_len]
tm = np.array([t for t, _ in frames])
for k, tr in enumerate(tracks):
if not (tr['t'][0] - 0.3 <= tm[len(tm) // 2] <= tr['t'][-1] + 0.3):
continue # not on screen during this window -> scores 0 here
B = track_boxes(tr, tm) * scale
cx = gaussian_filter1d((B[:, 0] + B[:, 2]) / 2, 8, mode='nearest')
cy = gaussian_filter1d((B[:, 1] + B[:, 3]) / 2, 8, mode='nearest')
side = 1.4 * np.max(np.maximum(B[:, 2] - B[:, 0], B[:, 3] - B[:, 1]))
jobs.append((k, w, frames, cx, cy, side)) # crops are built lazily (memory)
for b in range(0, len(jobs), batch_size):
chunk = jobs[b:b + batch_size]
X = np.stack([np.stack([square_crop(f, x, y, side, 256) for (_, f), x, y in zip(frames, cx, cy)])
for _, _, frames, cx, cy, side in chunk]).astype(np.float32).transpose(0, 1, 4, 2, 3)
out = gpu.submit(sess.run, None, {'video': X}).result() if gpu else sess.run(None, {'video': X})
for j, (k, w, *_) in enumerate(chunk):
per_window[k, w] = float(np.mean(sigmoid(out[OUT_PERIODICITY][j].reshape(seq_len, -1)[:, 0])))
if progress:
progress(min(1.0, (b + batch_size) / len(jobs)))
used = [w for w, fr in enumerate(probes) if len(fr) >= seq_len // 2]
if not used:
return np.zeros(len(tracks))
top = max(1, int(math.ceil(len(used) / 2)))
return np.sort(per_window[:, used], 1)[:, ::-1][:, :top].mean(1)
def select_jumpers(tracks, scores, n, frame_w=None, spread_penalty=0.6, duration=None):
"""The most active person, then (for groups) the most active people near them: each addition is
ranked by activity minus a penalty for distance from the group so far (keeps one team together).
Activity is weighted by the fraction of the video each person is on screen, which breaks ties between
equally rhythmic people in favour of the athletes (warm-up jumpers / passers-by come and go)."""
if duration:
cover = np.array([min(1.0, (tr['t'][-1] - tr['t'][0]) / duration) for tr in tracks])
scores = np.asarray(scores) * (0.5 + 0.5 * cover)
centre = lambda k: ((tracks[k]['b'][:, 0] + tracks[k]['b'][:, 2]) / 2).mean()
fw = frame_w or max(1.0, max(tr['b'][:, 2].max() for tr in tracks))
sel = [int(np.argmax(scores))] if len(scores) else []
while len(sel) < min(n, len(tracks)):
gx = np.mean([centre(k) for k in sel])
rest = [k for k in range(len(tracks)) if k not in sel]
sel.append(max(rest, key=lambda k: scores[k] - spread_penalty * abs(centre(k) - gx) / fw))
print('Jumper candidates (activity = periodicity of the counting model on a crop around each person):')
for k in np.argsort(-scores)[:8]:
tr = tracks[k]
b = tr['b'].mean(0)
print(f' {"*" if k in sel else " "} #{k:<3d} activity {scores[k]:.2f} on screen {tr["t"][0]:5.1f}-{tr["t"][-1]:5.1f} s '
f'box ~({b[0]:.0f},{b[1]:.0f})-({b[2]:.0f},{b[3]:.0f})')
return sel
def dets_frame_w(dets, path):
cap = cv2.VideoCapture(path)
w = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
cap.release()
return w or None
def group_box(tracks, sel, times):
"""Box around the selected jumpers, centred on their centre of mass and wide enough to hold all of them."""
B = np.stack([track_boxes(tracks[k], times) for k in sel]) # (N, T, 4)
cx = ((B[..., 0] + B[..., 2]) / 2).mean(0)
cy = ((B[..., 1] + B[..., 3]) / 2).mean(0)
hw = np.maximum(cx - B[..., 0].min(0), B[..., 2].max(0) - cx)
hh = np.maximum(cy - B[..., 1].min(0), B[..., 3].max(0) - cy)
return np.stack([cx - hw, cy - hh, cx + hw, cy + hh], -1)
# ββββββββββββββββββββββββββββ beep detection ββββββββββββββββββββββββββββ
def _stream_start(ffprobe, path, kind):
try:
out = subprocess.run([ffprobe, '-v', 'error', '-select_streams', f'{kind}:0', '-show_entries',
'stream=start_time', '-of', 'csv=p=0', path], capture_output=True, text=True, check=True)
return float(out.stdout.strip().splitlines()[0])
except (subprocess.CalledProcessError, ValueError, IndexError, OSError):
return 0.0
def detect_beeps(video_path, ffmpeg, event_length, beep_file, beep_height=0.8, tolerance=0.5):
"""Find the official start / end beeps (same strategy as NextJump-Tournament/app.py:
cross-correlate the audio with the reference beep, then take the pair of peaks whose spacing
is closest to the event length, lowering the peak threshold until a good pair is found).
Differences from the app: times are the *onset* of the beep tone (the app's mode='same'
correlation peak sits ~0.18 s after the onset, i.e. about one jump late), and the
audio/video stream start offset is compensated. Returns (start_s, end_s) in video time, or None."""
from scipy.io import wavfile
from scipy.signal import correlate, find_peaks
fs, ref = wavfile.read(beep_file)
ref = ref.astype(np.float64)
if ref.ndim > 1:
ref = ref.mean(1)
env = np.abs(ref)
ref_onset = np.argmax(env > 0.1 * env.max()) / fs # tone starts ~20 ms into beep.WAV
proc = subprocess.run([ffmpeg, '-v', 'error', '-i', video_path, '-vn', '-ac', '1', '-ar', str(fs),
'-f', 's16le', '-'], capture_output=True)
audio = np.frombuffer(proc.stdout, np.int16).astype(np.float64)
if proc.returncode != 0 or len(audio) < len(ref) * 2:
print('Beep detection: no usable audio track')
return None
ffprobe = os.path.join(os.path.dirname(ffmpeg), 'ffprobe' + os.path.splitext(ffmpeg)[1])
offset = _stream_start(ffprobe, video_path, 'a') - _stream_start(ffprobe, video_path, 'v')
corr = correlate(audio, ref, mode='valid') # index = sample where the reference starts
corr = 2 * (corr - corr.min()) / (corr.max() - corr.min() + 1e-12) - 1
best, best_diff = None, float('inf')
h = beep_height
while h >= 0.3 - 1e-9:
peaks, _ = find_peaks(corr, height=h, distance=fs // 2)
for a in range(len(peaks)):
for b in range(a + 1, len(peaks)):
diff = abs((peaks[b] - peaks[a]) / fs - event_length)
if diff < best_diff:
best_diff, best = diff, (peaks[a], peaks[b])
if best_diff < 0.25:
break
h -= 0.05
if best is None or best_diff > tolerance:
print(f'Beep detection: no beep pair ~{event_length:g} s apart found'
+ (f' (closest was off by {best_diff:.2f} s)' if best else ''))
return None
t0, t1 = best[0] / fs + ref_onset + offset, best[1] / fs + ref_onset + offset
print(f'Beeps: start {t0:.3f} s, end {t1:.3f} s (event {t1 - t0:.3f} s)')
return t0, t1
# ββββββββββββββββββββββββββββ analysis pass ββββββββββββββββββββββββββββ
class Resampler:
"""Nearest-frame resampling from src_fps to dst_fps, fed one source frame at a time.
take(i) returns how many destination samples use source frame i (0, 1, or >1 when upsampling)."""
def __init__(self, src_fps, dst_fps):
self.r, self.k = src_fps / dst_fps, 0
def take(self, i):
n = 0
while int(math.floor(self.k * self.r + 0.5)) == i:
n += 1
self.k += 1
return n
def pick_rate(src_fps, target_fps):
"""Rate closest to *target_fps* reachable from *src_fps* by keeping every n-th frame
(integer decimation => even frame spacing, no judder). Never upsamples."""
if target_fps is None or src_fps <= target_fps * 1.2:
return src_fps
return src_fps / max(1, round(src_fps / target_fps))
def analyse_video(path, max_frames, mirror, img_size, device, src_fps, model_fps, out_fps, run_pose=True,
pose_fps=None, collect_model=True, progress=None, pose_region=None, model_crop=None,
model_sink=None):
"""Decode pass building
- model frames at *model_fps* (the rate the counting model was trained on) if collect_model,
- keypoints at *pose_fps* (default: every output frame), interpolated onto the output frames,
- the source-frame index of every output frame (at *out_fps*).
pose_region(t) -> (cx, cy, size) seeds MoveNet on the selected jumper; model_crop(t) -> (cx, cy, side)
feeds the counting model a square crop around the selected jumper(s) instead of the full frame.
Returns (model_frames | None, keypoints | None, frame_size, out_src)."""
total = _total_frames(path, max_frames)
tracker = PoseTracker(device) if run_pose else None
model_rs, out_rs = Resampler(src_fps, model_fps), Resampler(src_fps, out_fps)
pose_rs = Resampler(src_fps, pose_fps or out_fps)
model_frames = model_sink if model_sink is not None else []
pose_t, pose_kp, out_src = [], [], []
cur, last = [0, 0], [-1]
def need(i):
nm = model_rs.take(i) if collect_model else 0
out_src.extend([i] * out_rs.take(i))
npose = pose_rs.take(i) if tracker is not None else 0
cur[:] = nm, npose
last[0] = i
if i % 50 == 0:
print(f'\rAnalysing frame {i + 1}/{total} ', end='', flush=True)
if progress:
progress(min(1.0, (i + 1) / max(1, total)))
return any(cur)
size = None
for i, frame in iter_selected(path, max_frames, mirror, need):
nm, npose = cur
size = (frame.shape[1], frame.shape[0])
t = i / src_fps
if nm:
if model_crop is not None:
model_frames += [cv2.cvtColor(square_crop(frame, *model_crop(t), img_size), cv2.COLOR_BGR2RGB)] * nm
else:
# Same preprocessing as training / graphics/main.py: plain resize to a square.
model_frames += [model_input(frame, img_size)] * nm
if npose:
pose_t.append(t)
pose_kp.append(tracker(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB), pose_region(t) if pose_region else None))
out_src = np.array(out_src, dtype=np.int64)
kps = None
if pose_kp:
P, tt = np.stack(pose_kp), out_src / src_fps
kps = np.stack([np.stack([np.interp(tt, pose_t, P[:, j, c]) for c in range(3)], -1) for j in range(17)], 1)
print(f'\rAnalysed {last[0] + 1} frames ({size[0]}x{size[1]} @ {src_fps:.2f} fps) -> '
+ (f'model {len(model_frames)} @ {model_fps:.2f} fps, ' if collect_model else '')
+ (f'pose {len(pose_kp)} @ {pose_fps or out_fps:.1f} fps, ' if pose_kp else '')
+ f'output {len(out_src)} @ {out_fps:.2f} fps')
if not collect_model:
model_frames = None
elif model_sink is None:
model_frames = np.stack(model_frames)
return model_frames, kps, size, out_src
def make_session(onnx_path, use_cuda):
import torch # imported before onnxruntime so its CUDA DLLs are loaded
import onnxruntime as ort
opts = ort.SessionOptions()
opts.log_severity_level = 4 # silence the noisy CUDA-provider fallback errors
providers = ['CPUExecutionProvider']
if use_cuda and 'CUDAExecutionProvider' in ort.get_available_providers():
# The default arena grows by powers of two until VRAM is full; on Windows the driver then
# spills into shared system memory and every window gets ~10x slower. Allocate exactly
# what is requested and cap the arena so torch (MoveNet / detector) keeps headroom.
total = torch.cuda.get_device_properties(0).total_memory if torch.cuda.is_available() else 8 << 30
providers.insert(0, ('CUDAExecutionProvider', {'arena_extend_strategy': 'kSameAsRequested',
'gpu_mem_limit': str(int(total * 0.6)),
'cudnn_conv_algo_search': 'HEURISTIC'}))
sess = ort.InferenceSession(onnx_path, sess_options=opts, providers=providers)
if use_cuda and 'CUDAExecutionProvider' not in sess.get_providers():
print('Note: onnxruntime CUDA provider unavailable (CUDA 12 / cuDNN 9 libs not found) - running ONNX on CPU')
print(f'ONNX providers: {sess.get_providers()}')
return sess
def run_model(sess, frames, stride, batch_size, progress=None):
"""Sliding-window inference with overlap averaging. *frames*: (N, H, W, 3) uint8."""
seq_len = sess.get_inputs()[0].shape[1]
n = len(frames)
if n < seq_len:
frames = np.concatenate([frames, np.repeat(frames[-1:], seq_len - n, axis=0)])
starts = list(range(0, len(frames) - seq_len + 1, stride))
if starts[-1] != len(frames) - seq_len:
starts.append(len(frames) - seq_len)
m = len(frames)
acc = {k: np.zeros((m, d)) for k, d in (('period', 2), ('periodicity', 1), ('marks', 1), ('phase', 2))}
overlaps = np.zeros((m, 1))
for b in range(0, len(starts), batch_size):
chunk = starts[b:b + batch_size]
X = np.stack([frames[s:s + seq_len] for s in chunk]).astype(np.float32).transpose(0, 1, 4, 2, 3)
try:
out = sess.run(None, {'video': X})
except Exception as e:
if len(chunk) == 1 or 'allocate' not in str(e):
raise
# out of (GPU) memory for this batch size: run the windows one at a time instead
outs = [sess.run(None, {'video': X[j:j + 1]}) for j in range(len(chunk))]
out = [np.concatenate([o[k] for o in outs]) for k in range(len(outs[0]))]
for j, s in enumerate(chunk):
acc['period'][s:s + seq_len] += out[OUT_PERIOD][j].reshape(seq_len, -1)[:, :2]
acc['periodicity'][s:s + seq_len] += out[OUT_PERIODICITY][j].reshape(seq_len, -1)[:, :1]
acc['marks'][s:s + seq_len] += out[OUT_MARKS][j].reshape(seq_len, -1)[:, :1]
acc['phase'][s:s + seq_len] += out[OUT_PHASE][j].reshape(seq_len, -1)[:, :2]
overlaps[s:s + seq_len] += 1
print(f'\rInference window {min(b + batch_size, len(starts))}/{len(starts)} ', end='', flush=True)
if progress:
progress(min(b + batch_size, len(starts)) / len(starts))
print()
res = {k: (v / np.maximum(overlaps, 1))[:n] for k, v in acc.items()}
return res
def _run_window(sess, frames):
L = len(frames)
X = np.stack(frames)[None].astype(np.float32).transpose(0, 1, 4, 2, 3)
out = sess.run(None, {'video': X})
return (out[OUT_PERIOD][0].reshape(L, -1)[:, :2], out[OUT_PERIODICITY][0].reshape(L, -1)[:, :1],
out[OUT_MARKS][0].reshape(L, -1)[:, :1], out[OUT_PHASE][0].reshape(L, -1)[:, :2])
class StreamingCounter(list):
"""The list of counting-model frames, which runs each sliding window on the (single-thread) GPU queue
as soon as its frames have arrived -- inference overlaps decoding instead of waiting for it.
Same windows and overlap averaging as run_model()."""
def __init__(self, sess, stride, gpu):
super().__init__()
self.sess, self.stride, self.gpu = sess, stride, gpu
self.L = sess.get_inputs()[0].shape[1]
self.next, self.jobs = 0, {}
def __iadd__(self, items):
self.extend(items)
while self.next + self.L <= len(self):
self.jobs[self.next] = self.gpu.submit(_run_window, self.sess, self[self.next:self.next + self.L])
self.next += self.stride
return self
def finish(self, progress=None):
n = len(self)
frames = list(self) + [self[-1]] * max(0, self.L - n)
last = len(frames) - self.L
if last not in self.jobs:
self.jobs[last] = self.gpu.submit(_run_window, self.sess, frames[last:last + self.L])
m = len(frames)
acc = [np.zeros((m, d)) for d in (2, 1, 1, 2)]
overlaps = np.zeros((m, 1))
for k, (st, fu) in enumerate(sorted(self.jobs.items())):
for a, o in zip(acc, fu.result()):
a[st:st + self.L] += o
overlaps[st:st + self.L] += 1
print(f'\rInference window {k + 1}/{len(self.jobs)} ', end='', flush=True)
if progress:
progress((k + 1) / len(self.jobs))
print()
return {key: (a / np.maximum(overlaps, 1))[:n] for key, a in zip(('period', 'periodicity', 'marks', 'phase'), acc)}
def compute_analytics(res, fps, periodicity_thresh, mark_thresh, event=None, skip_after_miss=False,
min_miss_s=0.25):
"""*event*: optional (start_s, end_s) from the beeps. When given, counting runs only between
the beeps: nothing before the start beep, frozen at the end beep.
*skip_after_miss*: rulebook judging β the first jump after each miss is not counted."""
from scipy.signal import medfilt, find_peaks
period_legs = res['period'][:, 0]
period_arms = res['period'][:, 1]
periodicity = sigmoid(medfilt(res['periodicity'][:, 0], 5))
marks = sigmoid(res['marks'][:, 0])
phase = np.arctan2(-res['phase'][:, 1], res['phase'][:, 0]) # negated sin: bottom = start of jump
active = (periodicity > periodicity_thresh).astype(float)
jps_legs = np.where(period_legs > 2, fps / np.maximum(period_legs, 1e-6), 0)
jps_arms = np.where(period_arms > 2, fps / np.maximum(period_arms, 1e-6), 0)
jps_legs = gaussian_filter1d(jps_legs, 2) * active
jps_arms = gaussian_filter1d(jps_arms, 2) * active
n = len(marks)
if event is not None:
# first model frame at/after the start beep .. last model frame before the end beep
ev_s = int(min(n - 1, math.ceil(event[0] * fps - 1e-6)))
ev_e = int(max(ev_s, min(n - 1, math.ceil(event[1] * fps - 1e-6) - 1)))
in_ev = np.zeros(n, bool)
in_ev[ev_s:ev_e + 1] = True
active = active * in_ev
jps_legs, jps_arms = jps_legs * in_ev, jps_arms * in_ev
peaks, _ = find_peaks(marks, distance=3, height=mark_thresh)
is_peak = np.zeros(len(marks), bool)
is_peak[peaks] = True
# Count accumulation β identical logic to claude_anim.py / graphics/main.py
count = np.zeros(len(marks))
c = 0.0
for k in range(len(marks)):
if event is not None and k > ev_e:
count[k] = count[k - 1] # frozen at the end beep
continue
if period_legs[k] < 2 or not active[k]:
c = float(int(c))
elif is_peak[k]:
c = float(int(c)) if math.modf(c)[0] < 0.1 else float(int(c) + 1.01)
else:
c += 1.0 / (period_legs[k] + 1e-8)
count[k] = c
count_int = count.astype(int)
if event is not None:
jump_start, jump_end = ev_s, ev_e
else:
started = np.where(count_int > 0)[0]
jump_start = int(started[0]) if len(started) else 0
act_idx = np.where(active > 0)[0]
jump_end = int(act_idx[-1]) if len(act_idx) else len(count) - 1
jump_end = max(jump_end, jump_start)
# Start pace = mean speed over the first 3 s of actual jumping (from the first counted jump,
# so the reaction time after the start beep doesn't drag it down); pace line starts there too.
counted = np.where(count_int[jump_start:jump_end + 1] > 0)[0]
first_jump = jump_start + int(counted[0]) if len(counted) else jump_start
win = min(int(3 * fps), jump_end + 1 - first_jump)
baseline = float(np.mean(jps_legs[first_jump:first_jump + win])) if win > 0 else 0.0
pace = np.zeros(len(count))
t = (np.arange(len(count)) - first_jump) / fps
pace[first_jump:] = baseline * np.clip(t[first_jump:], 0, (jump_end - first_jump) / fps)
# Misses: the model's periodicity drops out for >= min_miss_s after jumping has started.
# Rulebook option: the first counted jump after each miss is not counted (judges' realtime rule).
misses, skipped = [], []
inact = np.r_[False, active[first_jump + 1:jump_end + 1] == 0, False]
edges = np.flatnonzero(np.diff(inact.astype(int)))
for a, b in zip(edges[::2] + first_jump, edges[1::2] + first_jump): # [a, b) inactive model frames
if b - a < min_miss_s * fps:
continue
misses.append((a, b))
if skip_after_miss:
inc = np.flatnonzero(np.diff(count_int[b - 1:jump_end + 1]) > 0)
if len(inc):
f = b + int(inc[0]) # frame where the first jump after the miss is counted
skipped.append(f)
count_int[f:] -= 1
count_int = np.maximum(count_int, 0)
if misses:
print(f'Misses: {len(misses)} at ' + ', '.join(f'{a / fps:.1f} s' for a, _ in misses)
+ (f' -> {len(skipped)} jump(s) after a miss not counted' if skip_after_miss else ''))
final = int(count_int[jump_end])
# Per-jump timestamps from the mark peaks, refined to sub-frame precision (parabolic fit) so
# rhythm stats aren't quantised to whole frames.
bt = []
for k in peaks:
if jump_start <= k <= jump_end and 0 < k < len(marks) - 1:
a, b, c = marks[k - 1], marks[k], marks[k + 1]
den = a - 2 * b + c
bt.append(k + (0.5 * (a - c) / den if den < 0 else 0.0))
beat_times = np.array(bt) / fps
print(f'Jump window: frame {jump_start} -> {jump_end} ({(jump_end - jump_start) / fps:.1f} s), '
f'{final} jumps, start pace {baseline:.2f} JPS')
return dict(jps=jps_legs, jps_arms=jps_arms, periodicity=periodicity, active=active, marks=marks,
phase=phase, count=count_int, pace=pace, jump_start=jump_start, jump_end=jump_end,
final=final, baseline=baseline, beat_times=beat_times, event=event, first_jump=first_jump,
miss_times=[(a / fps, b / fps) for a, b in misses], skip_times=[f / fps for f in skipped],
skip_after_miss=skip_after_miss)
def to_output_timeline(an, model_fps, times):
"""Resample per-model-frame analytics onto output frames at the given times (seconds)."""
n = len(an['count'])
j = np.clip(np.asarray(times) * model_fps, 0, n - 1)
grid = np.arange(n)
out = dict(an)
for k in ('jps', 'jps_arms', 'periodicity', 'marks', 'pace'):
out[k] = np.interp(j, grid, an[k])
out['active'] = an['active'][np.round(j).astype(int)]
out['phase'] = np.arctan2(np.interp(j, grid, np.sin(an['phase'])), np.interp(j, grid, np.cos(an['phase'])))
out['count'] = an['count'][np.floor(j + 1e-6).astype(int)]
times = np.asarray(times)
out['times'] = times
out['first_jump'] = int(min(np.searchsorted(j, an['first_jump']), len(j) - 1))
if an.get('event') is not None:
# exact beep times: first output frame at/after the start beep, last frame before the end beep
out['jump_start'] = int(min(np.searchsorted(times, an['event'][0] - 1e-6), len(j) - 1))
out['jump_end'] = int(max(out['jump_start'], np.searchsorted(times, an['event'][1] - 1e-6) - 1))
out['count'][:out['jump_start']] = 0
else:
out['jump_start'] = int(min(np.searchsorted(j, an['jump_start']), len(j) - 1))
out['jump_end'] = int(max(out['jump_start'], np.searchsorted(j, an['jump_end'], side='right') - 1))
if an.get('event') is not None:
out['count'][out['jump_end']:] = an['final']
else:
out['count'][out['jump_end']:] = np.maximum(out['count'][out['jump_end']:], an['final'])
return out
# ββββββββββββββββββββββββββββ virtual camera ββββββββββββββββββββββββββββ
def _interp_invalid(x, valid):
idx = np.arange(len(x))
if valid.sum() == 0:
return None
return np.interp(idx, idx[valid], x[valid])
def boxes_from_keypoints(kps, conf=0.3):
"""Per-frame body box from keypoints, padded from eyes/ankles to head-top/soles; NaN where unreliable."""
good = kps[:, :, 2] > conf
valid = good.sum(1) >= 6
x = np.where(good, kps[:, :, 0], np.nan)
y = np.where(good, kps[:, :, 1], np.nan)
with np.errstate(all='ignore'):
x0, x1 = np.nanmin(x, 1), np.nanmax(x, 1)
y0, y1 = np.nanmin(y, 1), np.nanmax(y, 1)
span = y1 - y0
boxes = np.stack([x0, y0 - 0.12 * span, x1, y1 + 0.06 * span], 1)
boxes[~valid] = np.nan
return boxes
def compute_camera_path(boxes, frame_size, out_size, fps, fill, anchor_y, max_zoom, smooth_sec):
"""Turn per-frame subject boxes (x0, y0, x1, y1; NaN rows = unknown) into a smooth crop path
(cx, cy, crop_w, crop_h).
The crop height is chosen so the jumper's body occupies *fill* of the frame
height (leaving room for the rope arc), the body centre sits at *anchor_y*
of the crop height, and the path is heavily low-passed so the camera glides
rather than bouncing with every jump."""
W, H = frame_size
aspect = out_size[0] / out_size[1]
n = len(boxes) if boxes is not None else 0
# Largest crop of the output aspect that fits inside the source.
max_h = min(H, W / aspect)
if n == 0:
return np.tile([W / 2, H / 2, max_h * aspect, max_h], (1, 1)), False
valid = np.isfinite(boxes).all(1)
if valid.sum() < max(5, 0.1 * n):
print('Warning: jumper rarely detected β falling back to a centred crop')
return np.tile([W / 2, H / 2, max_h * aspect, max_h], (n, 1)), False
x0, y0, x1, y1 = boxes.T
cx = _interp_invalid((x0 + x1) / 2, valid)
cy = _interp_invalid((y0 + y1) / 2, valid)
bh = _interp_invalid(y1 - y0, valid)
bw = _interp_invalid(x1 - x0, valid)
# Robust smoothing: median kills pose glitches, gaussian removes jump bounce.
med = max(3, int(fps * 0.5) | 1)
sig = max(1.0, smooth_sec * fps)
cx = gaussian_filter1d(median_filter(cx, med, mode='nearest'), sig, mode='nearest')
cy = gaussian_filter1d(median_filter(cy, med, mode='nearest'), sig, mode='nearest')
# Size: rolling max (arms overhead / rope) then slower smoothing to avoid "breathing" zoom.
bh = gaussian_filter1d(median_filter(bh, int(fps) | 1, mode='nearest'), sig * 2, mode='nearest')
bw = gaussian_filter1d(median_filter(bw, int(fps) | 1, mode='nearest'), sig * 2, mode='nearest')
crop_h = np.maximum(bh / fill, (bw * 1.35) / aspect) # keep arms + rope width in frame
crop_h = np.clip(crop_h, max_h / max_zoom, max_h)
crop_w = crop_h * aspect
# Place body centre at anchor_y of the crop, then keep the crop inside the frame.
ccx = np.clip(cx, crop_w / 2, W - crop_w / 2)
ccy = cy + (0.5 - anchor_y) * crop_h
ccy = np.clip(ccy, crop_h / 2, H - crop_h / 2)
print(f'Camera: crop {crop_w.min():.0f}-{crop_w.max():.0f} x {crop_h.min():.0f}-{crop_h.max():.0f} px '
f'from {W}x{H} (zoom {max_h / crop_h.max():.2f}-{max_h / crop_h.min():.2f}x)')
return np.stack([ccx, ccy, crop_w, crop_h], 1), True
def crop_frame(frame, cam, out_size, interp=cv2.INTER_CUBIC):
"""Sub-pixel crop + resize (smooth camera motion without 1-px jitter). Returns (view, M) where M maps
source pixels to output pixels.
The crop is taken at source resolution with getRectSubPix (bilinear, sub-pixel centre, replicated
border) and then resized once -- several times cheaper than warping straight to the output size."""
cx, cy, cw, ch = cam
W, H = out_size
pw, ph = max(2, int(round(cw))), max(2, int(round(ch)))
patch = cv2.getRectSubPix(frame, (pw, ph), (float(cx), float(cy)))
sx, sy = W / pw, H / ph
view = cv2.resize(patch, (W, H), interpolation=cv2.INTER_AREA if sx < 1 else interp)
M = np.float32([[sx, 0, W / 2 - cx * sx], [0, sy, H / 2 - cy * sy]])
return view, M
# ββββββββββββββββββββββββββββ graphics ββββββββββββββββββββββββββββ
def load_font(size, bold=True):
cands = [os.path.join(ROOT, 'graphics', 'art', 'SalmaproMedium.otf')]
win = os.path.join(os.environ.get('WINDIR', 'C:/Windows'), 'Fonts')
cands += [os.path.join(win, 'segoeuib.ttf' if bold else 'segoeui.ttf'),
os.path.join(win, 'arialbd.ttf' if bold else 'arial.ttf'), 'DejaVuSans-Bold.ttf']
for c in cands:
try:
return ImageFont.truetype(c, int(size))
except OSError:
continue
return ImageFont.load_default()
def load_numeric_font(size):
# Bold sans with even digit widths so the counter doesn't wobble.
win = os.path.join(os.environ.get('WINDIR', 'C:/Windows'), 'Fonts')
for c in (os.path.join(win, 'seguisb.ttf'), os.path.join(win, 'segoeuib.ttf'),
os.path.join(win, 'arialbd.ttf'), 'DejaVuSans-Bold.ttf'):
try:
return ImageFont.truetype(c, int(size))
except OSError:
continue
return load_font(size)
C_GOLD = (240, 192, 64)
GAUGE_STOPS = [(0.0, C_ACCENT), (0.45, C_ROPE), (0.75, C_FEET), (1.0, C_BAD)]
def lerp_colors(stops, t):
pos = [s[0] for s in stops]
return np.stack([np.interp(t, pos, [s[1][c] for s in stops]) for c in range(3)], -1)
def over(dst, src):
"""Porter-Duff 'over' for float RGBA arrays in [0, 1] (straight alpha)."""
sa, da = src[..., 3:4], dst[..., 3:4]
oa = sa + da * (1 - sa)
rgb = (src[..., :3] * sa + dst[..., :3] * da * (1 - sa)) / np.maximum(oa, 1e-6)
return np.concatenate([rgb, oa], -1)
class SpeedGauge:
"""Procedural 270-degree arc gauge: gradient value arc with glow, end-cap and peak marker.
Rendered analytically in numpy so it is anti-aliased at 1x without supersampling."""
START, SWEEP = 135.0, 270.0 # degrees, clockwise from 3 o'clock (image coords)
def __init__(self, size, max_jps, u):
self.S = S = int(size)
self.max = max_jps
yy, xx = np.mgrid[0:S, 0:S].astype(np.float32) + 0.5
dx, dy = xx - S / 2, yy - S / 2
r = np.hypot(dx, dy)
P = ((np.degrees(np.arctan2(dy, dx)) - self.START) % 360) / self.SWEEP
gap_mid = (1 + 360 / self.SWEEP) / 2
self.P = np.where(P > gap_mid, P - 360 / self.SWEEP, P) # progress along arc, <0 / >1 in the gap
self.R = R = S * 0.40
self.w = w = S * 0.085
self.px = np.radians(self.SWEEP) * R # pixels per unit progress
ring = np.clip(w / 2 - np.abs(r - R) + 0.5, 0, 1)
in_sweep = np.clip(np.minimum(self.P, 1 - self.P) * self.px + 0.5, 0, 1)
self.ring = (ring * in_sweep)[..., None]
self.rgb8 = lerp_colors(GAUGE_STOPS, np.clip(self.P, 0, 1)).astype(np.uint8)
glow = cv2.GaussianBlur(self.ring[..., 0], (0, 0), w * 0.55)
glow = glow / glow.max() * 0.55
ring2 = self.ring[..., 0]
self.val_a = (ring2 + glow * (1 - ring2)).astype(np.float32) # arc + glow, combined alpha
self.P = self.P.astype(np.float32)
# anti-aliased white end-cap patch
cr = w * 0.30
k = int(np.ceil(cr + 2)) * 2 + 1
yk, xk = np.mgrid[0:k, 0:k] + 0.5 - k / 2
cap = np.zeros((k, k, 4), np.uint8)
cap[..., :3] = 255
cap[..., 3] = np.uint8(np.clip(cr - np.hypot(xk, yk) + 0.5, 0, 1) * 255)
self.cap = Image.fromarray(cap)
# static base: dark face + faint track + ticks + numerals (drawn once)
face = np.clip((R - w / 2 - 2) - r + 0.5, 0, 1)[..., None] * 0.55
base = over(np.concatenate([np.broadcast_to(np.array(C_BG) / 255, (S, S, 3)), face], -1),
np.concatenate([np.ones((S, S, 3)), self.ring * 0.13], -1))
ss = 2
ticks = Image.new('RGBA', (S * ss, S * ss), (0, 0, 0, 0))
d = ImageDraw.Draw(ticks)
f_num = load_font(22 * u * ss)
c = S * ss / 2
for k in range(int(max_jps * 2) + 1):
v = k / 2
a = np.radians(self.START + self.SWEEP * v / max_jps)
major = k % 2 == 0
r0 = (R + w / 2 + 5 * u) * ss
r1 = r0 + (14 if major else 7) * u * ss
d.line([(c + r0 * np.cos(a), c + r0 * np.sin(a)), (c + r1 * np.cos(a), c + r1 * np.sin(a))],
fill=(255, 255, 255, 190 if major else 90), width=int(max(1, (3 if major else 2) * u * ss)))
if major and int(v) % 2 == 0:
rt = (R - w / 2 - 22 * u) * ss
d.text((c + rt * np.cos(a), c + rt * np.sin(a)), f'{int(v)}', font=f_num,
fill=C_MUTED + (220,), anchor='mm')
ticks = np.asarray(ticks.resize((S, S), Image.LANCZOS)).astype(np.float32) / 255
self.base = Image.fromarray(np.uint8(over(base, ticks) * 255))
def _pos(self, v):
a = np.radians(self.START + self.SWEEP * np.clip(v / self.max, 0, 1))
return self.S / 2 + self.R * np.cos(a), self.S / 2 + self.R * np.sin(a), a
def render(self, v, peak=None):
t = float(np.clip(v / self.max, 0, 1))
out = np.empty((self.S, self.S, 4), np.uint8)
out[..., :3] = self.rgb8
if t > 0:
m = np.clip((t - self.P) * self.px + 0.5, 0, 1)
out[..., 3] = (m * self.val_a * 255).astype(np.uint8)
else:
out[..., 3] = 0
img = Image.fromarray(out)
if t > 0: # bright end cap
hx, hy, _ = self._pos(v)
k = self.cap.size[0]
img.alpha_composite(self.cap, (int(round(hx - k / 2)), int(round(hy - k / 2))))
if peak is not None and peak > 0: # peak marker: short gold bar just outside the ring
_, _, a = self._pos(peak)
r0, r1 = self.R + self.w / 2 + 2, self.R + self.w * 1.4
c = self.S / 2
ImageDraw.Draw(img).line([(c + r0 * np.cos(a), c + r0 * np.sin(a)), (c + r1 * np.cos(a), c + r1 * np.sin(a))],
fill=C_GOLD + (255,), width=max(2, int(self.w * 0.22)))
return img
def vertical_gradient(w, h, color, a0, a1, power=1.6):
t = np.linspace(0, 1, h)[:, None] ** power
alpha = (a0 + (a1 - a0) * t) * 255
arr = np.zeros((h, w, 4), np.uint8)
arr[..., :3] = color
arr[..., 3] = np.repeat(alpha, w, 1).astype(np.uint8)
return Image.fromarray(arr)
class Layout:
"""All positions in output pixels, derived from the output format. Boxes are (x0, y0, x1, y1)."""
def __init__(self, fmt, W, H):
self.fmt, self.W, self.H = fmt, W, H
u = self.u = (W if fmt == 'vertical' else H) / 1080
m = self.margin = int(56 * u)
s = lambda v: int(v * u)
if fmt == 'vertical':
self.top_grad_h = int(H * 0.16)
self.bot_grad_y = int(H * 0.50)
self.logo_w = int(W * 0.36)
self.logo_xy = ('center', s(60))
self.title_y = s(60) + int(self.logo_w * 183 / 1058) + s(24)
self.phase_box = (W - m - s(170), s(230), W - m, s(400))
self.gauge_box = (W - m - s(360), H - s(800), W - m, H - s(440))
self.count_xy = (m, H - s(540))
self.count_size = s(240)
self.trace_box = (m, H - s(410), m + s(600), H - s(240))
self.splits_box = (m + s(630), H - s(410), W - m, H - s(240))
self.beat_box = (m, H - s(205), W - m, H - s(135))
self.chip_y = H - s(80)
else:
self.top_grad_h = int(H * 0.20)
self.bot_grad_y = int(H * 0.55)
self.logo_w = s(380)
self.logo_xy = (m, s(44))
self.title_y = None
self.phase_box = (W - m - s(170), s(40), W - m, s(210))
# left column: speed trace + count; right column: splits + gauge; centre kept clear
self.trace_box = (m, H - s(560), m + s(560), H - s(400))
self.count_xy = (m, H - s(165))
self.count_size = s(210)
self.splits_box = (W - m - s(400), H - s(610), W - m, H - s(450))
self.gauge_box = (W - m - s(390), H - s(430), W - m - s(10), H - s(50))
self.beat_box = (W // 2 - s(330), H - s(100), W // 2 + s(330), H - s(40))
self.chip_y = H - s(58)
def split_seconds(duration):
for s in (5, 10, 15, 20, 30, 60):
if duration / s <= 8:
return s
return int(math.ceil(duration / 8))
class Overlay:
def __init__(self, fmt, out_size, an, fps, title, dial_max, show_pace):
W, H = out_size
self.W, self.H, self.fps, self.an, self.show_pace = W, H, fps, an, show_pace
L = self.L = Layout(fmt, W, H)
u = L.u
self.f_count = load_numeric_font(L.count_size)
self.f_label = load_font(38 * u)
self.f_small = load_font(28 * u)
self.f_tiny = load_font(22 * u)
self.f_gauge = load_numeric_font(92 * u)
self.f_title = load_font(42 * u)
js, je = an['jump_start'], an['jump_end']
self.js, self.je = js, max(je, js + 1)
n = len(an['count'])
# ββ derived stats ββ
# detected jump times (model mark peaks) -> fractional output-frame positions
bt = an.get('beat_times')
if bt is None or len(bt) < 2:
bt = np.where(np.diff(an['count'], prepend=0) > 0)[0] / fps
self.jumps = np.asarray(bt) * fps # model time == source time; output frame m sits at m / fps
iv = np.diff(bt)
med = _running_median(iv, 9)
self.jump_dev = np.r_[0, np.abs(iv - med) / np.maximum(med, 1e-6)] # timing deviation per jump
self.peak = np.maximum.accumulate(an['jps'])
dur = (self.je - js) / fps
self.split_s = split_seconds(dur)
nsp = max(1, int(math.ceil(dur / self.split_s - 1e-6)))
self.split_b = [min(js + int(round(k * self.split_s * fps)), self.je + 1) for k in range(nsp + 1)]
c_at = lambda f: int(an['count'][min(f, n - 1)]) if f >= 0 else 0
self.c_at = c_at
self.split_final = [c_at(self.split_b[k + 1] - 1) - c_at(self.split_b[k] - 1) for k in range(nsp)]
self.split_max = max(1, max(self.split_final)) * 1.15
act = an['jps'][js:self.je + 1]
self.avg_jps = float(act[act > 0].mean()) if (act > 0).any() else 0.0
# ββ static layer: gradients, logo, title, cards, gauge base ββ
static = Image.new('RGBA', (W, H), (0, 0, 0, 0))
static.alpha_composite(vertical_gradient(W, L.top_grad_h, C_BG, 0.75, 0.0, power=1.0), (0, 0))
static.alpha_composite(vertical_gradient(W, H - L.bot_grad_y, C_BG, 0.0, 0.92), (0, L.bot_grad_y))
d = ImageDraw.Draw(static)
logo_path = os.path.join(ROOT, 'nextjump_logo.png')
logo_h = 0
if os.path.isfile(logo_path):
logo = Image.open(logo_path).convert('RGBA')
logo_h = int(L.logo_w * logo.height / logo.width)
logo = logo.resize((L.logo_w, logo_h), Image.LANCZOS)
lx = (W - L.logo_w) // 2 if L.logo_xy[0] == 'center' else L.logo_xy[0]
static.alpha_composite(logo, (lx, L.logo_xy[1]))
else:
d.text((W // 2 if fmt == 'vertical' else L.margin, L.logo_xy[1]), 'NEXTJUMP',
font=self.f_title, fill=C_ACCENT + (255,), anchor='mt' if fmt == 'vertical' else 'lt')
if title:
if fmt == 'vertical':
d.text((W // 2, L.title_y), title.upper(), font=self.f_title, fill=C_TEXT + (235,),
anchor='mt', stroke_width=int(2 * u), stroke_fill=C_BG + (160,))
else:
tx = L.logo_xy[0] + L.logo_w + int(36 * u)
ty = L.logo_xy[1] + logo_h // 2
d.line([(tx, ty - int(26 * u)), (tx, ty + int(26 * u))], fill=C_ACCENT + (255,), width=max(2, int(4 * u)))
d.text((tx + int(30 * u), ty), title.upper(), font=self.f_title, fill=C_TEXT + (235,),
anchor='lm', stroke_width=int(2 * u), stroke_fill=C_BG + (160,))
pad = int(16 * u)
for box, label in ((L.trace_box, 'SPEED (JUMPS / SEC)'), (L.splits_box, f'{self.split_s} S SPLITS'),
(L.beat_box, None)):
x0, y0, x1, y1 = box
d.rounded_rectangle([x0 - pad, y0 - pad, x1 + pad, y1 + pad], radius=int(18 * u), fill=C_BG + (150,))
if label:
d.text((x0, y0), label, font=self.f_tiny, fill=C_MUTED + (255,), anchor='lt')
gx0, gy0, gx1, gy1 = L.gauge_box
self.gauge = SpeedGauge(min(gx1 - gx0, gy1 - gy0), dial_max, u)
static.alpha_composite(self.gauge.base, (gx0, gy0))
self.static = static
alpha_rows = np.flatnonzero(np.asarray(static)[:, :, 3].max(1) > 0)
bands, start = [], None
for r0, r1 in zip(np.r_[alpha_rows[:1], alpha_rows[1:][np.diff(alpha_rows) > 1]],
np.r_[alpha_rows[:-1][np.diff(alpha_rows) > 1], alpha_rows[-1:]]):
bands.append((int(r0), static.crop((0, int(r0), W, int(r1) + 1))))
self.static_bands = bands
# ββ speed trace axis (fixed: the full jump window is known offline) ββ
seg = an['jps'][js:self.je + 1]
self.ymax = max(1.0, float(np.percentile(seg, 99)) * 1.2 if len(seg) else 1.0, an['baseline'] * 1.2)
# frames since the count last went up (drives the count "pop"), precomputed so render() is stateless
ar = np.arange(n)
inc = np.where(np.diff(an['count'], prepend=an['count'][0]) > 0, ar, -10 ** 6)
self.pop_age = ar - np.maximum.accumulate(inc)
# beep-timed event (None when beep detection is off / failed)
self.event = an.get('event')
self.times = an.get('times')
if self.times is None:
self.times = np.arange(n) / fps
self.f_banner = load_font(88 * u)
# misses and (rulebook) skipped jumps as output-frame positions
fr = lambda t: int(np.searchsorted(self.times, t))
self.misses = [(fr(a), fr(b)) for a, b in an.get('miss_times', [])]
self.skips = [fr(t) for t in an.get('skip_times', [])]
self.skip_rule = an.get('skip_after_miss', False)
self.skipped_beats = {int(np.argmin(np.abs(self.jumps - f))) for f in self.skips} if len(self.jumps) else set()
# ββ helpers ββ
def _chip(self, d, xy, text, font, fg, bg, anchor='lm', pad=(18, 8)):
u = self.L.u
bbox = d.textbbox(xy, text, font=font, anchor=anchor)
px, py = int(pad[0] * u), int(pad[1] * u)
d.rounded_rectangle([bbox[0] - px, bbox[1] - py, bbox[2] + px, bbox[3] + py],
radius=int(14 * u), fill=bg)
d.text(xy, text, font=font, fill=fg, anchor=anchor)
return bbox[2] + px
def _trace(self, idx):
"""Anti-aliased JPS trace over the whole run (2x supersampled), start pace dashed."""
x0, y0, x1, y1 = self.L.trace_box
w, h = x1 - x0, y1 - y0
ss = 2
img = Image.new('RGBA', (w * ss, h * ss), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
u = self.L.u * ss
an = self.an
top = int(38 * u)
H_ = h * ss
X = lambda k: (k - self.js) / (self.je - self.js) * (w * ss - 1)
Y = lambda v: H_ - 2 - (v / self.ymax) * (H_ - top - 4)
d.line([(0, H_ - 2), (w * ss, H_ - 2)], fill=(255, 255, 255, 70), width=max(1, int(2 * u)))
if self.event is not None:
lw = max(2, int(3 * u))
d.line([(lw / 2, top - 6 * u), (lw / 2, H_ - 2)], fill=C_GOOD + (230,), width=lw)
d.line([(w * ss - lw / 2, top - 6 * u), (w * ss - lw / 2, H_ - 2)], fill=C_BAD + (230,), width=lw)
for a, b in self.misses: # misses so far, shaded red
if a <= idx:
d.rectangle([X(a), top, max(X(a) + 2 * u, X(min(b, idx))), H_ - 2], fill=C_BAD + (70,))
if self.show_pace and an['baseline'] > 0:
yb = Y(an['baseline'])
for xs in range(0, w * ss, int(28 * u)):
d.line([(xs, yb), (xs + int(14 * u), yb)], fill=C_ACCENT + (170,), width=max(1, int(2 * u)))
end = min(idx, self.je)
if end > self.js:
ks = np.unique(np.linspace(self.js, end, min(end - self.js + 1, w)).astype(int))
pts = [(X(k), Y(v)) for k, v in zip(ks, an['jps'][ks])]
d.polygon([(pts[0][0], H_ - 2)] + pts + [(pts[-1][0], H_ - 2)], fill=C_FEET + (55,))
d.line(pts, fill=C_FEET + (255,), width=max(2, int(4 * u)))
kp = self.js + int(np.argmax(an['jps'][self.js:end + 1])) # peak so far
px, py = X(kp), Y(an['jps'][kp])
r = int(6 * u)
d.ellipse([px - r, py - r, px + r, py + r], fill=C_GOLD + (255,))
r = int(9 * u)
px, py = pts[-1]
d.ellipse([px - r, py - r, px + r, py + r], fill=(255, 255, 255, 255), outline=C_FEET + (255,),
width=max(1, int(3 * u)))
return img.reduce(ss), (x0, y0)
def _splits(self, d, idx):
x0, y0, x1, y1 = self.L.splits_box
u = self.L.u
top = y0 + int(34 * u)
base = y1 - int(4 * u)
n = len(self.split_final)
gap = int(10 * u)
bw = (x1 - x0 - gap * (n - 1)) / n
cur = int(np.searchsorted(self.split_b, idx, side='right')) - 1
done = [k for k in range(n) if k < cur]
best = max(done, key=lambda k: self.split_final[k]) if len(done) > 1 else None
for k in range(n):
bx0 = x0 + k * (bw + gap)
d.rounded_rectangle([bx0, top, bx0 + bw, base], radius=int(6 * u), fill=(255, 255, 255, 18))
if k > cur or idx < self.js:
continue
val = self.split_final[k] if k < cur else self.c_at(idx) - self.c_at(self.split_b[k] - 1)
if val <= 0:
continue
by = base - (base - top - int(26 * u)) * val / self.split_max
col = C_FEET if k == cur else (C_GOLD if k == best else C_ACCENT)
d.rounded_rectangle([bx0, by, bx0 + bw, base], radius=int(6 * u), fill=col + (235,))
d.text((bx0 + bw / 2, by - int(4 * u)), f'{val}', font=self.f_tiny, fill=(255, 255, 255, 255), anchor='mb')
def _beats(self, d, idx):
"""Scrolling strip of the last few seconds of jumps, coloured by timing consistency."""
x0, y0, x1, y1 = self.L.beat_box
u = self.L.u
label_w = int(200 * u)
hist = self.jumps[self.jumps <= idx]
# rhythm score: 100 * (1 - coefficient of variation) of the last 12 intervals
iv = np.diff(hist[-13:]) / self.fps
score = f'{max(0, 100 * (1 - iv.std() / iv.mean())):.0f}%' if len(iv) >= 4 else '--'
cy = (y0 + y1) / 2
d.text((x0, cy - int(2 * u)), 'RHYTHM', font=self.f_tiny, fill=C_MUTED + (255,), anchor='ls')
d.text((x0, cy + int(4 * u)), score, font=self.f_label, fill=(255, 255, 255, 255), anchor='lt')
tx0 = x0 + label_w
win = 3.0 * self.fps
d.line([(tx0, cy), (x1, cy)], fill=(255, 255, 255, 40), width=max(1, int(2 * u)))
th = (y1 - y0) * 0.36
if self.event is not None:
for f, col, lab in ((self.js, C_GOOD, 'START'), (self.je + 1, C_BAD, 'END')):
if idx - win < f <= idx:
x = x1 - (idx - f) / win * (x1 - tx0)
d.line([(x, y0 - 4 * u), (x, y1 + 4 * u)], fill=col + (235,), width=max(2, int(3 * u)))
d.text((x + 6 * u, y0 - 2 * u), lab, font=self.f_tiny, fill=col + (255,), anchor='lt')
for a, b in self.misses:
if a <= idx and b > idx - win:
xa = x1 - (idx - max(a, idx - win)) / win * (x1 - tx0)
xb = x1 - (idx - min(b, idx)) / win * (x1 - tx0)
d.rectangle([xa, y0, max(xa + 2 * u, xb), y1], fill=C_BAD + (60,))
for j in np.where((self.jumps <= idx) & (self.jumps > idx - win))[0]:
age = (idx - self.jumps[j]) / win
x = x1 - age * (x1 - tx0)
dev = self.jump_dev[j]
col = C_ROPE if dev < 0.08 else (C_FEET if dev < 0.2 else C_BAD)
fresh = (idx - self.jumps[j]) < 0.08 * self.fps
hh = th * (1.35 if fresh else 1.0)
a = int(255 * (0.25 + 0.75 * (1 - age)))
if j in self.skipped_beats: # rulebook: first jump after a miss is not counted
d.rounded_rectangle([x - 4 * u, cy - hh, x + 4 * u, cy + hh], radius=int(3 * u),
outline=(255, 255, 255, a), width=max(1, int(2 * u)))
continue
d.rounded_rectangle([x - 3 * u, cy - hh, x + 3 * u, cy + hh], radius=int(3 * u),
fill=((255, 255, 255) if fresh else col) + (a,))
def _phase(self, idx):
"""Rope-phase orbit: where the rope is in its rotation (bottom = passing the feet)."""
x0, y0, x1, y1 = self.L.phase_box
S = min(x1 - x0, y1 - y0)
ss = 2
img = Image.new('RGBA', (S * ss, S * ss), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
u = self.L.u * ss
c = S * ss / 2
R = c * 0.72
d.ellipse([c - c * 0.98, c - c * 0.98, c + c * 0.98, c + c * 0.98], fill=C_BG + (150,))
d.ellipse([c - R, c - R, c + R, c + R], outline=(255, 255, 255, 60), width=max(1, int(3 * u)))
d.line([(c, c + R - 10 * u), (c, c + R + 10 * u)], fill=C_FEET + (220,), width=max(1, int(3 * u)))
active = self.an['active'][idx] and self.js <= idx <= self.je
if active:
n_tr = max(2, int(0.12 * self.fps))
ph = self.an['phase'][max(0, idx - n_tr):idx + 1]
for k, a in enumerate(ph):
t = (k + 1) / len(ph)
r = (3 + 6 * t) * u
px, py = c + R * np.cos(a), c - R * np.sin(a)
d.ellipse([px - r, py - r, px + r, py + r], fill=C_ROPE + (int(40 + 150 * t),))
a = ph[-1]
px, py = c + R * np.cos(a), c - R * np.sin(a)
d.line([(c, c), (px, py)], fill=C_ROPE + (110,), width=max(1, int(3 * u)))
r = 11 * u
d.ellipse([px - r, py - r, px + r, py + r], fill=(255, 255, 255, 255), outline=C_ROPE + (255,),
width=max(1, int(3 * u)))
d.text((c, c), 'ROPE', font=self.f_tiny.font_variant(size=int(self.f_tiny.size * ss)),
fill=C_MUTED + (255,), anchor='mm')
return img.reduce(ss), (x0 + (x1 - x0 - S) // 2, y0)
def render(self, frame, idx):
an, L, u = self.an, self.L, self.L.u
# RGB base + 'RGBA' draw mode => translucent shapes blend instead of overwriting
img = Image.fromarray(frame)
for y, band in self.static_bands:
img.paste(band, (0, y), band)
d = ImageDraw.Draw(img, 'RGBA')
cnt = int(an['count'][idx])
done = idx > self.je if self.event is not None else idx > self.je + int(0.3 * self.fps)
jumping = self.js <= idx <= self.je
pop = int(max(0, 6 - self.pop_age[idx]))
# ββ big count ββ
cx, cy = L.count_xy
f = self.f_count if pop == 0 else self.f_count.font_variant(size=int(L.count_size * (1 + 0.06 * pop / 6)))
d.text((cx, cy), f'{an["final"] if done else cnt}', font=f, fill=(255, 255, 255, 255), anchor='ls',
stroke_width=int(3 * u), stroke_fill=C_BG + (120,))
sub_y = cy + int(50 * u)
if done:
label = 'FINAL SCORE' + (f' Β· {len(self.misses)} MISS{"ES" if len(self.misses) != 1 else ""}'
if self.misses else '')
d.text((cx + int(6 * u), sub_y), label, font=self.f_label, fill=C_ACCENT + (255,), anchor='ls')
else:
x = d.textbbox((cx + int(6 * u), sub_y), 'JUMPS', font=self.f_label, anchor='ls')[2]
d.text((cx + int(6 * u), sub_y), 'JUMPS', font=self.f_label, fill=C_TEXT + (255,), anchor='ls',
stroke_width=int(2 * u), stroke_fill=C_BG + (140,))
if jumping and idx > an.get('first_jump', self.js) + int(3 * self.fps):
# projection: current count + current speed for the rest of the run
proj = int(round(cnt + an['jps'][idx] * (self.je - idx) / self.fps))
d.text((x + int(22 * u), sub_y), f'ON PACE FOR {proj}', font=self.f_small,
fill=C_TEXT + (220,), anchor='ls')
# ββ gauge ββ
gx0, gy0, gx1, gy1 = L.gauge_box
v = self.avg_jps if done else float(an['jps'][idx])
peak = float(self.peak[min(idx, self.je)])
g = self.gauge.render(v, peak if idx >= self.js else None)
img.paste(g, (gx0, gy0), g)
gc = (gx0 + self.gauge.S / 2, gy0 + self.gauge.S / 2)
d.text((gc[0], gc[1] + int(8 * u)), f'{v:.1f}', font=self.f_gauge, fill=(255, 255, 255, 255), anchor='ms')
d.text((gc[0], gc[1] + int(46 * u)), 'AVG JUMPS / SEC' if done else 'JUMPS / SEC',
font=self.f_tiny, fill=C_MUTED + (255,), anchor='ms')
if idx >= self.js and peak > 0:
d.text((gc[0], gy0 + self.gauge.S * 0.90), f'PEAK {peak:.1f}', font=self.f_small,
fill=C_GOLD + (255,), anchor='ms')
# ββ panels ββ
tr, pos = self._trace(idx)
img.paste(tr, pos, tr)
self._splits(d, idx)
self._beats(d, idx)
ph, pos = self._phase(idx)
img.paste(ph, pos, ph)
# ββ chips: timer | pace delta | status ββ
x = L.margin
if self.event is not None:
t = float(np.clip(self.times[idx] - self.event[0], 0, self.event[1] - self.event[0]))
else:
t = max(0.0, (min(idx, self.je) - self.js) / self.fps) if idx >= self.js else 0.0
x = self._chip(d, (x, L.chip_y), f'{int(t // 60)}:{t % 60:04.1f}', self.f_label,
(255, 255, 255, 255), (255, 255, 255, 40)) + int(18 * u)
if self.show_pace and idx > an.get('first_jump', self.js) + int(3 * self.fps):
k = min(idx, self.je)
delta = int(round(an['count'][k] - an['pace'][k]))
col = C_GOOD if delta >= 0 else C_BAD
x = self._chip(d, (x, L.chip_y), f'{delta:+d} VS START PACE',
self.f_small, (255, 255, 255, 255), col + (200,)) + int(18 * u)
in_miss = any(a <= idx < b for a, b in self.misses)
after_skip = any(f <= idx < f + int(1.0 * self.fps) for f in self.skips)
waiting_skip = self.skip_rule and any(b <= idx < f for (a, b), f in zip(self.misses, self.skips))
if jumping and (in_miss or (idx >= an.get('first_jump', self.js) and not an['active'][idx])):
self._chip(d, (x, L.chip_y), 'MISS', self.f_small, (255, 255, 255, 255), C_BAD + (230,))
elif jumping and (after_skip or waiting_skip):
self._chip(d, (x, L.chip_y), 'FIRST JUMP AFTER MISS NOT COUNTED', self.f_small,
(255, 255, 255, 255), C_BAD + (170,))
elif self.event is not None and idx < self.js:
self._chip(d, (x, L.chip_y), 'WAITING FOR BEEP', self.f_small, (255, 255, 255, 255), (255, 255, 255, 40))
if self.event is not None:
self._event_bar(d, idx)
self._beep_flash(d, idx)
return np.asarray(img)
def _event_bar(self, d, idx):
"""Full-width event clock along the bottom edge: fills from the start beep to the end beep."""
u, W, H = self.L.u, self.W, self.H
h = max(4, int(8 * u))
y0 = H - h
dur = self.event[1] - self.event[0]
t = float(np.clip((self.times[idx] - self.event[0]) / dur, 0, 1))
d.rectangle([0, y0, W, H], fill=(255, 255, 255, 45))
if t > 0:
d.rectangle([0, y0, int(W * t), H], fill=(C_BAD if t >= 1 else C_ACCENT) + (235,))
for k in range(1, len(self.split_b) - 1): # split boundaries
xs = int(W * (self.split_b[k] - self.js) / max(1, self.je + 1 - self.js))
d.line([(xs, y0), (xs, H)], fill=C_BG + (200,), width=max(1, int(2 * u)))
def _beep_flash(self, d, idx):
"""Border flash + banner for ~1.2 s after each beep (green START, red TIME)."""
u, W, H = self.L.u, self.W, self.H
for t_ev, col, text in ((self.event[0], C_GOOD, 'START'), (self.event[1], C_BAD, 'TIME')):
age = self.times[idx] - t_ev
if not 0 <= age < 1.2:
continue
k = 1 - age / 1.2
bw = int(16 * u)
d.rectangle([bw // 2, bw // 2, W - bw // 2, H - bw // 2], outline=col + (int(230 * k),), width=bw)
scale = 1 + 0.25 * max(0.0, 1 - age / 0.15) # quick pop-in
f = self.f_banner.font_variant(size=int(self.f_banner.size * scale))
cx, cy = W / 2, H * 0.30
bb = d.textbbox((cx, cy), text, font=f, anchor='mm')
px, py = int(40 * u), int(16 * u)
a = int(255 * min(1.0, 3 * k))
d.rounded_rectangle([bb[0] - px, bb[1] - py, bb[2] + px, bb[3] + py], radius=int(22 * u),
fill=col + (int(0.9 * a),))
d.text((cx, cy), text, font=f, fill=(255, 255, 255, a), anchor='mm')
# ββββββββββββββββββββββββββββ parallel render ββββββββββββββββββββββββββββ
#
# Each worker process renders + encodes a contiguous segment of the output on its own (decode -> crop ->
# overlay -> its own ffmpeg), then the segments are joined losslessly (concat demuxer, -c copy) and the
# audio is muxed in. Frames are fully independent (the overlay is stateless), so this scales with cores.
_RENDER = {}
def _nvenc_args(ffmpeg, cq):
"""h264_nvenc arguments if this ffmpeg + driver can actually use NVENC, else None (cached)."""
key = ('nvenc', ffmpeg)
if key not in _RENDER:
_RENDER[key] = None
for preset in (['-preset', 'p5', '-tune', 'hq'], ['-preset', 'slow']):
args = ['-c:v', 'h264_nvenc'] + preset + ['-rc', 'vbr', '-cq', str(cq), '-b:v', '0', '-pix_fmt', 'yuv420p']
r = subprocess.run([ffmpeg, '-v', 'error', '-f', 'lavfi', '-i', 'color=black:s=256x256:d=0.1', '-frames:v', '2']
+ args + ['-f', 'null', '-'], capture_output=True)
if r.returncode == 0:
_RENDER[key] = args
break
return _RENDER[key]
def encoder_args(ffmpeg, encoder, preset, crf, threads):
if encoder in ('auto', 'nvenc'):
nv = _nvenc_args(ffmpeg, crf + 2)
if nv:
return nv, 'h264_nvenc'
if encoder == 'nvenc':
print('Note: NVENC not usable with this ffmpeg / driver - using libx264')
return (['-c:v', 'libx264', '-preset', preset, '-crf', str(crf), '-pix_fmt', 'yuv420p', '-profile:v', 'high',
'-threads', str(threads)], f'libx264 {preset}')
def _render_worker_boot(counter, cv_threads=1):
"""Pool initializer: runs when the (pre-spawned) worker starts, long before the render config exists."""
cv2.setNumThreads(cv_threads) # many workers x OpenCV's own thread pool would oversubscribe the CPU
if cv_threads: # ... and so would 8 workers x a 16-thread H.264 decoder each
os.environ.setdefault('OPENCV_FFMPEG_CAPTURE_OPTIONS', 'threads;2')
_RENDER['counter'] = counter
def _render_worker_init(cfg_path, counter=None, cv_threads=1):
import pickle
if counter is not None:
_render_worker_boot(counter, cv_threads)
if _RENDER.get('cfg_path') == cfg_path:
return
with open(cfg_path, 'rb') as f:
cfg = pickle.load(f)
cfg['ov'] = Overlay(*cfg['overlay'])
cfg['cfg_path'] = cfg_path
_RENDER.update(cfg)
def _render_segment(seg, cfg_path=None):
if cfg_path:
_render_worker_init(cfg_path)
m0, m1, seg_path = seg
R = _RENDER
t_start = time.perf_counter()
W, H = R['size']
cmd = [R['ffmpeg'], '-y', '-loglevel', 'error', '-f', 'rawvideo', '-pix_fmt', 'yuv420p', '-s', f'{W}x{H}',
'-r', f'{R["fps"]:.6f}', '-i', '-'] + R['enc'] + ['-an', seg_path]
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
cap = cv2.VideoCapture(R['video'])
i, frame, done = -1, None, 0
prof = [0.0, 0.0, 0.0]
first = int(R['out_src'][m0])
if first > 0:
# Seek straight to the segment (OpenCV decodes forward from the previous keyframe, frame-exact);
# otherwise every worker would decode the whole video up to its segment.
cap.set(cv2.CAP_PROP_POS_FRAMES, first)
if int(round(cap.get(cv2.CAP_PROP_POS_FRAMES))) == first:
i = first - 1
else: # container without reliable seeking: start over
cap.release()
cap = cv2.VideoCapture(R['video'])
try:
for m in range(m0, m1):
src = int(R['out_src'][m])
while i < src:
if not cap.grab():
raise RuntimeError(f'video ended early at frame {i}')
i, frame = i + 1, None
if frame is None:
ok, bgr = cap.retrieve()
frame = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
if R['mirror']:
frame = np.ascontiguousarray(frame[:, ::-1])
t1 = time.perf_counter()
view, M = crop_frame(frame, R['cam'][m], (W, H), R['interp'])
if R['kps'] is not None:
draw_skeleton(view, R['kps'][m], M)
t2 = time.perf_counter()
yuv = cv2.cvtColor(R['ov'].render(view, m), cv2.COLOR_RGB2YUV_I420)
t3 = time.perf_counter()
proc.stdin.write(yuv.tobytes())
t4 = time.perf_counter()
prof[0] += t2 - t1; prof[1] += t3 - t2; prof[2] += t4 - t3
done += 1
if done % 10 == 0:
with R['counter'].get_lock():
R['counter'].value += 10
finally:
proc.stdin.close()
proc.wait()
cap.release()
if proc.returncode != 0:
raise RuntimeError(f'ffmpeg (segment) exited with code {proc.returncode}')
with R['counter'].get_lock():
R['counter'].value += done % 10
if os.environ.get('NEXTJUMP_PROFILE'):
print(f'[seg {m0}] crop {prof[0]:.1f}s overlay {prof[1]:.1f}s write {prof[2]:.1f}s', flush=True)
return seg_path, time.perf_counter() - t_start, m1 - m0
class RenderPool:
"""Spawn the render worker processes early (they only import this module and wait), so their start-up
cost overlaps the GPU analysis instead of delaying the render."""
def __init__(self, workers):
import threading
import multiprocessing as mp
self.workers = workers
self.ctx = mp.get_context('spawn')
self.counter = self.ctx.Value('i', 0)
self._ex = None
self._starter = threading.Thread(target=self._start, daemon=True) if workers > 1 else None
if self._starter:
self._starter.start()
def _start(self):
from concurrent.futures import ProcessPoolExecutor
while self.workers > 1:
ex = ProcessPoolExecutor(self.workers, mp_context=self.ctx, initializer=_render_worker_boot,
initargs=(self.counter,))
try:
for fu in [ex.submit(time.sleep, 0) for _ in range(self.workers)]: # make every process start
fu.result()
self._ex = ex
return
except Exception as e: # e.g. a worker could not start because memory is short
ex.shutdown(wait=False, cancel_futures=True)
print(f'\nNote: render pool with {self.workers} workers failed ({type(e).__name__}); retrying with fewer')
self.workers //= 2
@property
def ex(self):
if self._starter:
self._starter.join()
return self._ex
def close(self):
if self.ex:
self.ex.shutdown(wait=True, cancel_futures=True)
def render_video(cfg, n, workers, output, audio_src, progress=None, pool=None):
import pickle
tmp = tempfile.mkdtemp(prefix='nextjump_render_')
own_pool = pool is None
pool = pool or RenderPool(max(1, min(workers, n // 30)))
try:
cfg_path = os.path.join(tmp, 'cfg.pkl')
with open(cfg_path, 'wb') as f:
pickle.dump(cfg, f, protocol=pickle.HIGHEST_PROTOCOL)
k = max(1, min(pool.workers, n // 30))
bounds = np.linspace(0, n, k + 1).astype(int)
segs = [(int(bounds[j]), int(bounds[j + 1]), os.path.join(tmp, f'seg{j:02d}.mp4')) for j in range(k)]
counter = pool.counter
counter.value = 0
print(f'Rendering {n} frames in {k} parallel segment(s)')
def tick():
print(f'\rRendering {min(n, counter.value)}/{n} ', end='', flush=True)
if progress:
progress(min(1.0, counter.value / n))
if k == 1 or pool.ex is None:
segs = [(0, n, os.path.join(tmp, 'seg00.mp4'))]
_render_worker_init(cfg_path, counter, cv_threads=0)
stats = [_render_segment(segs[0])]
else:
futs = [pool.ex.submit(_render_segment, sg, cfg_path) for sg in segs]
while not all(fu.done() for fu in futs):
time.sleep(0.25)
tick()
stats = [fu.result() for fu in futs] # re-raises worker errors
tick()
print(f'\nSegments: ' + ', '.join(f'{nf} fr / {dt:.1f}s' for _, dt, nf in stats))
lst = os.path.join(tmp, 'segments.txt')
with open(lst, 'w') as f:
f.writelines(f"file '{sg[2].replace(os.sep, '/')}'\n" for sg in segs)
cmd = [cfg['ffmpeg'], '-y', '-loglevel', 'error', '-f', 'concat', '-safe', '0', '-i', lst]
if audio_src:
cmd += ['-i', audio_src, '-map', '0:v', '-map', '1:a?', '-c:a', 'aac', '-b:a', '192k', '-shortest']
cmd += ['-c:v', 'copy', '-movflags', '+faststart', output]
subprocess.run(cmd, check=True)
finally:
if own_pool:
pool.close()
shutil.rmtree(tmp, ignore_errors=True)
def draw_skeleton(frame, kp, M, conf=0.3):
pts = np.c_[kp[:, :2], np.ones(17)] @ M.T
t = max(2, int(frame.shape[1] / 400))
for a, b in SKELETON:
if kp[a, 2] > conf and kp[b, 2] > conf:
cv2.line(frame, tuple(np.int32(pts[a])), tuple(np.int32(pts[b])), C_ACCENT, t, cv2.LINE_AA)
for i in range(17):
if kp[i, 2] > conf:
cv2.circle(frame, tuple(np.int32(pts[i])), t + 2, (255, 255, 255), -1, cv2.LINE_AA)
# ββββββββββββββββββββββββββββ main ββββββββββββββββββββββββββββ
class StageTimer:
"""Wall-clock per pipeline stage; printed at the end of every run."""
def __init__(self):
self.t0 = self.t = time.perf_counter()
self.stages = []
def mark(self, name):
now = time.perf_counter()
self.stages.append((name, now - self.t))
self.t = now
def summary(self):
total = time.perf_counter() - self.t0
return 'Timing: ' + ', '.join(f'{k} {v:.1f}s' for k, v in self.stages if v >= 0.05) + f' | total {total:.1f}s'
def main(argv=None, progress=None):
"""CLI entry point; also callable from Python (e.g. the Gradio Space):
main(['--video', 'in.mp4', '--beep'], progress=lambda frac, desc: ...)
Returns a summary dict (output path, final count, beep times, ...)."""
def report(lo, hi, desc):
return (lambda f: progress(lo + (hi - lo) * f, desc)) if progress else None
p = argparse.ArgumentParser(description='Broadcast-ready NextJump visual from the ONNX export')
p.add_argument('--video', required=True)
p.add_argument('--output', default=None, help='Output mp4 (default: <video>_<format>.mp4)')
p.add_argument('--format', choices=list(FORMATS), default='vertical',
help='vertical = 1080x1920 (9:16), horizontal = 1920x1080 (16:9)')
p.add_argument('--resolution_scale', type=float, default=1.0, help='e.g. 2.0 for 4K / 2160x3840')
p.add_argument('--onnx', default=os.path.join(ROOT, 'checkpoint', 'onnx', 'nextjump_all_128.onnx'))
p.add_argument('--max_frames', type=int, default=100000)
p.add_argument('--turbo', action='store_true',
help='Fastest settings for instant replay: 30 fps analysis + output, lighter person detection '
'and probing, no pose model (frames on detector boxes), fast encoder. Any option given '
'explicitly still wins.')
p.add_argument('--stride', type=int, default=None, help='Sliding-window stride (frames; default 32, turbo 64)')
p.add_argument('--batch_size', type=int, default=1, help='Windows per ONNX call')
p.add_argument('--model_fps', type=float, default=None,
help='Frame rate fed to the counting model (default 60 = training rate; turbo 30). '
'Higher-rate sources are decimated to this')
p.add_argument('--out_fps', type=float, default=None,
help='Max output frame rate (default 60, turbo 30; sources above it are decimated)')
p.add_argument('--mirror', action='store_true')
p.add_argument('--cpu', action='store_true')
p.add_argument('--title', default='', help='Event / athlete title shown in the header')
# framing
p.add_argument('--fill', type=float, default=None,
help='Fraction of frame height the jumper occupies (default 0.50 vertical, 0.60 horizontal)')
p.add_argument('--anchor_y', type=float, default=None,
help='Vertical position of body centre in the frame, 0=top (default 0.40 vertical, 0.47 horizontal)')
p.add_argument('--max_zoom', type=float, default=2.5, help='Max zoom relative to the largest possible crop')
p.add_argument('--smooth', type=float, default=0.8, help='Camera smoothing (seconds)')
p.add_argument('--framing', choices=['smart', 'pose', 'off'], default='smart',
help='smart = detect people and frame the most active jumper(s); pose = follow whoever '
'MoveNet locks on to; off = static centred crop')
p.add_argument('--no_crop', action='store_true', help='Same as --framing off')
p.add_argument('--jumpers', type=int, default=1,
help='smart framing: number of people to frame (e.g. 2 for pairs, 3 for double dutch); '
'the camera follows the centre of mass of the group')
p.add_argument('--detector', default='coco',
help="smart framing: 'coco' (torchvision Faster R-CNN MobileNetV3) or a path to a "
'torch-pickled detector such as fasterrcnn_model_finetuned.pth')
p.add_argument('--det_fps', type=float, default=None,
help='smart framing: person detections per second (default 5, turbo 2.5)')
p.add_argument('--count_on_crop', action='store_true',
help='smart framing: feed the counting model a crop around the selected jumper(s) '
'instead of the full frame (helps when others in frame are moving)')
p.add_argument('--skip_after_miss', action='store_true',
help='Rulebook judging: the first jump after each miss is not counted')
# graphics
p.add_argument('--skeleton', action='store_true', help='Draw MoveNet skeleton on the jumper')
p.add_argument('--no_pace', action='store_true', help='Hide start-pace comparison')
p.add_argument('--dial_max', type=float, default=0,
help='Top of the speed gauge in jumps/sec (0 = auto: 10, or more for faster events)')
p.add_argument('--periodicity_threshold', type=float, default=0.2)
p.add_argument('--mark_threshold', type=float, default=0.15)
# event timing
p.add_argument('--beep', action='store_true',
help='Detect the official start/end beeps and count only between them')
p.add_argument('--event_length', type=float, default=30.0, help='Event length in seconds (beep spacing)')
p.add_argument('--beep_file', default=os.path.join(ROOT, 'beep.WAV'), help='Reference beep recording')
p.add_argument('--beep_threshold', type=float, default=0.8, help='Initial correlation peak threshold')
p.add_argument('--crf', type=int, default=None, help='Quality (lower = better; default 17, turbo 19)')
p.add_argument('--x264_preset', default=None, help='x264 speed/size trade-off (default veryfast)')
p.add_argument('--encoder', choices=['auto', 'x264', 'nvenc'], default='auto',
help='auto = NVIDIA NVENC when this ffmpeg + driver support it, else libx264')
p.add_argument('--render_workers', type=int, default=0,
help='Parallel render/encode processes (0 = auto: half the CPU threads, max 8)')
p.add_argument('--no_audio', action='store_true')
args = p.parse_args(argv)
turbo = args.turbo
for name, normal, fast in (('model_fps', MODEL_FPS, 30.0), ('out_fps', 60.0, 30.0), ('stride', 32, 64),
('det_fps', 5.0, 2.5), ('crf', 17, 19), ('x264_preset', 'veryfast', 'veryfast')):
if getattr(args, name) is None:
setattr(args, name, fast if turbo else normal)
W, H = FORMATS[args.format]
W, H = int(round(W * args.resolution_scale / 2) * 2), int(round(H * args.resolution_scale / 2) * 2)
fill = args.fill or (0.50 if args.format == 'vertical' else 0.60)
anchor_y = args.anchor_y if args.anchor_y is not None else (0.40 if args.format == 'vertical' else 0.47)
output = args.output or f'{os.path.splitext(os.path.basename(args.video))[0]}_{args.format}.mp4'
ffmpeg = find_ffmpeg()
T = StageTimer()
workers = args.render_workers or max(1, min(8, cpu_count() // 2))
pool = RenderPool(workers)
try:
return _run(args, T, pool, workers, W, H, fill, anchor_y, output, ffmpeg, report)
finally:
pool.close()
def _run(args, T, pool, workers, W, H, fill, anchor_y, output, ffmpeg, report):
turbo = args.turbo
import torch
device = torch.device('cuda' if torch.cuda.is_available() and not args.cpu else 'cpu')
print(f'Device: {device} | output {W}x{H} ({args.format}) -> {output}')
framing = 'off' if args.no_crop else args.framing
cap = cv2.VideoCapture(args.video)
src_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
n_src = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
cap.release()
duration = min(n_src if n_src > 0 else args.max_frames, args.max_frames) / src_fps
model_fps, out_fps = pick_rate(src_fps, args.model_fps), pick_rate(src_fps, args.out_fps)
if abs(model_fps - args.model_fps) > 0.1 * args.model_fps:
print(f'Warning: source is {src_fps:.2f} fps; the counting model expects ~{args.model_fps:.0f} fps, '
f'so counts may be less reliable')
sess = make_session(args.onnx, device.type == 'cuda')
from concurrent.futures import ThreadPoolExecutor
gpu = ThreadPoolExecutor(1) # single ONNX queue: streaming counter + jumper probe
sink = StreamingCounter(sess, args.stride, gpu)
T.mark('setup')
# ββ 0. event beeps (audio only; also tells the jumper probe where the event is) ββ
event = None
if args.beep:
event = detect_beeps(args.video, ffmpeg, args.event_length, args.beep_file, args.beep_threshold)
if event is None:
print('Warning: beeps not found - counting over the whole video instead')
T.mark('beeps')
# ββ 0b. smart framing: find people, probe each one's activity, keep the N most active ββ
tracks, sel = [], []
if framing == 'smart':
detector = load_person_detector(args.detector, device)
seq_len = sess.get_inputs()[0].shape[1]
windows = probe_windows(duration, seq_len / model_fps, event, n=3 if turbo else 5)
scan = scan_people(args.video, args.max_frames, args.mirror, src_fps, model_fps, args.det_fps, detector,
device, windows, det_side=640 if turbo else 960,
model_sink=None if args.count_on_crop else sink,
progress=report(0.0, 0.25, 'Finding the jumpers'))
dets, probes, pscale = scan['dets'], scan['probes'], scan['scale']
del detector
T.mark('people scan')
if device.type == 'cuda':
torch.cuda.empty_cache()
tracks = build_tracks(dets)
n_all = len(tracks)
tracks = prefilter_tracks(tracks, duration, keep=max(4 if turbo else 6, 2 * args.jumpers + (1 if turbo else 2)))
print(f'{n_all} people tracked; probing the {len(tracks)} most likely jumpers')
if tracks:
scores = score_tracks(sess, tracks, probes, pscale, 1,
progress=report(0.25, 0.32, 'Picking the most active jumper'), gpu=gpu)
sel = select_jumpers(tracks, scores, max(1, args.jumpers), frame_w=dets_frame_w(dets, args.video),
duration=duration)
del probes
T.mark('jumper probe')
if not sel:
print('Warning: no people detected - falling back to pose framing')
framing = 'pose'
base = 0.32 if sel else 0.0
pose_region = model_crop = None
if sel and len(sel) == 1:
tr0 = tracks[sel[0]]
def pose_region(t):
x0, y0, x1, y1 = track_boxes(tr0, t)[0]
return ((x0 + x1) / 2, (y0 + y1) / 2, 1.25 * max(x1 - x0, y1 - y0))
if sel and args.count_on_crop:
gt = np.arange(0, duration + 0.1, 0.1)
G = group_box(tracks, sel, gt)
gcx = gaussian_filter1d((G[:, 0] + G[:, 2]) / 2, 10, mode='nearest')
gcy = gaussian_filter1d((G[:, 1] + G[:, 3]) / 2, 10, mode='nearest')
gside = gaussian_filter1d(median_filter(1.5 * np.maximum(G[:, 2] - G[:, 0], G[:, 3] - G[:, 1]), 21,
mode='nearest'), 10, mode='nearest')
def model_crop(t):
return np.interp(t, gt, gcx), np.interp(t, gt, gcy), np.interp(t, gt, gside)
# ββ 1. analysis pass (skipped entirely when the people scan already produced the model input and no
# pose is needed: turbo single-jumper framing runs on the detector boxes) ββ
model_frames = scan['model_frames'] if framing == 'smart' or sel else None # full-frame input is selection-independent
need_pose = framing == 'pose' or (len(sel) == 1 and (args.skeleton or not turbo))
pose_fps = out_fps if args.skeleton else min(out_fps, 10.0 if turbo else 15.0) # camera is smoothed anyway
if model_frames is None or need_pose:
mf, kps, frame_size, out_src = analyse_video(
args.video, args.max_frames, args.mirror, 256, device, src_fps, model_fps, out_fps, run_pose=need_pose,
pose_fps=pose_fps, collect_model=model_frames is None, model_sink=sink if model_frames is None else None,
progress=report(base, base + (0.4 - base * 0.6), 'Tracking the jumper'),
pose_region=pose_region, model_crop=model_crop)
model_frames = mf if mf is not None else model_frames
else:
kps, frame_size = None, scan['frame_size']
out_src = schedule(scan['n_frames'], src_fps, out_fps)
n = len(out_src)
out_times = out_src / src_fps
T.mark('analysis pass')
# ββ 2. counting model (on the model timeline), then resample onto output frames ββ
mid = base + (0.4 - base * 0.6)
if isinstance(model_frames, StreamingCounter):
res = model_frames.finish(progress=report(mid, 0.68, 'Counting jumps'))
else:
res = run_model(sess, model_frames, args.stride, args.batch_size, progress=report(mid, 0.68, 'Counting jumps'))
gpu.shutdown()
del model_frames, sess, sink
an = compute_analytics(res, model_fps, args.periodicity_threshold, args.mark_threshold, event,
skip_after_miss=args.skip_after_miss)
an = to_output_timeline(an, model_fps, out_times)
T.mark('counting model')
# ββ 3. camera path ββ
boxes = None
if framing == 'pose' and kps is not None:
boxes = boxes_from_keypoints(kps)
elif sel and len(sel) == 1:
# keypoints of the selected jumper; fall back to their detector box where the pose is unreliable
boxes = boxes_from_keypoints(kps) if kps is not None else np.full((n, 4), np.nan)
db = track_boxes(tracks[sel[0]], out_times)
kc = (boxes[:, :2] + boxes[:, 2:]) / 2
off = ~((kc[:, 0] >= db[:, 0]) & (kc[:, 0] <= db[:, 2]) & (kc[:, 1] >= db[:, 1]) & (kc[:, 1] <= db[:, 3]))
boxes[off | ~np.isfinite(boxes).all(1)] = db[off | ~np.isfinite(boxes).all(1)]
elif sel:
boxes = group_box(tracks, sel, out_times)
if sel and len(sel) > 1 and boxes is not None:
need_w = 1.35 * np.nanmedian(boxes[:, 2] - boxes[:, 0])
max_w = min(frame_size[0], frame_size[1] * W / H)
if need_w > max_w * 1.05:
print(f'Note: the {len(sel)} selected people span ~{need_w:.0f} px but a {args.format} crop of this '
f'video is at most {max_w:.0f} px wide; framing on their centre of mass '
f'(try --format horizontal to fit the whole group)')
cam, tracked = compute_camera_path(boxes, frame_size, (W, H), out_fps, fill, anchor_y,
args.max_zoom, args.smooth)
tracked = tracked and kps is not None
if len(cam) == 1:
cam = np.repeat(cam, n, 0)
# ββ 4. render ββ
dial_max = args.dial_max or float(max(10, 2 * math.ceil(np.max(an['jps'], initial=0) * 1.1 / 2)))
overlay_args = (args.format, (W, H), an, out_fps, args.title, dial_max, not args.no_pace)
overlay = Overlay(*overlay_args) # main-process copy: summary stats only
enc, enc_name = encoder_args(ffmpeg, args.encoder, args.x264_preset, args.crf,
threads=max(1, cpu_count() // max(1, min(workers, n // 30 or 1))))
print(f'Encoder: {enc_name}')
cfg = dict(video=args.video, mirror=args.mirror, out_src=out_src, cam=cam,
kps=kps if (args.skeleton and tracked) else None, size=(W, H), fps=out_fps, enc=enc, ffmpeg=ffmpeg,
overlay=overlay_args, interp=cv2.INTER_LINEAR if turbo else cv2.INTER_CUBIC)
T.mark('setup render')
render_video(cfg, n, workers, output, None if args.no_audio else args.video,
progress=report(0.68, 1.0, 'Rendering video'), pool=pool)
T.mark('render')
print(f'\nSaved -> {output} ({W}x{H}, {out_fps:.2f} fps, {n} frames, final count {an["final"]})')
print(T.summary())
return dict(output=output, final=an['final'], event=event, beep_requested=args.beep, framing=framing,
jumpers=len(sel), misses=len(an['miss_times']), skipped=len(an['skip_times']),
duration=(an['jump_end'] - an['jump_start']) / out_fps, avg_jps=float(overlay.avg_jps),
peak_jps=float(np.max(an['jps'])) if len(an['jps']) else 0.0, splits=overlay.split_final,
split_seconds=overlay.split_s, size=(W, H), fps=out_fps, frames=n)
if __name__ == '__main__':
main()
"""
claude_anim_v2.py β broadcast-ready NextJump overlay video from the ONNX export.
Pipeline
1. Pass 1 (analysis): decode the video once, building
- 256x256 model frames for the ONNX counting model, sampled at 60 fps
(the training rate - feeding e.g. 120 fps footage makes it over-count), and
- MoveNet-Thunder keypoints (with crop-region tracking) for framing.
2. Sliding-window ONNX inference -> period length, periodicity, marks, phase.
3. Keypoints -> a smoothed "virtual camera" crop path that keeps the jumper
(and the rope arc) well framed at the target aspect ratio.
4. Pass 2 (render): decode again, crop/zoom each frame along the camera path,
composite broadcast graphics and pipe straight into ffmpeg (audio copied).
Example
python claude_anim_v2.py --video ali.mp4 --format vertical
python claude_anim_v2.py --video 30s_wr_male_new_b_60fps.mp4 \
--format horizontal --title "30s SPEED | WORLD RECORD"
"""
import os
import sys
import math
import time
import shutil
import tempfile
import argparse
import subprocess
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
# scipy is imported lazily: the render worker processes never need it (each import costs memory)
def gaussian_filter1d(*args, **kwargs):
from scipy.ndimage import gaussian_filter1d as f
return f(*args, **kwargs)
def median_filter(*args, **kwargs):
from scipy.ndimage import median_filter as f
return f(*args, **kwargs)
def cpu_count():
"""CPUs this process may actually use (container / affinity limits, e.g. a 2-vCPU Space on a big host)."""
try:
return max(1, len(os.sched_getaffinity(0)))
except AttributeError:
return max(1, os.cpu_count() or 1)
def _running_median(x, size):
"""1-D median filter with edge replication (numpy only; used by the overlay in render workers)."""
if len(x) == 0:
return x
h = size // 2
xp = np.pad(np.asarray(x, float), h, mode='edge')
return np.median(np.lib.stride_tricks.sliding_window_view(xp, size), axis=1)
ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.append(os.path.join(ROOT, 'movenet-pytorch'))
# Output order of checkpoint/onnx/nextjump_all_128.onnx. The graph's output
# *names* are shifted by one (masks were prepended to RepNet.forward's return
# tuple), so always index by position, never by name.
OUT_MASKS, OUT_PERIOD, OUT_PERIODICITY, OUT_MARKS, OUT_EVENT, OUT_FOOT, OUT_PHASE = range(7)
# COCO-17 keypoint indices (MoveNet order)
NOSE, L_SHOULDER, R_SHOULDER, L_HIP, R_HIP, L_ANKLE, R_ANKLE = 0, 5, 6, 11, 12, 15, 16
SKELETON = [(0, 1), (0, 2), (1, 3), (2, 4), (5, 6), (5, 7), (7, 9), (6, 8), (8, 10),
(5, 11), (6, 12), (11, 12), (11, 13), (13, 15), (12, 14), (14, 16)]
# ββ Palette (matches claude_anim.py) ββ
C_BG = (13, 17, 23)
C_ACCENT = (88, 166, 255)
C_FEET = (255, 157, 0)
C_ROPE = (78, 205, 196)
C_GOOD = (63, 185, 80)
C_BAD = (248, 81, 73)
C_TEXT = (230, 237, 243)
C_MUTED = (139, 148, 158)
MODEL_FPS = 60.0 # the counting model was trained on 60 fps clips (see eval.py data dirs)
FORMATS = {'vertical': (1080, 1920), 'horizontal': (1920, 1080)}
def sigmoid(x):
return 1 / (1 + np.exp(-x))
def hex_rgb(h):
h = h.lstrip('#')
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def find_ffmpeg():
# NEXTJUMP_FFMPEG lets you point at a newer build (e.g. one whose NVENC works with your driver)
override = os.environ.get('NEXTJUMP_FFMPEG')
if override and os.path.isfile(override):