fix/upload-outputs-and-hough-shape

#1
Files changed (2) hide show
  1. app.py +168 -363
  2. pipeline/autolines.py +7 -2
app.py CHANGED
@@ -1,370 +1,175 @@
1
  """
2
- VAR-style offside visualizer Gradio app.
3
 
4
- Flow:
5
- upload -> scrub to the frame -> draw 2 goal-parallel lines on that frame
6
- -> Detect players (GPU, once/frame) -> click players to select
7
- -> mark defenders -> Build the 3D scene with a draggable offside plane.
 
8
 
9
- The GPU is touched ONLY inside pipeline.gpu (detect_frame / reconstruct_selected);
10
- every other callback here runs on cached numpy and stays on the CPU.
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 gradio as gr
21
-
22
- from pipeline.video import probe_video, grab_frame
23
- from pipeline.overlay import (annotate_detections, draw_lines, draw_masks,
24
- pick_box, pick_mask)
25
- from pipeline.autolines import propose_lines
26
- from pipeline import geometry as G
27
-
28
- # detector radio choice -> (backend, overlay/selection style)
29
- DETECTORS = {
30
- "ViTDet (boxes)": ("vitdet", "boxes"),
31
- "RF-DETR (boxes)": ("rfdetr", "boxes"),
32
- "RF-DETR (segments)": ("rfdetr", "segments"),
33
- }
34
-
35
-
36
- def _resolve(detector):
37
- return DETECTORS.get(detector, ("vitdet", "boxes"))
38
-
39
-
40
- def _render(frame, people, selected, style):
41
- if style == "segments":
42
- return draw_masks(frame, people, selected)
43
- return annotate_detections(frame, people, selected)
44
-
45
-
46
- def _pick(people, x, y, style):
47
- return pick_mask(people, x, y) if style == "segments" else pick_box(people, x, y)
48
-
49
-
50
- # ============================================================================
51
- # Stage (a): upload + frame scrubbing (pure CPU)
52
- # ============================================================================
53
- def on_upload(video_path):
54
- """New clip: size the slider, show frame 0, reset lines/frame state."""
55
- if not video_path:
56
- return (gr.update(maximum=1, value=0), None, "Upload a clip to begin.",
57
- 0, None, [], "")
58
- n, fps = probe_video(video_path)
59
- n_max = max(n - 1, 0)
60
- frame = grab_frame(video_path, 0)
61
- return (
62
- gr.update(maximum=max(n_max, 1), value=0),
63
- frame,
64
- f"{n} frames @ {fps:.1f} fps scrub to the moment the ball is played, "
65
- "then auto-detect or click the 2 goal-parallel lines.",
66
- n_max,
67
- frame, # st_frame: clean copy for line redraws
68
- [], # st_lines reset
69
- "", # line_status reset
70
- [], # st_families reset
71
- 0, # st_fam_idx reset
72
- )
73
-
74
-
75
- def on_scrub(video_path, idx):
76
- """Show the new frame and reset any half-drawn / proposed lines on it."""
77
- if not video_path:
78
- return None, None, [], "", [], 0
79
- frame = grab_frame(video_path, int(idx))
80
- return frame, frame, [], "", [], 0
81
-
82
-
83
- def step_frame(idx, delta, n_max):
84
- return max(0, min(int(n_max), int(idx) + delta))
85
-
86
-
87
- # ============================================================================
88
- # Stage (b): draw 2 goal-parallel lines on the scrubbed frame (CPU)
89
- # ============================================================================
90
- def on_line_click(line_pts, clean_frame, evt: gr.SelectData):
91
- """Collect 4 clicks = 2 lines; always redraw from the clean frame (no drift).
92
-
93
- A manual click clears any auto-proposed families (so Flip stops applying).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  """
95
- pts = list(line_pts) if line_pts else []
96
- if len(pts) >= 4:
97
- pts = []
98
- pts.append([float(evt.index[0]), float(evt.index[1])])
99
- img = draw_lines(clean_frame, pts)
100
- status = {1: "line 1: 1/2", 2: "line 1 set", 3: "line 2: 1/2",
101
- 4: "both lines set ✓ — now Detect players"}.get(len(pts), "")
102
- return pts, img, status, [], 0
103
-
104
-
105
- def on_auto_lines(clean_frame):
106
- """Propose the 2 goal-parallel lines from detected pitch lines."""
107
- if clean_frame is None:
108
- return [], None, "Scrub to a frame first.", [], 0
109
- fams = propose_lines(clean_frame)
110
- if not fams:
111
- return [], gr.update(), ("No clear pitch lines found — draw the 2 "
112
- "goal-parallel lines by hand."), [], 0
113
- pts = fams[0]["pts"]
114
- img = draw_lines(clean_frame, pts)
115
- status = (f"Proposed goal-parallel lines (direction 1/{len(fams)}). If the "
116
- "offside axis looks wrong, click **Flip line direction**, or just "
117
- "click the frame to redraw by hand.")
118
- return pts, img, status, fams, 0
119
-
120
-
121
- def on_flip_lines(families, fam_idx, clean_frame):
122
- """Switch the proposal to the other detected line-family."""
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
- segs = segs[:, 0]
 
 
 
 
 
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