File size: 15,134 Bytes
f670bbd
 
 
 
 
aac8661
f670bbd
 
 
89d66bd
f670bbd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aac8661
f670bbd
 
89d66bd
f670bbd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aac8661
 
 
 
 
 
 
 
 
f670bbd
 
 
 
89d66bd
 
 
 
aac8661
 
 
f670bbd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aac8661
 
 
 
f670bbd
 
 
 
 
 
 
 
 
 
89d66bd
 
 
f670bbd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89d66bd
f670bbd
 
 
 
89d66bd
aac8661
 
 
 
 
89d66bd
aac8661
 
 
 
 
f670bbd
 
89d66bd
 
f670bbd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89d66bd
f670bbd
 
 
 
 
 
89d66bd
f670bbd
 
 
 
 
 
89d66bd
 
 
 
 
f670bbd
 
aac8661
89d66bd
aac8661
 
 
 
 
89d66bd
aac8661
89d66bd
 
aac8661
 
 
 
 
 
 
 
89d66bd
aac8661
 
 
 
 
 
 
 
 
89d66bd
 
 
aac8661
 
89d66bd
 
 
 
aac8661
 
 
 
 
 
 
 
 
 
 
89d66bd
 
aac8661
89d66bd
 
aac8661
 
 
 
 
 
 
 
f670bbd
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
"""
NextJump Broadcast — Hugging Face Space.

Upload a jump-rope video and get back a broadcast-ready overlay video (vertical 9:16 or
horizontal 16:9) with the AI jump count, speed gauge, splits, rhythm and — optionally —
official start/end beep detection. All of the work is done by claude_anim_v2.main();
this file is only the Gradio front end.

Layout expected next to this file (run sync_assets.py from the repo to populate it):
    claude_anim_v2.py, beep.WAV, elitejumps_logo.png, nextjump_logo.png, graphics/art/SalmaproMedium.otf,
    movenet-pytorch/{movenet/, _models/movenet_thunder.pth}
When run from inside the repo without syncing, the repo root is used instead.

The counting checkpoint is private and is NOT part of the Space: it is downloaded at startup from the
private model repo (like NextJump-Tournament/app.py), authenticated with the DATASET_SECRET Space secret.
    NEXTJUMP_MODEL_REPO   model repo id            (default lumos-motion/nextjump)
    NEXTJUMP_MODEL_FILE   ONNX file in that repo   (default nextjump_all_128.onnx)
    DATASET_SECRET / HF_TOKEN   read token for the private repo (locally, a `huggingface-cli login` also works)
    NEXTJUMP_ONNX         optional local path that skips the download
"""
import os
import sys
import time
import uuid
import base64
import tempfile

import cv2
import gradio as gr
from huggingface_hub import hf_hub_download

HERE = os.path.dirname(os.path.abspath(__file__))
for _root in (HERE, os.path.dirname(HERE)):
    if os.path.isfile(os.path.join(_root, 'claude_anim_v2.py')):
        sys.path.insert(0, _root)
        break
import claude_anim_v2 as nj  # noqa: E402

MODEL_REPO = os.environ.get('NEXTJUMP_MODEL_REPO', 'lumos-motion/nextjump')
MODEL_FILE = os.environ.get('NEXTJUMP_MODEL_FILE', 'nextjump_all_128.onnx')
HF_TOKEN = os.environ.get('DATASET_SECRET') or os.environ.get('HF_TOKEN')  # None -> cached CLI login


def resolve_model():
    """Path to the counting checkpoint, downloaded (and cached) from the private model repo."""
    override = os.environ.get('NEXTJUMP_ONNX')
    if override:
        return override
    try:
        path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE, repo_type='model', token=HF_TOKEN)
        print(f'Counting model: {MODEL_REPO}/{MODEL_FILE} -> {path}')
        return path
    except Exception as e:
        local = os.path.join(nj.ROOT, 'checkpoint', 'onnx', MODEL_FILE)   # local dev inside the repo
        if os.path.isfile(local):
            print(f'Could not download {MODEL_REPO}/{MODEL_FILE} ({type(e).__name__}); using local {local}')
            return local
        raise RuntimeError(f'Could not download {MODEL_FILE} from the private repo {MODEL_REPO} '
                           f'({type(e).__name__}: {e}). If this is an auth error, add a read token for the repo '
                           'as the DATASET_SECRET secret in the Space settings.') from e


