Spaces:
Sleeping
Sleeping
fix/upload-outputs-and-hough-shape
#1
by icarofracassi - opened
- app.py +168 -363
- pipeline/autolines.py +7 -2
app.py
CHANGED
|
@@ -1,370 +1,175 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
"""
|
| 12 |
|
| 13 |
-
import os
|
| 14 |
-
|
| 15 |
-
# headless GL + CUDA fragmentation hygiene must be set before any heavy import
|
| 16 |
-
os.environ.setdefault("PYOPENGL_PLATFORM", "egl")
|
| 17 |
-
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 18 |
-
|
| 19 |
import numpy as np
|
| 20 |
-
import
|
| 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 |
-
def
|
| 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 |
-
if
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
if not families:
|
| 124 |
-
return gr.update(), gr.update(), "Run **Auto-detect lines** first.", gr.update()
|
| 125 |
-
if len(families) < 2:
|
| 126 |
-
return (families[0]["pts"], draw_lines(clean_frame, families[0]["pts"]),
|
| 127 |
-
"Only one line direction was found — redraw by hand if it's wrong.", 0)
|
| 128 |
-
new_idx = (int(fam_idx) + 1) % len(families)
|
| 129 |
-
pts = families[new_idx]["pts"]
|
| 130 |
-
return (pts, draw_lines(clean_frame, pts),
|
| 131 |
-
f"Line direction {new_idx + 1}/{len(families)}.", new_idx)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
# ============================================================================
|
| 135 |
-
# Stage (c): detect (GPU, cached) + show boxes (CPU after the call)
|
| 136 |
-
# ============================================================================
|
| 137 |
-
def on_detect(video_path, idx, conf, detector):
|
| 138 |
-
"""Detect step: chosen detector (ViTDet / RF-DETR), boxes + masks. No meshes here."""
|
| 139 |
-
from pipeline.gpu import detect_frame
|
| 140 |
-
if not video_path:
|
| 141 |
-
return None, [], [], "Upload a clip first.", gr.update(choices=[], value=[])
|
| 142 |
-
backend, style = _resolve(detector)
|
| 143 |
-
people = detect_frame(video_path, idx, conf, backend)
|
| 144 |
-
if not people:
|
| 145 |
-
return (None, [], [], "No players detected — lower the confidence slider.",
|
| 146 |
-
gr.update(choices=[], value=[]))
|
| 147 |
-
annotated = _render(grab_frame(video_path, idx), people, [], style)
|
| 148 |
-
unit = "silhouette" if style == "segments" else "box"
|
| 149 |
-
msg = (f"Detected {len(people)} players with {detector}. Click a player's "
|
| 150 |
-
f"{unit} to select (click again to deselect).")
|
| 151 |
-
return annotated, people, [], msg, gr.update(choices=[], value=[])
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
# ============================================================================
|
| 155 |
-
# Stage (d): click players to select (CPU)
|
| 156 |
-
# ============================================================================
|
| 157 |
-
def on_select_player(people, selected, clean_frame, cur_def, detector, evt: gr.SelectData):
|
| 158 |
-
"""Toggle the clicked player; refresh highlight + defender choices (box or segment)."""
|
| 159 |
-
if not people:
|
| 160 |
-
return None, selected or [], "Detect players first.", gr.update()
|
| 161 |
-
_, style = _resolve(detector)
|
| 162 |
-
hit = _pick(people, evt.index[0], evt.index[1], style)
|
| 163 |
-
sel = list(selected or [])
|
| 164 |
-
if hit is not None:
|
| 165 |
-
sel.remove(hit) if hit in sel else sel.append(hit)
|
| 166 |
-
sel = sorted(sel)
|
| 167 |
-
img = _render(clean_frame, people, sel, style)
|
| 168 |
-
msg = (f"Selected players: {sel}. Mark defenders below, then Build."
|
| 169 |
-
if sel else "Click a player to select.")
|
| 170 |
-
keep_def = [d for d in (cur_def or []) if d in sel]
|
| 171 |
-
return img, sel, msg, gr.update(choices=sel, value=keep_def)
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
def on_detector_change(people, selected, clean_frame, detector):
|
| 175 |
-
"""Re-render current detections in the new style; hint to re-Detect on backend swap."""
|
| 176 |
-
if not people:
|
| 177 |
-
return gr.update(), "Detector set — click Detect players to apply."
|
| 178 |
-
_, style = _resolve(detector)
|
| 179 |
-
return _render(clean_frame, people, selected or [], style), \
|
| 180 |
-
"Re-rendered. For a different backend, click Detect players to re-run."
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
# ============================================================================
|
| 184 |
-
# Stage (e)+(f): place players + build the Plotly scene with a draggable plane
|
| 185 |
-
# ============================================================================
|
| 186 |
-
def on_build(video_path, idx, people_det, selected_ids, line_pts, flip_up,
|
| 187 |
-
attack_dir, defender_ids):
|
| 188 |
-
from pipeline.gpu import reconstruct_selected, get_faces
|
| 189 |
-
if not selected_ids:
|
| 190 |
-
return None, "Click at least one player to select.", gr.update(), None, +1, [], {}
|
| 191 |
-
if not line_pts or len(line_pts) < 4:
|
| 192 |
-
return None, "Draw 2 goal-parallel lines (4 points) on the frame first.", \
|
| 193 |
-
gr.update(), None, +1, [], {}
|
| 194 |
-
|
| 195 |
-
# Reconstruct ONLY the selected players' boxes (the heavy GPU step).
|
| 196 |
-
selected_ids = sorted(int(i) for i in selected_ids)
|
| 197 |
-
boxes = [people_det[i]["bbox"] for i in selected_ids]
|
| 198 |
-
recon = reconstruct_selected(video_path, idx, boxes)
|
| 199 |
-
if not recon:
|
| 200 |
-
return None, "Reconstruction returned no meshes.", gr.update(), None, +1, [], {}
|
| 201 |
-
# Key meshes back to their original detection ids (recon order == boxes order).
|
| 202 |
-
people = {selected_ids[k]: recon[k] for k in range(len(recon))}
|
| 203 |
-
faces = get_faces()
|
| 204 |
-
h, w = grab_frame(video_path, idx).shape[:2]
|
| 205 |
-
focal = people[selected_ids[0]]["focal_length"]
|
| 206 |
-
gdir = G.goal_dir_from_lines(line_pts, focal, w, h)
|
| 207 |
-
|
| 208 |
-
placed = G.place_players(people, selected_ids, gdir, flip_up=flip_up)
|
| 209 |
-
med = float(np.median([placed[i][:, 2].max() for i in placed]))
|
| 210 |
-
masks = {i: G.non_arm_mask(people[i]) for i in selected_ids} # exclude arms/hands
|
| 211 |
-
|
| 212 |
-
attack_sign = +1 if str(attack_dir).startswith("+X") else -1
|
| 213 |
-
dset = [int(d) for d in (defender_ids or [])]
|
| 214 |
-
plane_x = G.offside_plane_x(placed, attack_sign, dset, masks)
|
| 215 |
-
fig = G.build_scene(placed, faces, plane_x, attack_sign, dset, masks)
|
| 216 |
-
|
| 217 |
-
allX = np.vstack(list(placed.values()))[:, 0]
|
| 218 |
-
x0, x1 = float(allX.min() - 4), float(allX.max() + 4)
|
| 219 |
-
plane_update = gr.update(minimum=x0, maximum=x1, value=float(plane_x),
|
| 220 |
-
visible=True, label="Drag the offside plane (X, m)")
|
| 221 |
-
|
| 222 |
-
warn = " ⚠ heights look wrong — toggle 'flip up'." if med < 1.0 else ""
|
| 223 |
-
return (fig, f"Median player height {med:.2f} m (expect ~1.7–1.9).{warn}",
|
| 224 |
-
plane_update, placed, attack_sign, dset, masks)
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
def on_plane(placed, plane_x, attack_sign, defender_ids, masks):
|
| 228 |
-
"""Re-render the scene at a new plane X — pure CPU on the cached placement."""
|
| 229 |
-
from pipeline.gpu import get_faces
|
| 230 |
-
if not placed:
|
| 231 |
-
return gr.update()
|
| 232 |
-
return G.build_scene(placed, get_faces(), float(plane_x),
|
| 233 |
-
int(attack_sign), defender_ids or [], masks)
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
def on_gen3js(placed, plane_x, attack_sign, defender_ids, masks):
|
| 237 |
-
"""Generate a clean three.js view of the current scene (uses the current plane)."""
|
| 238 |
-
from pipeline.gpu import get_faces
|
| 239 |
-
from pipeline import threed
|
| 240 |
-
if not placed:
|
| 241 |
-
return "<p style='color:#9a8bd0'>Build a 3D scene first, then generate.</p>"
|
| 242 |
-
return threed.scene_html(placed, get_faces(), float(plane_x),
|
| 243 |
-
int(attack_sign), defender_ids or [], masks)
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
# ============================================================================
|
| 247 |
-
# UI
|
| 248 |
-
# ============================================================================
|
| 249 |
-
# Roboflow-flavored theme (violet primary ≈ Roboflow purple #7C3AED) + light CSS.
|
| 250 |
-
RF_PURPLE = "#7C3AED"
|
| 251 |
-
THEME = gr.themes.Soft(primary_hue=gr.themes.colors.violet,
|
| 252 |
-
neutral_hue=gr.themes.colors.slate,
|
| 253 |
-
font=[gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"])
|
| 254 |
-
RF_CSS = """
|
| 255 |
-
.gradio-container {max-width: 1180px !important}
|
| 256 |
-
#rf-header {background: #7C3AED; color: #fff; padding: 18px 22px; border-radius: 14px;
|
| 257 |
-
margin-bottom: 6px}
|
| 258 |
-
#rf-header h2 {color: #fff !important; margin: 0 0 4px 0}
|
| 259 |
-
#rf-header p {color: #EDE7FF !important; margin: 0; font-size: 0.92rem}
|
| 260 |
-
.gr-button-primary, button.primary {background: #7C3AED !important; border-color: #7C3AED !important}
|
| 261 |
-
"""
|
| 262 |
-
|
| 263 |
-
with gr.Blocks(title="VAR Offside Visualizer") as demo:
|
| 264 |
-
gr.HTML(
|
| 265 |
-
"<div id='rf-header'><h2>VAR-style Offside Visualizer</h2>"
|
| 266 |
-
"<p>Upload → scrub → click 2 goal-parallel lines → Detect → "
|
| 267 |
-
"click players to select → mark defenders → Build</p></div>"
|
| 268 |
-
)
|
| 269 |
-
gr.Markdown(
|
| 270 |
-
"_Scale comes from reconstructed body height, so positions are approximate "
|
| 271 |
-
"metres — good for relative offside ordering, not sub-10 cm calls._"
|
| 272 |
-
)
|
| 273 |
-
|
| 274 |
-
# session state
|
| 275 |
-
st_nmax = gr.State(0) # last valid frame index
|
| 276 |
-
st_frame = gr.State(None) # clean RGB of the current frame
|
| 277 |
-
st_people = gr.State([]) # slim detections
|
| 278 |
-
st_lines = gr.State([]) # clicked / proposed line points
|
| 279 |
-
st_families = gr.State([]) # auto-proposed line families
|
| 280 |
-
st_fam_idx = gr.State(0) # which proposed family is active
|
| 281 |
-
st_selected = gr.State([]) # player ids selected by clicking
|
| 282 |
-
st_placed = gr.State(None) # placed meshes after build
|
| 283 |
-
st_attack = gr.State(+1)
|
| 284 |
-
st_defenders = gr.State([])
|
| 285 |
-
st_masks = gr.State({}) # per-player non-arm vertex masks
|
| 286 |
-
|
| 287 |
-
video = gr.Video(label="1. Upload match clip")
|
| 288 |
-
status = gr.Markdown()
|
| 289 |
-
|
| 290 |
-
with gr.Row():
|
| 291 |
-
frame_slider = gr.Slider(0, 1, value=0, step=1,
|
| 292 |
-
label="2. Scrub to the offside frame")
|
| 293 |
-
with gr.Row():
|
| 294 |
-
prev_btn = gr.Button("◀ prev frame")
|
| 295 |
-
next_btn = gr.Button("next frame ▶")
|
| 296 |
-
|
| 297 |
-
# Stage (b): lines drawn on the scrubbed frame — auto-proposed or by hand
|
| 298 |
-
frame_view = gr.Image(label="3. Goal-parallel lines: auto-detect or click 4 points",
|
| 299 |
-
interactive=True)
|
| 300 |
-
with gr.Row():
|
| 301 |
-
auto_lines_btn = gr.Button("✨ Auto-detect lines")
|
| 302 |
-
flip_lines_btn = gr.Button("↔ Flip line direction")
|
| 303 |
-
line_status = gr.Markdown()
|
| 304 |
-
|
| 305 |
-
# Stage (c)/(d): detect, then click to select
|
| 306 |
-
with gr.Row():
|
| 307 |
-
thr = gr.Slider(0.0, 0.95, value=0.3, step=0.05, label="Detection confidence")
|
| 308 |
-
detector = gr.Radio(list(DETECTORS.keys()), value="ViTDet (boxes)",
|
| 309 |
-
label="Detector")
|
| 310 |
-
detect_btn = gr.Button("4. Detect players (GPU)", variant="primary")
|
| 311 |
-
detect_view = gr.Image(label="5. Click a player to select (click again to deselect)",
|
| 312 |
-
interactive=True)
|
| 313 |
-
select_status = gr.Markdown()
|
| 314 |
-
|
| 315 |
-
# Stage (e)/(f)
|
| 316 |
-
defenders = gr.CheckboxGroup(choices=[], label="6. Defenders (incl. GK) — sets the offside line")
|
| 317 |
-
with gr.Row():
|
| 318 |
-
flip = gr.Checkbox(False, label="flip up (if players are upside-down)")
|
| 319 |
-
attack = gr.Radio(["−X ←", "+X →"], value="−X ←",
|
| 320 |
-
label="Attacking direction")
|
| 321 |
-
build_btn = gr.Button("7. Build 3D scene + offside line", variant="primary")
|
| 322 |
-
|
| 323 |
-
scene = gr.Plot(label="3D scene")
|
| 324 |
-
plane_slider = gr.Slider(-10, 10, value=0, step=0.05, visible=False,
|
| 325 |
-
label="Drag the offside plane (X, m)")
|
| 326 |
-
build_status = gr.Markdown()
|
| 327 |
-
|
| 328 |
-
gen3js_btn = gr.Button("🎥 Generate clean 3D scene (three.js)")
|
| 329 |
-
scene3js = gr.HTML()
|
| 330 |
-
|
| 331 |
-
# --- wiring ---
|
| 332 |
-
video.change(on_upload, [video],
|
| 333 |
-
[frame_slider, frame_view, status, st_nmax, st_frame, st_lines,
|
| 334 |
-
line_status, st_families, st_fam_idx])
|
| 335 |
-
frame_slider.change(on_scrub, [video, frame_slider],
|
| 336 |
-
[frame_view, st_frame, st_lines, line_status,
|
| 337 |
-
st_families, st_fam_idx])
|
| 338 |
-
prev_btn.click(lambda i, m: step_frame(i, -1, m), [frame_slider, st_nmax], [frame_slider])
|
| 339 |
-
next_btn.click(lambda i, m: step_frame(i, +1, m), [frame_slider, st_nmax], [frame_slider])
|
| 340 |
-
|
| 341 |
-
frame_view.select(on_line_click, [st_lines, st_frame],
|
| 342 |
-
[st_lines, frame_view, line_status, st_families, st_fam_idx])
|
| 343 |
-
auto_lines_btn.click(on_auto_lines, [st_frame],
|
| 344 |
-
[st_lines, frame_view, line_status, st_families, st_fam_idx])
|
| 345 |
-
flip_lines_btn.click(on_flip_lines, [st_families, st_fam_idx, st_frame],
|
| 346 |
-
[st_lines, frame_view, line_status, st_fam_idx])
|
| 347 |
-
|
| 348 |
-
detect_btn.click(on_detect, [video, frame_slider, thr, detector],
|
| 349 |
-
[detect_view, st_people, st_selected, select_status, defenders])
|
| 350 |
-
detect_view.select(on_select_player,
|
| 351 |
-
[st_people, st_selected, st_frame, defenders, detector],
|
| 352 |
-
[detect_view, st_selected, select_status, defenders])
|
| 353 |
-
detector.change(on_detector_change, [st_people, st_selected, st_frame, detector],
|
| 354 |
-
[detect_view, select_status])
|
| 355 |
-
|
| 356 |
-
build_btn.click(
|
| 357 |
-
on_build,
|
| 358 |
-
[video, frame_slider, st_people, st_selected, st_lines, flip, attack, defenders],
|
| 359 |
-
[scene, build_status, plane_slider, st_placed, st_attack, st_defenders, st_masks])
|
| 360 |
-
plane_slider.change(on_plane,
|
| 361 |
-
[st_placed, plane_slider, st_attack, st_defenders, st_masks],
|
| 362 |
-
[scene])
|
| 363 |
-
gen3js_btn.click(on_gen3js,
|
| 364 |
-
[st_placed, plane_slider, st_attack, st_defenders, st_masks],
|
| 365 |
-
[scene3js])
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
if __name__ == "__main__":
|
| 369 |
-
demo.queue().launch(server_name="0.0.0.0", server_port=7860,
|
| 370 |
-
theme=THEME, css=RF_CSS)
|
|
|
|
| 1 |
"""
|
| 2 |
+
Auto-PROPOSE the two goal-parallel lines (CPU, OpenCV only).
|
| 3 |
|
| 4 |
+
Fully-automatic goal-direction detection is unreliable on broadcast frames
|
| 5 |
+
(a full pitch homography can score "confident" yet be degenerate), so this only
|
| 6 |
+
*proposes*: it detects the white pitch lines, clusters them by vanishing point
|
| 7 |
+
into the two pitch line-families, and returns the 2 longest lines of each. The UI
|
| 8 |
+
shows one family, lets the user flip to the other, or redraw by hand.
|
| 9 |
|
| 10 |
+
Line detection (field/white masks + Hough merge) is ported from the project's
|
| 11 |
+
backend pitch-registration code, which is already proven on this footage.
|
| 12 |
"""
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
import numpy as np
|
| 15 |
+
import cv2
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ----------------------------- masks -------------------------------------- #
|
| 19 |
+
def field_mask(bgr):
|
| 20 |
+
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
| 21 |
+
h, s, v = hsv[..., 0], hsv[..., 1], hsv[..., 2]
|
| 22 |
+
green = ((h > 30) & (h < 95) & (s > 40) & (v > 40)).astype(np.uint8) * 255
|
| 23 |
+
green = cv2.morphologyEx(green, cv2.MORPH_CLOSE, np.ones((25, 25), np.uint8))
|
| 24 |
+
green = cv2.morphologyEx(green, cv2.MORPH_OPEN, np.ones((9, 9), np.uint8))
|
| 25 |
+
cnts, _ = cv2.findContours(green, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 26 |
+
mask = np.zeros_like(green)
|
| 27 |
+
if cnts:
|
| 28 |
+
cv2.drawContours(mask, [max(cnts, key=cv2.contourArea)], -1, 255, -1)
|
| 29 |
+
return mask
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def white_mask(bgr, fmask):
|
| 33 |
+
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
| 34 |
+
s, v = hsv[..., 1], hsv[..., 2]
|
| 35 |
+
w = ((v > 150) & (s < 60)).astype(np.uint8) * 255
|
| 36 |
+
w = cv2.bitwise_and(w, fmask)
|
| 37 |
+
return cv2.morphologyEx(w, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ----------------------------- line tools --------------------------------- #
|
| 41 |
+
def _seg_angle(seg):
|
| 42 |
+
x1, y1, x2, y2 = seg
|
| 43 |
+
return np.degrees(np.arctan2(y2 - y1, x2 - x1)) % 180.0
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _line_from_seg(seg):
|
| 47 |
+
x1, y1, x2, y2 = seg
|
| 48 |
+
l = np.cross([x1, y1, 1.0], [x2, y2, 1.0])
|
| 49 |
+
return l / (np.hypot(l[0], l[1]) or 1.0)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _intersect(l1, l2):
|
| 53 |
+
p = np.cross(l1, l2)
|
| 54 |
+
return None if abs(p[2]) < 1e-9 else np.array([p[0] / p[2], p[1] / p[2]])
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def merge_lines(wmask):
|
| 58 |
+
"""Cluster Hough segments into merged pitch lines (angle + signed distance)."""
|
| 59 |
+
segs = cv2.HoughLinesP(wmask, 1, np.pi / 180, threshold=70,
|
| 60 |
+
minLineLength=70, maxLineGap=30)
|
| 61 |
+
if segs is None:
|
| 62 |
+
return []
|
| 63 |
+
# cv2.HoughLinesP is documented to return shape (N, 1, 4), but some
|
| 64 |
+
# OpenCV builds/wheels squeeze the middle axis and hand back (N, 4)
|
| 65 |
+
# directly. reshape(-1, 4) works for both layouts, whereas segs[:, 0]
|
| 66 |
+
# silently mis-indexes on the squeezed form (each "segment" ends up a
|
| 67 |
+
# bare scalar, which then fails to unpack in _seg_angle/_line_from_seg).
|
| 68 |
+
segs = segs.reshape(-1, 4)
|
| 69 |
+
used = np.zeros(len(segs), bool)
|
| 70 |
+
merged = []
|
| 71 |
+
for i in range(len(segs)):
|
| 72 |
+
if used[i]:
|
| 73 |
+
continue
|
| 74 |
+
ai, li = _seg_angle(segs[i]), _line_from_seg(segs[i])
|
| 75 |
+
group = [segs[i]]; used[i] = True
|
| 76 |
+
for j in range(i + 1, len(segs)):
|
| 77 |
+
if used[j]:
|
| 78 |
+
continue
|
| 79 |
+
da = abs(ai - _seg_angle(segs[j]))
|
| 80 |
+
if min(da, 180 - da) > 6:
|
| 81 |
+
continue
|
| 82 |
+
mid = np.array([(segs[j][0] + segs[j][2]) / 2,
|
| 83 |
+
(segs[j][1] + segs[j][3]) / 2, 1.0])
|
| 84 |
+
if abs(li @ mid) < 14:
|
| 85 |
+
group.append(segs[j]); used[j] = True
|
| 86 |
+
pts = np.array([[s[0], s[1]] for s in group] +
|
| 87 |
+
[[s[2], s[3]] for s in group], dtype=np.float32)
|
| 88 |
+
vx, vy, x0, y0 = cv2.fitLine(pts, cv2.DIST_L2, 0, 0.01, 0.01).ravel()
|
| 89 |
+
l = _line_from_seg((x0, y0, x0 + vx, y0 + vy))
|
| 90 |
+
proj = (pts - [x0, y0]) @ np.array([vx, vy])
|
| 91 |
+
a = np.array([x0, y0]) + proj.min() * np.array([vx, vy])
|
| 92 |
+
b = np.array([x0, y0]) + proj.max() * np.array([vx, vy])
|
| 93 |
+
merged.append(dict(line=l, a=a, b=b, length=float(np.hypot(*(b - a)))))
|
| 94 |
+
return merged
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ----------------------- vanishing-point families ------------------------- #
|
| 98 |
+
def _line_dir(l):
|
| 99 |
+
d = np.array([-l[1], l[0]])
|
| 100 |
+
return d / (np.linalg.norm(d) + 1e-9)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _vp_inliers(lines, vp, thr_deg=2.5):
|
| 104 |
+
inl = []
|
| 105 |
+
for k, m in enumerate(lines):
|
| 106 |
+
mid = (m["a"] + m["b"]) / 2.0
|
| 107 |
+
to_vp = vp - mid
|
| 108 |
+
n = np.linalg.norm(to_vp)
|
| 109 |
+
if n < 1e-6:
|
| 110 |
+
inl.append(k); continue
|
| 111 |
+
cosang = abs(_line_dir(m["line"]) @ (to_vp / n))
|
| 112 |
+
if np.degrees(np.arccos(min(cosang, 1.0))) < thr_deg:
|
| 113 |
+
inl.append(k)
|
| 114 |
+
return inl
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _best_family(lines):
|
| 118 |
+
"""VP supported by the most total line-length; returns (inlier_idxs, vp)."""
|
| 119 |
+
best = None
|
| 120 |
+
for i in range(len(lines)):
|
| 121 |
+
for j in range(i + 1, len(lines)):
|
| 122 |
+
vp = _intersect(lines[i]["line"], lines[j]["line"])
|
| 123 |
+
if vp is None:
|
| 124 |
+
continue
|
| 125 |
+
inl = _vp_inliers(lines, vp)
|
| 126 |
+
score = sum(lines[k]["length"] for k in inl)
|
| 127 |
+
if best is None or score > best[0]:
|
| 128 |
+
best = (score, inl, vp)
|
| 129 |
+
return (best[1], best[2]) if best else (None, None)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _family_points(fam):
|
| 133 |
+
"""4 points (2 longest lines, 2 endpoints each): [l1a, l1b, l2a, l2b]."""
|
| 134 |
+
fam = sorted(fam, key=lambda m: -m["length"])[:2]
|
| 135 |
+
pts = []
|
| 136 |
+
for m in fam:
|
| 137 |
+
pts += [[float(m["a"][0]), float(m["a"][1])],
|
| 138 |
+
[float(m["b"][0]), float(m["b"][1])]]
|
| 139 |
+
return pts
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def propose_lines(frame_rgb):
|
| 143 |
+
"""Return up to 2 families, each as {"pts": [[x,y]*4], "vp": [x,y], "n": int}.
|
| 144 |
+
|
| 145 |
+
families[0] is the larger (by total length); the goal-parallel one may be
|
| 146 |
+
either — the UI lets the user flip. Empty list if too few lines are found.
|
| 147 |
"""
|
| 148 |
+
bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
|
| 149 |
+
merged = merge_lines(white_mask(bgr, field_mask(bgr)))
|
| 150 |
+
longl = sorted([m for m in merged if m["length"] > 80],
|
| 151 |
+
key=lambda m: -m["length"])[:14]
|
| 152 |
+
if len(longl) < 2:
|
| 153 |
+
return []
|
| 154 |
+
|
| 155 |
+
fams = []
|
| 156 |
+
inl, vp = _best_family(longl)
|
| 157 |
+
if inl is None:
|
| 158 |
+
return []
|
| 159 |
+
fam1 = [longl[k] for k in inl]
|
| 160 |
+
fams.append({"pts": _family_points(fam1), "vp": vp.tolist(), "n": len(fam1)})
|
| 161 |
+
|
| 162 |
+
rest = [longl[k] for k in range(len(longl)) if k not in inl]
|
| 163 |
+
if len(rest) >= 2:
|
| 164 |
+
inl2, vp2 = _best_family(rest)
|
| 165 |
+
if inl2 is not None:
|
| 166 |
+
fam2 = [rest[k] for k in inl2]
|
| 167 |
+
if len(fam2) >= 2:
|
| 168 |
+
fams.append({"pts": _family_points(fam2),
|
| 169 |
+
"vp": vp2.tolist(), "n": len(fam2)})
|
| 170 |
+
# The largest family is usually the lines PERPENDICULAR to the goal on broadcast
|
| 171 |
+
# angles, so show the second family first (it's usually goal-parallel); the Flip
|
| 172 |
+
# button still reaches the other one.
|
| 173 |
+
if len(fams) == 2:
|
| 174 |
+
fams = [fams[1], fams[0]]
|
| 175 |
+
return fams
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pipeline/autolines.py
CHANGED
|
@@ -60,7 +60,12 @@ def merge_lines(wmask):
|
|
| 60 |
minLineLength=70, maxLineGap=30)
|
| 61 |
if segs is None:
|
| 62 |
return []
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
used = np.zeros(len(segs), bool)
|
| 65 |
merged = []
|
| 66 |
for i in range(len(segs)):
|
|
@@ -167,4 +172,4 @@ def propose_lines(frame_rgb):
|
|
| 167 |
# button still reaches the other one.
|
| 168 |
if len(fams) == 2:
|
| 169 |
fams = [fams[1], fams[0]]
|
| 170 |
-
return fams
|
|
|
|
| 60 |
minLineLength=70, maxLineGap=30)
|
| 61 |
if segs is None:
|
| 62 |
return []
|
| 63 |
+
# cv2.HoughLinesP is documented to return shape (N, 1, 4), but some
|
| 64 |
+
# OpenCV builds/wheels squeeze the middle axis and hand back (N, 4)
|
| 65 |
+
# directly. reshape(-1, 4) works for both layouts, whereas segs[:, 0]
|
| 66 |
+
# silently mis-indexes on the squeezed form (each "segment" ends up a
|
| 67 |
+
# bare scalar, which then fails to unpack in _seg_angle/_line_from_seg).
|
| 68 |
+
segs = segs.reshape(-1, 4)
|
| 69 |
used = np.zeros(len(segs), bool)
|
| 70 |
merged = []
|
| 71 |
for i in range(len(segs)):
|
|
|
|
| 172 |
# button still reaches the other one.
|
| 173 |
if len(fams) == 2:
|
| 174 |
fams = [fams[1], fams[0]]
|
| 175 |
+
return fams
|