Skip to content

Commit d3a7af7

Browse files
Add PASS_ALTERNATIVES freeze planner, overlays, and mode.
Reuse PassQualityScorer for lane scoring, existing pass annotators for freeze UI, and BallPositionHistory for ball-speed freeze gates — no parallel scoring or draw stacks. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 698b86a commit d3a7af7

9 files changed

Lines changed: 684 additions & 27 deletions

File tree

examples/soccer/README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,15 @@ on the field.
177177
--device mps --mode PASS_NETWORK --tracker bytetrack
178178
```
179179

180-
- `PASS_ALTERNATIVES`*(library Stage 0 on this branch)* Lane scoring
181-
(`pass_options`) and quality decoration for detected passes. Freeze-moment
182-
planner + CLI mode runner still to land. Not part of `ALL`.
180+
- `PASS_ALTERNATIVES` — Freeze moments with ranked open teammate pass lanes
181+
(lane scoring via `pass_options` / `PassQualityScorer`). Opt-in only (not
182+
part of `ALL`). Requires ball weights like `PASS_NETWORK`; prefer a full clip.
183+
184+
```bash
185+
python main.py --source_video_path data/08fd33_0.mp4 \
186+
--target_video_path data/renders/08fd33_0-pass-alternatives.mp4 \
187+
--device mps --mode PASS_ALTERNATIVES --tracker bytetrack
188+
```
183189

184190
- `ALL` — Runs DIRECTION, SPEED, DISTANCE, SPEED_AND_DISTANCE (all players), and
185191
SPEED_AND_DISTANCE (spotlight) in one pass. Builds a shared

examples/soccer/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from direction import run_direction
2828
from distance import run_distance
29+
from pass_alternatives import run_pass_alternatives
2930
from pass_network import run_pass_network
3031
from run_all import run_all
3132
from speed import run_speed
@@ -93,12 +94,13 @@ class Mode(Enum):
9394
DISTANCE = 'DISTANCE'
9495
SPEED_AND_DISTANCE = 'SPEED_AND_DISTANCE'
9596
PASS_NETWORK = 'PASS_NETWORK'
97+
PASS_ALTERNATIVES = 'PASS_ALTERNATIVES'
9698
ALL = 'ALL'
9799

98100

99101
ANALYTICS_MODES = (
100102
Mode.DIRECTION, Mode.SPEED, Mode.DISTANCE,
101-
Mode.SPEED_AND_DISTANCE, Mode.PASS_NETWORK, Mode.ALL,
103+
Mode.SPEED_AND_DISTANCE, Mode.PASS_NETWORK, Mode.PASS_ALTERNATIVES, Mode.ALL,
102104
)
103105

104106

@@ -113,6 +115,8 @@ def run_analytics_mode(mode: Mode, args: argparse.Namespace) -> None:
113115
run_speed_and_distance(args)
114116
elif mode == Mode.PASS_NETWORK:
115117
run_pass_network(args)
118+
elif mode == Mode.PASS_ALTERNATIVES:
119+
run_pass_alternatives(args)
116120
elif mode == Mode.ALL:
117121
run_all(args)
118122
else:
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""PASS_ALTERNATIVES mode: freeze moments with ranked open pass lanes."""
2+
3+
from __future__ import annotations
4+
5+
import supervision as sv
6+
7+
from sports.annotators.passing import (
8+
CARRIER_SHADOW_BGR,
9+
annotate_ball,
10+
annotate_pass_players,
11+
draw_carrier_ground_ellipse,
12+
draw_hud_bar,
13+
draw_pass_alternatives_overlay,
14+
draw_radar_minimap,
15+
)
16+
from sports.common.kinematics import feet_xy
17+
from sports.common.pass_alternatives import (
18+
DEFAULT_FINAL_OPTION_EXTRA_SECONDS,
19+
DEFAULT_FREEZE_SECONDS,
20+
DEFAULT_OPTION_REVEAL_SECONDS,
21+
DEFAULT_SLOWDOWN_RAMP_SECONDS,
22+
slowdown_hold_count,
23+
)
24+
from sports.common.pass_pitch import lane_scoring_transformer_for_frame
25+
from sports.common.possession import find_control_carrier
26+
from sports.common.tracking import open_video
27+
from sports.common.video_tracking import VideoTrackingSession, build_video_tracking_session
28+
29+
30+
def run_pass_alternatives(args, session: VideoTrackingSession | None = None) -> None:
31+
"""Render pass-alternative freeze frames to ``args.target_video_path``."""
32+
if session is None:
33+
session = build_video_tracking_session(args, need_homography=True)
34+
_render_pass_alternatives(args, session)
35+
36+
37+
def _annotate_live(
38+
frame,
39+
dets,
40+
*,
41+
radar_h,
42+
locked_goals,
43+
) -> object:
44+
image = frame.copy()
45+
image = annotate_pass_players(image, dets, show_tracker_ids=True)
46+
image = annotate_ball(image, dets)
47+
carrier = find_control_carrier(dets, transformer=radar_h)
48+
if carrier is not None:
49+
draw_carrier_ground_ellipse(
50+
image,
51+
feet_xy(dets)[carrier.index],
52+
transformer=radar_h,
53+
color_bgr=CARRIER_SHADOW_BGR,
54+
radius_m=0.5,
55+
alpha=0.42,
56+
filled=True,
57+
thickness=1,
58+
)
59+
image = draw_radar_minimap(
60+
image,
61+
dets,
62+
radar_h,
63+
locked_goal_defenders=locked_goals,
64+
)
65+
return draw_hud_bar(image, "PASS ALTERNATIVES")
66+
67+
68+
def _render_pass_alternatives(args, session: VideoTrackingSession) -> None:
69+
locks = session.team_locks()
70+
locked_goals = locks.locked_goal_defenders
71+
pass_by_frame = session.pass_by_frame
72+
freeze_events = session.pass_alternative_events()
73+
events_by_frame = {e.frame_idx: e for e in freeze_events}
74+
event_frames = sorted(events_by_frame)
75+
fps = float(session.fps)
76+
width, height = session.width, session.height
77+
ramp_frames = max(1, int(round(DEFAULT_SLOWDOWN_RAMP_SECONDS * fps)))
78+
reveal_frames = max(4, int(round(DEFAULT_OPTION_REVEAL_SECONDS * fps)))
79+
gap_filled = (
80+
session.gap_filled_transforms_by_frame if session.kp_by_frame is not None else {}
81+
)
82+
minimap_transforms = session.minimap_transforms_by_frame
83+
pitch_confidence = 0.9
84+
85+
print(f"PASS_ALTERNATIVES: {len(freeze_events)} freeze events")
86+
87+
cap, _, _, _ = open_video(args.source_video_path)
88+
try:
89+
with sv.VideoSink(args.target_video_path, sv.VideoInfo(width, height, fps)) as sink:
90+
frame_idx = 0
91+
while True:
92+
ret, frame = cap.read()
93+
if not ret:
94+
break
95+
frame_idx += 1
96+
if session.max_frames is not None and frame_idx > session.max_frames:
97+
break
98+
dets = pass_by_frame.get(frame_idx)
99+
if dets is None:
100+
continue
101+
102+
kps = (session.kp_by_frame or {}).get(frame_idx)
103+
radar_h = minimap_transforms.get(frame_idx)
104+
lane_h = lane_scoring_transformer_for_frame(
105+
gap_filled, frame_idx, kps, pitch_confidence=pitch_confidence
106+
) or radar_h
107+
108+
live = _annotate_live(
109+
frame, dets, radar_h=radar_h, locked_goals=locked_goals,
110+
)
111+
frames_until = next(
112+
(ef - frame_idx for ef in event_frames if ef >= frame_idx), None
113+
)
114+
hold = (
115+
slowdown_hold_count(frames_until, ramp_frames=ramp_frames)
116+
if frames_until is not None
117+
else 1
118+
)
119+
for _ in range(hold):
120+
sink.write_frame(live)
121+
122+
if frame_idx not in events_by_frame:
123+
continue
124+
125+
event = events_by_frame[frame_idx]
126+
n_options = min(3, len(event.options))
127+
phases: list[tuple[int, int]] = [(0, reveal_frames)]
128+
phases.extend((i, reveal_frames) for i in range(1, n_options + 1))
129+
min_freeze = sum(h for _, h in phases)
130+
extra_hold = max(
131+
0, int(round(DEFAULT_FREEZE_SECONDS * fps)) - min_freeze
132+
)
133+
final_extra = max(
134+
4, int(round(DEFAULT_FINAL_OPTION_EXTRA_SECONDS * fps))
135+
)
136+
if phases:
137+
phases[-1] = (
138+
phases[-1][0],
139+
phases[-1][1] + extra_hold + final_extra,
140+
)
141+
for revealed, phase_hold in phases:
142+
for step in range(phase_hold):
143+
progress = (step + 1) / max(phase_hold, 1)
144+
overlay = draw_pass_alternatives_overlay(
145+
frame,
146+
dets,
147+
event,
148+
revealed_options=revealed,
149+
reveal_progress=progress,
150+
transformer=lane_h,
151+
locked_goal_defenders=locked_goals,
152+
metric=True,
153+
)
154+
sink.write_frame(overlay)
155+
finally:
156+
cap.release()
157+
print(f"Wrote {args.target_video_path}")

sports/annotators/passing.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,13 +559,88 @@ def draw_pass_network_end_card(
559559
return card
560560

561561

562+
RANK_COLORS_BGR = (
563+
(80, 180, 255), # gold-ish BGR
564+
(200, 200, 200), # silver
565+
(180, 130, 70), # bronze
566+
)
567+
RANK_LABELS = ("1st", "2nd", "3rd")
568+
569+
570+
def draw_pass_alternatives_overlay(
571+
frame: np.ndarray,
572+
dets: sv.Detections,
573+
event,
574+
*,
575+
revealed_options: int | None = None,
576+
reveal_progress: float = 1.0,
577+
transformer=None,
578+
locked_goal_defenders: tuple[int, int] | None = None,
579+
metric: bool = True,
580+
) -> np.ndarray:
581+
"""Dimmed freeze frame with ranked pass-lane arrows (reuses shared draw helpers)."""
582+
dim = (frame.astype(np.float32) * 0.32).astype(np.uint8)
583+
options = list(event.options)
584+
visible = options
585+
if revealed_options is not None:
586+
visible = options[: max(0, revealed_options)]
587+
588+
dim = annotate_pass_players(dim, dets, show_tracker_ids=True)
589+
dim = annotate_ball(dim, dets)
590+
591+
feet = feet_xy(dets)
592+
carrier_xy = feet[event.carrier.index]
593+
cx, cy = int(carrier_xy[0]), int(carrier_xy[1])
594+
draw_carrier_ground_ellipse(
595+
dim,
596+
carrier_xy,
597+
transformer=transformer,
598+
color_bgr=CARRIER_SHADOW_BGR,
599+
radius_m=0.55,
600+
alpha=0.55,
601+
filled=True,
602+
thickness=2,
603+
)
604+
605+
if revealed_options == 0:
606+
draw_score_chip(dim, "ON BALL", (cx, cy - 42), bg_bgr=ROBOFLOW_PURPLE_BGR)
607+
return draw_hud_bar(dim, "PASS ALTERNATIVES")
608+
609+
progress = float(np.clip(reveal_progress, 0.0, 1.0))
610+
for rank, option in enumerate(visible):
611+
color = RANK_COLORS_BGR[min(rank, len(RANK_COLORS_BGR) - 1)]
612+
recv_xy = feet[option.receiver_index]
613+
rx, ry = int(recv_xy[0]), int(recv_xy[1])
614+
is_new = rank == len(visible) - 1
615+
alpha = ease_out_cubic(progress) if is_new else 1.0
616+
draw_glow_arrow(dim, (cx, cy), (rx, ry), color, thickness=5, alpha=alpha)
617+
if alpha < 0.85:
618+
continue
619+
midx, midy = (cx + rx) // 2, (cy + ry) // 2
620+
label = RANK_LABELS[min(rank, len(RANK_LABELS) - 1)]
621+
chip = f"{label} {option.score:.2f}"
622+
if metric:
623+
chip += f" {option.length:.1f} m"
624+
draw_score_chip(dim, chip, (midx, midy), bg_bgr=color)
625+
626+
dim = draw_radar_minimap(
627+
dim,
628+
dets,
629+
transformer,
630+
locked_goal_defenders=locked_goal_defenders,
631+
)
632+
return draw_hud_bar(dim, "PASS ALTERNATIVES - top open lanes")
633+
634+
562635
# Re-export for runners that already use motion.draw_radar_minimap
563636
__all__ = [
564637
"annotate_ball",
565638
"annotate_pass_players",
566639
"draw_carrier_ground_ellipse",
567640
"draw_collaboration_web",
641+
"draw_glow_arrow",
568642
"draw_hud_bar",
643+
"draw_pass_alternatives_overlay",
569644
"draw_pass_network_end_card",
570645
"draw_pass_network_frame_overlays",
571646
"draw_radar_minimap",

0 commit comments

Comments
 (0)