mastefan commited on
Commit
1608755
·
verified ·
1 Parent(s): 8d175d8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +318 -148
app.py CHANGED
@@ -14,76 +14,125 @@
14
  #
15
  # Fencing Scoreboard Clips - YOLO x AutoGluon (Gradio)
16
 
17
- import gradio as gr
18
- import cv2
 
 
 
 
 
19
  import numpy as np
20
- import pathlib
21
- import zipfile
22
- import shutil
23
  import pandas as pd
24
- import subprocess
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  from ultralytics import YOLO
26
- from autogluon.tabular import TabularPredictor
27
- from huggingface_hub import hf_hub_download
28
 
29
- # -------------------
30
- # Globals and setup
31
- # -------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  CACHE_DIR = pathlib.Path("hf_assets")
33
- CACHE_DIR.mkdir(exist_ok=True)
 
34
  DEBUG_DIR = pathlib.Path("debug_frames")
35
  DEBUG_DIR.mkdir(exist_ok=True)
36
 
37
- YOLO_CONF = 0.25
38
- YOLO_IOU = 0.45
39
- KEEP_CONF = 0.70
40
- CLIP_PAD_S = 2.0
41
- GROUP_GAP_S = 1.0
42
-
43
- AG_REPO_ID = "your-hf-username/your-predictor-repo"
44
- AG_ZIP_NAME = "predictor_native.zip"
45
-
46
- _yolo_model = None
47
- _ag_predictor = None
48
-
49
- def yolo():
50
- global _yolo_model
51
- if _yolo_model is None:
52
- print("[INFO] Loading YOLO model...")
53
- _yolo_model = YOLO("yolov8n.pt") # replace with your fine-tuned model path
54
- return _yolo_model
55
-
56
- def ag_predictor():
57
- global _ag_predictor
58
- if _ag_predictor is None:
59
- print("[INFO] Loading AutoGluon predictor from HF Hub...")
60
- z = hf_hub_download(repo_id=AG_REPO_ID, filename=AG_ZIP_NAME, cache_dir=CACHE_DIR)
61
- extract_dir = CACHE_DIR / "ag_predictor_native"
62
- if extract_dir.exists():
63
- shutil.rmtree(extract_dir)
64
- with zipfile.ZipFile(z, "r") as zip_ref:
65
- zip_ref.extractall(extract_dir)
66
- _ag_predictor = TabularPredictor.load(
67
- str(extract_dir),
68
- require_version_match=False,
69
- require_py_version_match=False
70
- )
71
- return _ag_predictor
72
-
73
- # -------------------
74
- # Scoreboard isolation
75
- # -------------------
76
  def isolate_scoreboard_color(frame_bgr: np.ndarray,
77
  conf: float = YOLO_CONF,
78
  iou: float = YOLO_IOU,
79
  keep_conf: float = KEEP_CONF,
80
  debug: bool = False,
81
  frame_id: int = None) -> np.ndarray:
 
 
 
 
 
 
 
82
  H, W = frame_bgr.shape[:2]
83
  gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
84
  gray = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
85
 
86
- primary_thr = max(0.70, keep_conf)
87
  fallback_thr = max(0.65, primary_thr - 0.05)
88
 
89
  chosen_box = None
