Skip to content

feat(soccer): player motion analytics — speed, distance, direction, and run-all - #58

Open
SkalskiP wants to merge 50 commits into
mainfrom
feat/soccer-analytics-speed-distance
Open

feat(soccer): player motion analytics — speed, distance, direction, and run-all#58
SkalskiP wants to merge 50 commits into
mainfrom
feat/soccer-analytics-speed-distance

Conversation

@SkalskiP

Copy link
Copy Markdown
Collaborator

Summary

Adds player-motion analytics to the soccer demo (examples/soccer/), building on the existing detection, tracking, and team-classification pipeline.

AlexBodner and others added 30 commits June 22, 2026 10:41
Add analytics/ package with CLI entry point, shared support utilities
(tracker factory, Kalman velocity helpers, detector factories, annotation
helpers), and DIRECTION mode: BoTSORT tracking + team-colored ground
ellipses + image-space Kalman joystick direction dots, no homography.

requirements.txt already contains trackers and inference>=0.45.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add analytics/homography.py (RansacViewTransformer, PitchHomographyTracker,
replay_tracker_transforms, MetricContext, ensure_pitch_homography_maps,
homography_from_keypoints_radar, keypoints_from_inference_field — in-memory
only, no disk cache) and analytics/speed.py: SPEED mode that builds a
MetricContext, computes per-frame Kalman ground speed via the gap-filled H
map, and renders m/s + km/h badges + radar minimap per player.

Co-authored-by: Cursor <cursoragent@cursor.com>
Extend support.py with PlayerTrack, collect_tracks, compute_kinematics
(homography mode — smoothed pitch trajectory integration, gated H only),
and cumulative_distance_at_frame.  Add analytics/distance.py: DISTANCE
mode runs a two-pass pipeline (collect tracks → compute_kinematics →
render per-player cumulative-distance labels) and appends a 3-second
distance-ranking leaderboard end-card.

Co-authored-by: Cursor <cursoragent@cursor.com>
…races

Add analytics/player_focus.py: PLAYER_FOCUS mode.
Default (no --track-id): annotate all players, per-tracker-id hashed-color
radar traces on minimap, full-frame ellipses + joystick dots.
With --track-id N: spotlight that player (Gaussian dim), HUD showing live
Kalman ground speed (gap-filled H) + cumulative kinematics distance, radar
minimap with that player's trace highlighted.

Co-authored-by: Cursor <cursoragent@cursor.com>
analytics/main.py only puts examples/soccer on sys.path, but the sports
library is not pip-installed and lives at the repo root. Add the repo root
to sys.path in analytics/__init__.py so the analytics features can import
sports regardless of the current working directory.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add an additive, optional goalkeeper team-assignment path ported and adapted
from world_cup_projects (common/teams.py + common/pitch.py) into a new
analytics/goalkeepers.py:

- infer_goal_defenders: defensive-block (3 most defensive players per team)
  goal-side handshake instead of whole-team centroids
- resolve_goalkeepers_team_by_goal: GK -> team defending the nearer goal mouth
- apply_goalkeeper_teams_by_goal: per-frame GK team from pitch distance to goals
- warmup_goal_defenders_radar + stabilize_goalkeeper_teams: clip-level lock of
  each GK tracklet to one defending team (sports-radar pitch space)
- compute_goalkeeper_lock / apply_goalkeeper_frame: wiring helpers

New CLI flag in analytics/main.py: --gk-assignment {goal_distance,centroid},
default goal_distance. SPEED / DISTANCE / PLAYER_FOCUS use the goal-distance
path (per-frame assignment + clip lock) when selected, and fall back to the
existing centroid resolve_goalkeepers_team_id otherwise. DIRECTION has no
homography by design, so it always uses the centroid rule. Existing repo
behavior (centroid) is unchanged and remains available as the fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
…pass

Add lock_teams_by_tracklet_majority (ported from world_cup_projects/common/
teams.py) and a single detect->track->classify pass (compute_clip_locks) that
returns the per-tracklet team lock, the goalkeeper lock, and the locked goal
defenders, replacing the GK-only compute_goalkeeper_lock.

Co-authored-by: Cursor <cursoragent@cursor.com>
…e, team-colored direction dot

