mastefan commited on
Commit
37f607b
·
verified ·
1 Parent(s): 189f017

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +581 -126
app.py CHANGED
@@ -14,148 +14,603 @@
14
  #
15
  # Fencing Scoreboard Clips - YOLO x AutoGluon (Gradio)
16
 
17
- # app.py Hugging Face Spaces-ready version
18
- # Author: Michael Stefanov
19
- # Purpose: Detect scoring touches in fencing bouts (YOLO + AutoGluon)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
- import os, zipfile, shutil, pathlib, tempfile
22
- import numpy as np, pandas as pd, cv2, gradio as gr
23
  from ultralytics import YOLO
24
- from autogluon.tabular import TabularPredictor
25
-
26
- # ---------------------------------------------------------------------
27
- # Configuration
28
- # ---------------------------------------------------------------------
29
- ASSETS_DIR = pathlib.Path("hf_assets")
30
- YOLO_PATH = ASSETS_DIR / "best.pt"
31
- AG_ZIP = ASSETS_DIR / "autogluon_predictor_dir.zip"
32
- EXTRACT_DIR = ASSETS_DIR / "ag_predictor_native"
33
- OUTPUT_DIR = pathlib.Path("outputs"); OUTPUT_DIR.mkdir(exist_ok=True)
34
-
35
- FRAME_SKIP = 2
36
- KEEP_CONF = 0.85
37
- YOLO_CONF = 0.25
38
- YOLO_IOU = 0.5
39
- CLIP_PAD_S = 2.0
40
-
41
- # ---------------------------------------------------------------------
42
- # Load models once
43
- # ---------------------------------------------------------------------
44
- print("Loading YOLO and AutoGluon models ...")
45
- _YOLO = YOLO(str(YOLO_PATH))
46
-
47
- if EXTRACT_DIR.exists():
48
- shutil.rmtree(EXTRACT_DIR)
49
- with zipfile.ZipFile(AG_ZIP, "r") as zf:
50
- zf.extractall(EXTRACT_DIR)
51
- _AG = TabularPredictor.load(str(EXTRACT_DIR))
52
-
53
- # ---------------------------------------------------------------------
54
- # Helper functions
55
- # ---------------------------------------------------------------------
56
- def isolate_scoreboard_color(frame_bgr):
57
- """Grayscale background, keep color in main scoreboard bbox."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  H, W = frame_bgr.shape[:2]
 
 
59
  gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
60
  gray = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
61
 
62
- res = _YOLO.predict(frame_bgr, conf=YOLO_CONF, iou=YOLO_IOU, verbose=False)
63
- if len(res) and getattr(res[0], "boxes", None) is not None and len(res[0].boxes) > 0:
64
- boxes = res[0].boxes.xyxy.cpu().numpy()
65
- scores = res[0].boxes.conf.cpu().numpy()
66
- valid = [(b, s) for b, s in zip(boxes, scores) if s >= KEEP_CONF]
67
- if valid:
68
- b, _ = max(valid, key=lambda bs: (bs[0][2]-bs[0][0])*(bs[0][3]-bs[0][1]))
69
- x1, y1, x2, y2 = map(int, b)
70
- gray[y1:y2, x1:x2] = frame_bgr[y1:y2, x1:x2]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  return gray
72
 
73
- def _count_color(rgb, ch, thr, dom):
74
- R,G,B = rgb[:,:,0], rgb[:,:,1], rgb[:,:,2]
75
- if ch == "r":
76
- mask = (R>thr) & (R>dom*G) & (R>dom*B)
 
 
 
 
 
 
77
  else:
78
- mask = (G>thr) & (G>dom*R) & (G>dom*B)
79
- return np.sum(mask)
80
-
81
- def features_from_video(video):
82
- cap = cv2.VideoCapture(video)
83
- fps = cap.get(cv2.CAP_PROP_FPS) or 30
84
- rec=[]
85
- i=0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  while True:
87
- ok,f = cap.read()
88
- if not ok: break
89
- if i%FRAME_SKIP==0:
90
- ts=i/fps
91
- m= isolate_scoreboard_color(f)
92
- rgb=cv2.cvtColor(m, cv2.COLOR_BGR2RGB)
93
- r=_count_color(rgb,"r",150,1.2)/(rgb.size/3)
94
- g=_count_color(rgb,"g",100,1.05)/(rgb.size/3)
95
- rec.append({"timestamp":ts,"red_ratio":r,"green_ratio":g})
96
- i+=1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  cap.release()
98
- df=pd.DataFrame(rec)
99
- if df.empty: return df,fps
100
- df["red_diff"]=df["red_ratio"].diff().fillna(0)
101
- df["green_diff"]=df["green_ratio"].diff().fillna(0)
102
- return df,fps
103
-
104
- def predict_score(df):
105
- cols=["red_ratio","green_ratio","red_diff","green_diff"]
106
- pred=_AG.predict(df[cols])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  try:
108
- proba=_AG.predict_proba(df[cols])
109
- if 1 in proba.columns: return proba[1]
110
- except Exception: pass
111
- s=pd.Series(pred).astype(float)
112
- return (s-s.min())/(s.max()-s.min()+1e-9)
113
-
114
- def detect_touches(df,score,fps):
115
- peaks=[]
116
- for i in range(1,len(score)-1):
117
- if score[i]>0.7 and score[i]>score[i-1] and score[i]>score[i+1]:
118
- peaks.append(df.iloc[i]["timestamp"])
119
- return peaks[:3] # cap to 3 clips for speed
120
-
121
- def cut_clip(video,start,end,out_path):
122
- import subprocess
123
- cmd=["ffmpeg","-y","-ss",str(start),"-to",str(end),"-i",video,
124
- "-c","copy",out_path]
125
- subprocess.run(cmd,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
126
  return out_path
127
 
128
- def extract_score_clips(video_file):
129
- df,fps=features_from_video(video_file)
 
 
 
 
 
130
  if df.empty:
131
- return [],"No frames processed."
132
- score=predict_score(df)
133
- events=detect_touches(df,score,fps)
 
 
 
 
 
 
 
 
 
 
 
 
134
  if not events:
135
- return [],"No touches detected."
136
- clips=[]
137
- base=os.path.splitext(os.path.basename(video_file))[0]
138
- for i,t in enumerate(events):
139
- s=max(0,t-CLIP_PAD_S); e=t+CLIP_PAD_S
140
- out=str(OUTPUT_DIR / f"{base}_touch_{i+1}.mp4")
141
- cut_clip(video_file,s,e,out)
142
- clips.append((out,f"Touch {i+1} @ {t:.2f}s"))
143
- return clips,f"Detected {len(clips)} touch(es)."
144
-
145
- # ---------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  # Gradio UI
147
- # ---------------------------------------------------------------------
148
- def run(video):
149
- if not video: return [],"Please upload a video."
150
- return extract_score_clips(video)
151
-
152
- demo = gr.Interface(
153
- fn=run,
154
- inputs=gr.Video(label="Upload fencing bout"),
155
- outputs=[gr.Gallery(label="Detected Clips"), gr.Textbox(label="Status")],
156
- title="🤺 Fencing Scoreboard Detector",
157
- description="YOLO + AutoGluon model to detect scoreboard-light touches."
158
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
  if __name__ == "__main__":
161
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
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
+ # ----------------------------
274
+ # AutoGluon inference + event picking
275
+ # ----------------------------
276
+ def predict_scores(df: pd.DataFrame) -> pd.Series:
277
+ feat_cols = ["red_ratio", "green_ratio", "red_diff", "green_diff", "z_red", "z_green"]
278
+ X = df[feat_cols].copy()
279
+ pred = ag_predictor().predict(X)
280
+
281
+ # Prefer classification proba if available
282
+ try:
283
+ proba = ag_predictor().predict_proba(X)
284
+ if isinstance(proba, pd.DataFrame) and (1 in proba.columns):
285
+ return proba[1]
286
+ except Exception:
287
+ pass
288
+
289
+ # Fallback: normalize regression-like output to 0..1 robustly
290
+ s = pd.Series(pred).astype(float)
291
+ rng = (s.quantile(0.95) - s.quantile(0.05)) or 1.0
292
+ return ((s - s.quantile(0.05)) / rng).clip(0, 1)
293
+
294
+ def pick_events(df: pd.DataFrame, score: pd.Series, fps: float) -> List[float]:
295
+ """
296
+ Adaptive hybrid event detection:
297
+ - Adaptive raw threshold = 0.7 × max score
298
+ - Adaptive z-threshold = max(2.0, 0.6 × max z-score)
299
+ - Must be a local peak
300
+ - Enforce min spacing (1.0s) and group gap (1.5s)
301
+ - Ignore any detections before 1.0s
302
+ """
303
+ max_score = score.max()
304
+ raw_cutoff = 0.7 * max_score if max_score > 0 else 0.4
305
+
306
+ z = rolling_z(score, win=45)
307
+ max_z = z.max()
308
+ z_cutoff = max(2.0, 0.6 * max_z)
309
+
310
+ print(f"[DEBUG] Predictor score stats: min={score.min():.3f}, max={max_score:.3f}, mean={score.mean():.3f}")
311
+ print(f"[DEBUG] Adaptive thresholds: raw>{raw_cutoff:.3f}, z>{z_cutoff:.2f}")
312
+
313
+ out_times = []
314
+ min_dist_frames = max(1, int(1.0 * max(1.0, fps))) # 1.0s spacing
315
+ y = score.values
316
+ last_kept = -min_dist_frames
317
+
318
+ for i in range(1, len(y)-1):
319
+ ts = float(df.iloc[i]["timestamp"])
320
+ local_peak = y[i] > y[i-1] and y[i] > y[i+1]
321
+ 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:
322
+ out_times.append(ts)
323
+ last_kept = i
324
+
325
+ if not out_times and len(y) > 0:
326
+ best_idx = int(np.argmax(y))
327
+ ts = float(df.iloc[best_idx]["timestamp"])
328
+ if ts >= 1.0:
329
+ out_times = [ts]
330
+ print(f"[DEBUG] Fallback → using global max at {ts:.2f}s")
331
+ else:
332
+ print(f"[DEBUG] Ignored fallback at {ts:.2f}s (within first second)")
333
+
334
+ out_times.sort()
335
+
336
+ grouped = []
337
+ for t in out_times:
338
+ if (not grouped) or (t - grouped[-1]) > GROUP_GAP_S:
339
+ grouped.append(t)
340
+
341
+ print(f"[DEBUG] Final detected events: {grouped}")
342
+ return grouped
343
+
344
+ def save_event_snapshot(video_path: str, timestamp: float, out_path: str, fps: float):
345
+ """Save a snapshot frame at timestamp with YOLO bbox drawn."""
346
+ cap = cv2.VideoCapture(video_path)
347
+ if not cap.isOpened():
348
+ print("[ERROR] Could not open video for snapshot.")
349
+ return None
350
+
351
+ frame_idx = int(timestamp * fps)
352
+ cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
353
+ ret, frame = cap.read()
354
  cap.release()
355
+
356
+ if not ret or frame is None:
357
+ print(f"[WARN] Could not grab frame at {timestamp:.2f}s")
358
+ return None
359
+
360
+ masked = isolate_scoreboard_color(frame, debug=False)
361
+ res = yolo().predict(frame, conf=YOLO_CONF, iou=YOLO_IOU, verbose=False)
362
+
363
+ if len(res) and getattr(res[0], "boxes", None) is not None and len(res[0].boxes) > 0:
364
+ boxes = res[0].boxes.xyxy.cpu().numpy()
365
+ scores = res[0].boxes.conf.cpu().numpy()
366
+ valid = [(box, score) for box, score in zip(boxes, scores) if float(score) >= KEEP_CONF]
367
+ if valid:
368
+ largest, _ = max(valid, key=lambda bs: (bs[0][2]-bs[0][0])*(bs[0][3]-bs[0][1]))
369
+ x1, y1, x2, y2 = [int(round(v)) for v in largest]
370
+ cv2.rectangle(masked, (x1, y1), (x2, y2), (0, 255, 0), 3)
371
+
372
+ cv2.imwrite(out_path, masked)
373
+ print(f"[DEBUG] Saved snapshot → {out_path}")
374
+ return out_path
375
+
376
+ import matplotlib.pyplot as plt
377
+ def save_debug_plot(df: pd.DataFrame, score: pd.Series, events: List[float], base_name="debug_plot"):
378
+ plt.figure(figsize=(12, 5))
379
+ plt.plot(df["timestamp"], score, label="Predicted Score")
380
+ plt.axhline(y=0.5, color="gray", linestyle="--", alpha=0.5)
381
+ first = True
382
+ for ev in events:
383
+ plt.axvline(x=ev, color="red", linestyle="--", label="Detected Event" if first else None)
384
+ first = False
385
+ plt.xlabel("Time (s)")
386
+ plt.ylabel("Score")
387
+ plt.title("AutoGluon Score vs Time")
388
+ plt.legend()
389
+ out_path = DEBUG_DIR / f"{base_name}.png"
390
+ plt.savefig(out_path)
391
+ plt.close()
392
+ print(f"[DEBUG] Saved debug score plot → {out_path}")
393
+
394
+
395
+ # ----------------------------
396
+ # Clip cutting (ffmpeg w/ moviepy fallback)
397
+ # ----------------------------
398
+ def _probe_duration(video_path: str) -> float:
399
+ try:
400
+ if ffmpeg is None:
401
+ raise RuntimeError("ffmpeg-python not available")
402
+ meta = ffmpeg.probe(video_path)
403
+ return float(meta["format"]["duration"])
404
+ except Exception:
405
+ return 0.0
406
+
407
+ def cut_clip(video_path: str, start: float, end: float, out_path: str) -> str:
408
+ # Fast path (copy) if ffmpeg available
409
  try:
410
+ cmd = ["ffmpeg", "-y", "-ss", str(max(0, start)), "-to", str(max(start, end)),
411
+ "-i", video_path, "-c", "copy", out_path]
412
+ sp = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
413
+ if sp.returncode == 0 and os.path.exists(out_path):
414
+ return out_path
415
+ except Exception:
416
+ pass
417
+
418
+ # Fallback: moviepy re-encode
419
+ from moviepy.editor import VideoFileClip
420
+ clip = VideoFileClip(video_path).subclip(max(0, start), max(start, end))
421
+ clip.write_videofile(out_path, codec="libx264", audio_codec="aac", verbose=False, logger=None)
 
 
 
 
 
 
422
  return out_path
423
 
424
+ # ----------------------------
425
+ # Orchestrator: detect + cut + debug
426
+ # ----------------------------
427
+
428
+ def extract_score_clips(video_path: str, debug: bool = True) -> Tuple[List[Tuple[str, str]], str]:
429
+ print("[INFO] Running full detection pipeline...")
430
+ df, fps = extract_feature_timeseries(video_path, frame_skip=FRAME_SKIP, debug=debug)
431
  if df.empty:
432
+ print("[WARN] Empty dataframe — no frames processed.")
433
+ return [], "No frames processed."
434
+
435
+ print("[INFO] Feature extraction done. Running predictor...")
436
+ score = predict_scores(df)
437
+
438
+ # Bail early if the model produced no signal at all
439
+ if score.max() <= 1e-6:
440
+ print("[WARN] Flat scores from predictor (possible YOLO miss or feature mismatch).")
441
+ return [], "⚠️ No scoreboard detected or illumination scores flat. Please check video or model."
442
+
443
+ print("[INFO] Picking events from predictor scores...")
444
+ events = pick_events(df, score, fps)
445
+ print(f"[INFO] Picked {len(events)} event(s): {events}")
446
+
447
  if not events:
448
+ topk = np.argsort(score.values)[-5:][::-1]
449
+ dbg = [(float(df.iloc[i]['timestamp']), float(score.iloc[i])) for i in topk]
450
+ print(f"[DEBUG] Top-5 peaks (ts,score): {dbg}")
451
+ return [], "⚠️ No touches confidently detected in this video."
452
+
453
+ duration = _probe_duration(video_path)
454
+ if duration <= 0:
455
+ duration = float(df["timestamp"].max() + CLIP_PAD_S + 0.5)
456
+
457
+ clips = []
458
+ snapshots = []
459
+ base = os.path.splitext(os.path.basename(video_path))[0]
460
+ for i, t in enumerate(events):
461
+ s = max(0.0, t - CLIP_PAD_S)
462
+ e = min(duration, t + CLIP_PAD_S)
463
+ clip_path = os.path.join(tempfile.gettempdir(), f"{base}_score_{i+1:02d}.mp4")
464
+ img_path = os.path.join(tempfile.gettempdir(), f"{base}_score_{i+1:02d}.jpg")
465
+ cut_clip(video_path, s, e, clip_path)
466
+ save_event_snapshot(video_path, t, img_path, fps)
467
+ label = f"Touch {i+1} @ {t:.2f}s"
468
+ clips.append((clip_path, label))
469
+ snapshots.append(img_path)
470
+
471
+ if debug:
472
+ debug_csv = DEBUG_DIR / f"scores_{base}.csv"
473
+ pd.DataFrame({"timestamp": df["timestamp"], "score": score}).to_csv(debug_csv, index=False)
474
+ print(f"[DEBUG] Saved score debug CSV → {debug_csv}")
475
+ save_debug_plot(df, score, events, base_name=base)
476
+ print(f"[DEBUG] Saved debug frames in {DEBUG_DIR}/")
477
+
478
+ return clips, f"✅ Detected {len(clips)} event(s). Snapshots saved to temp."
479
+
480
+ import time
481
+
482
+ def looping_progress():
483
+ """
484
+ Infinite generator that loops the fencer animation from 0 → 100%.
485
+ Yields progress bar HTML until stopped by the pipeline finishing.
486
+ """
487
+ while True:
488
+ for i in range(101):
489
+ bar = _make_progress_bar(i)
490
+ yield gr.update(value=bar, visible=True)
491
+ time.sleep(0.05) # controls speed of march (~5s per loop)
492
+
493
+ # ----------------------------
494
  # Gradio UI
495
+ # ----------------------------
496
+ CSS = """
497
+ .gradio-container {max-width: 900px; margin: auto;}
498
+ .header {text-align: center; margin-bottom: 20px;}
499
+ .full-width {width: 100% !important;}
500
+ .progress-bar {
501
+ width: 100%;
502
+ height: 30px;
503
+ background-color: #e0e0e0;
504
+ border-radius: 15px;
505
+ margin: 15px 0;
506
+ position: relative;
507
+ overflow: hidden;
508
+ }
509
+ .progress-fill {
510
+ height: 100%;
511
+ background-color: #4CAF50;
512
+ border-radius: 15px;
513
+ text-align: center;
514
+ line-height: 30px;
515
+ color: white;
516
+ font-weight: bold;
517
+ transition: width 0.3s;
518
+ }
519
+ .fencer {
520
+ position: absolute;
521
+ top: -5px;
522
+ font-size: 24px;
523
+ transition: left 0.3s;
524
+ transform: scaleX(-1); /* flip to face right */
525
+ }
526
+ """
527
+
528
+ def _make_progress_bar(percent: int, final_text: str = None):
529
+ text = f"{percent}%" if not final_text else final_text
530
+ return f"""
531
+ <div class="progress-bar">
532
+ <div id="progress-fill" class="progress-fill" style="width:{percent}%">{text}</div>
533
+ <div id="fencer" class="fencer" style="left:{percent}%">🤺</div>
534
+ </div>
535
+ """
536
+
537
+ def run_with_progress(video_file):
538
+ if not video_file:
539
+ yield [], "Please upload a video file.", gr.update(visible=False)
540
+ return
541
+
542
+ # Step 1: Extract frames + features
543
+ yield [], "🔄 Extracting frames...", _make_progress_bar(20)
544
+ df, fps = extract_feature_timeseries(video_file, frame_skip=FRAME_SKIP, debug=False)
545
+ if df.empty:
546
+ yield [], "❌ No frames processed!", _make_progress_bar(100, "No Frames ❌")
547
+ return
548
+
549
+ # Step 2–4: Predict & pick events via the single orchestrator
550
+ yield [], "🔄 Scoring & detecting touches...", _make_progress_bar(80)
551
+ clips, status_msg = extract_score_clips(video_file, debug=True)
552
+
553
+ # Step 5: Done (and cutting already handled in orchestrator)
554
+ final_bar = _make_progress_bar(100, f"Detected {len(clips)} Touches ⚡" if clips else "No Touches")
555
+ yield clips, status_msg, final_bar
556
+
557
+ with gr.Blocks(css=CSS, title="Fencing Scoreboard Detector") as demo:
558
+ with gr.Row(elem_classes="header"):
559
+ gr.Markdown(
560
+ "## 🤺 Fencing Score Detector\n"
561
+ "Upload a fencing bout video. We’ll detect scoreboard lights (YOLO + AutoGluon), "
562
+ "and return 4-second highlight clips around each scoring event."
563
+ )
564
+
565
+ in_video = gr.Video(label="Upload Bout Video", elem_classes="full-width", height=400)
566
+ run_btn = gr.Button("⚡ Detect Touches", elem_classes="full-width")
567
+
568
+ progress_html = gr.HTML(value="", label="Processing Progress", visible=False)
569
+ status = gr.Markdown("Ready.")
570
+ gallery = gr.Gallery(
571
+ label="Detected Clips",
572
+ columns=1,
573
+ height=400,
574
+ preview=True,
575
+ allow_preview=True,
576
+ show_download_button=True,
577
+ visible=False
578
+ )
579
+
580
+ def wrapped_run(video_file):
581
+ if not video_file:
582
+ yield gr.update(value=[], visible=False), "Please upload a video file.", gr.update(value=_make_progress_bar(0), visible=False)
583
+ return
584
+
585
+ # Start looping animation
586
+ progress_iter = looping_progress()
587
+
588
+ # Run pipeline in background, but yield progress until it finishes
589
+ import threading
590
+
591
+ result = {}
592
+
593
+ def run_pipeline():
594
+ clips, status_msg = extract_score_clips(video_file, debug=False)
595
+ result["clips"] = clips
596
+ result["status"] = status_msg
597
+
598
+ t = threading.Thread(target=run_pipeline)
599
+ t.start()
600
+
601
+ while t.is_alive():
602
+ yield gr.update(value=[], visible=False), "Processing...", next(progress_iter)
603
+
604
+ # When pipeline is done → final bar at 100% + output
605
+ clips, status_msg = result["clips"], result["status"]
606
+ final_bar = _make_progress_bar(100, "✅ Done")
607
+ yield gr.update(value=clips, visible=True), status_msg, final_bar
608
+
609
+ run_btn.click(
610
+ fn=wrapped_run,
611
+ inputs=in_video,
612
+ outputs=[gallery, status, progress_html],
613
+ )
614
 
615
  if __name__ == "__main__":
616
+ demo.launch(debug=True)