# Render worker processes (multiprocessing 'spawn') re-import this file as __mp_main__: they must not
# download models or build the UI again, so all of that is skipped there.
IS_RENDER_WORKER = __name__ == '__mp_main__'
ONNX_PATH = None if IS_RENDER_WORKER else resolve_model()   # at startup, so the first request doesn't wait
if not IS_RENDER_WORKER:
    try:   # also warm the torchvision person-detector weights used by smart framing (public, ~75 MB)
        nj.load_person_detector('coco', 'cpu')
    except Exception as e:
        print(f'Could not pre-load the person detector ({e}); it will be downloaded on first use')

MAX_SECONDS = float(os.environ.get('NEXTJUMP_MAX_SECONDS', 200))   # longest accepted upload
OUT_DIR = os.path.join(tempfile.gettempdir(), 'nextjump_space')
KEEP_OUTPUTS_S = 2 * 3600
FORMATS = {'Social (TikTok / Reels / Shorts, full screen)': 'social',
           'Broadcast vertical 9:16 (full stats panel)': 'vertical',
           'Broadcast horizontal 16:9 (YouTube / TV)': 'horizontal'}
DEFAULT_FILL = {'social': 0.25, 'vertical': 0.50, 'horizontal': 0.60}
FRAMING = {'Smart: find the most active jumper(s)': 'smart',
           'Pose: follow the first person found': 'pose',
           'Off: fixed centre crop': 'off'}


def probe(path):
    cap = cv2.VideoCapture(path)
    fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
    frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    w, h = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
    ok = cap.isOpened() and cap.read()[0]
    cap.release()
    return dict(ok=ok, fps=fps, frames=frames, w=w, h=h, duration=frames / fps if fps else 0.0)


def cleanup_old_outputs():
    if not os.path.isdir(OUT_DIR):
        return
    now = time.time()
    for f in os.listdir(OUT_DIR):
        p = os.path.join(OUT_DIR, f)
        try:
            if now - os.path.getmtime(p) > KEEP_OUTPUTS_S:
                os.remove(p)
        except OSError:
            pass


def summary_markdown(s, info):
    lines = [f'## {s["final"]} jumps']
    if s['event'] is not None:
        t0, t1 = s['event']
        lines.append(f'Counted between the beeps: start **{t0:.2f} s**, end **{t1:.2f} s** '
                     f'({t1 - t0:.2f} s event). Nothing before the start beep or after the end beep is counted.')
    elif s['beep_requested']:
        lines.append('⚠️ Beep detection was on, but no start/end beep pair matching the event length was found, '
                     'so the whole video was counted.')
    else:
        lines.append(f'Counted over the detected jumping window ({s["duration"]:.1f} s).')
    if s.get('misses'):
        lines.append(f'\n**{s["misses"]}** miss{"es" if s["misses"] != 1 else ""} detected'
                     + (f'; the first jump after each miss was not counted (rulebook), '
                        f'**{s["skipped"]}** jump{"s" if s["skipped"] != 1 else ""} removed.' if s.get('skipped') else '.'))
    lines.append(f'\nAverage speed **{s["avg_jps"]:.2f}** jumps/s · peak **{s["peak_jps"]:.2f}** jumps/s')
    if s['splits']:
        lines.append(f'\n| {s["split_seconds"]} s split | ' + ' | '.join(str(k + 1) for k in range(len(s['splits']))) + ' |')
        lines.append('|---|' + '---|' * len(s['splits']))
        lines.append('| jumps | ' + ' | '.join(str(v) for v in s['splits']) + ' |')
    lines.append(f'\n<sub>Output {s["size"][0]}×{s["size"][1]} @ {s["fps"]:.2f} fps, {s["frames"]} frames · '
                 f'source {info["w"]}×{info["h"]} @ {info["fps"]:.2f} fps</sub>')
    return '\n'.join(lines)


