Utkarsh430 commited on
Commit
aa6d7bc
·
verified ·
1 Parent(s): 7681020

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +648 -0
  2. requirements.txt +6 -0
  3. tracker.py +126 -0
app.py ADDED
@@ -0,0 +1,648 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py — Sports Observer
3
+ Gradio app for Hugging Face Spaces.
4
+ UI follows DESIGN.md "The Digital Observer" spec exactly:
5
+ background #0b0e14 void black
6
+ surface #161a21 primary workspace
7
+ surface-high #1c2028 panels
8
+ primary #a1ffc2 green accent
9
+ secondary #00d2fd cyan accent
10
+ tertiary #ff7350 orange alert
11
+ on-surface #ecedf6 body text (never pure white)
12
+ Space Grotesk headlines / Inter body / IBM Plex Mono data
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import math
18
+ import os
19
+ import tempfile
20
+ import traceback
21
+ from collections import defaultdict, deque
22
+ from pathlib import Path
23
+
24
+ import cv2
25
+ import numpy as np
26
+ import gradio as gr
27
+
28
+ # ── Palette (BGR for OpenCV) ───────────────────────────────────────────────
29
+ PALETTE_BGR = [
30
+ (253,210,0),(194,255,161),(80,115,255),(187,212,0),
31
+ (29,178,255),(134,219,61),(56,56,255),(255,115,100),
32
+ (255,194,0),(49,210,207),(151,157,255),(23,204,146),
33
+ (255,56,132),(31,112,255),(52,147,26),(255,56,203),
34
+ (168,153,44),(200,149,255),(10,249,72),(133,0,82),
35
+ ]
36
+ PPM = 20.0 # pixels per metre
37
+
38
+
39
+ # ══════════════════════════════════════════════════════════════════════════
40
+ # PIPELINE
41
+ # ══════════════════════════════════════════════════════════════════════════
42
+
43
+ def process_video(video_path, conf, iou, show_traj, show_speed, traj_len, progress=gr.Progress()):
44
+ if video_path is None:
45
+ return None, None, '{"status":"waiting"}', _status("idle", "Upload a video to begin.")
46
+ try:
47
+ return _run(video_path, float(conf), float(iou), bool(show_traj),
48
+ bool(show_speed), int(traj_len), progress)
49
+ except Exception as exc:
50
+ traceback.print_exc()
51
+ return None, None, json.dumps({"error": str(exc)}), _status("error", str(exc))
52
+
53
+
54
+ def _find_working_fourcc(fps, W, H):
55
+ """Try several codecs and return (fourcc, suffix) for the first one that works."""
56
+ import shutil
57
+ candidates = [
58
+ ("avc1", ".mp4"), # H.264 — best for browsers
59
+ ("H264", ".mp4"),
60
+ ("X264", ".mp4"),
61
+ ("mp4v", ".mp4"), # MPEG-4 fallback (needs re-encode for browser)
62
+ ]
63
+ for codec, ext in candidates:
64
+ test_path = tempfile.mktemp(suffix=f"_test{ext}")
65
+ try:
66
+ fourcc = cv2.VideoWriter_fourcc(*codec)
67
+ w = cv2.VideoWriter(test_path, fourcc, fps, (W, H))
68
+ if w.isOpened():
69
+ # Write a test frame to make sure it really works
70
+ w.write(np.zeros((H, W, 3), dtype=np.uint8))
71
+ w.release()
72
+ if Path(test_path).exists() and Path(test_path).stat().st_size > 0:
73
+ Path(test_path).unlink(missing_ok=True)
74
+ print(f"[Codec] Using {codec}")
75
+ return fourcc, ext, codec
76
+ w.release()
77
+ except Exception:
78
+ pass
79
+ finally:
80
+ Path(test_path).unlink(missing_ok=True)
81
+ # absolute fallback
82
+ print("[Codec] Falling back to mp4v")
83
+ return cv2.VideoWriter_fourcc(*"mp4v"), ".mp4", "mp4v"
84
+
85
+
86
+ def _reencode_for_browser(input_path, output_path):
87
+ """Try to re-encode to H.264 with ffmpeg/ffmpeg.exe. Returns True on success."""
88
+ import subprocess, shutil
89
+
90
+ # Check if ffmpeg is available
91
+ ffmpeg_cmd = shutil.which("ffmpeg")
92
+ if ffmpeg_cmd is None:
93
+ print("[Encode] ffmpeg not found, skipping re-encode")
94
+ return False
95
+
96
+ try:
97
+ result = subprocess.run(
98
+ [ffmpeg_cmd, "-y", "-i", input_path,
99
+ "-vcodec", "libx264", "-crf", "23",
100
+ "-preset", "fast", "-movflags", "+faststart",
101
+ output_path],
102
+ capture_output=True, text=True, timeout=600,
103
+ )
104
+ if result.returncode == 0 and Path(output_path).exists() and Path(output_path).stat().st_size > 0:
105
+ print("[Encode] H.264 re-encode successful")
106
+ return True
107
+ else:
108
+ print(f"[Encode] ffmpeg failed: {result.stderr[:300]}")
109
+ return False
110
+ except Exception as e:
111
+ print(f"[Encode] ffmpeg error: {e}")
112
+ return False
113
+
114
+
115
+ def _run(video_path, conf, iou, show_traj, show_speed, traj_len, progress):
116
+ from ultralytics import YOLO
117
+ import supervision as sv
118
+
119
+ # ── open video ────────────────────────────────────────────────────────
120
+ cap = cv2.VideoCapture(video_path)
121
+ if not cap.isOpened():
122
+ raise RuntimeError("Cannot open video file.")
123
+
124
+ W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
125
+ H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
126
+ fps = float(cap.get(cv2.CAP_PROP_FPS) or 30.0)
127
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 1
128
+
129
+ # ── writers ───────────────────────────────────────────────────────────
130
+ fourcc, ext, codec_name = _find_working_fourcc(fps, W, H)
131
+ tmp_raw = tempfile.mktemp(suffix=f"_raw{ext}")
132
+ tmp_out = tempfile.mktemp(suffix="_out.mp4")
133
+ writer = cv2.VideoWriter(tmp_raw, fourcc, fps, (W, H))
134
+ if not writer.isOpened():
135
+ raise RuntimeError(f"Cannot create video writer with codec {codec_name}. "
136
+ "Please install ffmpeg or an H.264-capable OpenCV build.")
137
+
138
+ # ── model ─────────────────────────────────────────────────────────────
139
+ model = YOLO("yolov8n.pt")
140
+
141
+ # ── tracker (supervision 0.21 stable API) ─────────────────────────────
142
+ byte_tracker = sv.ByteTrack(
143
+ track_activation_threshold=conf,
144
+ lost_track_buffer=max(30, int(fps * 2)),
145
+ minimum_matching_threshold=iou,
146
+ frame_rate=int(fps),
147
+ )
148
+
149
+ # ── state ─────────────────────────────────────────────────────────────
150
+ trajs: dict = defaultdict(lambda: deque(maxlen=traj_len))
151
+ prev_c: dict = {}
152
+ speeds: dict = {}
153
+ hm_acc = np.zeros((H, W), dtype=np.float32)
154
+ counts = []
155
+ fi = 0
156
+
157
+ progress(0, desc="Initialising…")
158
+
159
+ while True:
160
+ ret, frame = cap.read()
161
+ if not ret:
162
+ break
163
+
164
+ # detect
165
+ res = model(frame, conf=conf, iou=iou, classes=[0], verbose=False)[0]
166
+ dets = sv.Detections.from_ultralytics(res)
167
+
168
+ # track
169
+ if len(dets) > 0:
170
+ tracked = byte_tracker.update_with_detections(dets)
171
+ else:
172
+ tracked = sv.Detections.empty()
173
+
174
+ out = frame.copy()
175
+
176
+ # collect active tracks
177
+ active_tracks = []
178
+ if tracked.tracker_id is not None and len(tracked) > 0:
179
+ for i, tid in enumerate(tracked.tracker_id):
180
+ if tid is None:
181
+ continue
182
+ tid = int(tid)
183
+ x1, y1, x2, y2 = [int(v) for v in tracked.xyxy[i]]
184
+ active_tracks.append({"id": tid, "box": (x1,y1,x2,y2)})
185
+
186
+ cx, cy = (x1+x2)//2, (y1+y2)//2
187
+ trajs[tid].append((cx, cy))
188
+ if 0 <= cy < H and 0 <= cx < W:
189
+ cv2.circle(hm_acc, (cx, cy), 18, 1.0, -1)
190
+
191
+ # speed EMA
192
+ if show_speed and tid in prev_c:
193
+ d = math.hypot(cx - prev_c[tid][0], cy - prev_c[tid][1])
194
+ spd = (d / PPM) * fps * 3.6
195
+ speeds[tid] = 0.7 * speeds.get(tid, spd) + 0.3 * spd
196
+ prev_c[tid] = (cx, cy)
197
+
198
+ # ── draw trajectories ─────────────────────────────────────────────
199
+ if show_traj:
200
+ ovl = out.copy()
201
+ for tid, pts_dq in trajs.items():
202
+ pts = list(pts_dq)
203
+ col = PALETTE_BGR[tid % len(PALETTE_BGR)]
204
+ for j in range(1, len(pts)):
205
+ a = j / max(len(pts), 1)
206
+ c = tuple(int(v * a) for v in col)
207
+ cv2.line(ovl, pts[j-1], pts[j], c, 2, cv2.LINE_AA)
208
+ cv2.addWeighted(ovl, 0.70, out, 0.30, 0, out)
209
+
210
+ # ── draw boxes + labels (DESIGN.md colours) ───────────────────────
211
+ for t in active_tracks:
212
+ tid = t["id"]
213
+ x1, y1, x2, y2 = t["box"]
214
+
215
+ # secondary #00d2fd (BGR: 253,210,0)
216
+ cv2.rectangle(out, (x1,y1), (x2,y2), (253,210,0), 2)
217
+
218
+ s_str = f" {speeds[tid]:.0f}km/h" if (show_speed and tid in speeds) else ""
219
+ label = f"#{tid}{s_str}"
220
+ fs, tk = 0.45, 1
221
+ (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, fs, tk)
222
+ lx = x1
223
+ ly = max(y1 - 4, th + 6)
224
+ # secondary-container #00677e (BGR: 126,103,0)
225
+ cv2.rectangle(out, (lx, ly-th-4), (lx+tw+8, ly+2), (126,103,0), -1)
226
+ # on-secondary-container #eefaff
227
+ cv2.putText(out, label, (lx+4, ly-1),
228
+ cv2.FONT_HERSHEY_SIMPLEX, fs, (255,250,238), tk, cv2.LINE_AA)
229
+
230
+ # ── HUD ───────────────────────────────────────────────────────────
231
+ n = len(active_tracks)
232
+ hud = f"SUBJECTS:{n:02d} FRAME:{fi:05d}"
233
+ (hw, hh), _ = cv2.getTextSize(hud, cv2.FONT_HERSHEY_SIMPLEX, 0.47, 1)
234
+ cv2.rectangle(out, (8,8), (hw+20, hh+16), (0,0,0), -1)
235
+ # primary #a1ffc2 (BGR: 194,255,161)
236
+ cv2.putText(out, hud, (13, hh+10),
237
+ cv2.FONT_HERSHEY_SIMPLEX, 0.47, (194,255,161), 1, cv2.LINE_AA)
238
+
239
+ writer.write(out)
240
+ counts.append({"frame": fi, "count": n})
241
+ fi += 1
242
+
243
+ if fi % 25 == 0:
244
+ progress(fi / total, desc=f"Frame {fi}/{total} · Subjects: {n}")
245
+
246
+ cap.release()
247
+ writer.release()
248
+
249
+ # ── re-encode to browser-compatible H.264 if needed ───────────────────
250
+ final = tmp_raw
251
+ if codec_name not in ("avc1", "H264", "X264"):
252
+ # mp4v isn't browser-playable, try re-encoding with ffmpeg
253
+ if _reencode_for_browser(tmp_raw, tmp_out):
254
+ final = tmp_out
255
+ Path(tmp_raw).unlink(missing_ok=True)
256
+ else:
257
+ # Last resort: serve the mp4v file as-is; Gradio may still handle it
258
+ print("[Warning] Output video may not play in browser without ffmpeg. "
259
+ "Install ffmpeg for best results: https://ffmpeg.org/download.html")
260
+ final = tmp_raw
261
+
262
+ # ── heatmap ───────────────────────────────────────────────────────────
263
+ hm_path = tempfile.mktemp(suffix="_hm.png")
264
+ norm = cv2.normalize(hm_acc, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
265
+ cv2.imwrite(hm_path, cv2.applyColorMap(norm, cv2.COLORMAP_JET))
266
+
267
+ uid = len(trajs)
268
+ stats = json.dumps({
269
+ "total_frames" : fi,
270
+ "unique_ids" : uid,
271
+ "all_track_ids" : list(trajs.keys()),
272
+ "fps" : round(fps, 2),
273
+ "counts_over_time": counts[-300:],
274
+ }, indent=2)
275
+
276
+ return (
277
+ final,
278
+ hm_path,
279
+ stats,
280
+ _status("ok", f"Complete · {fi} frames processed · {uid} unique IDs tracked"),
281
+ )
282
+
283
+
284
+ def _status(kind: str, msg: str) -> str:
285
+ cfg = {
286
+ "ok": ("#a1ffc2", "SYSTEM NOMINAL"),
287
+ "error": ("#ff716c", "SYSTEM ERROR"),
288
+ "idle": ("#45484f", "STANDBY"),
289
+ }
290
+ col, prefix = cfg.get(kind, cfg["idle"])
291
+ dot_anim = "animation:pulse 2s ease-in-out infinite;" if kind == "ok" else ""
292
+ return f"""
293
+ <div style="display:flex;align-items:center;gap:10px;padding:10px 16px;
294
+ background:{col}14;border-radius:6px;margin-top:8px;">
295
+ <span style="width:7px;height:7px;border-radius:50%;background:{col};
296
+ flex-shrink:0;{dot_anim}"></span>
297
+ <span style="font-family:'IBM Plex Mono',monospace;font-size:.72rem;
298
+ color:{col};letter-spacing:.06em;">
299
+ <span style="opacity:.5;margin-right:8px;">{prefix}</span>{msg}
300
+ </span>
301
+ </div>
302
+ <style>
303
+ @keyframes pulse{{0%,100%{{opacity:1;transform:scale(1)}}50%{{opacity:.3;transform:scale(.7)}}}}
304
+ </style>"""
305
+
306
+
307
+ # ══════════════════════════════════════════════════════════════════════════
308
+ # DESIGN.md CSS — "The Digital Observer"
309
+ # ══════════════════════════════════════════════════════════════════════════
310
+
311
+ CSS = """
312
+ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500&family=IBM+Plex+Mono:wght@400;500&display=swap');
313
+
314
+ /* ── Reset & base ── */
315
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
316
+ body { background: #0b0e14 !important; }
317
+
318
+ .gradio-container {
319
+ background: #0b0e14 !important;
320
+ max-width: 1240px !important;
321
+ margin: 0 auto !important;
322
+ padding: 2.25rem !important;
323
+ font-family: 'Inter', sans-serif !important;
324
+ color: #ecedf6 !important;
325
+ }
326
+
327
+ /* ── Masthead ── */
328
+ #masthead {
329
+ background: #161a21;
330
+ border-radius: 10px;
331
+ padding: 1.75rem 2rem 1.5rem;
332
+ margin-bottom: 1.75rem;
333
+ position: relative;
334
+ overflow: hidden;
335
+ }
336
+ /* sensor-sweep gradient texture (DESIGN.md §2) */
337
+ #masthead::after {
338
+ content: '';
339
+ position: absolute;
340
+ inset: 0;
341
+ background: linear-gradient(135deg, #a1ffc21a 0%, #00fc9a0d 40%, transparent 70%);
342
+ pointer-events: none;
343
+ }
344
+ #masthead .eyebrow {
345
+ font-family: 'IBM Plex Mono', monospace;
346
+ font-size: .65rem;
347
+ letter-spacing: .15em;
348
+ text-transform: uppercase;
349
+ color: #3d4555;
350
+ margin-bottom: .5rem;
351
+ display: block;
352
+ }
353
+ #masthead h1 {
354
+ font-family: 'Space Grotesk', sans-serif;
355
+ font-size: 2rem;
356
+ font-weight: 700;
357
+ color: #ecedf6;
358
+ letter-spacing: -.03em;
359
+ line-height: 1.1;
360
+ }
361
+ #masthead h1 em {
362
+ font-style: normal;
363
+ color: #a1ffc2;
364
+ }
365
+ .badge-row {
366
+ display: flex;
367
+ gap: 6px;
368
+ margin-top: .85rem;
369
+ flex-wrap: wrap;
370
+ }
371
+ .bdg {
372
+ font-family: 'IBM Plex Mono', monospace;
373
+ font-size: .6rem;
374
+ letter-spacing: .07em;
375
+ padding: 3px 9px;
376
+ border-radius: 4px;
377
+ border: 1px solid;
378
+ }
379
+ .bdg-p { color:#a1ffc2; border-color:#a1ffc228; background:#a1ffc20e; }
380
+ .bdg-s { color:#00d2fd; border-color:#00d2fd28; background:#00d2fd0e; }
381
+ .bdg-t { color:#ff7350; border-color:#ff735028; background:#ff73500e; }
382
+ .bdg-n { color:#45484f; border-color:#45484f40; }
383
+
384
+ /* ── Workspace grid ── */
385
+ .workspace {
386
+ display: grid;
387
+ grid-template-columns: 310px 1fr;
388
+ gap: 1.75rem;
389
+ align-items: start;
390
+ }
391
+
392
+ /* ── Control panel ── */
393
+ .ctrl {
394
+ background: #161a21;
395
+ border-radius: 10px;
396
+ padding: .9rem;
397
+ }
398
+ /* Section labels — no borders, tonal only (DESIGN.md No-Line rule) */
399
+ .sec {
400
+ font-family: 'IBM Plex Mono', monospace;
401
+ font-size: .6rem;
402
+ font-weight: 500;
403
+ letter-spacing: .14em;
404
+ text-transform: uppercase;
405
+ color: #2e3340;
406
+ padding: .75rem 0 .3rem;
407
+ margin-top: .5rem;
408
+ }
409
+ .sec:first-child { padding-top: 0; margin-top: 0; }
410
+
411
+ /* ── Gradio element overrides ── */
412
+ .gradio-container label,
413
+ .gradio-container .label-wrap span,
414
+ .gradio-container .svelte-1gfkn6j {
415
+ font-family: 'Inter', sans-serif !important;
416
+ font-size: .78rem !important;
417
+ color: #6b7585 !important;
418
+ font-weight: 400 !important;
419
+ }
420
+ .gradio-container input[type=range] { accent-color: #00d2fd !important; }
421
+ .gradio-container input[type=checkbox] { accent-color: #a1ffc2 !important; }
422
+ .gradio-container .wrap { background: #161a21 !important; border: none !important; }
423
+
424
+ /* ── Primary CTA (DESIGN.md §5 Buttons) ── */
425
+ #run-btn > button {
426
+ width: 100% !important;
427
+ background: #a1ffc2 !important;
428
+ color: #00391e !important;
429
+ font-family: 'Space Grotesk', sans-serif !important;
430
+ font-size: .9rem !important;
431
+ font-weight: 700 !important;
432
+ border: none !important;
433
+ border-radius: 6px !important; /* rounded-md */
434
+ height: 44px !important;
435
+ margin-top: .9rem !important;
436
+ letter-spacing: .02em !important;
437
+ transition: opacity .15s, transform .1s !important;
438
+ cursor: pointer !important;
439
+ }
440
+ #run-btn > button:hover { opacity: .86 !important; }
441
+ #run-btn > button:active { transform: scale(.98) !important; }
442
+
443
+ /* ── Output panel ── */
444
+ .out-panel {
445
+ display: flex;
446
+ flex-direction: column;
447
+ gap: .9rem;
448
+ }
449
+
450
+ /* ── Telemetry cards — glassmorphism (DESIGN.md §2 Glass Rule) ── */
451
+ .telem {
452
+ display: grid;
453
+ grid-template-columns: repeat(3, 1fr);
454
+ gap: 8px;
455
+ }
456
+ .tcard {
457
+ background: rgba(34, 38, 47, .60);
458
+ backdrop-filter: blur(12px);
459
+ -webkit-backdrop-filter: blur(12px);
460
+ border-radius: 8px;
461
+ padding: 12px 14px;
462
+ position: relative;
463
+ overflow: hidden;
464
+ }
465
+ /* sensor-sweep top accent */
466
+ .tcard::before {
467
+ content: '';
468
+ position: absolute;
469
+ top: 0; left: 0; right: 0; height: 2px;
470
+ background: linear-gradient(90deg, #a1ffc2, #00fc9a);
471
+ opacity: .10;
472
+ }
473
+ .tv {
474
+ font-family: 'Space Grotesk', sans-serif;
475
+ font-size: 1.55rem;
476
+ font-weight: 700;
477
+ line-height: 1;
478
+ margin-bottom: 4px;
479
+ }
480
+ .tk {
481
+ font-family: 'IBM Plex Mono', monospace;
482
+ font-size: .58rem;
483
+ letter-spacing: .12em;
484
+ text-transform: uppercase;
485
+ color: #2e3340;
486
+ }
487
+ .ca { color: #a1ffc2; } /* primary */
488
+ .cs { color: #00d2fd; } /* secondary */
489
+ .ct { color: #ff7350; } /* tertiary */
490
+
491
+ /* ── Video well — recessed (DESIGN.md §4 Layering) ── */
492
+ .video-well {
493
+ background: #000000;
494
+ border-radius: 8px;
495
+ overflow: hidden;
496
+ }
497
+ .gradio-container video { border-radius: 6px; background: #000; }
498
+
499
+ /* ── Tabs ── */
500
+ .gradio-container .tab-nav {
501
+ background: #10131a !important;
502
+ border-radius: 6px 6px 0 0 !important;
503
+ border: none !important;
504
+ padding: 0 6px !important;
505
+ }
506
+ .gradio-container .tab-nav button {
507
+ font-family: 'IBM Plex Mono', monospace !important;
508
+ font-size: .68rem !important;
509
+ letter-spacing: .07em !important;
510
+ color: #2e3340 !important;
511
+ border: none !important;
512
+ padding: 9px 16px !important;
513
+ background: transparent !important;
514
+ text-transform: uppercase !important;
515
+ }
516
+ .gradio-container .tab-nav button.selected {
517
+ color: #00d2fd !important;
518
+ border-bottom: 2px solid #00d2fd !important;
519
+ }
520
+
521
+ /* ── Code block ── */
522
+ .gradio-container .codemirror-wrapper,
523
+ .gradio-container .cm-editor {
524
+ background: #000000 !important;
525
+ border-radius: 0 0 6px 6px !important;
526
+ }
527
+
528
+ /* ── Tip bar ── */
529
+ .tip {
530
+ background: #10131a;
531
+ border-radius: 6px;
532
+ padding: 9px 16px;
533
+ margin-top: 1.75rem;
534
+ font-family: 'IBM Plex Mono', monospace;
535
+ font-size: .62rem;
536
+ color: #2e3340;
537
+ letter-spacing: .05em;
538
+ }
539
+ .tip b { color: #45484f; font-weight: 500; }
540
+ """
541
+
542
+ # ── HTML blocks ─────────────────────────────────���─────────────────────────
543
+
544
+ MASTHEAD_HTML = """
545
+ <div id="masthead">
546
+ <span class="eyebrow">Computer Vision · Multi-Object Tracking · Applied AI</span>
547
+ <h1>Sports <em>Observer</em></h1>
548
+ <div class="badge-row">
549
+ <span class="bdg bdg-p">YOLOv8n</span>
550
+ <span class="bdg bdg-s">ByteTrack</span>
551
+ <span class="bdg bdg-t">Trajectory Trails</span>
552
+ <span class="bdg bdg-n">Speed Estimation</span>
553
+ <span class="bdg bdg-n">Heatmap</span>
554
+ <span class="bdg bdg-n">HF Spaces</span>
555
+ </div>
556
+ </div>
557
+ """
558
+
559
+ TELEM_HTML = """
560
+ <div class="telem">
561
+ <div class="tcard"><div class="tv ca" id="t-ids">—</div><div class="tk">Unique IDs</div></div>
562
+ <div class="tcard"><div class="tv cs" id="t-fr">—</div><div class="tk">Frames</div></div>
563
+ <div class="tcard"><div class="tv ct" id="t-fps">—</div><div class="tk">Source FPS</div></div>
564
+ </div>
565
+ """
566
+
567
+ TIP_HTML = """
568
+ <div class="tip">
569
+ <b>TIP</b> &nbsp;·&nbsp; 15–60 s clips give best results on CPU &nbsp;·&nbsp;
570
+ Lower confidence → more detections &nbsp;·&nbsp;
571
+ Works with football, cricket, basketball, athletics footage
572
+ </div>
573
+ """
574
+
575
+
576
+ # ══════════════════════════════════════════════════════════════════════════
577
+ # GRADIO UI
578
+ # ══════════════════════════════════════════════════════════════════════════
579
+
580
+ def build_app() -> gr.Blocks:
581
+ with gr.Blocks(
582
+ css=CSS,
583
+ title="Sports Observer",
584
+ theme=gr.themes.Base(
585
+ primary_hue=gr.themes.colors.green,
586
+ secondary_hue=gr.themes.colors.cyan,
587
+ neutral_hue=gr.themes.colors.slate,
588
+ ),
589
+ ) as demo:
590
+
591
+ gr.HTML(MASTHEAD_HTML)
592
+ gr.HTML('<div class="workspace">')
593
+
594
+ # ── LEFT: Control Panel ───────────────────────────────────────────
595
+ gr.HTML('<div class="ctrl">')
596
+
597
+ gr.HTML('<div class="sec">Input Stream</div>')
598
+ video_in = gr.Video(label="Upload video", height=210, elem_classes="video-well")
599
+
600
+ gr.HTML('<div class="sec">Detection Parameters</div>')
601
+ conf = gr.Slider(0.10, 0.90, value=0.30, step=0.05, label="Confidence threshold")
602
+ iou = gr.Slider(0.10, 0.90, value=0.50, step=0.05, label="IoU threshold (NMS)")
603
+
604
+ gr.HTML('<div class="sec">Visualisation</div>')
605
+ show_traj = gr.Checkbox(value=True, label="Trajectory trails")
606
+ show_speed = gr.Checkbox(value=True, label="Speed estimates (km/h)")
607
+ traj_len = gr.Slider(10, 120, value=60, step=10, label="Trail length (frames)")
608
+
609
+ run_btn = gr.Button("▶ Run Tracker", elem_id="run-btn", variant="primary")
610
+
611
+ gr.HTML('</div>') # close .ctrl
612
+
613
+ # ── RIGHT: Output Panel ───────────────────────────────────────────
614
+ gr.HTML('<div class="out-panel">')
615
+
616
+ gr.HTML(TELEM_HTML)
617
+
618
+ with gr.Tabs():
619
+ with gr.TabItem("Stream Output"):
620
+ video_out = gr.Video(label="", height=340, elem_classes="video-well")
621
+ with gr.TabItem("Movement Heatmap"):
622
+ heatmap_out = gr.Image(label="", height=340)
623
+ with gr.TabItem("Telemetry JSON"):
624
+ stats_out = gr.Textbox(label="Telemetry JSON", lines=16, max_lines=20)
625
+
626
+ status_out = gr.HTML("")
627
+
628
+ gr.HTML('</div>') # close .out-panel
629
+ gr.HTML('</div>') # close .workspace
630
+
631
+ gr.HTML(TIP_HTML)
632
+
633
+ # ── Wire ─────────────────────────────────────────────────────────
634
+ run_btn.click(
635
+ fn=process_video,
636
+ inputs=[video_in, conf, iou, show_traj, show_speed, traj_len],
637
+ outputs=[video_out, heatmap_out, stats_out, status_out],
638
+ )
639
+
640
+ return demo
641
+
642
+
643
+ if __name__ == "__main__":
644
+ build_app().launch(
645
+ server_name="0.0.0.0",
646
+ server_port=7860,
647
+ show_error=True,
648
+ )
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ ultralytics>=8.2.0
2
+ supervision>=0.21.0
3
+ opencv-python-headless>=4.9.0
4
+ numpy>=1.26.0
5
+ Pillow>=10.0.0
6
+ matplotlib>=3.9.0
tracker.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tracker.py
3
+ ByteTrack wrapper using supervision 0.21 stable API.
4
+ Maintains trajectory history and speed estimates per track ID.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ from collections import defaultdict, deque
10
+ from typing import Dict, List, Optional, Tuple
11
+
12
+ import numpy as np
13
+
14
+
15
+ # pixels-per-metre calibration (rough: 200px ≈ 10m pitch width)
16
+ DEFAULT_PPM = 20.0
17
+
18
+
19
+ class TrackState:
20
+ """Stores trajectory + speed per track ID."""
21
+
22
+ def __init__(self, traj_len: int = 60, ppm: float = DEFAULT_PPM) -> None:
23
+ self.ppm = ppm
24
+ self.traj_len = traj_len
25
+ self.trajs: Dict[int, deque] = defaultdict(lambda: deque(maxlen=traj_len))
26
+ self.prev: Dict[int, Tuple[int,int]] = {}
27
+ self.speeds: Dict[int, float] = {}
28
+
29
+ def update(self, tracks: list[dict], fps: float) -> None:
30
+ """
31
+ tracks: list of {"id": int, "xyxy": [x1,y1,x2,y2]}
32
+ """
33
+ new_prev: Dict[int, Tuple[int,int]] = {}
34
+ for t in tracks:
35
+ tid = int(t["id"])
36
+ x1, y1, x2, y2 = t["xyxy"]
37
+ cx, cy = int((x1+x2)/2), int((y1+y2)/2)
38
+ new_prev[tid] = (cx, cy)
39
+ self.trajs[tid].append((cx, cy))
40
+
41
+ if tid in self.prev and fps > 0:
42
+ d = math.hypot(cx - self.prev[tid][0], cy - self.prev[tid][1])
43
+ spd = (d / self.ppm) * fps * 3.6 # km/h
44
+ old = self.speeds.get(tid, spd)
45
+ self.speeds[tid] = 0.7 * old + 0.3 * spd # EMA smooth
46
+
47
+ self.prev = new_prev
48
+
49
+ def trajectory(self, tid: int) -> List[Tuple[int,int]]:
50
+ return list(self.trajs[tid])
51
+
52
+ def speed(self, tid: int) -> Optional[float]:
53
+ return self.speeds.get(tid)
54
+
55
+ @property
56
+ def all_ids(self) -> List[int]:
57
+ return list(self.trajs.keys())
58
+
59
+
60
+ class SportsTracker:
61
+ """
62
+ Wraps supervision 0.21 ByteTracker.
63
+ Input/output uses plain Python dicts — no supervision objects exposed.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ fps: float = 30.0,
69
+ conf: float = 0.30,
70
+ iou: float = 0.50,
71
+ traj_len: int = 60,
72
+ ppm: float = DEFAULT_PPM,
73
+ ) -> None:
74
+ import supervision as sv
75
+
76
+ self.fps = fps
77
+ self.state = TrackState(traj_len=traj_len, ppm=ppm)
78
+
79
+ # supervision 0.21 ByteTrack constructor
80
+ self._tracker = sv.ByteTrack(
81
+ track_activation_threshold=conf,
82
+ lost_track_buffer=max(30, int(fps * 2)),
83
+ minimum_matching_threshold=iou,
84
+ frame_rate=int(fps),
85
+ )
86
+
87
+ def update(self, detections: list[dict]) -> list[dict]:
88
+ """
89
+ Args:
90
+ detections: list of {"xyxy": [x1,y1,x2,y2], "conf": float}
91
+
92
+ Returns:
93
+ list of {"id": int, "xyxy": [x1,y1,x2,y2], "conf": float}
94
+ """
95
+ import supervision as sv
96
+
97
+ if not detections:
98
+ return []
99
+
100
+ xyxy = np.array([d["xyxy"] for d in detections], dtype=np.float32)
101
+ confs = np.array([d["conf"] for d in detections], dtype=np.float32)
102
+ cids = np.zeros(len(detections), dtype=int)
103
+
104
+ sv_det = sv.Detections(
105
+ xyxy=xyxy,
106
+ confidence=confs,
107
+ class_id=cids,
108
+ )
109
+
110
+ tracked = self._tracker.update_with_detections(sv_det)
111
+
112
+ results = []
113
+ if tracked.tracker_id is None:
114
+ return results
115
+
116
+ for i, tid in enumerate(tracked.tracker_id):
117
+ if tid is None:
118
+ continue
119
+ results.append({
120
+ "id": int(tid),
121
+ "xyxy": tracked.xyxy[i].tolist(),
122
+ "conf": float(tracked.confidence[i]) if tracked.confidence is not None else 0.0,
123
+ })
124
+
125
+ self.state.update(results, self.fps)
126
+ return results