Port from world_cup_projects: 5-frame moving-average on image feet before
warping to pitch (HOMOGRAPHY_XY_SMOOTH/_smooth_xy); defended goal-mouth shading
on the radar (draw_goals_on_pitch); radial m/s speed badge chip + legend
(draw_speed_badge/draw_speed_legend) replacing the raw putText label; and
team-colored joystick dots with an optional show_ids switch for ellipses.

Co-authored-by: Cursor <cursoragent@cursor.com>
…on modes

Wire compute_clip_locks into direction/speed/distance/player_focus: apply the
clip-level team-id lock before GK assignment, draw radial speed badges + legend
(speed, player_focus follow-all), shade defended goals on the radar/trace
minimaps, and render team-colored direction dots with no track-id numbers in
DIRECTION.

Co-authored-by: Cursor <cursoragent@cursor.com>
…entry

Add DIRECTION/SPEED/DISTANCE/PLAYER_FOCUS to main.py's Mode enum and dispatch
them to the analytics/ helper modules, move the analytics CLI flags into
main.py's argparse (existing six modes and their defaults unchanged), add a
repo-root bootstrap so 'import sports' resolves, and delete the separate
analytics/main.py entry point.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ed pitch

The gated pitch homography picked plain-vs-mirror by keypoint reprojection error
alone, which on a near-symmetric pitch could lock onto the mirrored fit and
corrupt the radar, goalkeeper goal-distance assignment, and distance. Add a
layout score (valid_pitch_cm + _players_on_pitch_score + _score_homography_
candidate) that prefers the orientation placing players in-bounds with the teams
separated, thread optional per-frame player detections through _fit_frame,
PitchHomographyTracker.update, ensure_pitch_homography_maps, and
replay_tracker_transforms, and detect players inline in the homography pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
The minimap mixed homography sources (gated radar H for live dots, ungated
keypoint H for traces) and could draw a mirrored gated H. Make the keypoint-radar
H the single source of truth for both traces and live dots in speed and
player_focus (gated radar H only as a fallback), drop the dead 'or True' guard,
filter obviously off-pitch warps, and feed per-frame player detections into the
homography pass so its gated orientation is layout-aware too.

Co-authored-by: Cursor <cursoragent@cursor.com>
…asses

Adds analytics/cache.py (FrameCache + build_or_load_detections/keypoints) and a
build_metric_from_maps helper so the per-frame player detections and pitch
keypoints are computed once and reused across the homography, clip-lock, first,
and render passes (and across reruns), instead of re-detecting every pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
…potlight

Tracker ids are minted from a class-level counter shared across tracker
instances, so the goalkeeper-lock, first, and render passes produced disjoint id
spaces; the PLAYER_FOCUS --track-id spotlight could never match and per-tracklet
locks were effectively no-ops. Reset the counter in create_player_tracker so
every pass starts ids from 0, and in PLAYER_FOCUS read Kalman velocity from the
already-advanced render tracker instead of running a second tracker.update that
re-fragmented ids.

Co-authored-by: Cursor <cursoragent@cursor.com>
Pass a zero-arg detector factory into build_or_load_detections/keypoints so
the detector model is only loaded on a cache miss. On a cache hit the run
serves detections and pitch keypoints straight from disk and never
initializes the player/pitch models.

Co-authored-by: Cursor <cursoragent@cursor.com>
The cache holds regenerable per-frame detections and pitch keypoints and must
never be committed.

Co-authored-by: Cursor <cursoragent@cursor.com>
The ground ellipse used half the bounding-box width and a full 360-degree
ellipse, so players' markers rendered too small. Match sv.EllipseAnnotator
geometry instead: semi-axes of the full box width and 0.35x that width with the
open -45..235 degree arc, shared by every analytics mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
… ids, drop id labels

In single-track mode only the focused player is now annotated (ellipse + speed
badge); the rest stay dimmed by the spotlight with no markers of their own.
Raw tracker-id numbers are no longer drawn in either focus mode, and the HUD
names the subject as FOCUS PLAYER rather than exposing the id. Track ids are kept
consistent across the goalkeeper-lock, first, and render passes (each pass uses a
fresh tracker whose shared id counter is reset), so --track-id refers to one
stable player for the whole clip.

Co-authored-by: Cursor <cursoragent@cursor.com>
…goalkeepers

Key the clip-level team lock on tracker_id rather than detected class so a track
whose class flips between player and goalkeeper gets one combined team for the
whole clip. Goalkeeper-detected frames now have their team resolved (goal-distance
or centroid) before the majority vote, so they contribute votes too, and the
unified lock is applied as the final team step in every analytics render
(direction/speed/distance/player_focus) regardless of per-frame class.

