Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions examples/soccer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ on the field.
Pitch keypoints are cached on disk like player detections. Optional:
`--pitch-detector`, `--pitch-model-path`, `--pitch-model-id`.

- `DISTANCE` — Direction dots (same as DIRECTION) plus cumulative distance chips (m),
per-player trace lines on the radar minimap, and a 3-second distance
leaderboard end-card. No m/s badges. Distance integrates via gated pitch homography.

```bash
python main.py --source_video_path data/2e57b9_0.mp4 \
--target_video_path data/renders/2e57b9_0-distance.mp4 \
--device mps --mode DISTANCE --max-frames 90
```

Requires pitch keypoints (same flags as SPEED).

## 🗺️ roadmap

- [ ] Add smoothing to eliminate flickering in RADAR mode.
Expand Down
174 changes: 174 additions & 0 deletions examples/soccer/distance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import cv2
import numpy as np
import supervision as sv

from sports.annotators.motion import (
annotate_team_ellipses,
build_trace_minimap,
dim_frame,
draw_distance_end_card,
draw_distance_labels,
draw_joystick_dots,
draw_speed_legend,
overlay_minimap,
spotlight,
)
from sports.common.kinematics import (
JoystickDotSmoother,
KalmanVelocitySmoother,
cumulative_distance_at_frame,
feet_xy,
)
from sports.common.tracking import open_video
from sports.common.video_tracking import VideoTrackingSession, build_video_tracking_session
from speed import (
KalmanSpeedDisplaySmoother,
_annotate_speed_overlay,
_speed_by_tid,
)


def run_distance(args, session=None) -> None:
"""Render direction dots + distance chips, trace minimap, and distance leaderboard end-card."""
if session is None:
session = build_video_tracking_session(args, need_homography=True)
_render_speed_distance_traces(args, session, show_speed=False, append_end_card=True)


def _annotate_player_overlay(
frame: np.ndarray,
dets: sv.Detections,
joy_smoother: JoystickDotSmoother,
speed_by_tid: dict[int, float],
*,
show_speed: bool,
) -> None:
"""Team ellipses + direction dots; optional m/s badges when show_speed."""
if show_speed:
_annotate_speed_overlay(
frame, dets, speed_by_tid, joy_smoother, show_legend=False,
)
else:
annotate_team_ellipses(frame, dets)
draw_joystick_dots(frame, dets, joy_smoother)


def _render_speed_distance_traces(
args,
session: VideoTrackingSession,
*,
show_speed: bool = True,
focus_tid: int | None = None,
append_end_card: bool = False,
) -> None:
fps, width, height = session.fps, session.width, session.height
gap_filled = session.gap_filled_transforms_by_frame
minimap_transforms = session.minimap_transforms_by_frame
locks = session.team_locks()
raw_tracks = session.tracks
tracked_lookup = session.tracked_by_frame()

vel_smoother = KalmanVelocitySmoother(alpha=0.3)
speed_smoother = KalmanSpeedDisplaySmoother(alpha=0.3)
joy_smoother = JoystickDotSmoother(alpha=0.32)
trace_by_tid: dict[int, list[np.ndarray]] = {}

cap, _, _, _ = open_video(args.source_video_path)
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
try:
with sv.VideoSink(args.target_video_path, sv.VideoInfo(width, height, fps)) as sink:
frame_idx = 0
while True:
ret, frame = cap.read()
if not ret:
break
frame_idx += 1
if args.max_frames is not None and frame_idx > args.max_frames:
break

tracked = tracked_lookup.get(frame_idx)
if tracked is None:
tracked = sv.Detections.empty()
dets = session.apply_replay_teams(
tracked,
locks=locks,
vel_smoother=vel_smoother,
)

radar_h = minimap_transforms.get(frame_idx)
if radar_h is not None and dets.tracker_id is not None:
fxy = feet_xy(dets)
xy_cm = radar_h.transform_points(fxy.astype(np.float32))
for i, tid in enumerate(dets.tracker_id):
tid = int(tid)
if tid < 0:
continue
if focus_tid is not None and tid != focus_tid:
continue
trace_by_tid.setdefault(tid, []).append(xy_cm[i].copy())

speed_by_tid: dict[int, float] = {}
if show_speed:
speed_by_tid = _speed_by_tid(
dets, gap_filled.get(frame_idx), fps, speed_smoother,
only_tid=focus_tid,
)

dist_by_tid: dict[int, float] = {}
if dets.tracker_id is not None:
for tid in dets.tracker_id:
tid = int(tid)
track = raw_tracks.get(tid)
if track is None:
continue
dist = cumulative_distance_at_frame(track, frame_idx)
if dist is not None:
dist_by_tid[tid] = dist