@@ -91,7 +140,7 @@ def isolate_scoreboard_color(frame_bgr: np.ndarray,
91
  if len(res):
92
  r = res[0]
93
  if getattr(r, "boxes", None) is not None and len(r.boxes) > 0:
94
- boxes = r.boxes.xyxy.cpu().numpy()
95
  scores = r.boxes.conf.cpu().numpy()
96
  candidates = list(zip(boxes, scores))
97
 
@@ -114,92 +163,240 @@ def isolate_scoreboard_color(frame_bgr: np.ndarray,
114
  dbg = gray.copy()
115
  if chosen_box is not None:
116
  x1, y1, x2, y2 = [int(round(v)) for v in chosen_box]
117
- cv2.rectangle(dbg, (x1, y1), (x2, y2), (0, 255, 0), 2)
118
  out_path = DEBUG_DIR / f"frame_{frame_id:06d}.jpg"
119
  cv2.imwrite(str(out_path), dbg)
120
  print(f"[DEBUG] Saved debug frame → {out_path}")
121
 
122
  return gray
123
 
124
- # -------------------
125
- # Event picking
126
- # -------------------
127
- def pick_events(df: pd.DataFrame, score: pd.Series, fps: float) -> list:
128
- # simple hybrid threshold (as tuned earlier)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  max_score = score.max()
130
  raw_cutoff = 0.7 * max_score if max_score > 0 else 0.4
131
- z = (score - score.rolling(45, min_periods=1).mean()) / (score.rolling(45, min_periods=1).std()+1e-9)
132
- z_cutoff = max(2.0, 0.6 * z.max())
 
133
 
 
 
 
 
 
134
  out_times = []
135
- for i in range(1, len(score)-1):
136
- ts = float(df.iloc[i]["timestamp"])
137
- if ((score.iloc[i] > raw_cutoff) or (z.iloc[i] > z_cutoff)):
138
- if score.iloc[i] > score.iloc[i-1] and score.iloc[i] > score.iloc[i+1]:
139
- if ts >= 1.0: # guard against first second
140
- out_times.append(ts)
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  grouped = []
143
  for t in out_times:
144
  if (not grouped) or (t - grouped[-1]) > GROUP_GAP_S:
145
  grouped.append(t)
 
146
  return grouped
147
 
148
- # -------------------
149
- # Video clipping
150
- # -------------------
151
- def cut_clip(video_path, start, end, out_path):
152
- cmd = [
153
- "ffmpeg", "-y", "-i", str(video_path),
154
- "-ss", str(start), "-to", str(end),
155
- "-c", "copy", str(out_path)
156
- ]
157
- subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
158
-
159
- def extract_score_clips(video_path: str, debug: bool = False):
160
- cap = cv2.VideoCapture(video_path)
161
- if not cap.isOpened():
162
- return [], " Could not open video."
163
-
164
- fps = cap.get(cv2.CAP_PROP_FPS)
165
- frames = []
166
- timestamps = []
167
- idx = 0
168
- while True:
169
- ret, frame = cap.read()
170
- if not ret:
171
- break
172
- ts = idx / fps
173
- masked = isolate_scoreboard_color(frame, debug=debug, frame_id=idx if debug else None)
174
- red_ratio = float((masked[:,:,2] > 150).mean()) # crude feature
175
- frames.append([ts, red_ratio])
176
- timestamps.append(ts)
177
- idx += 1
178
- cap.release()
179
-
180
- if not frames:
181
- return [], "⚠️ No frames processed."
182
-
183
- df = pd.DataFrame(frames, columns=["timestamp","red_ratio"])
184
- pred = ag_predictor().predict_proba(df[["red_ratio"]])
185
- score = pd.Series(pred[1].values, index=df.index)
 
186
 
187
  events = pick_events(df, score, fps)
188
  if not events:
189
  return [], "⚠️ No touches confidently detected in this video."
190
 
 
 
 
 
191
  clips = []
192
- for i, t in enumerate(events, 1):
193
- s = max(0.0, t - CLIP_PAD_S)
194
- e = min(df["timestamp"].max(), t + CLIP_PAD_S)
195
- out_path = f"clip_{i}.mp4"
196
- cut_clip(video_path, s, e, out_path)
197
- clips.append((out_path, f"Touch at {t:.2f}s"))
198
- return clips, f"✅ Detected {len(events)} touches."
199
-
200
- # -------------------
201
- # Progress bar builder
202
- # -------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  def _make_progress_bar(percent: int, final_text: str = None):
204
  text = f"{percent}%" if not final_text else final_text
205
  return f"""
@@ -209,38 +406,11 @@ def _make_progress_bar(percent: int, final_text: str = None):
209
  </div>
210
  """
211
 
212
- # -------------------
213
- # Wrapped run (step-based)
214
- # -------------------
215
- def wrapped_run(video_file):
216
  if not video_file:
217
- yield gr.update(value=[], visible=False), "Please upload a video file.", gr.update(value="", visible=False)
218
  return
219
-
220
- yield gr.update(value=[], visible=False), "Processing started...", gr.update(value=_make_progress_bar(10), visible=True)
221
- yield gr.update(value=[], visible=False), "Extracting frames...", gr.update(value=_make_progress_bar(40), visible=True)
222
- yield gr.update(value=[], visible=False), "Running predictor...", gr.update(value=_make_progress_bar(70), visible=True)
223
-
224
- clips, status_msg = extract_score_clips(video_file, debug=False)
225
- final_bar = _make_progress_bar(100, "✅ Done")
226
- yield gr.update(value=clips, visible=bool(clips)), status_msg, gr.update(value=final_bar, visible=True)
227
-
228
- # -------------------
229
- # Gradio UI
230
- # -------------------
231
- with gr.Blocks() as demo:
232
- gr.Markdown("## 🤺 Fencing Score Detector\nUpload a bout video and detect touches.")
233
-
234
- in_video = gr.Video(label="Upload Bout Video") # fixed: no type="filepath"
235
- run_btn = gr.Button("Detect Touches", elem_id="detect-btn")
236
-
237
- status = gr.Markdown("Status messages will appear here.")
238
- progress_html = gr.HTML("")
239
- gallery = gr.Gallery(label="Detected Clips", visible=False)
240
-
241
- run_btn.click(fn=wrapped_run, inputs=in_video, outputs=[gallery, status, progress_html])
242
-
243
- if __name__ == "__main__":
244
- demo.queue(max_size=20)
245
- demo.launch(debug=True)
246
 
 
14
  #
15
  # Fencing Scoreboard Clips - YOLO x AutoGluon (Gradio)
16
 
17
+ # -*- coding: utf-8 -*-
18
+ # Fencing Scoreboard Clips - YOLO x AutoGluon (Gradio)
19
+
20
+ import os, sys, zipfile, shutil, subprocess, tempfile, pathlib
21
+ from typing import List, Tuple
22
+ import uuid
23
+
24
  import numpy as np
 
 
 
25
  import pandas as pd
26
+ import cv2
27
+ import gradio as gr
28
+
29
+ def _pip(pkgs: List[str]):
30
+ import subprocess, sys
31
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", *pkgs])
32
+
33
+ try:
34
+ import ultralytics
35
+ except:
36
+ _pip(["ultralytics"])
37
+ import ultralytics
38
+
39
+ try:
40
+ import ffmpeg
41
+ except:
42
+ try:
43
+ _pip(["ffmpeg-python"])
44
+ import ffmpeg
45
+ except:
46
+ ffmpeg = None
47
+
48
+ try:
49
+ from autogluon.tabular import TabularPredictor
50
+ except:
51
+ _pip(["autogluon.tabular"])
52
+ from autogluon.tabular import TabularPredictor
53
+
54
+ try:
55
+ from huggingface_hub import hf_hub_download
56
+ except:
57
+ _pip(["huggingface_hub"])
58
+ from huggingface_hub import hf_hub_download
59
+
60
  from ultralytics import YOLO
 
 
61
 
62
+ # ----------------------------
63
+ # Config Hugging Face repos
64
+ # ----------------------------
65
+ YOLO_REPO_ID = os.getenv("YOLO_REPO_ID", "mastefan/fencing-scoreboard-yolov8")
66
+ YOLO_FILENAME = os.getenv("YOLO_FILENAME", "best.pt")
67
+
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
+ FRAME_SKIP = int(os.getenv("FRAME_SKIP", "2"))
72
+ KEEP_CONF = float(os.getenv("KEEP_CONF", "0.85"))
73
+ YOLO_CONF = float(os.getenv("YOLO_CONF", "0.25"))
74
+ YOLO_IOU = float(os.getenv("YOLO_IOU", "0.50"))
75
+ GROUP_GAP_S = float(os.getenv("GROUP_GAP_S","1.5"))
76
+ CLIP_PAD_S = float(os.getenv("CLIP_PAD_S","2.0"))
77
+
78
  CACHE_DIR = pathlib.Path("hf_assets")
79
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
80
+
81
  DEBUG_DIR = pathlib.Path("debug_frames")
82
  DEBUG_DIR.mkdir(exist_ok=True)
83
 
84
+ # ----------------
85
+ # Model loaders
86
+ # ----------------
87
+ def load_yolo_from_hub() -> YOLO:
88
+ w = hf_hub_download(repo_id=YOLO_REPO_ID, filename=YOLO_FILENAME, cache_dir=CACHE_DIR)
89
+ return YOLO(w)
90
+
91
+ def load_autogluon_tabular_from_hub() -> TabularPredictor:
92
+ z = hf_hub_download(repo_id=AG_REPO_ID, filename=AG_ZIP_NAME, cache_dir=CACHE_DIR)
93
+ extract_dir = CACHE_DIR / "ag_predictor_native"
94
+ if extract_dir.exists():
95
+ shutil.rmtree(extract_dir)
96
+ with zipfile.ZipFile(z, "r") as zip_ref:
97
+ zip_ref.extractall(extract_dir)
98
+ return TabularPredictor.load(str(extract_dir))
99
+
100
+ _YOLO = None
101
+ _AG_PRED = None
102
+
103
+ def yolo() -> YOLO:
104
+ global _YOLO
105
+ if _YOLO is None:
106
+ _YOLO = load_yolo_from_hub()
107
+ return _YOLO
108
+
109
+ def ag_predictor() -> TabularPredictor:
110
+ global _AG_PRED
111
+ if _AG_PRED is None:
112
+ _AG_PRED = load_autogluon_tabular_from_hub()
113
+ return _AG_PRED
114
+
115
+ # ----------------------------
116
+ # Vision helpers
117
+ # ----------------------------
 
 
 
 
 
118
  def isolate_scoreboard_color(frame_bgr: np.ndarray,
119
  conf: float = YOLO_CONF,
120
  iou: float = YOLO_IOU,
121
  keep_conf: float = KEEP_CONF,
122
  debug: bool = False,
123
  frame_id: int = None) -> np.ndarray:
124
+ """
125
+ Grayscale everything except the chosen scoreboard bbox.
126
+ Strategy:
127
+ - Pick largest bbox ≥0.70
128
+ - Else, pick largest ≥0.65
129
+ - Keep only one box
130
+ """
131
  H, W = frame_bgr.shape[:2]
132
  gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
133
  gray = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
134
 
135
+ primary_thr = max(0.70, keep_conf)
136
  fallback_thr = max(0.65, primary_thr - 0.05)
137
 
138
  chosen_box = None
 
140
  if len(res):
141
  r = res[0]
142
  if getattr(r, "boxes", None) is not None and len(r.boxes) > 0:
143
+ boxes = r.boxes.xyxy.cpu().numpy()
144
  scores = r.boxes.conf.cpu().numpy()
145
  candidates = list(zip(boxes, scores))
146
 
 
163
  dbg = gray.copy()
164
  if chosen_box is not None:
165
  x1, y1, x2, y2 = [int(round(v)) for v in chosen_box]
166
+ cv2.rectangle(dbg, (x1,y1), (x2,y2), (0,255,0), 2)
167
  out_path = DEBUG_DIR / f"frame_{frame_id:06d}.jpg"
168
  cv2.imwrite(str(out_path), dbg)
169
  print(f"[DEBUG] Saved debug frame → {out_path}")
170
 
171
  return gray
172
 
173
+ def color_pixel_ratio(rgb: np.ndarray, ch: int) -> float:
174
+ R, G, B = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
175
+ if ch == 0:
176
+ mask = (R > 150) & (R > 1.2*G) & (R > 1.2*B)
177
+ else:
178
+ mask = (G > 100) & (G > 1.05*R) & (G > 1.05*B)
179
+ return np.sum(mask) / (rgb.shape[0]*rgb.shape[1] + 1e-9)
180
+
181
+ def rolling_z(series: pd.Series, win: int = 45) -> pd.Series:
182
+ med = series.rolling(win, min_periods=5).median()
183
+ mad = series.rolling(win, min_periods=5).apply(
184
+ lambda x: np.median(np.abs(x - np.median(x))), raw=True
185
+ )
186
+ mad = mad.replace(0, mad[mad > 0].min() if (mad > 0).any() else 1.0)
187
+ return (series - med) / mad
188
+
189
+ # ----------------------------
190
+ # Video → features
191
+ # ----------------------------
192
+ def extract_feature_timeseries(video_path: str,
193
+ frame_skip: int = FRAME_SKIP,
194
+ debug: bool = False) -> Tuple[pd.DataFrame, float]:
195
+ print("[INFO] Starting frame extraction...")
196
+ cap = cv2.VideoCapture(video_path)
197
+ if not cap.isOpened():
198
+ return pd.DataFrame(), 0.0
199
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
200
+ records, frame_idx = [], 0
201
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
202
+
203
+ while True:
204
+ ret, frame = cap.read()
205
+ if not ret:
206
+ break
207
+ if frame_idx % frame_skip == 0:
208
+ ts = frame_idx / fps
209
+ masked = isolate_scoreboard_color(frame, debug=debug, frame_id=frame_idx)
210
+ rgb = cv2.cvtColor(masked, cv2.COLOR_BGR2RGB)
211
+ red_ratio = color_pixel_ratio(rgb, 0)
212
+ green_ratio = color_pixel_ratio(rgb, 1)
213
+ records.append({
214
+ "frame_id": frame_idx,
215
+ "timestamp": ts,
216
+ "red_ratio": red_ratio,
217
+ "green_ratio": green_ratio,
218
+ })
219
+ frame_idx += 1
220
+
221
+ cap.release()
222
+ df = pd.DataFrame(records)
223
+ print(f"[INFO] Processed {len(df)} frames out of {total_frames} (fps={fps:.2f})")
224
+
225
+ if df.empty:
226
+ return df, fps
227
+
228
+ df["red_diff"] = df["red_ratio"].diff().fillna(0)
229
+ df["green_diff"] = df["green_ratio"].diff().fillna(0)
230
+ df["z_red"] = rolling_z(df["red_ratio"])
231
+ df["z_green"] = rolling_z(df["green_ratio"])
232
+
233
+ if debug:
234
+ out_csv = DEBUG_DIR / f"features_{uuid.uuid4().hex}.csv"
235
+ df.to_csv(out_csv, index=False)
236
+ print(f"[DEBUG] Saved features CSV → {out_csv}")
237
+
238
+ return df, fps
239
+
240
+ # ----------------------------
241
+ # Predictor + event picking
242
+ # ----------------------------
243
+ def predict_scores(df: pd.DataFrame) -> pd.Series:
244
+ feat_cols = ["red_ratio", "green_ratio", "red_diff", "green_diff", "z_red", "z_green"]
245
+ X = df[feat_cols].copy()
246
+ pred = ag_predictor().predict(X)
247
+ try:
248
+ proba = ag_predictor().predict_proba(X)
249
+ if isinstance(proba, pd.DataFrame) and (1 in proba.columns):
250
+ return proba[1]
251
+ except Exception:
252
+ pass
253
+ s = pd.Series(pred).astype(float)
254
+ rng = (s.quantile(0.95) - s.quantile(0.05)) or 1.0
255
+ return ((s - s.quantile(0.05)) / rng).clip(0, 1)
256
+
257
+ def pick_events(df: pd.DataFrame, score: pd.Series, fps: float,
258
+ min_start_guard_s: float = 1.0,
259
+ guard_enable_min_duration_s: float = 6.0) -> List[float]:
260
  max_score = score.max()
261
  raw_cutoff = 0.7 * max_score if max_score > 0 else 0.4
262
+ z = rolling_z(score, win=45)
263
+ max_z = z.max()
264
+ z_cutoff = max(2.0, 0.6 * max_z)
265
 
266
+ print(f"[DEBUG] Predictor score stats: min={score.min():.3f}, max={max_score:.3f}, mean={score.mean():.3f}")
267
+ print(f"[DEBUG] Adaptive thresholds: raw>{raw_cutoff:.3f}, z>{z_cutoff:.2f}")
268
+
269
+ duration_est = float(df["timestamp"].max()) if not df.empty else 0.0
270
+ enforce_guard = duration_est >= guard_enable_min_duration_s
271
  out_times = []
272
+ min_dist_frames = max(1, int(1.0 * max(1.0, fps)))
273
+ y = score.values
274
+ last_kept = -min_dist_frames
 
 
 
275
 
276
+ for i in range(1, len(y)-1):
277
+ ts = float(df.iloc[i]["timestamp"])
278
+ local_peak = y[i] > y[i-1] and y[i] > y[i+1]
279
+ if ((z.iloc[i] > z_cutoff) or (y[i] > raw_cutoff)) and local_peak and (i - last_kept) >= min_dist_frames:
280
+ if (not enforce_guard) or (ts >= min_start_guard_s):
281
+ out_times.append(ts)
282
+ last_kept = i
283
+
284
+ if not out_times and len(y) > 0:
285
+ best_idx = int(np.argmax(y))
286
+ ts_best = float(df.iloc[best_idx]["timestamp"])
287
+ if (not enforce_guard) or (ts_best >= min_start_guard_s):
288
+ out_times = [ts_best]
289
+ print(f"[DEBUG] Fallback → using global max at {ts_best:.2f}s")
290
+
291
+ out_times.sort()
292
  grouped = []
293
  for t in out_times:
294
  if (not grouped) or (t - grouped[-1]) > GROUP_GAP_S:
295
  grouped.append(t)
296
+ print(f"[DEBUG] Final detected events: {grouped}")
297
  return grouped
298
 
299
+ # ----------------------------
300
+ # Clip helpers
301
+ # ----------------------------
302
+ def _probe_duration(video_path: str) -> float:
303
+ try:
304
+ if ffmpeg is None:
305
+ raise RuntimeError("ffmpeg-python not available")
306
+ meta = ffmpeg.probe(video_path)
307
+ return float(meta["format"]["duration"])
308
+ except:
309
+ return 0.0
310
+
311
+ def cut_clip(video_path: str, start: float, end: float, out_path: str) -> str:
312
+ try:
313
+ cmd = ["ffmpeg", "-y", "-ss", str(max(0, start)), "-to", str(max(start, end)),
314
+ "-i", video_path, "-c", "copy", out_path]
315
+ sp = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
316
+ if sp.returncode == 0 and os.path.exists(out_path):
317
+ return out_path
318
+ except:
319
+ pass
320
+ from moviepy.editor import VideoFileClip
321
+ clip = VideoFileClip(video_path).subclip(max(0, start), max(start, end))
322
+ clip.write_videofile(out_path, codec="libx264", audio_codec="aac", verbose=False, logger=None)
323
+ return out_path
324
+
325
+ # ----------------------------
326
+ # Orchestrator
327
+ # ----------------------------
328
+ def extract_score_clips(video_path: str, debug: bool = False) -> Tuple[List[Tuple[str, str]], str]:
329
+ print("[INFO] Running full detection pipeline...")
330
+ df, fps = extract_feature_timeseries(video_path, frame_skip=FRAME_SKIP, debug=debug)
331
+ if df.empty:
332
+ return [], "No frames processed."
333
+
334
+ score = predict_scores(df)
335
+ if score.max() <= 1e-6:
336
+ print("[WARN] Flat scores from predictor (possible YOLO miss or feature mismatch).")
337
+ return [], "⚠️ No scoreboard detected or illumination scores flat. Please check video or model."
338
 
339
  events = pick_events(df, score, fps)
340
  if not events:
341
  return [], "⚠️ No touches confidently detected in this video."
342
 
343
+ duration = _probe_duration(video_path)
344
+ if duration <= 0:
345
+ duration = float(df["timestamp"].max() + CLIP_PAD_S + 0.5)
346
+
347
  clips = []
348
+ base = os.path.splitext(os.path.basename(video_path))[0]
349
+ for i, t in enumerate(events):
350
+ s = t - CLIP_PAD_S
351
+ e = t + CLIP_PAD_S
352
+ if s < 0:
353
+ e = min(duration, e - s)
354
+ s = 0
355
+ elif e > duration:
356
+ s = max(0, s - (e - duration))
357
+ e = duration
358
+ clip_path = os.path.join(tempfile.gettempdir(), f"{base}_score_{i+1:02d}.mp4")
359
+ cut_clip(video_path, s, e, clip_path)
360
+ label = f"Touch {i+1} @ {t:.2f}s"
361
+ clips.append((clip_path, label))
362
+
363
+ return clips, f"✅ Detected {len(clips)} event(s)."
364
+
365
+ # ----------------------------
366
+ # Gradio UI
367
+ # ----------------------------
368
+ CSS = """
369
+ .gradio-container {max-width: 900px; margin: auto;}
370
+ .header {text-align: center; margin-bottom: 20px;}
371
+ .full-width {width: 100% !important;}
372
+ .progress-bar {
373
+ width: 100%;
374
+ height: 30px;
375
+ background-color: #e0e0e0;
376
+ border-radius: 15px;
377
+ margin: 15px 0;
378
+ position: relative;
379
+ overflow: hidden;
380
+ }
381
+ .progress-fill {
382
+ height: 100%;
383
+ background-color: #4CAF50;
384
+ border-radius: 15px;
385
+ text-align: center;
386
+ line-height: 30px;
387
+ color: white;
388
+ font-weight: bold;
389
+ transition: width 0.3s;
390
+ }
391
+ .fencer {
392
+ position: absolute;
393
+ top: -5px;
394
+ font-size: 24px;
395
+ transition: left 0.3s;
396
+ transform: scaleX(-1);
397
+ }
398
+ """
399
+
400
  def _make_progress_bar(percent: int, final_text: str = None):
401
  text = f"{percent}%" if not final_text else final_text
402
  return f"""
 
406
  </div>
407
  """
408
 
409
+ def run_with_progress(video_file):
 
 
 
410
  if not video_file:
411
+ yield [], "Please upload a video file.", gr.update(visible=False)
412
  return
413
+ yield [], "🔄 Extracting frames...", _make_progress_bar(20)
414
+ df, fps = extract_feature_timeseries(video_file, frame_skip=FRAME_SKIP, debug=False)
415
+ if df.empty:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416