Spaces:
Sleeping
Sleeping
File size: 16,514 Bytes
108571e a1d6f70 37f607b 26d4272 a1d6f70 43e4f43 3f39ba1 a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 c405c2d 37f607b c405c2d a1d6f70 c405c2d 05230d2 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b 9fc6edd 37f607b 9fc6edd 108571e 9fc6edd 108571e 9c22091 43e4f43 1cf1f49 9fc6edd 37f607b 9fc6edd 37f607b 9fc6edd 37f607b 9fc6edd 3f39ba1 9fc6edd 37f607b 9fc6edd 108571e 9fc6edd a1d6f70 37f607b a1d6f70 558a7f4 a1d6f70 54b934c a1d6f70 e7b57ee a1d6f70 37f607b a1d6f70 37f607b a1d6f70 1b040c9 87fd244 1b040c9 37f607b 1b040c9 000cde8 a1d6f70 558a7f4 a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 1608755 a1d6f70 37f607b a1d6f70 1608755 a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 37f607b a1d6f70 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 | # Fencing Scoreboard Clips - YOLO x AutoGluon (Gradio)
# ----------------------------------------------------
# Goal (AGENTS): Build a cohesive app that: upload video -> frame timestamps ->
# YOLO scoreboard detect + gray-mask background -> color feature timeseries ->
# AutoGluon Tabular detector -> multi-event 4s clips in a Gradio gallery.
#
# Plan (AGENTS):
# 1) Load YOLO weights from HF Hub; load AutoGluon Tabular predictor from HF Hub.
# 2) For each (skipped) frame: YOLO infer -> gray-mask non-scoreboard parts
# (keep color inside any bbox with conf>=0.85), then compute red/green features.
# 3) Roll features to add z-scores/diffs. Predict with AG Tabular.
# 4) Find local events with persistence + spacing; group & cut (-2s, +2s).
# 5) Gradio UI: video in → gallery of clips + status text out.
#
# Fencing Scoreboard Clips - YOLO x AutoGluon (Gradio)
import os, cv2, zipfile, shutil, tempfile, subprocess, pathlib
import numpy as np, pandas as pd
from typing import List, Tuple
import gradio as gr
# --- Patch for FastAI/AutoGluon deserialization ---
import fastai.tabular.core as ftc
from fastai.data.load import _FakeLoader, DataLoader
class TabWeightedDL(DataLoader):
"Compatibility patch for missing dataloader class in old AutoGluon FastAI models."
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
ftc.TabWeightedDL = TabWeightedDL
# =====================================================
# Configuration
# =====================================================
YOLO_REPO_ID = "mastefan/fencing-scoreboard-yolov8"
YOLO_FILENAME = "best.pt"
AG_REPO_ID = "emkessle/2024-24679-fencing-touch-predictor"
AG_ZIP_NAME = "autogluon_predictor_dir.zip"
FRAME_SKIP = 2
KEEP_CONF = 0.85
YOLO_CONF = 0.30
YOLO_IOU = 0.50
CLIP_PAD_S = 2.0
MIN_SEP_S = 1.2
GROUP_GAP_S = 1.5
DEBUG_MODE = False # set True to save debug images/CSVs
# =====================================================
# Dependency setup
# =====================================================
def _pip(pkgs):
import subprocess, sys
subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", *pkgs])
try:
from ultralytics import YOLO
except:
_pip(["ultralytics"]); from ultralytics import YOLO
try:
from autogluon.tabular import TabularPredictor
except:
_pip(["autogluon.tabular"]); from autogluon.tabular import TabularPredictor
try:
from huggingface_hub import hf_hub_download
except:
_pip(["huggingface_hub"]); from huggingface_hub import hf_hub_download
# =====================================================
# Model loading
# =====================================================
CACHE_DIR = pathlib.Path("hf_assets"); CACHE_DIR.mkdir(exist_ok=True)
def load_yolo_from_hub():
w = hf_hub_download(repo_id=YOLO_REPO_ID, filename=YOLO_FILENAME, cache_dir=CACHE_DIR)
print(f"[INFO] Loaded YOLO weights from {w}")
return YOLO(w)
def load_autogluon_tabular_from_hub():
"""Download and load AutoGluon predictor, removing any FastAI submodels."""
z = hf_hub_download(repo_id=AG_REPO_ID, filename=AG_ZIP_NAME, cache_dir=CACHE_DIR)
extract_dir = CACHE_DIR / "ag_predictor_native"
if extract_dir.exists():
shutil.rmtree(extract_dir)
with zipfile.ZipFile(z, "r") as zip_ref:
zip_ref.extractall(extract_dir)
# --- delete fastai models before loading to avoid deserialization errors ---
fastai_dirs = list(extract_dir.rglob("*fastai*"))
for p in fastai_dirs:
try:
if p.is_dir():
shutil.rmtree(p)
else:
p.unlink()
except Exception as e:
print(f"[WARN] Could not remove {p}: {e}")
print(f"[CLEANUP] Removed {len(fastai_dirs)} FastAI model files.")
# Now load normally (no version check)
from autogluon.tabular import TabularPredictor
predictor = TabularPredictor.load(str(extract_dir), require_py_version_match=False)
print(f"[INFO] Loaded AutoGluon predictor from {extract_dir}")
return predictor
_YOLO = None
_AGP = None
def yolo(): # lazy load
global _YOLO
if _YOLO is None: _YOLO = load_yolo_from_hub()
return _YOLO
def ag_predictor():
global _AGP
if _AGP is None: _AGP = load_autogluon_tabular_from_hub()
return _AGP
# =====================================================
# Image + feature utilities
# =====================================================
DEBUG_DIR = pathlib.Path("debug_frames"); DEBUG_DIR.mkdir(exist_ok=True)
def isolate_scoreboard_color(frame_bgr: np.ndarray,
conf: float = YOLO_CONF,
iou: float = YOLO_IOU,
keep_conf: float = KEEP_CONF,
debug: bool = False,
frame_id: int = None) -> np.ndarray:
"""
Improved version:
- Choose the largest bbox among candidates meeting confidence.
- Primary threshold: >= max(0.80, keep_conf)
- Fallback threshold: >= (primary - 0.05)
- Entire chosen bbox is restored to color; everything else is grayscale.
- Rejects low-saturation ROIs (flat/neutral areas).
"""
H, W = frame_bgr.shape[:2]
# Start fully grayscale
gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
gray = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
primary_thr = max(0.85, keep_conf)
fallback_thr = max(0.70, primary_thr - 0.03)
chosen_box = None
res = yolo().predict(frame_bgr, conf=conf, iou=iou, verbose=False)
if len(res):
r = res[0]
if getattr(r, "boxes", None) is not None and len(r.boxes) > 0:
boxes = r.boxes.xyxy.cpu().numpy()
scores = r.boxes.conf.cpu().numpy()
candidates = list(zip(boxes, scores))
# Prefer largest box meeting primary threshold
strong = [(b, s) for (b, s) in candidates if float(s) >= primary_thr]
if strong:
chosen_box, _ = max(strong, key=lambda bs: (bs[0][2]-bs[0][0]) * (bs[0][3]-bs[0][1]))
else:
# Fallback: largest box meeting fallback threshold
medium = [(b, s) for (b, s) in candidates if float(s) >= fallback_thr]
if medium:
chosen_box, _ = max(medium, key=lambda bs: (bs[0][2]-bs[0][0]) * (bs[0][3]-bs[0][1]))
if chosen_box is not None:
x1, y1, x2, y2 = [int(round(v)) for v in chosen_box]
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(W-1, x2), min(H-1, y2)
if x2 > x1 and y2 > y1:
# Single safeguard: reject very low-saturation ROIs
roi_color = frame_bgr[y1:y2, x1:x2]
if roi_color.size > 0:
hsv = cv2.cvtColor(roi_color, cv2.COLOR_BGR2HSV)
sat_mean = hsv[:, :, 1].mean()
if sat_mean < 30:
print(f"[WARN] Rejected bbox due to low saturation (mean={sat_mean:.1f})")
chosen_box = None
# If accepted, restore whole bbox to color
if chosen_box is not None:
gray[y1:y2, x1:x2] = frame_bgr[y1:y2, x1:x2]
# Optional debug save
if debug and frame_id is not None:
dbg = gray.copy()
if chosen_box is not None:
x1, y1, x2, y2 = [int(round(v)) for v in chosen_box]
cv2.rectangle(dbg, (x1, y1), (x2, y2), (0, 255, 0), 2)
out_path = DEBUG_DIR / f"frame_{frame_id:06d}.jpg"
cv2.imwrite(str(out_path), dbg)
print(f"[DEBUG] Saved debug frame → {out_path}")
return gray
def _count_color_pixels(rgb, ch):
R, G, B = rgb[:,:,0], rgb[:,:,1], rgb[:,:,2]
if ch==0: mask=(R>150)&(R>1.2*G)&(R>1.2*B)
else: mask=(G>100)&(G>1.05*R)&(G>1.05*B)
return int(np.sum(mask))
def color_pixel_ratio(rgb,ch): return _count_color_pixels(rgb,ch)/(rgb.shape[0]*rgb.shape[1]+1e-9)
def rolling_z(series, win=40):
med = series.rolling(win,min_periods=5).median()
mad = series.rolling(win,min_periods=5).apply(lambda x: np.median(np.abs(x-np.median(x))),raw=True)
mad = mad.replace(0, mad[mad>0].min() if (mad>0).any() else 1.0)
return (series-med)/mad
# =====================================================
# Video feature extraction
# =====================================================
def extract_feature_timeseries(video_path:str, frame_skip:int=FRAME_SKIP, debug:bool=DEBUG_MODE):
cap=cv2.VideoCapture(video_path)
if not cap.isOpened(): return pd.DataFrame(),0.0
fps=cap.get(cv2.CAP_PROP_FPS) or 30.0
total=int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 1
print(f"[INFO] Reading {total} frames @ {fps:.2f}fps ...")
if total > 1200:
frame_skip = 4
elif total > 2400:
frame_skip = 6
else:
frame_skip = FRAME_SKIP
rec,idx=[],0
while True:
ret,frame=cap.read()
if not ret: break
if idx%frame_skip==0:
ts=idx/fps
masked=isolate_scoreboard_color(frame,debug=debug,frame_id=idx)
rgb=cv2.cvtColor(masked,cv2.COLOR_BGR2RGB)
rec.append({
"frame_id":idx,"timestamp":ts,
"red_ratio":color_pixel_ratio(rgb,0),
"green_ratio":color_pixel_ratio(rgb,1)
})
idx+=1
cap.release()
df=pd.DataFrame(rec)
if df.empty: return df,fps
df["red_diff"]=df["red_ratio"].diff().fillna(0)
df["green_diff"]=df["green_ratio"].diff().fillna(0)
df["z_red"]=rolling_z(df["red_ratio"])
df["z_green"]=rolling_z(df["green_ratio"])
print(f"[INFO] Extracted {len(df)} processed frames.")
return df,fps
# =====================================================
# Predictor & event logic
# =====================================================
def predict_scores(df):
"""Predict illumination likelihoods using AutoGluon regression ensemble."""
feats = ["red_ratio", "green_ratio", "red_diff", "green_diff", "z_red", "z_green"]
X = df[feats].copy()
ag = ag_predictor()
# Get model list (older AG versions have .model_names(), newer have .model_names)
try:
models_all = ag.model_names()
except Exception:
models_all = ag.model_names
print(f"[INFO] Evaluating models: {models_all}")
preds_total = []
for m in models_all:
try:
print(f"[INFO] → Predicting with {m}")
y_pred = ag._learner.predict(X, model=m)
preds_total.append(pd.Series(y_pred, name=m))
except Exception as e:
print(f"[WARN] Skipping model {m}: {e}")
if not preds_total:
print("[ERROR] No usable models, returning zeros.")
return pd.Series(np.zeros(len(df)))
# Average predictions from working models
y_mean = pd.concat(preds_total, axis=1).mean(axis=1)
# Normalize 0–1 for consistency
rng = (y_mean.quantile(0.95) - y_mean.quantile(0.05)) or 1.0
score = ((y_mean - y_mean.quantile(0.05)) / rng).clip(0, 1)
print(f"[INFO] Used {len(preds_total)} valid submodels for regression scoring.")
return score
def pick_events(df,score,fps):
z=rolling_z(score,35); strong=(z>4.0); keep=strong.rolling(3,min_periods=1).sum()>=2
min_dist=max(1,int(MIN_SEP_S*fps))
y=score.values; out=[]; last=-min_dist
for i in range(1,len(y)-1):
if keep.iloc[i] and y[i]>y[i-1] and y[i]>y[i+1] and (i-last)>=min_dist:
out.append(float(df.iloc[i]["timestamp"])); last=i
if not out and len(y)>0: out=[float(df.iloc[int(np.argmax(y))]["timestamp"])]
grouped=[]
for t in sorted(out):
if (not grouped) or (t-grouped[-1])>GROUP_GAP_S: grouped.append(t)
return grouped
# =====================================================
# Clip utilities
# =====================================================
def _probe_duration(video_path):
try:
import ffmpeg
meta=ffmpeg.probe(video_path)
return float(meta["format"]["duration"])
except: return 0.0
def cut_clip(video_path,start,end,out_path):
try:
cmd=["ffmpeg","-y","-ss",str(start),"-to",str(end),"-i",video_path,"-c","copy",out_path]
sp=subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
if sp.returncode==0 and os.path.exists(out_path): return out_path
except: pass
from moviepy.editor import VideoFileClip
clip=VideoFileClip(video_path).subclip(start,end)
clip.write_videofile(out_path,codec="libx264",audio_codec="aac",verbose=False,logger=None)
return out_path
def extract_score_clips(video_path:str,debug:bool=DEBUG_MODE):
df,fps=extract_feature_timeseries(video_path,FRAME_SKIP,debug)
if df.empty: return [],"No frames processed."
score=predict_scores(df); events=pick_events(df,score,fps)
print(f"[INFO] Detected {len(events)} potential events: {events}")
dur=_probe_duration(video_path) or float(df["timestamp"].max()+CLIP_PAD_S+0.5)
out=[]; base=os.path.splitext(os.path.basename(video_path))[0]
for i,t in enumerate(events):
s=max(0,t-CLIP_PAD_S); e=min(dur,t+CLIP_PAD_S)
tmp=os.path.join(tempfile.gettempdir(),f"{base}_score_{i+1:02d}.mp4")
print(f"[INFO] Cutting clip {i+1}: {s:.2f}s→{e:.2f}s")
cut_clip(video_path,s,e,tmp)
out.append((tmp,f"Touch {i+1} @ {t:.2f}s"))
return out,f"✅ Detected {len(out)} event(s)."
# =====================================================
# Progress GUI helpers
# =====================================================
CSS = """
.gradio-container {max-width:900px;margin:auto;}
.full-width{width:100%!important;}
.progress-bar{width:100%;height:30px;background:#e0e0e0;border-radius:15px;margin:15px 0;position:relative;overflow:hidden;}
.progress-fill{height:100%;background:#4CAF50;border-radius:15px;text-align:center;line-height:30px;color:white;font-weight:bold;transition:width .3s;}
.fencer{position:absolute;top:-5px;font-size:24px;transition:left .3s;transform:scaleX(-1);}
"""
def _make_progress_bar(percent:int,final_text:str=None,label:str=""):
text=f"{percent}%" if not final_text else final_text
return f"""
<div class="progress-bar">
<div id="progress-fill" class="progress-fill" style="width:{percent}%">{label} {text}</div>
<div id="fencer" class="fencer" style="left:{percent}%">🤺</div>
</div>
"""
def run_with_progress(video_file):
if not video_file:
yield [],"Please upload a video.",_make_progress_bar(0)
return
print("[GUI] Starting processing...")
yield [],"🔄 Extracting frames...",_make_progress_bar(20,"","Pipeline")
df,fps=extract_feature_timeseries(video_file,FRAME_SKIP,DEBUG_MODE)
if df.empty:
yield [],"❌ No frames processed!",_make_progress_bar(100,"No Frames ❌","Pipeline");return
yield [],"🔄 YOLO masking...",_make_progress_bar(40,"","Pipeline")
yield [],"🔄 Feature analysis...",_make_progress_bar(60,"","Pipeline")
yield [],"🔄 Scoring...",_make_progress_bar(80,"","Pipeline")
clips,msg=extract_score_clips(video_file,DEBUG_MODE)
final=_make_progress_bar(100,f"Detected {len(clips)} Touches ⚡","Pipeline")
print("[GUI] Finished.")
yield clips,msg,final
# =====================================================
# Gradio interface
# =====================================================
with gr.Blocks(css=CSS,title="Fencing Scoreboard Detector") as demo:
gr.Markdown("## 🤺 Fencing Score Detector\nUpload a fencing bout video and automatically detect scoreboard lights using YOLO + AutoGluon.")
in_video=gr.Video(label="Upload Bout Video",elem_classes="full-width",height=400)
run_btn=gr.Button("⚡ Detect Touches",elem_classes="full-width")
progress_html=gr.HTML(value="",label="Progress",visible=False)
status=gr.Markdown("Ready.")
gallery=gr.Gallery(label="Detected Clips",columns=1,height=400,visible=False)
def wrapped_run(video_file):
print("[SYSTEM] User started detection.")
yield [],"Processing started...",gr.update(value=_make_progress_bar(0),visible=True)
for clips,msg,bar in run_with_progress(video_file):
print(f"[SYSTEM] {msg}")
yield gr.update(value=clips,visible=bool(clips)),msg,gr.update(value=bar,visible=True)
run_btn.click(fn=wrapped_run,inputs=in_video,outputs=[gallery,status,progress_html])
if __name__=="__main__":
demo.launch(debug=True)
|