Co-authored-by: Cursor <cursoragent@cursor.com>
…onal track-id chips

Co-authored-by: Cursor <cursoragent@cursor.com>
…raphy

Co-authored-by: Cursor <cursoragent@cursor.com>
Alpha-blend the minimap panel over the game footage (cv2.addWeighted on the
panel ROI) instead of pasting it opaque, so both the radar and the underlying
play stay visible. Adds a shared RADAR_MINIMAP_ALPHA (~0.6) and an overlay_minimap
helper used by the radar drawing for every demo that draws a minimap.

Co-authored-by: Cursor <cursoragent@cursor.com>
…us-all look

PLAYER_FOCUS now shows instant Kalman ground speed (the speed badge) and
accumulated distance (cumulative_distance_at_frame) as on-player chips in both
modes: follow-all annotates every tracked player, and the spotlight shows the
chips for the focused player, replacing the old lateral HUD text panel (spotlight
dim + radar minimap kept). Distance kinematics are computed for all tracked
players so every player can show its accumulated distance.

DISTANCE is restyled to the same follow-all look (all players + trace radar +
speed/distance chips) while keeping its distance leaderboard end-card so it stays
distinct from PLAYER_FOCUS follow-all. Factor the shared annotate-all-players plus
trace-radar rendering into support.helpers (annotate_motion_overlay,
build_trace_minimap, render_follow_all_frame) used by both demos.

Co-authored-by: Cursor <cursoragent@cursor.com>
Route every analytics feature through a shared trackable builder that drops
referee rows and outfield players that flicker onto a referee, flags whole
tracklets that ever coincide with a referee box (collected once in the clip
lock pass) and excludes them from tracking, kinematics and annotation. Also
enforce at most one goalkeeper per team per frame before tracking and align
the CMC frame-pass flag (botsort only) and the Kalman velocity state-index
guard with the rest of the motion stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
Lower the cumulative-distance kinematics floor from 10 frames to 2 (default
plus the DISTANCE and PLAYER_FOCUS call sites) so short-lived tracks are no
longer zeroed out. The smoothing and per-frame physical-step cap already
guard against spurious distance from brief tracks.

Co-authored-by: Cursor <cursoragent@cursor.com>
A candidate homography can clear the reprojection gate yet teleport the whole
image->pitch mapping for a single frame, spiking the radar trace past the
median smoothing and off-pitch outlier filter. Add a frame-to-frame jump gate:
after the reprojection gate, measure the largest pitch-space displacement (cm)
of the frame's keypoint correspondences between the previous accepted H and the
new candidate, and reject the candidate (holding the previous locked H like a
gap) when it exceeds SPEED_GATE_MAX_JUMP_CM (600 cm). The gate fires only
between consecutive accepts so the first frame and post-gap recovery are exempt,
protecting both the gated speed/distance transforms and the radar/trace H.

Co-authored-by: Cursor <cursoragent@cursor.com>
…oint H + jump gate)

The visible radar (minimap, per-track traces, live dots) read the gated, mirror-
capable radar_transforms, whose mirror branch let the minimap occasionally flip.
Switch the visible radar to a single shared homography fitted to the plain pitch
vertices only (no plain-vs-mirror candidates), so it physically cannot flip, and
recover stability without the mirror branch by holding the previous accepted matrix
on large frame-to-frame jumps (same jump rejection as the gated H, applied only
between consecutive accepts) plus a small EMA blend of the matrix. Trace median
smoothing and the off-pitch outlier filter are unchanged. The gated radar_transforms
(mirror + layout scoring + jump gate) still back the metrics (speed / distance /
goalkeeper goal-distance); only the visible radar uses the no-mirror map.

Co-authored-by: Cursor <cursoragent@cursor.com>
AlexBodner and others added 20 commits June 22, 2026 22:20
…H, no extra smoothing)

Drop the jump gate, EMA blend and held-matrix logic from the visible minimap
homography so the radar follows the raw per-frame no-mirror keypoint fit exactly
like the reference. Also remove the radar-trace median smoothing and off-pitch
outlier filter so the trace is drawn one-to-one from the per-frame positions.
The mirror-capable, jump-gated tracker H stays reserved for the metrics.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ce (track/homography/kinematics 1x)

