mastefan commited on
Commit
e7b57ee
·
verified ·
1 Parent(s): 10b5e29

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +261 -22
app.py CHANGED
@@ -17,6 +17,9 @@
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
@@ -27,13 +30,15 @@ import cv2
27
  import gradio as gr
28
 
29
  # ----------------
30
- # Flags
31
  # ----------------
32
- DEBUG_SAVE_FRAMES = False # disable debug frames by default
 
 
 
 
 
33
 
34
- # ----------------
35
- # Utility
36
- # ----------------
37
  def _pip(pkgs: List[str]):
38
  import subprocess, sys
39
  subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", *pkgs])
@@ -86,11 +91,6 @@ CLIP_PAD_S = float(os.getenv("CLIP_PAD_S","2.0"))
86
  CACHE_DIR = pathlib.Path("hf_assets")
87
  CACHE_DIR.mkdir(parents=True, exist_ok=True)
88
 
89
- DEBUG_DIR = pathlib.Path("debug_frames")
90
- if DEBUG_DIR.exists():
91
- shutil.rmtree(DEBUG_DIR) # wipe old debug frames at startup
92
- DEBUG_DIR.mkdir(exist_ok=True)
93
-
94
  # ----------------
95
  # Model loaders
96
  # ----------------
@@ -182,9 +182,143 @@ def isolate_scoreboard_color(frame_bgr: np.ndarray,
182
 
183
  return gray
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  # ----------------------------
186
  # Clip helpers
187
  # ----------------------------
 
 
 
 
 
 
 
 
 
188
  def cut_clip(video_path: str, start: float, end: float, out_path: str) -> str:
189
  try:
190
  cmd = ["ffmpeg", "-y", "-ss", str(max(0, start)), "-to", str(max(start, end)),
@@ -200,18 +334,18 @@ def cut_clip(video_path: str, start: float, end: float, out_path: str) -> str:
200
  return out_path
201
 
202
  # ----------------------------
203
- # Orchestrator (with cleanup)
204
  # ----------------------------
205
- def extract_score_clips(video_path: str, debug: bool = False):
206
  print("[INFO] Running full detection pipeline...")
207
- from moviepy.editor import VideoFileClip
208
  df, fps = extract_feature_timeseries(video_path, frame_skip=FRAME_SKIP, debug=debug)
209
  if df.empty:
210
  return [], "No frames processed."
211
 
212
  score = predict_scores(df)
213
  if score.max() <= 1e-6:
214
- return [], "⚠️ No scoreboard detected or illumination scores flat."
 
215
 
216
  events = pick_events(df, score, fps)
217
  if not events:
@@ -221,10 +355,11 @@ def extract_score_clips(video_path: str, debug: bool = False):
221
  if duration <= 0:
222
  duration = float(df["timestamp"].max() + CLIP_PAD_S + 0.5)
223
 
224
- clips, kept_paths = [], []
225
  base = os.path.splitext(os.path.basename(video_path))[0]
226
  for i, t in enumerate(events):
227
- s, e = t - CLIP_PAD_S, t + CLIP_PAD_S
 
228
  if s < 0:
229
  e = min(duration, e - s)
230
  s = 0
@@ -233,14 +368,118 @@ def extract_score_clips(video_path: str, debug: bool = False):
233
  e = duration
234
  clip_path = os.path.join(tempfile.gettempdir(), f"{base}_score_{i+1:02d}.mp4")
235
  cut_clip(video_path, s, e, clip_path)
236
- clips.append((clip_path, f"Touch {i+1} @ {t:.2f}s"))
237
- kept_paths.append(clip_path)
238
 
239
- # cleanup: delete unused temp clips
 
240
  for f in pathlib.Path(tempfile.gettempdir()).glob(f"{base}_score_*.mp4"):
241
- if str(f) not in kept_paths:
242
- try: f.unlink()
243
- except: pass
 
 
244
 
245
  return clips, f"✅ Detected {len(clips)} event(s)."
246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  # -*- coding: utf-8 -*-
18
  # Fencing Scoreboard Clips - YOLO x AutoGluon (Gradio)
19
 
20
+ # -*- coding: utf-8 -*-
21
+ # Fencing Scoreboard Clips - YOLO x AutoGluon (Gradio)
22
+
23
  import os, sys, zipfile, shutil, subprocess, tempfile, pathlib
24
  from typing import List, Tuple
25
  import uuid
 
30
  import gradio as gr
31
 
32
  # ----------------
33
+ # Debug control
34
  # ----------------
35
+ DEBUG_SAVE_FRAMES = False # disable debug images by default
36
+
37
+ DEBUG_DIR = pathlib.Path("debug_frames")
38
+ if DEBUG_DIR.exists():
39
+ shutil.rmtree(DEBUG_DIR) # wipe any leftover frames on startup
40
+ DEBUG_DIR.mkdir(exist_ok=True)
41
 
 
 
 
42
  def _pip(pkgs: List[str]):
43
  import subprocess, sys
44
  subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", *pkgs])
 
91
  CACHE_DIR = pathlib.Path("hf_assets")
92
  CACHE_DIR.mkdir(parents=True, exist_ok=True)
93
 
 
 
 
 
 
94
  # ----------------
95
  # Model loaders
96
  # ----------------
 
182
 
183
  return gray
184
 
185
+ def color_pixel_ratio(rgb: np.ndarray, ch: int) -> float:
186
+ R, G, B = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
187
+ if ch == 0:
188
+ mask = (R > 150) & (R > 1.2*G) & (R > 1.2*B)
189
+ else:
190
+ mask = (G > 100) & (G > 1.05*R) & (G > 1.05*B)
191
+ return np.sum(mask) / (rgb.shape[0]*rgb.shape[1] + 1e-9)
192
+
193
+ def rolling_z(series: pd.Series, win: int = 45) -> pd.Series:
194
+ med = series.rolling(win, min_periods=5).median()
195
+ mad = series.rolling(win, min_periods=5).apply(
196
+ lambda x: np.median(np.abs(x - np.median(x))), raw=True
197
+ )
198
+ mad = mad.replace(0, mad[mad > 0].min() if (mad > 0).any() else 1.0)
199
+ return (series - med) / mad
200
+
201
+ # ----------------------------
202
+ # Video → features
203
+ # ----------------------------
204
+ def extract_feature_timeseries(video_path: str,
205
+ frame_skip: int = FRAME_SKIP,
206
+ debug: bool = False) -> Tuple[pd.DataFrame, float]:
207
+ print("[INFO] Starting frame extraction...")
208
+ cap = cv2.VideoCapture(video_path)
209
+ if not cap.isOpened():
210
+ return pd.DataFrame(), 0.0
211
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
212
+ records, frame_idx = [], 0
213
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
214
+
215
+ while True:
216
+ ret, frame = cap.read()
217
+ if not ret:
218
+ break
219
+ if frame_idx % frame_skip == 0:
220
+ ts = frame_idx / fps
221
+ masked = isolate_scoreboard_color(frame, debug=debug, frame_id=frame_idx)
222
+ rgb = cv2.cvtColor(masked, cv2.COLOR_BGR2RGB)
223
+ red_ratio = color_pixel_ratio(rgb, 0)
224
+ green_ratio = color_pixel_ratio(rgb, 1)
225
+ records.append({
226
+ "frame_id": frame_idx,
227
+ "timestamp": ts,
228
+ "red_ratio": red_ratio,
229
+ "green_ratio": green_ratio,
230
+ })
231
+ frame_idx += 1
232
+
233
+ cap.release()
234
+ df = pd.DataFrame(records)
235
+ print(f"[INFO] Processed {len(df)} frames out of {total_frames} (fps={fps:.2f})")
236
+
237
+ if df.empty:
238
+ return df, fps
239
+
240
+ df["red_diff"] = df["red_ratio"].diff().fillna(0)
241
+ df["green_diff"] = df["green_ratio"].diff().fillna(0)
242
+ df["z_red"] = rolling_z(df["red_ratio"])
243
+ df["z_green"] = rolling_z(df["green_ratio"])
244
+
245
+ if DEBUG_SAVE_FRAMES and debug:
246
+ out_csv = DEBUG_DIR / f"features_{uuid.uuid4().hex}.csv"
247
+ df.to_csv(out_csv, index=False)
248
+
249
+ return df, fps
250
+
251
+ # ----------------------------
252
+ # Predictor + event picking
253
+ # ----------------------------
254
+ def predict_scores(df: pd.DataFrame) -> pd.Series:
255
+ feat_cols = ["red_ratio", "green_ratio", "red_diff", "green_diff", "z_red", "z_green"]
256
+ X = df[feat_cols].copy()
257
+ pred = ag_predictor().predict(X)
258
+ try:
259
+ proba = ag_predictor().predict_proba(X)
260
+ if isinstance(proba, pd.DataFrame) and (1 in proba.columns):
261
+ return proba[1]
262
+ except Exception:
263
+ pass
264
+ s = pd.Series(pred).astype(float)
265
+ rng = (s.quantile(0.95) - s.quantile(0.05)) or 1.0
266
+ return ((s - s.quantile(0.05)) / rng).clip(0, 1)
267
+
268
+ def pick_events(df: pd.DataFrame, score: pd.Series, fps: float,
269
+ min_start_guard_s: float = 1.0,
270
+ guard_enable_min_duration_s: float = 6.0) -> List[float]:
271
+ max_score = score.max()
272
+ raw_cutoff = 0.7 * max_score if max_score > 0 else 0.4
273
+ z = rolling_z(score, win=45)
274
+ max_z = z.max()
275
+ z_cutoff = max(2.0, 0.6 * max_z)
276
+
277
+ print(f"[DEBUG] Predictor score stats: min={score.min():.3f}, max={max_score:.3f}, mean={score.mean():.3f}")
278
+ print(f"[DEBUG] Adaptive thresholds: raw>{raw_cutoff:.3f}, z>{z_cutoff:.2f}")
279
+
280
+ duration_est = float(df["timestamp"].max()) if not df.empty else 0.0
281
+ enforce_guard = duration_est >= guard_enable_min_duration_s
282
+ out_times = []
283
+ min_dist_frames = max(1, int(1.0 * max(1.0, fps)))
284
+ y = score.values
285
+ last_kept = -min_dist_frames
286
+
287
+ for i in range(1, len(y)-1):
288
+ ts = float(df.iloc[i]["timestamp"])
289
+ local_peak = y[i] > y[i-1] and y[i] > y[i+1]
290
+ if ((z.iloc[i] > z_cutoff) or (y[i] > raw_cutoff)) and local_peak and (i - last_kept) >= min_dist_frames:
291
+ if (not enforce_guard) or (ts >= min_start_guard_s):
292
+ out_times.append(ts)
293
+ last_kept = i
294
+
295
+ if not out_times and len(y) > 0:
296
+ best_idx = int(np.argmax(y))
297
+ ts_best = float(df.iloc[best_idx]["timestamp"])
298
+ if (not enforce_guard) or (ts_best >= min_start_guard_s):
299
+ out_times = [ts_best]
300
+ print(f"[DEBUG] Fallback → using global max at {ts_best:.2f}s")
301
+
302
+ out_times.sort()
303
+ grouped = []
304
+ for t in out_times:
305
+ if (not grouped) or (t - grouped[-1]) > GROUP_GAP_S:
306
+ grouped.append(t)
307
+ print(f"[DEBUG] Final detected events: {grouped}")
308
+ return grouped
309
+
310
  # ----------------------------
311
  # Clip helpers
312
  # ----------------------------
313
+ def _probe_duration(video_path: str) -> float:
314
+ try:
315
+ if ffmpeg is None:
316
+ raise RuntimeError("ffmpeg-python not available")
317
+ meta = ffmpeg.probe(video_path)
318
+ return float(meta["format"]["duration"])
319
+ except:
320
+ return 0.0
321
+
322
  def cut_clip(video_path: str, start: float, end: float, out_path: str) -> str:
323
  try:
324
  cmd = ["ffmpeg", "-y", "-ss", str(max(0, start)), "-to", str(max(start, end)),
 
334
  return out_path
335
 
336
  # ----------------------------
337
+ # Orchestrator
338
  # ----------------------------
339
+ def extract_score_clips(video_path: str, debug: bool = False) -> Tuple[List[Tuple[str, str]], str]:
340
  print("[INFO] Running full detection pipeline...")
 
341
  df, fps = extract_feature_timeseries(video_path, frame_skip=FRAME_SKIP, debug=debug)
342
  if df.empty:
343
  return [], "No frames processed."
344
 
345
  score = predict_scores(df)
346
  if score.max() <= 1e-6:
347
+ print("[WARN] Flat scores from predictor (possible YOLO miss or feature mismatch).")
348
+ return [], "⚠️ No scoreboard detected or illumination scores flat. Please check video or model."
349
 
350
  events = pick_events(df, score, fps)
351
  if not events:
 
355
  if duration <= 0:
356
  duration = float(df["timestamp"].max() + CLIP_PAD_S + 0.5)
357
 
358
+ clips = []
359
  base = os.path.splitext(os.path.basename(video_path))[0]
360
  for i, t in enumerate(events):
361
+ s = t - CLIP_PAD_S
362
+ e = t + CLIP_PAD_S
363
  if s < 0:
364
  e = min(duration, e - s)
365
  s = 0
 
368
  e = duration
369
  clip_path = os.path.join(tempfile.gettempdir(), f"{base}_score_{i+1:02d}.mp4")
370
  cut_clip(video_path, s, e, clip_path)
371
+ label = f"Touch {i+1} @ {t:.2f}s"
372
+ clips.append((clip_path, label))
373
 
374
+ # cleanup: keep only returned clips
375
+ keep = {c[0] for c in clips}
376
  for f in pathlib.Path(tempfile.gettempdir()).glob(f"{base}_score_*.mp4"):
377
+ if str(f) not in keep:
378
+ try:
379
+ f.unlink()
380
+ except:
381
+ pass
382
 
383
  return clips, f"✅ Detected {len(clips)} event(s)."
384
 
385
+ # ----------------------------
386
+ # Gradio UI
387
+ # ----------------------------
388
+ CSS = """
389
+ .gradio-container {max-width: 900px; margin: auto;}
390
+ .header {text-align: center; margin-bottom: 20px;}
391
+ .full-width {width: 100% !important;}
392
+ .progress-bar {
393
+ width: 100%;
394
+ height: 30px;
395
+ background-color: #e0e0e0;
396
+ border-radius: 15px;
397
+ margin: 15px 0;
398
+ position: relative;
399
+ overflow: hidden;
400
+ }
401
+ .progress-fill {
402
+ height: 100%;
403
+ background-color: #4CAF50;
404
+ border-radius: 15px;
405
+ text-align: center;
406
+ line-height: 30px;
407
+ color: white;
408
+ font-weight: bold;
409
+ transition: width 0.3s;
410
+ }
411
+ .fencer {
412
+ position: absolute;
413
+ top: -5px;
414
+ font-size: 24px;
415
+ transition: left 0.3s;
416
+ transform: scaleX(-1);
417
+ }
418
+ """
419
+
420
+ def _make_progress_bar(percent: int, final_text: str = None):
421
+ text = f"{percent}%" if not final_text else final_text
422
+ return f"""
423
+ <div class="progress-bar">
424
+ <div id="progress-fill" class="progress-fill" style="width:{percent}%">{text}</div>
425
+ <div id="fencer" class="fencer" style="left:{percent}%">🤺</div>
426
+ </div>
427
+ """
428
+
429
+ def run_with_progress(video_file):
430
+ if not video_file:
431
+ yield [], "Please upload a video file.", gr.update(visible=False)
432
+ return
433
+
434
+ yield [], "🔄 Extracting frames...", _make_progress_bar(20)
435
+ df, fps = extract_feature_timeseries(video_file, frame_skip=FRAME_SKIP, debug=False)
436
+ if df.empty:
437
+ yield [], "❌ No frames processed!", _make_progress_bar(100, "No Frames ❌")
438
+ return
439
+
440
+ yield [], "🔄 Scoring & detecting touches...", _make_progress_bar(80)
441
+ clips, status_msg = extract_score_clips(video_file, debug=False)
442
+
443
+ final_bar = _make_progress_bar(
444
+ 100, f"Detected {len(clips)} Touches ⚡" if clips else "No Touches"
445
+ )
446
+ yield clips, status_msg, final_bar
447
+
448
+ with gr.Blocks(css=CSS, title="Fencing Scoreboard Detector") as demo:
449
+ with gr.Row(elem_classes="header"):
450
+ gr.Markdown(
451
+ "## 🤺 Fencing Score Detector\n"
452
+ "Upload a fencing bout video. The system detects scoreboard lights "
453
+ "(YOLO + AutoGluon) and returns highlight clips around each scoring event."
454
+ )
455
+
456
+ in_video = gr.Video(label="Upload Bout Video", elem_classes="full-width", height=400)
457
+ run_btn = gr.Button("⚡ Detect Touches", elem_classes="full-width")
458
+
459
+ progress_html = gr.HTML(value="", label="Processing Progress", visible=False)
460
+ status = gr.Markdown("Ready.")
461
+ gallery = gr.Gallery(
462
+ label="Detected Clips",
463
+ columns=1,
464
+ height=400,
465
+ preview=True,
466
+ allow_preview=True,
467
+ show_download_button=True,
468
+ visible=False
469
+ )
470
+
471
+ def wrapped_run(video_file):
472
+ yield gr.update(value=[], visible=False), "Processing started...", gr.update(value=_make_progress_bar(0), visible=True)
473
+ for clips, msg, bar in run_with_progress(video_file):
474
+ gallery_update = gr.update(value=clips, visible=bool(clips))
475
+ yield gallery_update, msg, gr.update(value=bar, visible=True)
476
+
477
+ run_btn.click(
478
+ fn=wrapped_run,
479
+ inputs=in_video,
480
+ outputs=[gallery, status, progress_html],
481
+ )
482
+
483
+ if __name__ == "__main__":
484
+ demo.launch(debug=True)
485
+