def generate(video, fmt_label, title, theme_label, turbo, beep, event_length, framing_label, jumpers, skip_after_miss,
             count_on_crop, out_fps, fill, smooth, max_zoom, foot_rings, skeleton, show_pace, mirror,
             progress=gr.Progress()):
    if not video:
        raise gr.Error('Please upload a video first.')
    info = probe(video)
    if not info['ok']:
        raise gr.Error('Could not read that video file. Please upload an MP4 or MOV.')
    if info['duration'] > MAX_SECONDS:
        raise gr.Error(f'That video is {info["duration"]:.0f} s long; the limit here is {MAX_SECONDS:.0f} s. '
                       'Please trim it around the event.')
    if info['fps'] < 45:
        gr.Warning(f'This video is {info["fps"]:.0f} fps. The counting model was trained on 60 fps footage, '
                   'so counts may be less accurate.')
    if beep and not event_length:
        raise gr.Error('Please enter the event length (seconds between the beeps).')

    cleanup_old_outputs()
    os.makedirs(OUT_DIR, exist_ok=True)
    fmt = fmt_label if fmt_label in FORMATS.values() else FORMATS.get(fmt_label, 'social')
    base = os.path.splitext(os.path.basename(video))[0][:40] or 'video'
    output = os.path.join(OUT_DIR, f'{base}_nextjump_{fmt}_{uuid.uuid4().hex[:6]}.mp4')

    argv = ['--video', video, '--output', output, '--onnx', ONNX_PATH, '--format', fmt, '--title', title or '',
            '--theme', theme_label if theme_label in ('elitejumps', 'nextjump') else 'elitejumps',
            '--fill', str(fill), '--smooth', str(smooth), '--max_zoom', str(max_zoom)]
    if out_fps != 'Auto':
        argv += ['--out_fps', str(out_fps)]
    if turbo:
        argv.append('--turbo')
    framing = framing_label if framing_label in FRAMING.values() else FRAMING.get(framing_label, 'smart')
    argv += ['--framing', framing, '--jumpers', str(int(jumpers or 1))]
    if count_on_crop and framing == 'smart':
        argv.append('--count_on_crop')
    if skip_after_miss:
        argv.append('--skip_after_miss')
    if beep:
        argv += ['--beep', '--event_length', str(float(event_length))]
    if foot_rings:
        argv.append('--fx')
    if skeleton:
        argv.append('--skeleton')
    if not show_pace:
        argv.append('--no_pace')
    if mirror:
        argv.append('--mirror')

    progress(0, desc='Starting')
    try:
        s = nj.main(argv, progress=lambda f, desc: progress(f, desc=desc))
    except Exception as e:  # surface a readable message in the UI; full trace goes to the logs
        import traceback
        traceback.print_exc()
        raise gr.Error(f'Processing failed: {e}')
    return s['output'], s['output'], summary_markdown(s, info)


def _logo_html():
    path = os.path.join(nj.ROOT, nj.THEMES[nj.DEFAULT_THEME]['logo'])
    logo = ''
    if os.path.isfile(path):
        with open(path, 'rb') as f:
            logo = f'<img src="data:image/png;base64,{base64.b64encode(f.read()).decode()}" alt="NextJump">'
    return f"""
<div id="nj-header">
  {logo or f'<span class="nj-word">{nj.THEMES[nj.DEFAULT_THEME]["wordmark"]}</span>'}
  <p>Upload a jump-rope video and get a broadcast-ready graphic: AI jump count, speed gauge, splits,
  rhythm and auto-framing on the jumper. Turn on beep detection for official timed events.</p>
</div>"""


CSS = """
#nj-header { background:#111113; border-radius:14px; padding:22px 26px; margin-bottom:6px;
             border-left:6px solid #E52421; }
#nj-header img { height:64px; display:block; margin-bottom:12px; }
#nj-header .nj-word { color:#E52421; font-weight:800; font-size:30px; letter-spacing:1px; }
#nj-header p { color:#D4D4D8; margin:0; font-size:15px; line-height:1.45; }
"""

