Simon9 commited on
Commit
655c5fa
Β·
verified Β·
1 Parent(s): ca40031

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +451 -291
app.py CHANGED
@@ -1,303 +1,463 @@
1
- # app.py
2
  import os
 
 
 
 
 
 
 
 
 
 
3
  import uuid
4
- import json
5
  import shutil
6
- import threading
7
-
8
- from fastapi import FastAPI, UploadFile, File
9
- from fastapi.responses import (
10
- JSONResponse,
11
- FileResponse,
12
- HTMLResponse,
13
- PlainTextResponse,
14
- RedirectResponse,
15
- )
16
- from fastapi.middleware.cors import CORSMiddleware
17
 
 
18
  from pipeline_full import run_full_pipeline
19
 
20
  BASE_RESULTS_DIR = "jobs"
 
21
 
22
- app = FastAPI(title="Football Analytics Space")
23
-
24
- app.add_middleware(
25
- CORSMiddleware,
26
- allow_origins=["*"], # lock down to your backend/nextjs domains later
27
- allow_credentials=True,
28
- allow_methods=["*"],
29
- allow_headers=["*"],
30
- )
31
-
32
-
33
- # -------------------- UI entrypoint --------------------
34
 
35
-
36
- @app.get("/")
37
- def root_ui():
38
- html = """
39
- <html>
40
- <head>
41
- <title>Football Analytics</title>
42
- <style>
43
- body { font-family: sans-serif; padding: 20px; }
44
- input[type=file] { margin-bottom: 10px; }
45
- pre { background: #f3f3f3; padding: 10px; border-radius: 4px; }
46
- </style>
47
- </head>
48
- <body>
49
- <h1>⚽ Football Analytics</h1>
50
- <p>Upload a match video to run the full pipeline (detections, stats, radar, heatmaps, Voronoi, ball path, clustering).</p>
51
- <form action="/analyze-video-ui" method="post" enctype="multipart/form-data">
52
- <input type="file" name="file" accept="video/*" required />
53
- <button type="submit">Analyze</button>
54
- </form>
55
- </body>
56
- </html>
57
  """
58
- return HTMLResponse(html)
59
-
60
-
61
- # -------------------- Start a job (UI) --------------------
62
-
63
-
64
- @app.post("/analyze-video-ui")
65
- async def analyze_video_ui(file: UploadFile = File(...)):
66
- job_id = str(uuid.uuid4())
67
- job_dir = os.path.join(BASE_RESULTS_DIR, job_id)
68
- os.makedirs(job_dir, exist_ok=True)
69
-
70
- video_path = os.path.join(job_dir, file.filename)
71
- with open(video_path, "wb") as f:
72
- shutil.copyfileobj(file.file, f)
73
-
74
- # Run pipeline in background so we can show progress page
75
- def worker():
76
- from pipeline_full import run_full_pipeline # import inside to avoid circular imports
77
- try:
78
- run_full_pipeline(video_path, job_dir)
79
- except Exception as e:
80
- # write error to status
81
- status_path = os.path.join(job_dir, "status.json")
82
- with open(status_path, "w", encoding="utf-8") as f:
83
- json.dump(
84
- {"stage": "error", "progress": 1.0, "message": str(e)},
85
- f,
86
- )
87
-
88
- threading.Thread(target=worker, daemon=True).start()
89
-
90
- # Redirect immediately to job progress page
91
- return RedirectResponse(f"/job/{job_id}", status_code=303)
92
-
93
-
94
- # -------------------- Job progress page --------------------
95
-
96
-
97
- @app.get("/job/{job_id}")
98
- def job_page(job_id: str):
99
- html = f"""
100
- <html>
101
- <head>
102
- <title>Job {job_id} - Progress</title>
103
- <style>
104
- body {{ font-family: sans-serif; padding: 20px; }}
105
- #message {{ margin-top: 10px; }}
106
- #progress-container {{ margin-top: 20px; width: 100%; max-width: 400px; }}
107
- progress {{ width: 100%; height: 20px; }}
108
- </style>
109
- </head>
110
- <body>
111
- <h1>Job {job_id}</h1>
112
- <p>Processing video... This may take a while for long clips.</p>
113
-
114
- <div id="progress-container">
115
- <progress id="progress-bar" value="0" max="1"></progress>
116
- <div id="message">Starting...</div>
117
- </div>
118
-
119
- <script>
120
- async function pollStatus() {{
121
- try {{
122
- const res = await fetch("/status/{job_id}");
123
- if (!res.ok) throw new Error("status error");
124
- const data = await res.json();
125
- document.getElementById("progress-bar").value = data.progress || 0;
126
- document.getElementById("message").innerText = data.stage + ": " + (data.message || "");
127
-
128
- if (data.stage === "done") {{
129
- window.location.href = "/job-result/{job_id}";
130
- }} else if (data.stage === "error") {{
131
- document.getElementById("message").innerText = "Error: " + data.message;
132
- }} else {{
133
- setTimeout(pollStatus, 2000);
134
- }}
135
- }} catch (e) {{
136
- document.getElementById("message").innerText = "Error fetching status. Retrying...";
137
- setTimeout(pollStatus, 5000);
138
- }}
139
- }}
140
-
141
- pollStatus();
142
- </script>
143
- </body>
144
- </html>
145
  """