radar_transformer = radar_h
if focus_tid is not None:
spotlight_mask = (
dets.tracker_id == focus_tid
if dets.tracker_id is not None
else np.zeros(len(dets), dtype=bool)
)
visible = bool(spotlight_mask.any())
if visible:
box = dets.xyxy[spotlight_mask][0]
cx_f = int((box[0] + box[2]) / 2)
cy_f = int(box[3])
annotated = spotlight(frame, cx_f, cy_f)
else:
annotated = dim_frame(frame)
marked = dets[spotlight_mask]
_annotate_player_overlay(
annotated, marked, joy_smoother, speed_by_tid, show_speed=show_speed,
)
draw_distance_labels(annotated, marked, dist_by_tid)
mini_radar = build_trace_minimap(
dets, radar_transformer, trace_by_tid, focus_tid,
)
overlay_minimap(annotated, mini_radar)
else:
annotated = frame.copy()
_annotate_player_overlay(
annotated, dets, joy_smoother, speed_by_tid, show_speed=show_speed,
)
draw_distance_labels(annotated, dets, dist_by_tid)
if show_speed:
draw_speed_legend(annotated)
mini_radar = build_trace_minimap(
dets, radar_transformer, trace_by_tid, None,
)
overlay_minimap(annotated, mini_radar)
sink.write_frame(annotated)

if append_end_card:
end_card = draw_distance_end_card(width, height, raw_tracks)
n_end_frames = max(1, int(fps * 3))
for _ in range(n_end_frames):
sink.write_frame(end_card)
finally:
cap.release()

print(f"Wrote {args.target_video_path}")
6 changes: 5 additions & 1 deletion examples/soccer/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)

from direction import run_direction
from distance import run_distance
from speed import run_speed

PARENT_DIR = os.path.dirname(os.path.abspath(__file__))
Expand Down Expand Up @@ -86,16 +87,19 @@ class Mode(Enum):
RADAR = 'RADAR'
DIRECTION = 'DIRECTION'
SPEED = 'SPEED'
DISTANCE = 'DISTANCE'


ANALYTICS_MODES = (Mode.DIRECTION, Mode.SPEED)
ANALYTICS_MODES = (Mode.DIRECTION, Mode.SPEED, Mode.DISTANCE)


def run_analytics_mode(mode: Mode, args: argparse.Namespace) -> None:
if mode == Mode.DIRECTION:
run_direction(args)
elif mode == Mode.SPEED:
run_speed(args)
elif mode == Mode.DISTANCE:
run_distance(args)
else:
raise NotImplementedError(f"Mode {mode} is not an analytics mode.")

Expand Down
28 changes: 23 additions & 5 deletions examples/soccer/speed.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def _speed_by_tid(
transformer: ViewTransformer | None,
fps: float,
speed_smoother: KalmanSpeedDisplaySmoother,
only_tid: int | None = None,
) -> dict[int, float]:
speed_by_tid: dict[int, float] = {}
if dets.tracker_id is None or transformer is None or dets.data is None:
Expand All @@ -86,6 +87,8 @@ def _speed_by_tid(
tid = int(tid)
if tid < 0:
continue
if only_tid is not None and tid != only_tid:
continue
vx, vy = float(kf_vx[i]), float(kf_vy[i])
if not (np.isfinite(vx) and np.isfinite(vy)):
continue
Expand All @@ -97,6 +100,24 @@ def _speed_by_tid(
return speed_by_tid


def _annotate_speed_overlay(
frame: np.ndarray,
dets: sv.Detections,
speed_by_tid: dict[int, float],
joy_smoother: JoystickDotSmoother,
*,
show_legend: bool = True,
) -> None:
"""Team ellipses + speed badges + optional legend (in-place)."""
annotate_team_ellipses(frame, dets)
draw_joystick_dots(
frame, dets, joy_smoother,
speed_by_tid=speed_by_tid, show_speed=True,
)
if show_legend:
draw_speed_legend(frame)


def _render_speed(args, session: VideoTrackingSession) -> None:
fps, width, height = session.fps, session.width, session.height
gap_filled = session.gap_filled_transforms_by_frame
Expand Down Expand Up @@ -134,12 +155,9 @@ def _render_speed(args, session: VideoTrackingSession) -> None:
)

annotated = frame.copy()
annotated = annotate_team_ellipses(annotated, dets)
draw_joystick_dots(
annotated, dets, joy_smoother,
speed_by_tid=speed_by_tid, show_speed=True,
_annotate_speed_overlay(
annotated, dets, speed_by_tid, joy_smoother,
)
draw_speed_legend(annotated)
draw_radar_minimap(
annotated, dets, minimap_transforms.get(frame_idx),
)
Expand Down
Loading