Introduce ClipAnalysis to factor the shared expensive pipeline (cached
detections/keypoints, single BoTSORT tracking pass with stable tracker_ids,
referee filtering, team + goalkeeper resolution, MetricContext homography
maps, and kinematics) into one reusable computation per clip.

Refactor distance.py and player_focus.py to always replay from a precomputed
ClipAnalysis, and give direction.py / speed.py a dual path: the standalone
single-mode CLI keeps its own tracker (byte-for-byte unchanged), while run-all
replays the shared analysis. Add the run_all orchestrator and Mode.ALL wiring
so all five renders share one BoTSORT pass; a tracker-build counter verifies
tracking runs exactly once.

Co-authored-by: Cursor <cursoragent@cursor.com>
…oothing

Port world_cup draw_kalman_joystick_dots sizing into draw_joystick_dots:
dot radius scales with bbox width (round(width*0.13) clamped 3..8) and a
sqrt speed stick reaches the ground-ellipse edge at full deflection, replacing
the fixed dot_radius=5 / fixed arm model. Drop the dark outline ring for parity.

Restore radar trace polyline smoothing in draw_trace_on_minimap: median-smooth
the visible points (HOMOGRAPHY_PITCH_SMOOTH window) and drop off-pitch warps via
valid_pitch_cm (margin_cm=80) so spikes stop rendering. Cosmetic only - the
no-mirror keypoint minimap homography is untouched. Applies to DISTANCE and
PLAYER_FOCUS (shared build_trace_minimap).

Co-authored-by: Cursor <cursoragent@cursor.com>
…facts, README + lint for PR

Co-authored-by: Cursor <cursoragent@cursor.com>
… purpose

Rename support.py → player_motion.py (shared tracking, kinematics, team
helpers, and render utilities) and clip_analysis.py → clip_pipeline.py
(orchestrated per-clip computation producing ClipAnalysis). Update imports
and module docstrings; no behavior changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ied trace smoothing window

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…zed homography

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…k-assignment flag

Co-authored-by: Cursor <cursoragent@cursor.com>
…IRECTION

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Extract _speed_by_tid and _draw_speed_overlay in speed.py so distance and
speed_and_distance reuse the same Kalman speed loop and badge rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
Remove PR-scoped and legacy naming notes from shared module headers.

Co-authored-by: Cursor <cursoragent@cursor.com>
Show the team-colored joystick dot on the ellipse center for slow or
undefined velocity so standing players stay visible in DIRECTION and SPEED modes.

Co-authored-by: Cursor <cursoragent@cursor.com>
These functions had no callers on the speed/distance branch; modes use
_draw_speed_overlay instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedpypi/​inference@​1.3.375100100100100
Addedpypi/​trackers@​2.5.010010090100100

View full report

@SkalskiP

Copy link
Copy Markdown
Collaborator Author

This PR contains more than 5,000 lines. I am not able to review every line with enough attention, especially since GitHub does not load the full diff in the browser.

To make the review manageable, we need to split this PR into smaller, focused PRs and merge each one separately.

So far, the project structure has followed this pattern:

  • examples/ should contain requirements.txt, README.md, and the main main.py script.
  • sports/ should contain the implementation of all reusable tools.

The tools should work beyond this single use case. They should be composable and reusable in other projects. Please also group them by responsibility. For example:

  • sports/annotators/ should contain annotation-related utilities.
  • sports/common/team.py should contain team-related utilities.

Please also address the following points:

  • Remove every unused function, class, constant, and variable.
  • Keep function, class, and argument names reasonably short, but descriptive enough to explain their purpose. For example: _dim_frame, _spotlight, or cx.
  • Keep naming consistent with conventions already used in the project.
  • Use descriptive file and utility names. Long technical explanations at the top of files should not be necessary.
  • Add Google-style docstrings to every public function and class.
  • Use precise types. Typing something as object is almost as uninformative as using Any.

Comments used only as visual separators are unnecessary. For example:

# -- paths ----------------------------------------------------------------
# ---------------------------------------------------------------------------
# Build-or-load helpers (the public entry points used by the analytics features)
# ---------------------------------------------------------------------------
# ── end-card (3 seconds) — keeps DISTANCE distinct from follow-all ──

Please remove comments like these. Comments should explain non-obvious decisions or logic, rather than divide the file into visual sections.

I suggest starting by splitting the current PR into smaller parts based on clear responsibilities. Once this is done, I will be able to review each part properly and provide more useful feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants