Utkarsh430 commited on
Commit
f5a7e32
Β·
verified Β·
1 Parent(s): 776cf94

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +649 -0
  2. detector.py +57 -0
app.py ADDED
@@ -0,0 +1,649 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Feed low-conf detections to enable ByteTrack's 2nd pass matching.
165
+ # Reduce imgsz to 480 for massive speed boost without losing much accuracy.
166
+ res = model(frame, conf=0.1, iou=iou, classes=[0], imgsz=480, verbose=False)[0]
167
+ dets = sv.Detections.from_ultralytics(res)
168
+
169
+ # track
170
+ if len(dets) > 0:
171
+ tracked = byte_tracker.update_with_detections(dets)
172
+ else:
173
+ tracked = sv.Detections.empty()
174
+
175
+ out = frame.copy()
176
+
177
+ # collect active tracks
178
+ active_tracks = []
179
+ if tracked.tracker_id is not None and len(tracked) > 0:
180
+ for i, tid in enumerate(tracked.tracker_id):
181
+ if tid is None:
182
+ continue
183
+ tid = int(tid)
184
+ x1, y1, x2, y2 = [int(v) for v in tracked.xyxy[i]]
185
+ active_tracks.append({"id": tid, "box": (x1,y1,x2,y2)})
186
+
187
+ cx, cy = (x1+x2)//2, (y1+y2)//2
188
+ trajs[tid].append((cx, cy))
189
+ if 0 <= cy < H and 0 <= cx < W:
190
+ cv2.circle(hm_acc, (cx, cy), 18, 1.0, -1)
191
+
192
+ # speed EMA
193
+ if show_speed and tid in prev_c:
194
+ d = math.hypot(cx - prev_c[tid][0], cy - prev_c[tid][1])
195
+ spd = (d / PPM) * fps * 3.6
196
+ speeds[tid] = 0.7 * speeds.get(tid, spd) + 0.3 * spd
197
+ prev_c[tid] = (cx, cy)
198
+
199
+ # ── draw trajectories ─────────────────────────────────────────────
200
+ if show_traj:
201
+ ovl = out.copy()
202
+ for tid, pts_dq in trajs.items():
203
+ pts = list(pts_dq)
204
+ col = PALETTE_BGR[tid % len(PALETTE_BGR)]
205
+ for j in range(1, len(pts)):
206
+ a = j / max(len(pts), 1)
207
+ c = tuple(int(v * a) for v in col)
208
+ cv2.line(ovl, pts[j-1], pts[j], c, 2, cv2.LINE_AA)
209
+ cv2.addWeighted(ovl, 0.70, out, 0.30, 0, out)
210
+
211
+ # ── draw boxes + labels (DESIGN.md colours) ───────────────────────
212
+ for t in active_tracks:
213
+ tid = t["id"]
214
+ x1, y1, x2, y2 = t["box"]
215
+
216
+ # secondary #00d2fd (BGR: 253,210,0)
217
+ cv2.rectangle(out, (x1,y1), (x2,y2), (253,210,0), 2)
218
+
219
+ s_str = f" {speeds[tid]:.0f}km/h" if (show_speed and tid in speeds) else ""
220
+ label = f"#{tid}{s_str}"
221
+ fs, tk = 0.45, 1
222
+ (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, fs, tk)
223
+ lx = x1
224
+ ly = max(y1 - 4, th + 6)
225
+ # secondary-container #00677e (BGR: 126,103,0)
226
+ cv2.rectangle(out, (lx, ly-th-4), (lx+tw+8, ly+2), (126,103,0), -1)
227
+ # on-secondary-container #eefaff
228
+ cv2.putText(out, label, (lx+4, ly-1),
229
+ cv2.FONT_HERSHEY_SIMPLEX, fs, (255,250,238), tk, cv2.LINE_AA)
230
+
231
+ # ── HUD ───────────────────────────────────────────────────────────
232
+ n = len(active_tracks)
233
+ hud = f"SUBJECTS:{n:02d} FRAME:{fi:05d}"
234
+ (hw, hh), _ = cv2.getTextSize(hud, cv2.FONT_HERSHEY_SIMPLEX, 0.47, 1)
235
+ cv2.rectangle(out, (8,8), (hw+20, hh+16), (0,0,0), -1)
236
+ # primary #a1ffc2 (BGR: 194,255,161)
237
+ cv2.putText(out, hud, (13, hh+10),
238
+ cv2.FONT_HERSHEY_SIMPLEX, 0.47, (194,255,161), 1, cv2.LINE_AA)
239
+
240
+ writer.write(out)
241
+ counts.append({"frame": fi, "count": n})
242
+ fi += 1
243
+
244
+ if fi % 25 == 0:
245
+ progress(fi / total, desc=f"Frame {fi}/{total} Β· Subjects: {n}")
246
+
247
+ cap.release()
248
+ writer.release()
249
+
250
+ # ── re-encode to browser-compatible H.264 if needed ───────────────────
251
+ final = tmp_raw
252
+ if codec_name not in ("avc1", "H264", "X264"):
253
+ # mp4v isn't browser-playable, try re-encoding with ffmpeg
254
+ if _reencode_for_browser(tmp_raw, tmp_out):
255
+ final = tmp_out
256
+ Path(tmp_raw).unlink(missing_ok=True)
257
+ else:
258
+ # Last resort: serve the mp4v file as-is; Gradio may still handle it
259
+ print("[Warning] Output video may not play in browser without ffmpeg. "
260
+ "Install ffmpeg for best results: https://ffmpeg.org/download.html")
261
+ final = tmp_raw
262
+
263
+ # ── heatmap ───────────────────────────────────────────────────────────
264
+ hm_path = tempfile.mktemp(suffix="_hm.png")
265
+ norm = cv2.normalize(hm_acc, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
266
+ cv2.imwrite(hm_path, cv2.applyColorMap(norm, cv2.COLORMAP_JET))
267
+
268
+ uid = len(trajs)
269
+ stats = json.dumps({
270
+ "total_frames" : fi,
271
+ "unique_ids" : uid,
272
+ "all_track_ids" : list(trajs.keys()),
273
+ "fps" : round(fps, 2),
274
+ "counts_over_time": counts[-300:],
275
+ }, indent=2)
276
+
277
+ return (
278
+ final,
279
+ hm_path,
280
+ stats,
281
+ _status("ok", f"Complete Β· {fi} frames processed Β· {uid} unique IDs tracked"),
282
+ )
283
+
284
+
285
+ def _status(kind: str, msg: str) -> str:
286
+ cfg = {
287
+ "ok": ("#a1ffc2", "SYSTEM NOMINAL"),
288
+ "error": ("#ff716c", "SYSTEM ERROR"),
289
+ "idle": ("#45484f", "STANDBY"),
290
+ }
291
+ col, prefix = cfg.get(kind, cfg["idle"])
292
+ dot_anim = "animation:pulse 2s ease-in-out infinite;" if kind == "ok" else ""
293
+ return f"""
294
+ <div style="display:flex;align-items:center;gap:10px;padding:10px 16px;
295
+ background:{col}14;border-radius:6px;margin-top:8px;">
296
+ <span style="width:7px;height:7px;border-radius:50%;background:{col};
297
+ flex-shrink:0;{dot_anim}"></span>
298
+ <span style="font-family:'IBM Plex Mono',monospace;font-size:.72rem;
299
+ color:{col};letter-spacing:.06em;">
300
+ <span style="opacity:.5;margin-right:8px;">{prefix}</span>{msg}
301
+ </span>
302
+ </div>
303
+ <style>
304
+ @keyframes pulse{{0%,100%{{opacity:1;transform:scale(1)}}50%{{opacity:.3;transform:scale(.7)}}}}
305
+ </style>"""
306
+
307
+
308
+ # ══════════════════════════════════════════════════════════════════════════
309
+ # DESIGN.md CSS β€” "The Digital Observer"
310
+ # ══════════════════════════════════════════════════════════════════════════
311
+
312
+ CSS = """
313
+ @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');
314
+
315
+ /* ── Reset & base ── */
316
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
317
+ body { background: #0b0e14 !important; }
318
+
319
+ .gradio-container {
320
+ background: #0b0e14 !important;
321
+ max-width: 1240px !important;
322
+ margin: 0 auto !important;
323
+ padding: 2.25rem !important;
324
+ font-family: 'Inter', sans-serif !important;
325
+ color: #ecedf6 !important;
326
+ }
327
+
328
+ /* ── Masthead ── */
329
+ #masthead {
330
+ background: #161a21;
331
+ border-radius: 10px;
332
+ padding: 1.75rem 2rem 1.5rem;
333
+ margin-bottom: 1.75rem;
334
+ position: relative;
335
+ overflow: hidden;
336
+ }
337
+ /* sensor-sweep gradient texture (DESIGN.md Β§2) */
338
+ #masthead::after {
339
+ content: '';
340
+ position: absolute;
341
+ inset: 0;
342
+ background: linear-gradient(135deg, #a1ffc21a 0%, #00fc9a0d 40%, transparent 70%);
343
+ pointer-events: none;
344
+ }
345
+ #masthead .eyebrow {
346
+ font-family: 'IBM Plex Mono', monospace;
347
+ font-size: .65rem;
348
+ letter-spacing: .15em;
349
+ text-transform: uppercase;
350
+ color: #3d4555;
351
+ margin-bottom: .5rem;
352
+ display: block;
353
+ }
354
+ #masthead h1 {
355
+ font-family: 'Space Grotesk', sans-serif;
356
+ font-size: 2rem;
357
+ font-weight: 700;
358
+ color: #ecedf6;
359
+ letter-spacing: -.03em;
360
+ line-height: 1.1;
361
+ }
362
+ #masthead h1 em {
363
+ font-style: normal;
364
+ color: #a1ffc2;
365
+ }
366
+ .badge-row {
367
+ display: flex;
368
+ gap: 6px;
369
+ margin-top: .85rem;
370
+ flex-wrap: wrap;
371
+ }
372
+ .bdg {
373
+ font-family: 'IBM Plex Mono', monospace;
374
+ font-size: .6rem;
375
+ letter-spacing: .07em;
376
+ padding: 3px 9px;
377
+ border-radius: 4px;
378
+ border: 1px solid;
379
+ }
380
+ .bdg-p { color:#a1ffc2; border-color:#a1ffc228; background:#a1ffc20e; }
381
+ .bdg-s { color:#00d2fd; border-color:#00d2fd28; background:#00d2fd0e; }
382
+ .bdg-t { color:#ff7350; border-color:#ff735028; background:#ff73500e; }
383
+ .bdg-n { color:#45484f; border-color:#45484f40; }
384
+
385
+ /* ── Workspace grid ── */
386
+ .workspace {
387
+ display: grid;
388
+ grid-template-columns: 310px 1fr;
389
+ gap: 1.75rem;
390
+ align-items: start;
391
+ }
392
+
393
+ /* ── Control panel ── */
394
+ .ctrl {
395
+ background: #161a21;
396
+ border-radius: 10px;
397
+ padding: .9rem;
398
+ }
399
+ /* Section labels β€” no borders, tonal only (DESIGN.md No-Line rule) */
400
+ .sec {
401
+ font-family: 'IBM Plex Mono', monospace;
402
+ font-size: .6rem;
403
+ font-weight: 500;
404
+ letter-spacing: .14em;
405
+ text-transform: uppercase;
406
+ color: #2e3340;
407
+ padding: .75rem 0 .3rem;
408
+ margin-top: .5rem;
409
+ }
410
+ .sec:first-child { padding-top: 0; margin-top: 0; }
411
+
412
+ /* ── Gradio element overrides ── */
413
+ .gradio-container label,
414
+ .gradio-container .label-wrap span,
415
+ .gradio-container .svelte-1gfkn6j {
416
+ font-family: 'Inter', sans-serif !important;
417
+ font-size: .78rem !important;
418
+ color: #6b7585 !important;
419
+ font-weight: 400 !important;
420
+ }
421
+ .gradio-container input[type=range] { accent-color: #00d2fd !important; }
422
+ .gradio-container input[type=checkbox] { accent-color: #a1ffc2 !important; }
423
+ .gradio-container .wrap { background: #161a21 !important; border: none !important; }
424
+
425
+ /* ── Primary CTA (DESIGN.md Β§5 Buttons) ── */
426
+ #run-btn > button {
427
+ width: 100% !important;
428
+ background: #a1ffc2 !important;
429
+ color: #00391e !important;
430
+ font-family: 'Space Grotesk', sans-serif !important;
431
+ font-size: .9rem !important;
432
+ font-weight: 700 !important;
433
+ border: none !important;
434
+ border-radius: 6px !important; /* rounded-md */
435
+ height: 44px !important;
436
+ margin-top: .9rem !important;
437
+ letter-spacing: .02em !important;
438
+ transition: opacity .15s, transform .1s !important;
439
+ cursor: pointer !important;
440
+ }
441
+ #run-btn > button:hover { opacity: .86 !important; }
442
+ #run-btn > button:active { transform: scale(.98) !important; }
443
+
444
+ /* ── Output panel ── */
445
+ .out-panel {
446
+ display: flex;
447
+ flex-direction: column;
448
+ gap: .9rem;
449
+ }
450
+
451
+ /* ── Telemetry cards β€” glassmorphism (DESIGN.md Β§2 Glass Rule) ── */
452
+ .telem {
453
+ display: grid;
454
+ grid-template-columns: repeat(3, 1fr);
455
+ gap: 8px;
456
+ }
457
+ .tcard {
458
+ background: rgba(34, 38, 47, .60);
459
+ backdrop-filter: blur(12px);
460
+ -webkit-backdrop-filter: blur(12px);
461
+ border-radius: 8px;
462
+ padding: 12px 14px;
463
+ position: relative;
464
+ overflow: hidden;
465
+ }
466
+ /* sensor-sweep top accent */
467
+ .tcard::before {
468
+ content: '';
469
+ position: absolute;
470
+ top: 0; left: 0; right: 0; height: 2px;
471
+ background: linear-gradient(90deg, #a1ffc2, #00fc9a);
472
+ opacity: .10;
473
+ }
474
+ .tv {
475
+ font-family: 'Space Grotesk', sans-serif;
476
+ font-size: 1.55rem;
477
+ font-weight: 700;
478
+ line-height: 1;
479
+ margin-bottom: 4px;
480
+ }
481
+ .tk {
482
+ font-family: 'IBM Plex Mono', monospace;
483
+ font-size: .58rem;
484
+ letter-spacing: .12em;
485
+ text-transform: uppercase;
486
+ color: #2e3340;
487
+ }
488
+ .ca { color: #a1ffc2; } /* primary */
489
+ .cs { color: #00d2fd; } /* secondary */
490
+ .ct { color: #ff7350; } /* tertiary */
491
+
492
+ /* ── Video well β€” recessed (DESIGN.md Β§4 Layering) ── */
493
+ .video-well {
494
+ background: #000000;
495
+ border-radius: 8px;
496
+ overflow: hidden;
497
+ }
498
+ .gradio-container video { border-radius: 6px; background: #000; }
499
+
500
+ /* ── Tabs ── */
501
+ .gradio-container .tab-nav {
502
+ background: #10131a !important;
503
+ border-radius: 6px 6px 0 0 !important;
504
+ border: none !important;
505
+ padding: 0 6px !important;
506
+ }
507
+ .gradio-container .tab-nav button {
508
+ font-family: 'IBM Plex Mono', monospace !important;
509
+ font-size: .68rem !important;
510
+ letter-spacing: .07em !important;
511
+ color: #2e3340 !important;
512
+ border: none !important;
513
+ padding: 9px 16px !important;
514
+ background: transparent !important;
515
+ text-transform: uppercase !important;
516
+ }
517
+ .gradio-container .tab-nav button.selected {
518
+ color: #00d2fd !important;
519
+ border-bottom: 2px solid #00d2fd !important;
520
+ }
521
+
522
+ /* ── Code block ── */
523
+ .gradio-container .codemirror-wrapper,
524
+ .gradio-container .cm-editor {
525
+ background: #000000 !important;
526
+ border-radius: 0 0 6px 6px !important;
527
+ }
528
+
529
+ /* ── Tip bar ── */
530
+ .tip {
531
+ background: #10131a;
532
+ border-radius: 6px;
533
+ padding: 9px 16px;
534
+ margin-top: 1.75rem;
535
+ font-family: 'IBM Plex Mono', monospace;
536
+ font-size: .62rem;
537
+ color: #2e3340;
538
+ letter-spacing: .05em;
539
+ }
540
+ .tip b { color: #45484f; font-weight: 500; }
541
+ """
542
+
543
+ # ── HTML blocks ───────────────────────────────────────────────────────────
544
+
545
+ MASTHEAD_HTML = """
546
+ <div id="masthead">
547
+ <span class="eyebrow">Computer Vision Β· Multi-Object Tracking Β· Applied AI</span>
548
+ <h1>Sports <em>Observer</em></h1>
549
+ <div class="badge-row">
550
+ <span class="bdg bdg-p">YOLOv8n</span>
551
+ <span class="bdg bdg-s">ByteTrack</span>
552
+ <span class="bdg bdg-t">Trajectory Trails</span>
553
+ <span class="bdg bdg-n">Speed Estimation</span>
554
+ <span class="bdg bdg-n">Heatmap</span>
555
+ <span class="bdg bdg-n">HF Spaces</span>
556
+ </div>
557
+ </div>
558
+ """
559
+
560
+ TELEM_HTML = """
561
+ <div class="telem">
562
+ <div class="tcard"><div class="tv ca" id="t-ids">β€”</div><div class="tk">Unique IDs</div></div>
563
+ <div class="tcard"><div class="tv cs" id="t-fr">β€”</div><div class="tk">Frames</div></div>
564
+ <div class="tcard"><div class="tv ct" id="t-fps">β€”</div><div class="tk">Source FPS</div></div>
565
+ </div>
566
+ """
567
+
568
+ TIP_HTML = """
569
+ <div class="tip">
570
+ <b>TIP</b> &nbsp;Β·&nbsp; 15–60 s clips give best results on CPU &nbsp;Β·&nbsp;
571
+ Lower confidence β†’ more detections &nbsp;Β·&nbsp;
572
+ Works with football, cricket, basketball, athletics footage
573
+ </div>
574
+ """
575
+
576
+
577
+ # ══════════════════════════════════════════════════════════════════════════
578
+ # GRADIO UI
579
+ # ══════════════════════════════════════════════════════════════════════════
580
+
581
+ def build_app() -> gr.Blocks:
582
+ with gr.Blocks(
583
+ css=CSS,
584
+ title="Sports Observer",
585
+ theme=gr.themes.Base(
586
+ primary_hue=gr.themes.colors.green,
587
+ secondary_hue=gr.themes.colors.cyan,
588
+ neutral_hue=gr.themes.colors.slate,
589
+ ),
590
+ ) as demo:
591
+
592
+ gr.HTML(MASTHEAD_HTML)
593
+ gr.HTML('<div class="workspace">')
594
+
595
+ # ── LEFT: Control Panel ───────────────────────────────────────────
596
+ gr.HTML('<div class="ctrl">')
597
+
598
+ gr.HTML('<div class="sec">Input Stream</div>')
599
+ video_in = gr.Video(label="Upload video", height=210, elem_classes="video-well")
600
+
601
+ gr.HTML('<div class="sec">Detection Parameters</div>')
602
+ conf = gr.Slider(0.10, 0.90, value=0.30, step=0.05, label="Confidence threshold")
603
+ iou = gr.Slider(0.10, 0.90, value=0.50, step=0.05, label="IoU threshold (NMS)")
604
+
605
+ gr.HTML('<div class="sec">Visualisation</div>')
606
+ show_traj = gr.Checkbox(value=True, label="Trajectory trails")
607
+ show_speed = gr.Checkbox(value=True, label="Speed estimates (km/h)")
608
+ traj_len = gr.Slider(10, 120, value=60, step=10, label="Trail length (frames)")
609
+
610
+ run_btn = gr.Button("β–Ά Run Tracker", elem_id="run-btn", variant="primary")
611
+
612
+ gr.HTML('</div>') # close .ctrl
613
+
614
+ # ── RIGHT: Output Panel ───────────────────────────────────────────
615
+ gr.HTML('<div class="out-panel">')
616
+
617
+ gr.HTML(TELEM_HTML)
618
+
619
+ with gr.Tabs():
620
+ with gr.TabItem("Stream Output"):
621
+ video_out = gr.Video(label="", height=340, elem_classes="video-well")
622
+ with gr.TabItem("Movement Heatmap"):
623
+ heatmap_out = gr.Image(label="", height=340)
624
+ with gr.TabItem("Telemetry JSON"):
625
+ stats_out = gr.Textbox(label="Telemetry JSON", lines=16, max_lines=20)
626
+
627
+ status_out = gr.HTML("")
628
+
629
+ gr.HTML('</div>') # close .out-panel
630
+ gr.HTML('</div>') # close .workspace
631
+
632
+ gr.HTML(TIP_HTML)
633
+
634
+ # ── Wire ─────────────────────────────────────────────────────────
635
+ run_btn.click(
636
+ fn=process_video,
637
+ inputs=[video_in, conf, iou, show_traj, show_speed, traj_len],
638
+ outputs=[video_out, heatmap_out, stats_out, status_out],
639
+ )
640
+
641
+ return demo
642
+
643
+
644
+ if __name__ == "__main__":
645
+ build_app().launch(
646
+ server_name="0.0.0.0",
647
+ server_port=7860,
648
+ show_error=True,
649
+ )
detector.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ detector.py
3
+ YOLOv8 person detection wrapper.
4
+ Returns raw lists so other modules stay decoupled from supervision version.
5
+ """
6
+ from __future__ import annotations
7
+ import numpy as np
8
+
9
+
10
+ class PersonDetector:
11
+ """
12
+ Wraps YOLOv8. Returns detections as a plain dict so the rest of the
13
+ pipeline never touches supervision directly from here.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ model_path: str = "yolov8n.pt",
19
+ conf: float = 0.30,
20
+ iou: float = 0.50,
21
+ device: str = "cpu",
22
+ ) -> None:
23
+ from ultralytics import YOLO
24
+ print(f"[Detector] loading {model_path} on {device}")
25
+ self.model = YOLO(model_path)
26
+ self.conf = conf
27
+ self.iou = iou
28
+ self.device = device
29
+
30
+ def detect(self, frame: np.ndarray) -> list[dict]:
31
+ """
32
+ Run YOLOv8 on one BGR frame.
33
+
34
+ Returns:
35
+ list of {"xyxy": [x1,y1,x2,y2], "conf": float}
36
+ """
37
+ results = self.model(
38
+ frame,
39
+ conf=0.1, # Pass low conf detections to ByteTrack
40
+ iou=self.iou,
41
+ classes=[0], # person only
42
+ imgsz=480, # Faster inference
43
+ verbose=False,
44
+ device=self.device,
45
+ )[0]
46
+
47
+ out = []
48
+ boxes = results.boxes
49
+ if boxes is None or len(boxes) == 0:
50
+ return out
51
+
52
+ for box in boxes:
53
+ xyxy = box.xyxy[0].cpu().numpy().tolist() # [x1,y1,x2,y2]
54
+ conf = float(box.conf[0].cpu().numpy())
55
+ out.append({"xyxy": xyxy, "conf": conf})
56
+
57
+ return out