146
- return HTMLResponse(html)
147
-
148
-
149
- # -------------------- Status endpoint (for progress polling) --------------------
150
-
151
-
152
- @app.get("/status/{job_id}")
153
- def job_status(job_id: str):
154
- job_dir = os.path.join(BASE_RESULTS_DIR, job_id)
155
- status_path = os.path.join(job_dir, "status.json")
156
- if not os.path.exists(job_dir):
157
- return JSONResponse(
158
- {"stage": "unknown", "progress": 0.0, "message": "Job not found"},
159
- status_code=404,
160
- )
161
- if not os.path.exists(status_path):
162
- # job queued / just started
163
- return JSONResponse(
164
- {"stage": "pending", "progress": 0.0, "message": "Queued"},
165
- status_code=200,
166
- )
167
- with open(status_path, "r", encoding="utf-8") as f:
168
- status = json.load(f)
169
- return JSONResponse(status)
170
-
171
-
172
- # -------------------- Final result page (UI) --------------------
173
-
174
-
175
- @app.get("/job-result/{job_id}")
176
- def job_result(job_id: str):
177
- job_dir = os.path.join(BASE_RESULTS_DIR, job_id)
178
- result_path = os.path.join(job_dir, "result.json")
179
- if not os.path.exists(result_path):
180
- return PlainTextResponse("Result not ready yet or job not found.", status_code=404)
181
-
182
- with open(result_path, "r", encoding="utf-8") as f:
183
- result = json.load(f)
184
-
185
- basic = result["basic"]
186
- adv = result["advanced"]
187
- ball = result["ball"]
188
- stats = result["stats"]
189
- siglip_html = result["siglip_html"]
190
-
191
- def make_link(path, label=None):
192
- if not path or not os.path.exists(path):
193
- return ""
194
- rel = os.path.relpath(path, BASE_RESULTS_DIR)
195
- url = f"/results/{rel}"
196
- label = label or os.path.basename(path)
197
- return f'<li><a href="{url}" target="_blank">{label}</a></li>'
198
-
199
- html = f"""
200
- <html>
201
- <head>
202
- <title>Football Analytics - Result {job_id}</title>
203
- <style>
204
- body {{ font-family: sans-serif; padding: 20px; }}
205
- pre {{ background: #f3f3f3; padding: 10px; border-radius: 4px; max-height: 400px; overflow: auto; }}
206
- h2 {{ margin-top: 30px; }}
207
- </style>
208
- </head>
209
- <body>
210
- <h1>Result for job {job_id}</h1>
211
-
212
- <h2>Basic Frames</h2>
213
- <ul>
214
- {make_link(basic["raw_frame"], "Raw Frame")}
215
- {make_link(basic["boxes_labels"], "Detections with Boxes + Labels")}
216
- {make_link(basic["ball_players"], "Ball vs Players (ellipse/triangle)")}
217
- </ul>
218
-
219
- <h2>Advanced Views</h2>
220
- <ul>
221
- {make_link(adv["frame_advanced"], "Annotated Frame with Teams + IDs")}
222
- {make_link(adv["radar"], "Radar View")}
223
- {make_link(adv["voronoi"], "Voronoi Diagram")}
224
- {make_link(adv["voronoi_blended"], "Voronoi Diagram (Blended)")}
225
- </ul>
226
-
227
- <h2>Ball Path</h2>
228
- <ul>
229
- {make_link(ball["ball_path_raw_img"], "Ball Path (Raw)")}
230
- {make_link(ball["ball_path_cleaned_img"], "Ball Path (Cleaned)")}
231
- </ul>
232
-
233
- <h2>SigLIP Clustering</h2>
234
- <ul>
235
- {make_link(siglip_html, "Open 3D Clustering Visualization")}
236
- </ul>
237
-
238
- <h2>Stats JSON</h2>
239
- <pre>{json.dumps(stats, indent=2)}</pre>
240
-
241
- <h2>API Usage</h2>
242
- <p>Your backend can call <code>POST /analyze-video</code> with a file to get the JSON response (same structure) with URLs to all artifacts:</p>
243
- <pre>
244
- POST /analyze-video
245
- - form-data: file=@your_video.mp4
246
-
247
- Artifacts can be accessed via:
248
- GET /results/&lt;path under jobs/&gt;
249
- Example: /results/{os.path.relpath(basic["raw_frame"], BASE_RESULTS_DIR)}
250
- </pre>
251
- </body>
252
- </html>
253
  """
