mastefan commited on
Commit
108571e
·
verified ·
1 Parent(s): a4cdecf

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +579 -0
app.py ADDED
@@ -0,0 +1,579 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fencing Scoreboard Clips - YOLO x AutoGluon (Gradio)
2
+ # ----------------------------------------------------
3
+ # Goal (AGENTS): Build a cohesive app that: upload video -> frame timestamps ->
4
+ # YOLO scoreboard detect + gray-mask background -> color feature timeseries ->
5
+ # AutoGluon Tabular detector -> multi-event 4s clips in a Gradio gallery.
6
+ #
7
+ # Plan (AGENTS):
8
+ # 1) Load YOLO weights from HF Hub; load AutoGluon Tabular predictor from HF Hub.
9
+ # 2) For each (skipped) frame: YOLO infer -> gray-mask non-scoreboard parts
10
+ # (keep color inside any bbox with conf>=0.85), then compute red/green features.
11
+ # 3) Roll features to add z-scores/diffs. Predict with AG Tabular.
12
+ # 4) Find local events with persistence + spacing; group & cut (-2s, +2s).
13
+ # 5) Gradio UI: video in → gallery of clips + status text out.
14
+ #
15
+ # Fencing Scoreboard Clips - YOLO x AutoGluon (Gradio)
16
+
17
+ import os, sys, zipfile, shutil, subprocess, tempfile, pathlib
18
+ from typing import List, Tuple
19
+ import uuid
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+ import cv2
24
+ import gradio as gr
25
+
26
+ # ---- Robust imports/installs for Colab/Spaces ----
27
+ def _pip(pkgs: List[str]):
28
+ import subprocess, sys
29
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", *pkgs])
30
+
31
+ try:
32
+ import ultralytics
33
+ except:
34
+ _pip(["ultralytics"])
35
+ import ultralytics
36
+
37
+ try:
38
+ import ffmpeg # optional helper for duration probe
39
+ except:
40
+ try:
41
+ _pip(["ffmpeg-python"])
42
+ import ffmpeg
43
+ except Exception:
44
+ ffmpeg = None
45
+
46
+ try:
47
+ from autogluon.tabular import TabularPredictor
48
+ except:
49
+ _pip(["autogluon.tabular"])
50
+ from autogluon.tabular import TabularPredictor
51
+
52
+ try:
53
+ from huggingface_hub import hf_hub_download
54
+ except:
55
+ _pip(["huggingface_hub"])
56
+ from huggingface_hub import hf_hub_download
57
+
58
+ from ultralytics import YOLO
59
+
60
+ # ----------------------------
61
+ # Config — HF Hub repositories
62
+ # ----------------------------
63
+ # YOLO scoreboard detector weights (pushed by your training file)
64
+ YOLO_REPO_ID = os.getenv("YOLO_REPO_ID", "mastefan/fencing-scoreboard-yolov8")
65
+ YOLO_FILENAME = os.getenv("YOLO_FILENAME", "best.pt")
66
+
67
+ # AutoGluon Tabular detector (your color/timeseries model zip)
68
+ AG_REPO_ID = os.getenv("AG_REPO_ID", "emkessle/2024-24679-fencing-touch-predictor")
69
+ AG_ZIP_NAME = os.getenv("AG_ZIP_NAME", "autogluon_predictor_dir.zip")
70
+
71
+ # Processing parameters
72
+ FRAME_SKIP = int(os.getenv("FRAME_SKIP", "2")) # process every Nth frame
73
+ KEEP_CONF = float(os.getenv("KEEP_CONF", "0.85"))# YOLO conf to keep color inside bbox
74
+ YOLO_CONF = float(os.getenv("YOLO_CONF", "0.25"))
75
+ YOLO_IOU = float(os.getenv("YOLO_IOU", "0.50"))
76
+ MIN_SEP_S = float(os.getenv("MIN_SEP_S", "1.2")) # min gap between events (s)
77
+ CLIP_PAD_S = float(os.getenv("CLIP_PAD_S","2.0")) # before/after padding each hit
78
+ GROUP_GAP_S = float(os.getenv("GROUP_GAP_S","1.5"))# cluster close frames to single event
79
+
80
+ # ----------------
81
+ # Model loaders
82
+ # ----------------
83
+ CACHE_DIR = pathlib.Path("hf_assets")
84
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
85
+
86
+ def load_yolo_from_hub() -> YOLO:
87
+ w = hf_hub_download(repo_id=YOLO_REPO_ID, filename=YOLO_FILENAME, cache_dir=CACHE_DIR)
88
+ return YOLO(w)
89
+
90
+ def load_autogluon_tabular_from_hub() -> TabularPredictor:
91
+ z = hf_hub_download(repo_id=AG_REPO_ID, filename=AG_ZIP_NAME, cache_dir=CACHE_DIR)
92
+ extract_dir = CACHE_DIR / "ag_predictor_native"
93
+ if extract_dir.exists():
94
+ shutil.rmtree(extract_dir)
95
+ with zipfile.ZipFile(z, "r") as zip_ref:
96
+ zip_ref.extractall(extract_dir)
97
+ return TabularPredictor.load(str(extract_dir))
98
+
99
+ _YOLO = None
100
+ _AG_PRED = None
101
+
102
+ def yolo() -> YOLO:
103
+ global _YOLO
104
+ if _YOLO is None:
105
+ _YOLO = load_yolo_from_hub()
106
+ return _YOLO
107
+
108
+ def ag_predictor() -> TabularPredictor:
109
+ global _AG_PRED
110
+ if _AG_PRED is None:
111
+ _AG_PRED = load_autogluon_tabular_from_hub()
112
+ return _AG_PRED
113
+
114
+ # ----------------------------
115
+ # Vision helpers
116
+ # ----------------------------
117
+ DEBUG_DIR = pathlib.Path("debug_frames")
118
+ DEBUG_DIR.mkdir(exist_ok=True)
119
+
120
+ def isolate_scoreboard_color(frame_bgr: np.ndarray,
121
+ conf: float = YOLO_CONF,
122
+ iou: float = YOLO_IOU,
123
+ keep_conf: float = KEEP_CONF,
124
+ debug: bool = False,
125
+ frame_id: int = None) -> np.ndarray:
126
+ """
127
+ Reverted version:
128
+ - Choose the largest bbox among candidates meeting confidence.
129
+ - Primary threshold: >= max(0.80, keep_conf)
130
+ - Fallback threshold: >= (primary - 0.02) (i.e., ~0.78 by default)
131
+ - Entire chosen bbox is restored to color; everything else is grayscale.
132
+ - Single safeguard: reject very low-saturation ROIs (likely flat/neutral areas).
133
+ """
134
+ H, W = frame_bgr.shape[:2]
135
+
136
+ # start fully grayscale
137
+ gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
138
+ gray = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
139
+
140
+ primary_thr = max(0.80, keep_conf) # accept ≥0.80 as "good"
141
+ fallback_thr = max(0.7, primary_thr - 0.05) # accept ≥0.75 as fallback
142
+
143
+
144
+ chosen_box = None
145
+ res = yolo().predict(frame_bgr, conf=conf, iou=iou, verbose=False)
146
+ if len(res):
147
+ r = res[0]
148
+ if getattr(r, "boxes", None) is not None and len(r.boxes) > 0:
149
+ boxes = r.boxes.xyxy.cpu().numpy()
150
+ scores = r.boxes.conf.cpu().numpy()
151
+ candidates = list(zip(boxes, scores))
152
+
153
+ # Prefer largest box that meets primary threshold
154
+ strong = [(b, s) for (b, s) in candidates if float(s) >= primary_thr]
155
+ if strong:
156
+ chosen_box, _ = max(strong, key=lambda bs: (bs[0][2]-bs[0][0]) * (bs[0][3]-bs[0][1]))
157
+ else:
158
+ # Fallback: largest box meeting fallback threshold
159
+ medium = [(b, s) for (b, s) in candidates if float(s) >= fallback_thr]
160
+ if medium:
161
+ chosen_box, _ = max(medium, key=lambda bs: (bs[0][2]-bs[0][0]) * (bs[0][3]-bs[0][1]))
162
+
163
+ if chosen_box is not None:
164
+ x1, y1, x2, y2 = [int(round(v)) for v in chosen_box]
165
+ x1, y1 = max(0, x1), max(0, y1)
166
+ x2, y2 = min(W-1, x2), min(H-1, y2)
167
+
168
+ if x2 > x1 and y2 > y1:
169
+ # Single safeguard: reject very low-saturation ROIs
170
+ roi_color = frame_bgr[y1:y2, x1:x2]
171
+ if roi_color.size > 0:
172
+ hsv = cv2.cvtColor(roi_color, cv2.COLOR_BGR2HSV)
173
+ sat_mean = hsv[:, :, 1].mean()
174
+ if sat_mean < 25: # flat/neutral area → reject
175
+ print(f"[WARN] Rejected bbox due to low saturation (mean={sat_mean:.1f})")
176
+ chosen_box = None
177
+
178
+ # If accepted, restore whole bbox to color
179
+ if chosen_box is not None:
180
+ gray[y1:y2, x1:x2] = frame_bgr[y1:y2, x1:x2]
181
+
182
+ # Optional debug save
183
+ if debug and frame_id is not None:
184
+ dbg = gray.copy()
185
+ if chosen_box is not None:
186
+ x1, y1, x2, y2 = [int(round(v)) for v in chosen_box]
187
+ cv2.rectangle(dbg, (x1, y1), (x2, y2), (0, 255, 0), 2)
188
+ out_path = DEBUG_DIR / f"frame_{frame_id:06d}.jpg"
189
+ cv2.imwrite(str(out_path), dbg)
190
+ print(f"[DEBUG] Saved debug frame → {out_path}")
191
+
192
+ return gray
193
+
194
+
195
+ # Color features
196
+ def _count_color_pixels(rgb: np.ndarray, ch: int,
197
+ red_thresh=150, green_thresh=100,
198
+ red_dom=1.2, green_dom=1.05) -> int:
199
+ R, G, B = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
200
+ if ch == 0:
201
+ mask = (R > red_thresh) & (R > red_dom*G) & (R > red_dom*B)
202
+ elif ch == 1:
203
+ mask = (G > green_thresh) & (G > green_dom*R) & (G > green_dom*B)
204
+ else:
205
+ raise ValueError("ch must be 0 (red) or 1 (green)")
206
+ return int(np.sum(mask))
207
+
208
+ def color_pixel_ratio(rgb: np.ndarray, ch: int) -> float:
209
+ return _count_color_pixels(rgb, ch) / float(rgb.shape[0]*rgb.shape[1] + 1e-9)
210
+
211
+ def rolling_z(series: pd.Series, win: int = 45) -> pd.Series:
212
+ med = series.rolling(win, min_periods=5).median()
213
+ mad = series.rolling(win, min_periods=5).apply(
214
+ lambda x: np.median(np.abs(x - np.median(x))), raw=True
215
+ )
216
+ mad = mad.replace(0, mad[mad > 0].min() if (mad > 0).any() else 1.0)
217
+ return (series - med) / mad
218
+
219
+ # ----------------------------
220
+ # Video → feature table
221
+ # ----------------------------
222
+ def extract_feature_timeseries(video_path: str,
223
+ frame_skip: int = FRAME_SKIP,
224
+ debug: bool = False) -> Tuple[pd.DataFrame, float]:
225
+ print("[INFO] Starting frame extraction...")
226
+ cap = cv2.VideoCapture(video_path)
227
+ if not cap.isOpened():
228
+ print("[ERROR] Could not open video.")
229
+ return pd.DataFrame(), 0.0
230
+
231
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
232
+ records, frame_idx = [], 0
233
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
234
+
235
+ while True:
236
+ ret, frame = cap.read()
237
+ if not ret:
238
+ break
239
+ if frame_idx % frame_skip == 0:
240
+ ts = frame_idx / fps
241
+ masked = isolate_scoreboard_color(frame, debug=debug, frame_id=frame_idx)
242
+ rgb = cv2.cvtColor(masked, cv2.COLOR_BGR2RGB)
243
+ red_ratio = color_pixel_ratio(rgb, 0)
244
+ green_ratio = color_pixel_ratio(rgb, 1)
245
+ records.append({
246
+ "frame_id": frame_idx,
247
+ "timestamp": ts,
248
+ "red_ratio": red_ratio,
249
+ "green_ratio": green_ratio,
250
+ })
251
+ frame_idx += 1
252
+
253
+ cap.release()
254
+ df = pd.DataFrame(records)
255
+ print(f"[INFO] Processed {len(df)} frames out of {total_frames} (fps={fps:.2f})")
256
+
257
+ if df.empty:
258
+ return df, fps
259
+
260
+ df["red_diff"] = df["red_ratio"].diff().fillna(0)
261
+ df["green_diff"] = df["green_ratio"].diff().fillna(0)
262
+ df["z_red"] = rolling_z(df["red_ratio"])
263
+ df["z_green"] = rolling_z(df["green_ratio"])
264
+
265
+ if debug:
266
+ out_csv = DEBUG_DIR / f"features_{uuid.uuid4().hex}.csv"
267
+ df.to_csv(out_csv, index=False)
268
+ print(f"[DEBUG] Saved feature CSV → {out_csv}")
269
+
270
+ return df, fps
271
+
272
+ # ----------------------------
273
+ # AutoGluon inference + event picking
274
+ # ----------------------------
275
+ def predict_scores(df: pd.DataFrame) -> pd.Series:
276
+ feat_cols = ["red_ratio", "green_ratio", "red_diff", "green_diff", "z_red", "z_green"]
277
+ X = df[feat_cols].copy()
278
+ pred = ag_predictor().predict(X)
279
+
280
+ # Prefer classification proba if available
281
+ try:
282
+ proba = ag_predictor().predict_proba(X)
283
+ if isinstance(proba, pd.DataFrame) and (1 in proba.columns):
284
+ return proba[1]
285
+ except Exception:
286
+ pass
287
+
288
+ # Fallback: normalize regression-like output to 0..1 robustly
289
+ s = pd.Series(pred).astype(float)
290
+ rng = (s.quantile(0.95) - s.quantile(0.05)) or 1.0
291
+ return ((s - s.quantile(0.05)) / rng).clip(0, 1)
292
+
293
+ def pick_events(df: pd.DataFrame, score: pd.Series, fps: float) -> List[float]:
294
+ """
295
+ Adaptive hybrid event detection:
296
+ - Adaptive raw threshold = 0.7 × max score
297
+ - Adaptive z-threshold = max(2.0, 0.6 × max z-score)
298
+ - Must be a local peak
299
+ - Enforce min spacing (1.0s) and group gap (1.5s)
300
+ - Ignore any detections before 1.0s
301
+ """
302
+ max_score = score.max()
303
+ raw_cutoff = 0.7 * max_score if max_score > 0 else 0.4
304
+
305
+ z = rolling_z(score, win=45)
306
+ max_z = z.max()
307
+ z_cutoff = max(2.0, 0.6 * max_z)
308
+
309
+ print(f"[DEBUG] Predictor score stats: min={score.min():.3f}, max={max_score:.3f}, mean={score.mean():.3f}")
310
+ print(f"[DEBUG] Adaptive thresholds: raw>{raw_cutoff:.3f}, z>{z_cutoff:.2f}")
311
+
312
+ out_times = []
313
+ min_dist_frames = max(1, int(1.0 * max(1.0, fps))) # 1.0s spacing
314
+ y = score.values
315
+ last_kept = -min_dist_frames
316
+
317
+ for i in range(1, len(y)-1):
318
+ ts = float(df.iloc[i]["timestamp"])
319
+ local_peak = y[i] > y[i-1] and y[i] > y[i+1]
320
+ if ts >= 1.0 and ((z.iloc[i] > z_cutoff) or (y[i] > raw_cutoff)) and local_peak and (i - last_kept) >= min_dist_frames:
321
+ out_times.append(ts)
322
+ last_kept = i
323
+
324
+ if not out_times and len(y) > 0:
325
+ best_idx = int(np.argmax(y))
326
+ ts = float(df.iloc[best_idx]["timestamp"])
327
+ if ts >= 1.0:
328
+ out_times = [ts]
329
+ print(f"[DEBUG] Fallback → using global max at {ts:.2f}s")
330
+ else:
331
+ print(f"[DEBUG] Ignored fallback at {ts:.2f}s (within first second)")
332
+
333
+ out_times.sort()
334
+
335
+ grouped = []
336
+ for t in out_times:
337
+ if (not grouped) or (t - grouped[-1]) > GROUP_GAP_S:
338
+ grouped.append(t)
339
+
340
+ print(f"[DEBUG] Final detected events: {grouped}")
341
+ return grouped
342
+
343
+ def save_event_snapshot(video_path: str, timestamp: float, out_path: str, fps: float):
344
+ """Save a snapshot frame at timestamp with YOLO bbox drawn."""
345
+ cap = cv2.VideoCapture(video_path)
346
+ if not cap.isOpened():
347
+ print("[ERROR] Could not open video for snapshot.")
348
+ return None
349
+
350
+ frame_idx = int(timestamp * fps)
351
+ cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
352
+ ret, frame = cap.read()
353
+ cap.release()
354
+
355
+ if not ret or frame is None:
356
+ print(f"[WARN] Could not grab frame at {timestamp:.2f}s")
357
+ return None
358
+
359
+ masked = isolate_scoreboard_color(frame, debug=False)
360
+ res = yolo().predict(frame, conf=YOLO_CONF, iou=YOLO_IOU, verbose=False)
361
+
362
+ if len(res) and getattr(res[0], "boxes", None) is not None and len(res[0].boxes) > 0:
363
+ boxes = res[0].boxes.xyxy.cpu().numpy()
364
+ scores = res[0].boxes.conf.cpu().numpy()
365
+ valid = [(box, score) for box, score in zip(boxes, scores) if float(score) >= KEEP_CONF]
366
+ if valid:
367
+ largest, _ = max(valid, key=lambda bs: (bs[0][2]-bs[0][0])*(bs[0][3]-bs[0][1]))
368
+ x1, y1, x2, y2 = [int(round(v)) for v in largest]
369
+ cv2.rectangle(masked, (x1, y1), (x2, y2), (0, 255, 0), 3)
370
+
371
+ cv2.imwrite(out_path, masked)
372
+ print(f"[DEBUG] Saved snapshot → {out_path}")
373
+ return out_path
374
+
375
+ import matplotlib.pyplot as plt
376
+ def save_debug_plot(df: pd.DataFrame, score: pd.Series, events: List[float], base_name="debug_plot"):
377
+ plt.figure(figsize=(12, 5))
378
+ plt.plot(df["timestamp"], score, label="Predicted Score")
379
+ plt.axhline(y=0.5, color="gray", linestyle="--", alpha=0.5)
380
+ first = True
381
+ for ev in events:
382
+ plt.axvline(x=ev, color="red", linestyle="--", label="Detected Event" if first else None)
383
+ first = False
384
+ plt.xlabel("Time (s)")
385
+ plt.ylabel("Score")
386
+ plt.title("AutoGluon Score vs Time")
387
+ plt.legend()
388
+ out_path = DEBUG_DIR / f"{base_name}.png"
389
+ plt.savefig(out_path)
390
+ plt.close()
391
+ print(f"[DEBUG] Saved debug score plot → {out_path}")
392
+
393
+ # ----------------------------
394
+ # Clip cutting (ffmpeg w/ moviepy fallback)
395
+ # ----------------------------
396
+ def _probe_duration(video_path: str) -> float:
397
+ try:
398
+ if ffmpeg is None:
399
+ raise RuntimeError("ffmpeg-python not available")
400
+ meta = ffmpeg.probe(video_path)
401
+ return float(meta["format"]["duration"])
402
+ except Exception:
403
+ return 0.0
404
+
405
+ def cut_clip(video_path: str, start: float, end: float, out_path: str) -> str:
406
+ # Fast path (copy) if ffmpeg available
407
+ try:
408
+ cmd = ["ffmpeg", "-y", "-ss", str(max(0, start)), "-to", str(max(start, end)),
409
+ "-i", video_path, "-c", "copy", out_path]
410
+ sp = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
411
+ if sp.returncode == 0 and os.path.exists(out_path):
412
+ return out_path
413
+ except Exception:
414
+ pass
415
+
416
+ # Fallback: moviepy re-encode
417
+ from moviepy.editor import VideoFileClip
418
+ clip = VideoFileClip(video_path).subclip(max(0, start), max(start, end))
419
+ clip.write_videofile(out_path, codec="libx264", audio_codec="aac", verbose=False, logger=None)
420
+ return out_path
421
+
422
+ # ----------------------------
423
+ # Orchestrator: detect + cut + debug
424
+ # ----------------------------
425
+ def extract_score_clips(video_path: str, debug: bool = True) -> Tuple[List[Tuple[str, str]], str]:
426
+ print("[INFO] Running full detection pipeline...")
427
+ df, fps = extract_feature_timeseries(video_path, frame_skip=FRAME_SKIP, debug=debug)
428
+ if df.empty:
429
+ print("[WARN] Empty dataframe — no frames processed.")
430
+ return [], "No frames processed."
431
+
432
+ print("[INFO] Feature extraction done. Running predictor...")
433
+ score = predict_scores(df)
434
+
435
+ # Bail early if the model produced no signal at all
436
+ if score.max() <= 1e-6:
437
+ print("[WARN] Flat scores from predictor (possible YOLO miss or feature mismatch).")
438
+ return [], "⚠️ No scoreboard detected or illumination scores flat. Please check video or model."
439
+
440
+ print("[INFO] Picking events from predictor scores...")
441
+ events = pick_events(df, score, fps)
442
+ print(f"[INFO] Picked {len(events)} event(s): {events}")
443
+
444
+ if not events:
445
+ topk = np.argsort(score.values)[-5:][::-1]
446
+ dbg = [(float(df.iloc[i]['timestamp']), float(score.iloc[i])) for i in topk]
447
+ print(f"[DEBUG] Top-5 peaks (ts,score): {dbg}")
448
+ return [], "⚠️ No touches confidently detected in this video."
449
+
450
+ duration = _probe_duration(video_path)
451
+ if duration <= 0:
452
+ duration = float(df["timestamp"].max() + CLIP_PAD_S + 0.5)
453
+
454
+ clips = []
455
+ snapshots = []
456
+ base = os.path.splitext(os.path.basename(video_path))[0]
457
+ for i, t in enumerate(events):
458
+ s = max(0.0, t - CLIP_PAD_S)
459
+ e = min(duration, t + CLIP_PAD_S)
460
+ clip_path = os.path.join(tempfile.gettempdir(), f"{base}_score_{i+1:02d}.mp4")
461
+ img_path = os.path.join(tempfile.gettempdir(), f"{base}_score_{i+1:02d}.jpg")
462
+ cut_clip(video_path, s, e, clip_path)
463
+ save_event_snapshot(video_path, t, img_path, fps)
464
+ label = f"Touch {i+1} @ {t:.2f}s"
465
+ clips.append((clip_path, label))
466
+ snapshots.append(img_path)
467
+
468
+ if debug:
469
+ debug_csv = DEBUG_DIR / f"scores_{base}.csv"
470
+ pd.DataFrame({"timestamp": df["timestamp"], "score": score}).to_csv(debug_csv, index=False)
471
+ print(f"[DEBUG] Saved score debug CSV → {debug_csv}")
472
+ save_debug_plot(df, score, events, base_name=base)
473
+ print(f"[DEBUG] Saved debug frames in {DEBUG_DIR}/")
474
+
475
+ return clips, f"✅ Detected {len(clips)} event(s). Snapshots saved to temp."
476
+
477
+ # ----------------------------
478
+ # Gradio UI
479
+ # ----------------------------
480
+ CSS = """
481
+ .gradio-container {max-width: 900px; margin: auto;}
482
+ .header {text-align: center; margin-bottom: 20px;}
483
+ .full-width {width: 100% !important;}
484
+ .progress-bar {
485
+ width: 100%;
486
+ height: 30px;
487
+ background-color: #e0e0e0;
488
+ border-radius: 15px;
489
+ margin: 15px 0;
490
+ position: relative;
491
+ overflow: hidden;
492
+ }
493
+ .progress-fill {
494
+ height: 100%;
495
+ background-color: #4CAF50;
496
+ border-radius: 15px;
497
+ text-align: center;
498
+ line-height: 30px;
499
+ color: white;
500
+ font-weight: bold;
501
+ transition: width 0.3s;
502
+ }
503
+ .fencer {
504
+ position: absolute;
505
+ top: -5px;
506
+ font-size: 24px;
507
+ transition: left 0.3s;
508
+ transform: scaleX(-1); /* flip to face right */
509
+ }
510
+ """
511
+
512
+ def _make_progress_bar(percent: int, final_text: str = None):
513
+ text = f"{percent}%" if not final_text else final_text
514
+ return f"""
515
+ <div class="progress-bar">
516
+ <div id="progress-fill" class="progress-fill" style="width:{percent}%">{text}</div>
517
+ <div id="fencer" class="fencer" style="left:{percent}%">🤺</div>
518
+ </div>
519
+ """
520
+
521
+ def run_with_progress(video_file):
522
+ if not video_file:
523
+ yield [], "Please upload a video file.", gr.update(visible=False)
524
+ return
525
+
526
+ # Step 1: Extract frames + features
527
+ yield [], "🔄 Extracting frames...", _make_progress_bar(20)
528
+ df, fps = extract_feature_timeseries(video_file, frame_skip=FRAME_SKIP, debug=False)
529
+ if df.empty:
530
+ yield [], "❌ No frames processed!", _make_progress_bar(100, "No Frames ❌")
531
+ return
532
+
533
+ # Step 2–4: Predict & pick events via the single orchestrator
534
+ yield [], "🔄 Scoring & detecting touches...", _make_progress_bar(80)
535
+ clips, status_msg = extract_score_clips(video_file, debug=True)
536
+
537
+ # Step 5: Done (and cutting already handled in orchestrator)
538
+ final_bar = _make_progress_bar(100, f"Detected {len(clips)} Touches ⚡" if clips else "No Touches")
539
+ yield clips, status_msg, final_bar
540
+
541
+ with gr.Blocks(css=CSS, title="Fencing Scoreboard Detector") as demo:
542
+ with gr.Row(elem_classes="header"):
543
+ gr.Markdown(
544
+ "## 🤺 Fencing Score Detector\n"
545
+ "Upload a fencing bout video. We’ll detect scoreboard lights (YOLO + AutoGluon), "
546
+ "and return 4-second highlight clips around each scoring event."
547
+ )
548
+
549
+ in_video = gr.Video(label="Upload Bout Video", elem_classes="full-width", height=400)
550
+ run_btn = gr.Button("⚡ Detect Touches", elem_classes="full-width")
551
+
552
+ progress_html = gr.HTML(value="", label="Processing Progress", visible=False)
553
+ status = gr.Markdown("Ready.")
554
+ gallery = gr.Gallery(
555
+ label="Detected Clips",
556
+ columns=1,
557
+ height=400,
558
+ preview=True,
559
+ allow_preview=True,
560
+ show_download_button=True,
561
+ visible=False
562
+ )
563
+
564
+ def wrapped_run(video_file):
565
+ # Show progress bar at start
566
+ yield gr.update(value=[], visible=False), "Processing started...", gr.update(value=_make_progress_bar(0), visible=True)
567
+ # Pipeline with progress
568
+ for clips, msg, bar in run_with_progress(video_file):
569
+ gallery_update = gr.update(value=clips, visible=bool(clips))
570
+ yield gallery_update, msg, gr.update(value=bar, visible=True)
571
+
572
+ run_btn.click(
573
+ fn=wrapped_run,
574
+ inputs=in_video,
575
+ outputs=[gallery, status, progress_html],
576
+ )
577
+
578
+ if __name__ == "__main__":
579
+ demo.launch(debug=True)