def build_demo():
    with gr.Blocks(title='NextJump Broadcast', css=CSS, theme=gr.themes.Soft(primary_hue='red'),
                   delete_cache=(3600, KEEP_OUTPUTS_S)) as demo:
        gr.HTML(_logo_html())
        with gr.Row(equal_height=False):
            with gr.Column(scale=1):
                video_in = gr.Video(label='Jump-rope video', sources=['upload'])
                fmt_in = gr.Radio([(k, v) for k, v in FORMATS.items()], value='social', label='Output format')
                title_in = gr.Textbox(label='Title', placeholder='e.g.  Ali  |  30s Speed', max_lines=1)
                theme_in = gr.Radio([('Elite Jumps', 'elitejumps'), ('NextJump', 'nextjump')], value='elitejumps',
                                    label='Graphics theme')
                turbo_in = gr.Checkbox(label='Turbo mode (instant replay)', value=True,
                                       info='About 2x faster: analyses and renders at 30 fps with lighter person detection.')
                with gr.Group():
                    beep_in = gr.Checkbox(label='Detect official start / end beeps', value=False,
                                          info='Counts only between the start beep and the end beep.')
                    event_len_in = gr.Number(label='Event length (seconds between beeps)', value=30,
                                             minimum=5, maximum=600, visible=False)
                with gr.Group():
                    framing_in = gr.Radio([(k, v) for k, v in FRAMING.items()], value='smart', label='Framing',
                                          info='Smart framing ignores judges and bystanders by checking who is actually jumping.')
                    jumpers_in = gr.Slider(1, 6, value=1, step=1, label='People to frame',
                                           info='1 for single rope; 2 for pairs; 3+ for double dutch (turners + jumpers).')
                skip_in = gr.Checkbox(label='Rulebook miss rule: don\'t count the first jump after a miss', value=False)
                with gr.Accordion('Advanced', open=False):
                    crop_count_in = gr.Checkbox(label='Count from a crop around the selected jumper(s)', value=False,
                                                info='Smart framing only. Can help when other people in frame are moving.')
                    fps_in = gr.Radio(['Auto', 30, 60], value='Auto', label='Output frame rate',
                                      info='Auto = 60 fps (30 fps in turbo mode). 30 fps renders about twice as fast.')
                    fill_in = gr.Slider(0.15, 0.85, value=DEFAULT_FILL['social'], step=0.01,
                                        label='Jumper size (fraction of frame height)',
                                        info='Smaller = wider shot. Capped at the widest crop the video allows.')
                    smooth_in = gr.Slider(0.2, 2.0, value=0.8, step=0.1, label='Camera smoothing (s)')
                    zoom_in = gr.Slider(1.0, 4.0, value=2.5, step=0.1, label='Max zoom')
                    fx_in = gr.Checkbox(label='Foot-strike rings', value=True,
                                        info='A ring on the floor at every landing (alternating feet when stepping), during the event.')
                    skeleton_in = gr.Checkbox(label='Show limb tracking (arms & legs)', value=False,
                                          info='Shown only during the event. Slower: runs the pose model on every frame.')
                    pace_in = gr.Checkbox(label='Show start-pace comparison', value=True)
                    mirror_in = gr.Checkbox(label='Mirror video', value=False)
                run_btn = gr.Button('Generate video', variant='primary', size='lg')
                gr.Markdown('<sub>Processing runs on the Space’s hardware and can take several minutes for a '
                            '30 s clip on CPU. Best results: 60 fps, whole body in frame, one jumper.</sub>')
            with gr.Column(scale=1):
                video_out = gr.Video(label='NextJump broadcast video', interactive=False)
                file_out = gr.File(label='Download')
                summary_out = gr.Markdown()

        beep_in.change(lambda on: gr.update(visible=on), beep_in, event_len_in)
        fmt_in.change(lambda fmt: gr.update(value=DEFAULT_FILL.get(fmt, 0.5)), fmt_in, fill_in)
        framing_in.change(lambda fr: gr.update(visible=fr == 'smart'), framing_in, jumpers_in)
        run_btn.click(generate,
                      inputs=[video_in, fmt_in, title_in, theme_in, turbo_in, beep_in, event_len_in, framing_in, jumpers_in,
                              skip_in, crop_count_in, fps_in, fill_in, smooth_in, zoom_in, fx_in, skeleton_in, pace_in, mirror_in],
                      outputs=[video_out, file_out, summary_out],
                      concurrency_limit=1)
    return demo


if not IS_RENDER_WORKER:
    demo = build_demo()
    demo.queue(max_size=16)

if __name__ == '__main__':
    demo.launch()