254
- return HTMLResponse(html)
255
-
256
-
257
- # -------------------- Public API for backend / Next.js --------------------
258
-
259
-
260
- @app.post("/analyze-video")
261
- async def analyze_video_api(file: UploadFile = File(...)):
262
- job_id = str(uuid.uuid4())
263
- job_dir = os.path.join(BASE_RESULTS_DIR, job_id)
264
- os.makedirs(job_dir, exist_ok=True)
265
-
266
- video_path = os.path.join(job_dir, file.filename)
267
- with open(video_path, "wb") as f:
268
- shutil.copyfileobj(file.file, f)
269
-
270
- # This one runs synchronously (for backend usage)
271
- result = run_full_pipeline(video_path, job_dir)
272
-
273
- def to_url(path: str) -> str:
274
- if not path:
275
- return ""
276
- rel = os.path.relpath(path, BASE_RESULTS_DIR)
277
- return f"/results/{rel}"
278
-
279
- response = {
280
- "job_id": job_id,
281
- "basic": {k: to_url(v) for k, v in result["basic"].items()},
282
- "advanced": {k: to_url(v) for k, v in result["advanced"].items()},
283
- "ball": {
284
- "ball_path_raw_img": to_url(result["ball"]["ball_path_raw_img"]),
285
- "ball_path_cleaned_img": to_url(result["ball"]["ball_path_cleaned_img"]),
286
- "ball_path_cleaned_coords": result["ball"]["ball_path_cleaned_coords"],
287
- },
288
- "stats": result["stats"],
289
- "siglip_html": to_url(result["siglip_html"]),
290
- }
291
-
292
- return JSONResponse(response)
293
-
294
-
295
- # -------------------- Serve artifacts under /results/... --------------------
296
-
297
-
298
- @app.get("/results/{path:path}")
299
- def get_result_file(path: str):
300
- full_path = os.path.join(BASE_RESULTS_DIR, path)
301
- if not os.path.exists(full_path):
302
- return PlainTextResponse("Not found", status_code=404)
303
- return FileResponse(full_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+
3
+ # Suppress inference model warnings (must be at the very top)
4
+ os.environ["CORE_MODEL_SAM_ENABLED"] = "False"
5
+ os.environ["CORE_MODEL_SAM2_ENABLED"] = "False"
6
+ os.environ["CORE_MODEL_SAM3_ENABLED"] = "False"
7
+ os.environ["CORE_MODEL_GAZE_ENABLED"] = "False"
8
+ os.environ["CORE_MODEL_GROUNDINGDINO_ENABLED"] = "False"
9
+ os.environ["CORE_MODEL_YOLO_WORLD_ENABLED"] = "False"
10
+
11
+ import gradio as gr
12
  import uuid
 
13
  import shutil
14
+ import json
15
+ import time
16
+ from pathlib import Path
17
+ from PIL import Image
 
 
 
 
 
 
 
18
 
19
+ # Import your existing pipeline
20
  from pipeline_full import run_full_pipeline
21
 
22
  BASE_RESULTS_DIR = "jobs"
23
+ Path(BASE_RESULTS_DIR).mkdir(exist_ok=True)
24
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
+ def monitor_progress(job_dir):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  """
28
+ Monitor the status.json file and yield progress updates
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  """
30
+ status_file = os.path.join(job_dir, "status.json")
31
+
32
+ last_progress = 0
33
+ last_stage = ""
34
+
35
+ # Keep checking until done or error
36
+ max_wait = 300 # 5 minutes max
37
+ start_time = time.time()
38
+
39
+ while (time.time() - start_time) < max_wait:
40
+ if os.path.exists(status_file):
41
+ try:
42
+ with open(status_file, 'r') as f:
43
+ status = json.load(f)
44
+
45
+ stage = status.get('stage', '')
46
+ progress = status.get('progress', 0)
47
+ message = status.get('message', '')
48
+
49
+ # Only yield if something changed
50
+ if stage != last_stage or progress != last_progress:
51
+ last_stage = stage
52
+ last_progress = progress
53
+
54
+ # Map stages to friendly names with emojis
55
+ stage_map = {
56
+ 'initializing': 'πŸš€ Initializing',
57
+ 'siglip': 'πŸ€– SigLIP Clustering',
58
+ 'team_classifier': 'πŸ‘• Team Classification',
59
+ 'basic_frames': '🎯 Basic Detection',
60
+ 'advanced_views': '🎨 Tactical Views',
61
+ 'ball_path': '⚽ Ball Tracking',
62
+ 'stats': 'πŸ“Š Statistics',
63
+ 'done': 'βœ… Complete',
64
+ 'error': '❌ Error'
65
+ }
66
+
67
+ friendly_stage = stage_map.get(stage, stage)
68
+ percentage = int(progress * 100)
69
+
70
+ status_msg = f"**[{percentage}%]** {friendly_stage}"
71
+ if message:
72
+ status_msg += f"\n\n_{message}_"
73
+
74
+ yield status_msg
75
+
76
+ # Stop if done or error
77
+ if stage in ['done', 'error']:
78
+ break
79
+
80
+ except Exception as e:
81
+ print(f"Error reading status: {e}")
82
+
83
+ time.sleep(1) # Poll every second
84
+
85
+ # Final status
86
+ yield "βœ… **Processing complete!** Loading results..."
87
+
88
+
89
+ def analyze_video(video_file):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  """
91
+ Main analysis function with real-time progress monitoring
92
+ """
93
+ # Initial empty outputs
94
+ empty_outputs = (None, None, None, None, None, None, None, None)
95
+
96
+ if video_file is None:
97
+ yield (*empty_outputs, "❌ **Please upload a video file**")
98
+ return
99
+
100
+ try:
101
+ # Create job directory
102
+ job_id = str(uuid.uuid4())
103
+ job_dir = os.path.join(BASE_RESULTS_DIR, job_id)
104
+ os.makedirs(job_dir, exist_ok=True)
105
+
106
+ yield (*empty_outputs, f"πŸ”§ **[0%]** Setting up job `{job_id[:8]}...`")
107
+
108
+ # Copy video file
109
+ video_input_path = video_file
110
+
111
+ if not os.path.exists(video_input_path):
112
+ yield (*empty_outputs, f"❌ **Video file not found:** `{video_input_path}`")
113
+ return
114
+
115
+ video_filename = f"input_{uuid.uuid4().hex[:8]}.mp4"
116
+ video_path = os.path.join(job_dir, video_filename)
117
+ shutil.copy2(video_input_path, video_path)
118
+
119
+ yield (*empty_outputs, f"πŸ“ **[5%]** Video copied successfully")
120
+
121
+ # Start pipeline in separate thread
122
+ import threading
123
+
124
+ pipeline_error = []
125
+ pipeline_complete = []
126
+
127
+ def run_pipeline_thread():
128
+ try:
129
+ result = run_full_pipeline(video_path, job_dir)
130
+ pipeline_complete.append(result)
131
+ except Exception as e:
132
+ pipeline_error.append(str(e))
133
+
134
+ thread = threading.Thread(target=run_pipeline_thread, daemon=True)
135
+ thread.start()
136
+
137
+ # Monitor progress while pipeline runs
138
+ for progress_msg in monitor_progress(job_dir):
139
+ yield (*empty_outputs, progress_msg)
140
+
141
+ # Wait for thread to complete
142
+ thread.join(timeout=10)
143
+
144
+ # Check for errors
145
+ if pipeline_error:
146
+ error_msg = f"""❌ **Error during analysis:**
147
+
148
+ ```
149
+ {pipeline_error[0]}
150
+ ```
151
+
152
+ **Troubleshooting:**
153
+ - Ensure ROBOFLOW_API_KEY is set in Space secrets
154
+ - Try a shorter video clip
155
+ - Check container logs for details
156
+ """
157
+ yield (*empty_outputs, error_msg)
158
+ return
159
+
160
+ # Load results
161
+ result_path = os.path.join(job_dir, "result.json")
162
+
163
+ if not os.path.exists(result_path):
164
+ yield (*empty_outputs, "⚠️ **Result file not found.** Pipeline may still be processing.")
165
+ return
166
+
167
+ with open(result_path, 'r') as f:
168
+ result = json.load(f)
169
+
170
+ # Extract results
171
+ basic = result["basic"]
172
+ adv = result["advanced"]
173
+ ball = result["ball"]
174
+ stats = result["stats"]
175
+ siglip_html = result["siglip_html"]
176
+
177
+ # Load images
178
+ def load_image(path, name="image"):
179
+ if not path or not os.path.exists(path):
180
+ print(f"⚠️ {name} not found at {path}")
181
+ return None
182
+ try:
183
+ img = Image.open(path)
184
+ if img.mode != 'RGB':
185
+ img = img.convert('RGB')
186
+ print(f"βœ… Loaded {name}: {img.size}")
187
+ return img
188
+ except Exception as e:
189
+ print(f"❌ Error loading {name}: {e}")
190
+ return None
191
+
192
+ print("=" * 60)
193
+ print("LOADING RESULT IMAGES...")
194
+
195
+ raw_frame = load_image(basic["raw_frame"], "Raw frame")
196
+ boxes_labels = load_image(basic["boxes_labels"], "Boxes/labels")
197
+ ball_players = load_image(basic["ball_players"], "Ball/players")
198
+
199
+ frame_advanced = load_image(adv["frame_advanced"], "Advanced frame")
200
+ radar = load_image(adv["radar"], "Radar")
201
+ voronoi = load_image(adv["voronoi"], "Voronoi")
202
+ voronoi_blended = load_image(adv["voronoi_blended"], "Voronoi blended")
203
+
204
+ ball_path_cleaned = load_image(ball["ball_path_cleaned_img"], "Ball path")
205
+
206
+ # Format stats
207
+ stats_text = json.dumps(stats, indent=2)
208
+
209
+ # Count loaded images
210
+ images_loaded = sum([
211
+ raw_frame is not None,
212
+ boxes_labels is not None,
213
+ ball_players is not None,
214
+ frame_advanced is not None,
215
+ radar is not None,
216
+ voronoi_blended is not None,
217
+ ball_path_cleaned is not None
218
+ ])
219
+
220
+ print(f"Images loaded: {images_loaded}/7")
221
+ print("=" * 60)
222
+
223
+ # Create success message
224
+ clustering_link = ""
225
+ if siglip_html and os.path.exists(siglip_html):
226
+ rel_path = os.path.relpath(siglip_html, ".")
227
+ clustering_link = f'\n\nπŸ“Š <a href="file/{rel_path}" target="_blank">**View 3D Clustering Visualization**</a>'
228
+
229
+ success_msg = f"""βœ… **[100%] Analysis Complete!**
230
+
231
+ **Job ID:** `{job_id}`
232
+
233
+ **Results Generated:**
234
+ - βœ… Player detections ({images_loaded}/7 images loaded)
235
+ - βœ… Team classifications
236
+ - βœ… Tactical visualizations
237
+ - βœ… Ball trajectory analysis
238
+ - βœ… Match statistics
239
+
240
+ {clustering_link}
241
+
242
+ ---
243
+ *Scroll through the tabs above to see all visualizations*
244
+
245
+ **Note:** If images appear dark, try uploading a brighter video with good lighting.
246
+ """
247
+
248
+ # Final yield with all results
249
+ yield (
250
+ raw_frame,
251
+ boxes_labels,
252
+ ball_players,
253
+ frame_advanced,
254
+ radar,
255
+ voronoi_blended,
256
+ ball_path_cleaned,
257
+ stats_text,
258
+ success_msg
259
+ )
260
+
261
+ except Exception as e:
262
+ import traceback
263
+ error_detail = traceback.format_exc()
264
+
265
+ error_msg = f"""❌ **Unexpected Error:**
266
+
267
+ ```python
268
+ {str(e)}
269
+ ```
270
+
271
+ **Debug Info:**
272
+ - Video: `{video_file if video_file else 'None'}`
273
+ - Job: `{job_dir if 'job_dir' in locals() else 'Not created'}`
274
+
275
+ **Full Traceback:**
276
+ ```
277
+ {error_detail}
278
+ ```
279
+ """
280
+ print("=" * 60)
281
+ print("UNEXPECTED ERROR:")
282
+ print(error_detail)
283
+ print("=" * 60)
284
+
285
+ yield (*empty_outputs, error_msg)
286
+
287
+
288
+ # Create Gradio interface
289
+ with gr.Blocks(
290
+ title="⚽ Afrigoals - Football Analytics",
291
+ theme=gr.themes.Soft()
292
+ ) as demo:
293
+
294
+ gr.Markdown("""
295
+ # ⚽ Afrigoals - Football Analytics Platform
296
+
297
+ AI-powered match analysis using computer vision and machine learning.
298
+
299
+ **Features:**
300
+ - 🎯 Player detection and tracking
301
+ - πŸ‘• Automatic team classification
302
+ - ⚽ Ball path tracking
303
+ - 🎨 Tactical visualizations (radar, Voronoi)
304
+ - πŸ“Š 3D clustering analysis
305
+ - πŸ“ˆ Match statistics
306
+
307
+ ---
308
+ """)
309
+
310
+ with gr.Row():
311
+ with gr.Column(scale=1):
312
+ video_input = gr.Video(
313
+ label="πŸ“Ή Upload Match Video",
314
+ sources=["upload"],
315
+ )
316
+
317
+ gr.Markdown("""
318
+ **Supported formats:** MP4, AVI, MOV, MKV
319
+
320
+ **Best results with:**
321
+ - 30-60 second clips
322
+ - Good lighting (daytime matches)
323
+ - Clear view of the pitch
324
+ - 720p or higher resolution
325
+
326
+ **Progress updates appear below** πŸ‘‡
327
+ """)
328
+
329
+ analyze_btn = gr.Button(
330
+ "πŸ” Analyze Video",
331
+ variant="primary",
332
+ size="lg"
333
+ )
334
+
335
+ with gr.Row():
336
+ with gr.Column():
337
+ gr.Markdown("### πŸ“Š Progress & Status")
338
+ status_output = gr.Markdown(
339
+ value="⏳ **Ready** - Upload a video and click Analyze to start"
340
+ )
341
+
342
+ gr.Markdown("---")
343
+ gr.Markdown("## πŸ“Š Analysis Results")
344
+ gr.Markdown("*Results will appear here after analysis (typically 30-90 seconds)*")
345
+
346
+ with gr.Tabs():
347
+ with gr.Tab("🎯 Basic Detections"):
348
+ gr.Markdown("### Player and Ball Detection Results")
349
+ with gr.Row():
350
+ with gr.Column():
351
+ gr.Markdown("#### Raw Frame")
352
+ raw_frame_output = gr.Image(
353
+ label="Original Frame",
354
+ type="pil"
355
+ )
356
+ with gr.Column():
357
+ gr.Markdown("#### Detections with Boxes")
358
+ boxes_output = gr.Image(
359
+ label="Bounding Boxes + Labels",
360
+ type="pil"
361
+ )
362
+ with gr.Row():
363
+ with gr.Column():
364
+ gr.Markdown("#### Ball vs Players")
365
+ ball_players_output = gr.Image(
366
+ label="Ball & Players Markers",
367
+ type="pil"
368
+ )
369
+
370
+ with gr.Tab("🎨 Advanced Views"):
371
+ gr.Markdown("### Tactical Analysis")
372
+ with gr.Row():
373
+ with gr.Column():
374
+ gr.Markdown("#### Annotated Frame with Teams")
375
+ advanced_output = gr.Image(
376
+ label="Teams + Player IDs",
377
+ type="pil"
378
+ )
379
+ with gr.Column():
380
+ gr.Markdown("#### Radar View")
381
+ radar_output = gr.Image(
382
+ label="Top-Down Tactical View",
383
+ type="pil"
384
+ )
385
+ with gr.Row():
386
+ with gr.Column():
387
+ gr.Markdown("#### Voronoi Diagram - Space Control")
388
+ voronoi_output = gr.Image(
389
+ label="Territory Control",
390
+ type="pil"
391
+ )
392
+
393
+ with gr.Tab("⚽ Ball Analysis"):
394
+ gr.Markdown("### Ball Movement Tracking")
395
+ with gr.Row():
396
+ with gr.Column():
397
+ gr.Markdown("#### Ball Path (Cleaned)")
398
+ ball_path_output = gr.Image(
399
+ label="Ball Trajectory",
400
+ type="pil"
401
+ )
402
+
403
+ with gr.Tab("πŸ“ˆ Statistics"):
404
+ gr.Markdown("### Match Statistics")
405
+ stats_output = gr.Code(
406
+ label="JSON Data",
407
+ language="json",
408
+ lines=25
409
+ )
410
+
411
+ # Wire up the button
412
+ analyze_btn.click(
413
+ fn=analyze_video,
414
+ inputs=[video_input],
415
+ outputs=[
416
+ raw_frame_output,
417
+ boxes_output,
418
+ ball_players_output,
419
+ advanced_output,
420
+ radar_output,
421
+ voronoi_output,
422
+ ball_path_output,
423
+ stats_output,
424
+ status_output
425
+ ]
426
+ )
427
+
428
+ gr.Markdown("""
429
+ ---
430
+ ## πŸ“ Information
431
+
432
+ ### ⏱️ Processing Time
433
+ | Video Length | Est. Time |
434
+ |--------------|-----------|
435
+ | 30 seconds | 30-60s |
436
+ | 1 minute | 60-120s |
437
+ | 2 minutes | 2-4 min |
438
+
439
+ ### πŸ”§ Technical Stack
440
+ - **Detection**: Roboflow YOLO models
441
+ - **Tracking**: ByteTrack algorithm
442
+ - **Embeddings**: SigLIP
443
+ - **Clustering**: UMAP + K-means
444
+ - **Visualization**: Supervision, Plotly, OpenCV
445
+
446
+ ### ⚠️ Requirements
447
+ - ROBOFLOW_API_KEY must be set in Space secrets
448
+ - GPU recommended for faster processing
449
+ - Good lighting improves detection quality
450
+
451
+ ---
452
+ **Built with ❀️ for African Football Analytics**
453
+ """)
454
+
455
+
456
+ # Launch
457
+ if __name__ == "__main__":
458
+ demo.queue() # Enable queuing for progress
459
+ demo.launch(
460
+ server_name="0.0.0.0",
461
+ server_port=7860,
462
+ show_error=True
463
+ )