Camera/CV Track — v2 Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: On top of the built v1 vision/ subproject, detect the ball and project both teams + the ball onto a top-down radar. Deliverables: the ball detected and marked on annotated.mp4, and a top-down radar animation (radar.mp4) showing both teams + the ball on the SoccerPitchConfiguration (12000×7000 cm). Land the load-bearing controls the ADR makes mandatory: a self-grading, held-out-validated homography calibrator, and honest ball post-processing (capped ≤0.5 s interpolation + interpolated=True + ball_known_fraction).
Architecture: v2 extends the existing one-pipeline/one-contract layout. It implements the three modules left as referenced-but-empty stubs in v1 — calibrate.py, pitch.py, radar.py — and extends detect.py (a second dedicated ball pass), report.py (ball marker), and pipeline.py (--ball/--radar, second NVENC encode). The pipeline grows to decode → detect(players+ball) → track → teams → pitch(homography) → radar → report. The deterministic geometry (homography projection, point-set conditioning guard) and ball post-process (single-candidate gate + capped interpolation + known-fraction) get full unit tests; the model-dependent ball pass gets a contract test + a benchmark-decides-default + an acceptance run.
Tech Stack: Python 3.11 · the v1 stack (Ultralytics YOLO11 · supervision 0.29 · transformers SigLIP + scikit-learn · OpenCV · torch CUDA/MPS · ffmpeg/NVENC · pytest) · v2 adds the dedicated football-ball-detection-rejhg ball model · sv.InferenceSlicer 2×2 tiling · OpenCV cv2.findHomography(..., cv2.RANSAC) + cv2.perspectiveTransform · the vendored sports ViewTransformer + SoccerPitchConfiguration · optional filterpy Kalman (linear fallback if absent).
Source spec: ADR-0023 (build-spec form) — this plan implements v2 only (§7-v2): ball + homography + radar. Not in v2: analytics/possession/distance (§7-v3), the GoPro 360 de-warp pre-pass (§7-v4). It builds directly on the v1 implementation plan (which delivered decode/detect(persons)/track/teams/report(annotated) + the privacy/reproducibility firewall).
Global Constraints¶
Every task's requirements implicitly include this section. Values are inherited verbatim from the v1 plan + ADR-0023 §3/§5/§10; unchanged in v2 except where a v2-specific line is added.
- Python 3.11 is the single pinned interpreter (
pyproject.tomlrequires-python == "3.11.*"); 3.10–3.13 are known-compatible-but-untested. - Dependency pins are unchanged from v1 (
requirements.txtranges, exact inrequirements.lock):ultralytics >=8.4.60,<8.5,supervision ==0.29.*,opencv-python4.x,torch/torchvisionfrom the cu12x CUDA wheel index,transformers,scikit-learn,numpy,PyYAML,pytest. v2 may addfilterpy(Kalman ball smoothing) as a range pin — but the ball post-process MUST degrade to a pure-numpy linear interpolation whenfilterpyis absent (no hard dependency for the deliverable). - Do NOT use
sv.ByteTrack(removed in supervision 0.30). Tracking stays BoT-SORT via Ultralyticsmodel.track(persist=True, tracker=<yaml>)(unchanged from v1). - Roboflow
sportsis VENDORED, not installed — v1 createdfootballcv/vendor/sports/(pinned upstream SHA, recorded invision/README.md). v2 is the first runtime consumer of itsViewTransformer+SoccerPitchConfiguration; if the v1 vendoring only stubbed those, this plan's Task 2/Task 5 land the real copied modules at the recorded SHA.ViewTransformer(source, target)buildsHviacv2.findHomographyinternally and exposestransform_points(points:(N,2)) -> (N,2);SoccerPitchConfigurationhaslength=12000,width=7000(cm) and a.verticesproperty of 32(x_cm, y_cm)keypoints. - AGPL note: Ultralytics is AGPL-3.0; fine while
vision/stays private/undistributed. The ball pass is also Ultralytics YOLO, so it sits behind the same swappabledetectinterface as the player pass — the RF-DETR escape hatch stays a contained change (ADR §5/§12-Q3). No publish planned. - Privacy gate (#1, non-negotiable): PUBLIC adult/pro footage ONLY, every phase. No youth footage anywhere in v2. The v1
vision/.gitignoreprivacy firewall is already in force; v2 adds one ignored artifact path — the per-mount real calibration isconfig/calibration.yaml(already gitignored by v1);config/calibration.example.yamlstays the committed template. All tests run on synthetic fixtures or the one SHA-pinned public clip — never children's data.radar.mp4is a derived tactical board with no faces, but it is still written to the gitignoredout/. - Offline guards: unchanged —
pipeline.pysetsHF_HUB_OFFLINE=1,TRANSFORMERS_OFFLINE=1, and disables Ultralytics analytics unconditionally at startup, asserts they are set, before loading any model (including the new ball model).calibrate.pyandpitch.pymake no network calls;radar.pymakes none. - Determinism: the geometry (
pitch), the point-set guard, and the ball post-process are fully deterministic and bit-reproducible (pure numpy/OpenCV on fixed inputs) — they get exact-value unit tests. The model-dependent ball pass inherits the v1 caveat ("two runs identical" = anchoring/known-fraction stability, not bit-equality; CUDA is not bit-reproducible; assertions pinned to the owner's 3060). - GPU budget: the 3060 has 12 GB; models load/release one at a time. v2 adds the ball model as a SECOND resident model — load the player detector, run the player pass; release; load the ball detector, run the ball pass; release; load SigLIP for teams.
yolo11x+ the ball model + SigLIP never co-resident (ADR §5). - Performance (ADR §10): two NVENC encode passes now (
annotated.mp4+radar.mp4), both 1080p (a coach clip does not need 4K). The radar is drawn with OpenCV, not matplotlib (matplotlib is tens of ms/frame). The ball pass config (plainimgsz=1280vs 2×2 tiling) is benchmarked and the recall-per-second winner is the default (Task 3) — record which config the wall-clock assumes. v2+ running slow / overnight is acceptable; TensorRT is optional, not a gate. - VERSION CONTROL: the repo is NOT a git repo today. There are therefore no per-task git commits; each task's checkpoint is the full test suite green. Where a step references git (none new in v2), it degrades gracefully when
.gitis absent. - EXECUTION (Docker only): all commands run in Docker via
vision/docker-compose.yml— never on the host. Read everyRun: pytest …/python -m footballcv …step asdocker compose run --rm test(suite) ordocker compose run --rm selftest, fromvision/; the GPU pipeline runs on the RTX 3060 desktop behind thegpuprofile (docker compose --profile gpu run --rm run …). The host.venvis not used. - Source-driven discipline: the OpenCV/
supervision/vendored-sportscalls in this plan are written against the pinned versions; the API shapes below were verified againstsupervision0.29 (InferenceSlicer(callback, slice_wh, overlap_wh, overlap_filter, iou_threshold)—overlap_whis absolute pixels, not a ratio; called asslicer(image);sv.crop_image(image=, xyxy=)) and the vendoredsports(ViewTransformer,SoccerPitchConfiguration). Re-confirm each against the installed versions before implementing the model/encode-touching steps. The deterministic-logic tasks (1, 2, 4, 7) are fully specified and need no external verification.
File Structure¶
Created/extended in this plan (all under vision/; matches ADR §6). v1 files are listed only where v2 touches them.
vision/
config/
calibration.example.yaml # v1 — committed template (image_points/pitch_points lists)
calibration.yaml # gitignored — REAL per-mount calibration, WRITTEN by calibrate.py (Task 1)
botsort_football.yaml # v1 — unchanged
footballcv/
types.py # v1 — UNCHANGED; BallObs/PlayerObs.pitch_xy already carry v2 fields
detect.py # EXTEND (Task 3) — add BallDetector (dedicated pass, tiling-or-plain)
pitch.py # NEW (Task 2) — load H from calibration, project feet + ball -> pitch_xy
calibrate.py # NEW (Task 1) — interactive 4+-point picker -> calibration.yaml; self-grading
radar.py # NEW (Task 5) — OpenCV top-down board from the WorldState stream
ball_postprocess.py # NEW (Task 4) — single-candidate gate + capped interpolation + known-fraction
report.py # EXTEND (Task 6) — draw ball marker on annotated frames
pipeline.py # EXTEND (Task 6) — --ball/--radar wiring + GK->pitch-space + 2nd NVENC pass
vendor/sports/ # v1 — v2 is the first runtime consumer (ViewTransformer, SoccerPitchConfiguration)
test/
conftest.py # v1 — EXTEND: add a synthetic-homography + synthetic-ball-track fixture
test_calibrate.py # NEW (Task 1)
test_pitch.py # NEW (Task 2)
test_ball_detect.py # NEW (Task 3)
test_ball_postprocess.py # NEW (Task 4)
test_radar.py # NEW (Task 5)
test_pipeline_v2.py # NEW (Task 6) — GK pitch-space + flag wiring (mocked models)
test_radar_anchoring.py # NEW (Task 7) — the §7-v2 numeric anchoring gate
models/MANIFEST.json (committed, v1) gains a verified ball entry — the v1 fetch_models.py already pulls football-ball-detection-rejhg; v2 is the first to use it via resolve_weight("ball", ...).
Task 1: Self-grading calibration (calibrate.py)¶
The load-bearing pre-pass for everything downstream (radar + possession). Interactive 4+-point picker writes config/calibration.yaml; self-grades on HELD-OUT points (§7.2 — fit on 4, measure on the rest; reprojecting the fit points proves nothing) and rejects near-collinear / small-convex-hull point sets that yield an ill-conditioned H (a centre-clustered set gives a stable wrong far side). The interactive picker is a thin OpenCV-window shell; the graded logic is pure and fully unit-tested with a synthetic known homography.
Files:
- Create: vision/footballcv/calibrate.py, vision/test/test_calibrate.py
- Extend: vision/test/conftest.py (synthetic-homography fixture)
Interfaces:
- Produces: grade_calibration(image_points:np.ndarray, pitch_points:np.ndarray, *, n_fit:int=4) -> Grade where Grade has .held_out_px_error:float, .verdict:str ("GOOD"|"RE-PICK"), .ill_conditioned:bool, .reason:str; point_set_is_degenerate(image_points:np.ndarray, *, min_hull_frac:float, min_collinearity:float) -> tuple[bool,str]; write_calibration(out_path:Path, image_points, pitch_points) -> dict; main(argv) CLI (--frame --at --out) — the interactive picker (not unit-tested; exercised at the acceptance run).
- [ ] Step 1: Add the synthetic-homography fixture
# append to vision/test/conftest.py
import numpy as np, cv2, pytest
@pytest.fixture
def known_homography():
"""A known image<->pitch homography + 8 well-separated correspondences.
Pitch points are cm on the 12000x7000 SoccerPitchConfiguration; image points
are their projection through a fixed, deliberately-perspective H (so fit-on-4 /
measure-on-rest has real, non-trivial held-out error near zero but not exactly 0)."""
pitch = np.array([[0, 0], [12000, 0], [12000, 7000], [0, 7000],
[6000, 0], [6000, 7000], [6000, 3500], [0, 3500]], np.float32)
# a plausible elevated-wide camera homography (pitch cm -> image px)
H = np.array([[0.05, 0.004, 60.0],
[0.0, 0.03, 40.0],
[0.0, 3e-6, 1.0]], np.float32)
img = cv2.perspectiveTransform(pitch.reshape(-1, 1, 2), H).reshape(-1, 2)
return {"image_points": img.astype(np.float32),
"pitch_points": pitch, "H": H}
- [ ] Step 2: Write the failing tests
# vision/test/test_calibrate.py
import numpy as np, pytest
from footballcv.calibrate import grade_calibration, point_set_is_degenerate
def test_held_out_error_is_small_for_a_consistent_point_set(known_homography):
g = grade_calibration(known_homography["image_points"],
known_homography["pitch_points"], n_fit=4)
# fit on 4, measure on the other 4: a consistent set reprojects tightly
assert g.held_out_px_error < 2.0
assert g.verdict == "GOOD"
assert g.ill_conditioned is False
def test_inconsistent_point_corrupts_held_out_error(known_homography):
img = known_homography["image_points"].copy()
img[5] += np.array([80.0, 80.0]) # shove one HELD-OUT point off
g = grade_calibration(img, known_homography["pitch_points"], n_fit=4)
assert g.held_out_px_error > 5.0 and g.verdict == "RE-PICK"
def test_rejects_near_collinear_point_set():
# all image points on (almost) one line -> ill-conditioned H
img = np.array([[100, 100], [200, 101], [300, 102], [400, 103],
[500, 104], [600, 105]], np.float32)
bad, reason = point_set_is_degenerate(img, min_hull_frac=0.02, min_collinearity=0.15)
assert bad and "collinear" in reason.lower()
def test_rejects_centre_clustered_small_hull_set():
# 6 points crammed in a tiny central blob -> small convex hull -> degenerate
img = np.array([[600, 350], [610, 352], [605, 360], [615, 358],
[608, 354], [612, 356]], np.float32)
bad, reason = point_set_is_degenerate(img, min_hull_frac=0.02, min_collinearity=0.15)
assert bad and "hull" in reason.lower()
def test_grade_flags_degenerate_set_as_ill_conditioned(known_homography):
img = np.array([[100, 100], [200, 101], [300, 102], [400, 103],
[500, 104], [600, 105], [700, 106], [800, 107]], np.float32)
g = grade_calibration(img, known_homography["pitch_points"], n_fit=4)
assert g.ill_conditioned is True and g.verdict == "RE-PICK"
- [ ] Step 3: Run to verify it fails
Run: python -m pytest vision/test/test_calibrate.py -v
Expected: FAIL — ModuleNotFoundError: footballcv.calibrate.
- [ ] Step 4: Implement
calibrate.py
# vision/footballcv/calibrate.py
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import cv2
import yaml
GOOD_PX_THRESHOLD = 5.0 # held-out reprojection error ceiling near the named region (§7.2, seed)
@dataclass
class Grade:
held_out_px_error: float # mean reprojection error on the HELD-OUT points (px)
verdict: str # "GOOD" | "RE-PICK"
ill_conditioned: bool # near-collinear / small-hull point set
reason: str
def _convex_hull_area(pts: np.ndarray) -> float:
hull = cv2.convexHull(pts.astype(np.float32))
return float(cv2.contourArea(hull))
MIN_HULL_AREA_PX = 2000.0 # absolute convex-hull floor (px^2); see (c) below
def point_set_is_degenerate(image_points: np.ndarray, *, min_hull_frac: float = 0.02,
min_collinearity: float = 0.15) -> tuple[bool, str]:
"""Reject sets that make H ill-conditioned (§7.2): (a) near-collinear (the points'
spread is essentially 1-D), (b) a small convex hull relative to their bounding box, or
(c) an absolutely tiny convex hull (centre-clustered -> confidently-wrong far side)."""
pts = np.asarray(image_points, np.float32)
if len(pts) < 4:
return True, "need >= 4 points"
# (a) collinearity: ratio of the smaller to larger PCA singular value of the centred set
centred = pts - pts.mean(axis=0)
sv = np.linalg.svd(centred, compute_uv=False)
spread_ratio = float(sv[1] / sv[0]) if sv[0] > 0 else 0.0
if spread_ratio < min_collinearity:
return True, f"near-collinear (spread ratio {spread_ratio:.3f} < {min_collinearity})"
hull_area = _convex_hull_area(pts)
# (b) small convex hull vs bounding box (relative)
bbox = (pts[:, 0].max() - pts[:, 0].min()) * (pts[:, 1].max() - pts[:, 1].min())
hull_frac = hull_area / bbox if bbox > 0 else 0.0
if hull_frac < min_hull_frac:
return True, f"small convex hull (hull frac {hull_frac:.3f} < {min_hull_frac})"
# (c) absolutely tiny convex hull = centre-clustered blob. The relative (b) check alone
# passes a tight blob whose hull still fills its own tiny bbox; an absolute floor catches it.
if hull_area < MIN_HULL_AREA_PX:
return True, f"small convex hull (hull area {hull_area:.0f} px^2 < {MIN_HULL_AREA_PX:.0f})"
return False, "ok"
def grade_calibration(image_points: np.ndarray, pitch_points: np.ndarray, *,
n_fit: int = 4) -> Grade:
"""Fit H on the first n_fit correspondences, measure reprojection error on the REST
(held-out, §7.2). Reprojecting the fit points is ~0 by construction and proves nothing."""
img = np.asarray(image_points, np.float32)
pit = np.asarray(pitch_points, np.float32)
degenerate, reason = point_set_is_degenerate(img)
if degenerate:
return Grade(held_out_px_error=float("inf"), verdict="RE-PICK",
ill_conditioned=True, reason=reason)
if len(img) <= n_fit:
raise ValueError(f"need > {n_fit} points so some are held out for validation")
H, _ = cv2.findHomography(img[:n_fit], pit[:n_fit], cv2.RANSAC)
if H is None:
return Grade(float("inf"), "RE-PICK", True, "findHomography failed (degenerate fit)")
held_img, held_pit = img[n_fit:], pit[n_fit:]
# Error reported in PIXELS at the held-out points: map the KNOWN held-out pitch coords back
# to image via H^-1 and compare to the ACTUAL held-out image points. (NOT a round-trip of the
# same image point through H then H^-1 — that is identity by construction and always reports
# ~0, so it never detects a corrupted held-out correspondence.)
Hinv = np.linalg.inv(H)
pred_img = cv2.perspectiveTransform(held_pit.reshape(-1, 1, 2), Hinv).reshape(-1, 2)
px_err = float(np.linalg.norm(pred_img - held_img, axis=1).mean())
verdict = "GOOD" if px_err <= GOOD_PX_THRESHOLD else "RE-PICK"
return Grade(held_out_px_error=px_err, verdict=verdict,
ill_conditioned=False, reason="ok" if verdict == "GOOD" else "held-out error high")
def write_calibration(out_path: Path, image_points, pitch_points) -> dict:
data = {"image_points": np.asarray(image_points, float).tolist(),
"pitch_points": np.asarray(pitch_points, float).tolist()}
Path(out_path).write_text(yaml.safe_dump(data, sort_keys=False))
return data
def _pick_points_interactive(frame, at):
"""OpenCV-window click picker (not unit-tested; exercised at the acceptance run).
Click >= 6 well-separated pitch landmarks; type the matching pitch (cm) coords at the
prompt. Returns (image_points, pitch_points). Verify cv2.setMouseCallback usage on-box."""
raise NotImplementedError("interactive picker — run via `python -m footballcv.calibrate`")
def main(argv=None) -> int:
import argparse
ap = argparse.ArgumentParser("footballcv.calibrate")
ap.add_argument("--frame", required=True); ap.add_argument("--at", default="00:00:30")
ap.add_argument("--out", default="config/calibration.yaml")
args = ap.parse_args(argv)
cap = cv2.VideoCapture(args.frame)
# seek to --at, grab one frame, pick points (interactive)
ok, frame = cap.read(); cap.release()
if not ok:
print("could not read a frame"); return 2
image_points, pitch_points = _pick_points_interactive(frame, args.at)
g = grade_calibration(np.array(image_points), np.array(pitch_points))
print(f"held-out reprojection error = {g.held_out_px_error:.2f} px -> {g.verdict}"
f"{'' if not g.ill_conditioned else ' (ILL-CONDITIONED: ' + g.reason + ')'}")
if g.verdict != "GOOD":
print("RE-PICK: choose more spread-out, non-collinear landmarks (§7.2)."); return 1
write_calibration(Path(args.out), image_points, pitch_points)
print(f"wrote {args.out}"); return 0
if __name__ == "__main__":
import sys; sys.exit(main())
_pick_points_interactiveis the only network-free-but-UI piece, deliberately not unit-tested (it needs a window + a human); the graded logic it feeds is fully covered. Themin_hull_frac/min_collinearitythresholds are seeds (§7.2) — tune against a real frame at the acceptance run.
- [ ] Step 5: Run to verify it passes
Run: python -m pytest vision/test/test_calibrate.py -v → PASS (5 tests). Checkpoint — full suite green.
Task 2: Homography projection (pitch.py)¶
Load H (or the correspondences) from config/calibration.yaml, project player feet points (bottom-centre of bbox) and the ball point to pitch_xy via a constant homography (cv2.perspectiveTransform). Wraps the vendored sports ViewTransformer. Fully unit-tested with the known homography; documents the feet-on-ground-plane caveat.
Files:
- Create: vision/footballcv/pitch.py, vision/test/test_pitch.py
Interfaces:
- Consumes: a loaded calibration + image-space points (feet / ball).
- Produces: feet_point(image_bbox:tuple) -> tuple (bottom-centre); class PitchProjector built from a calibration dict (from_calibration_file(path) -> PitchProjector) with project(points_xy:np.ndarray) -> np.ndarray ((N,2) px → (N,2) cm) and project_worldstate(ws:WorldState) -> WorldState (fills PlayerObs.pitch_xy from feet + BallObs.pitch_xy from image_xy); the constant H is built once at construction.
- [ ] Step 1: Write the failing tests
# vision/test/test_pitch.py
import numpy as np
from footballcv.pitch import PitchProjector, feet_point
from footballcv.types import PlayerObs, BallObs, WorldState
def test_feet_point_is_bottom_centre_of_bbox():
assert feet_point((10, 20, 30, 80)) == (20.0, 80.0)
def test_projection_recovers_known_pitch_points(known_homography):
proj = PitchProjector(image_points=known_homography["image_points"],
pitch_points=known_homography["pitch_points"])
out = proj.project(known_homography["image_points"])
# image points were generated FROM the pitch points through H -> recover them
assert np.allclose(out, known_homography["pitch_points"], atol=1.0)
def test_project_worldstate_fills_feet_and_ball(known_homography):
proj = PitchProjector(image_points=known_homography["image_points"],
pitch_points=known_homography["pitch_points"])
# a player whose FEET land exactly on a known image point -> known pitch point
feet_img = tuple(known_homography["image_points"][0]) # corner (0,0) cm
bbox = (feet_img[0] - 5, feet_img[1] - 40, feet_img[0] + 5, feet_img[1])
ball_img = tuple(known_homography["image_points"][6]) # centre (6000,3500)
ws = WorldState(0, 0.0, "raw",
[PlayerObs(1, "player", 0, bbox, None, 0.9)],
BallObs(image_xy=ball_img, pitch_xy=None, confidence=0.8, interpolated=False))
out = proj.project_worldstate(ws)
assert np.allclose(out.players[0].pitch_xy, [0.0, 0.0], atol=2.0)
assert np.allclose(out.ball.pitch_xy, [6000.0, 3500.0], atol=2.0)
def test_project_worldstate_leaves_ball_none_when_no_detection(known_homography):
proj = PitchProjector(image_points=known_homography["image_points"],
pitch_points=known_homography["pitch_points"])
ws = WorldState(0, 0.0, "raw", [],
BallObs(image_xy=None, pitch_xy=None, confidence=0.0, interpolated=False))
assert proj.project_worldstate(ws).ball.pitch_xy is None
-
[ ] Step 2: Run to verify it fails → FAIL (module missing).
-
[ ] Step 3: Implement
pitch.py
# vision/footballcv/pitch.py
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
import numpy as np
import yaml
def feet_point(image_bbox: tuple) -> tuple:
"""Bottom-centre of the bbox = where the player meets the ground plane.
CAVEAT (§7-v2): this assumes the feet are VISIBLE and on the ground. A clipped or
occluded far-side bbox bottom, or a jumping player, projects WRONG even with a perfect
H — a distinct pitch_xy error source from the homography itself (documented, not fixed)."""
x1, y1, x2, y2 = image_bbox
return ((x1 + x2) / 2.0, float(y2))
class PitchProjector:
"""Constant-H projector for a fixed-cam clip. Wraps the vendored `sports` ViewTransformer
(which builds H via cv2.findHomography and projects via cv2.perspectiveTransform)."""
def __init__(self, image_points, pitch_points):
from footballcv.vendor.sports import ViewTransformer # vendored, pinned SHA (§5)
self._vt = ViewTransformer(source=np.asarray(image_points, np.float32),
target=np.asarray(pitch_points, np.float32))
@classmethod
def from_calibration_file(cls, path: str | Path) -> "PitchProjector":
data = yaml.safe_load(Path(path).read_text())
return cls(image_points=data["image_points"], pitch_points=data["pitch_points"])
def project(self, points_xy: np.ndarray) -> np.ndarray:
pts = np.asarray(points_xy, np.float32).reshape(-1, 2)
return self._vt.transform_points(pts)
def project_worldstate(self, ws):
players = []
for p in ws.players:
xy = self.project(np.array([feet_point(p.image_bbox)]))[0]
players.append(replace(p, pitch_xy=(float(xy[0]), float(xy[1]))))
ball = ws.ball
if ball.image_xy is not None:
bxy = self.project(np.array([ball.image_xy]))[0]
ball = replace(ball, pitch_xy=(float(bxy[0]), float(bxy[1])))
return replace(ws, players=players, ball=ball)
If the v1 vendoring left
vendor/sports/__init__.pya stub, this is where the realViewTransformer(andSoccerPitchConfigurationfor Task 5) gets copied in at the recorded SHA. Verifytransform_pointsaccepts/returns(N,2)float32 against the vendored copy.
-
[ ] Step 4: Run to verify it passes → PASS (4 tests).
-
[ ] Step 5: Checkpoint — full suite green.
Task 3: Dedicated ball detection pass (detect.py extension)¶
Extend detect.py with the football-ball-detection-rejhg pass. Benchmark plain imgsz=1280 vs sv.InferenceSlicer 2×2 tiling for recall-per-second and make the winner the default (2×2 tiling MATCHES the model's training regime — a benchmark-decides-default, NOT a slow opt-in). Loads as a SECOND model (one resident at a time, 12 GB). Contract-tested with a mock; the recall benchmark runs at the acceptance step.
Files:
- Extend: vision/footballcv/detect.py
- Create: vision/test/test_ball_detect.py
Interfaces:
- Consumes: a frame np.ndarray.
- Produces: class BallDetector with detect(frame) -> sv.Detections (ball class only); a tiling: bool ctor flag (False = plain imgsz=1280, True = sv.InferenceSlicer 2×2); best_ball_candidate(det:sv.Detections) -> tuple[float,float,float]|None (the single highest-confidence ball (x, y, conf) from a Detections, image-centre of the bbox).
- [ ] Step 1: Write the failing contract test (mocked model — no weights)
# vision/test/test_ball_detect.py
import numpy as np
import supervision as sv
from footballcv.detect import BallDetector, best_ball_candidate
class _FakeBallModel:
names = {0: "ball"}
def predict(self, frame, **kw):
class _R: ...
return [_R()]
def test_ball_detector_keeps_only_ball_class(monkeypatch):
import footballcv.detect as d
det = sv.Detections(xyxy=np.array([[10, 10, 16, 16], [50, 50, 70, 90]], float),
confidence=np.array([0.7, 0.4]),
class_id=np.array([0, 0]),
data={"class_name": np.array(["ball", "player"])})
monkeypatch.setattr(d, "_detections_from_model_result", lambda r, names: det)
out = BallDetector(model=_FakeBallModel(), tiling=False).detect(np.zeros((96, 128, 3), np.uint8))
assert set(out.data["class_name"]) == {"ball"}
assert len(out) == 1
def test_best_ball_candidate_picks_highest_confidence_centre():
det = sv.Detections(xyxy=np.array([[10, 10, 16, 16], [40, 40, 46, 46]], float),
confidence=np.array([0.6, 0.9]),
class_id=np.array([0, 0]),
data={"class_name": np.array(["ball", "ball"])})
x, y, c = best_ball_candidate(det)
assert (round(x), round(y), c) == (43, 43, 0.9) # centre of the 0.9 box
def test_best_ball_candidate_none_when_empty():
assert best_ball_candidate(sv.Detections.empty()) is None
def test_tiling_flag_uses_inference_slicer(monkeypatch):
import footballcv.detect as d
calls = {"sliced": 0}
class _FakeSlicer:
def __init__(self, callback, **kw): self.callback = kw or callback
def __call__(self, image):
calls["sliced"] += 1
return sv.Detections.empty()
monkeypatch.setattr(d.sv, "InferenceSlicer", _FakeSlicer)
BallDetector(model=_FakeBallModel(), tiling=True).detect(np.zeros((96, 128, 3), np.uint8))
assert calls["sliced"] == 1 # tiling path routed through the slicer
-
[ ] Step 2: Run to verify it fails → FAIL (
BallDetectormissing). -
[ ] Step 3: Extend
detect.py
# append to vision/footballcv/detect.py
BALL_CLASS = "ball"
def best_ball_candidate(det: "sv.Detections") -> tuple[float, float, float] | None:
"""The single highest-confidence ball detection as (x_centre, y_centre, conf)."""
if len(det) == 0:
return None
i = int(np.argmax(det.confidence))
x1, y1, x2, y2 = det.xyxy[i]
return ((x1 + x2) / 2.0, (y1 + y2) / 2.0, float(det.confidence[i]))
class BallDetector:
"""Dedicated small-ball pass (football-ball-detection-rejhg). Loads as a SECOND model
(one resident at a time, 12 GB — release the player detector first, §5).
`tiling`: False = plain imgsz=1280; True = sv.InferenceSlicer 2x2 (matches the model's
2x2-tiled-640 training regime). The recall-per-second winner is the DEFAULT (benchmark in
the acceptance run); `tiling` defaults to the benchmarked winner once known."""
def __init__(self, weight_path: str | None = None, device: str = "cuda",
imgsz: int = 1280, conf: float = 0.2, tiling: bool = False, model=None):
if model is None:
from ultralytics import YOLO # AGPL; private use only
model = YOLO(weight_path)
self.model, self.device, self.imgsz, self.conf, self.tiling = \
model, device, imgsz, conf, tiling
def _predict_plain(self, frame: np.ndarray) -> "sv.Detections":
result = self.model.predict(frame, device=self.device, imgsz=self.imgsz,
conf=self.conf, verbose=False)[0]
return _ball_only(_detections_from_model_result(result, getattr(self.model, "names", {})))
def detect(self, frame: np.ndarray) -> "sv.Detections":
if not self.tiling:
return self._predict_plain(frame)
# 2x2 tiling: slice_wh = half the frame, overlap_wh ~100 px, NMS to dedupe seams.
# supervision 0.29: InferenceSlicer(callback, slice_wh, overlap_wh, overlap_filter, iou_threshold)
h, w = frame.shape[:2]
slicer = sv.InferenceSlicer(
callback=lambda tile: self._predict_plain(tile),
slice_wh=(w // 2 + 100, h // 2 + 100),
overlap_wh=(100, 100),
overlap_filter=sv.OverlapFilter.NON_MAX_SUPPRESSION,
iou_threshold=0.1)
return _ball_only(slicer(frame))
def _ball_only(det: "sv.Detections") -> "sv.Detections":
names = det.data.get("class_name") if det.data else None
if names is None:
return det
keep = np.array([n == BALL_CLASS for n in names])
return det[keep]
The
tilingdefault is set to whichever config wins the acceptance-run benchmark (recall-per-second on the 3060); record the assumed config in the run note + ADR §10.overlap_whis absolute pixels in supervision 0.29 (verified), not a ratio. The ball model loads viaresolve_weight("ball", ...)— SHA-verified like the player weight.
-
[ ] Step 4: Run to verify it passes → PASS (4 tests).
-
[ ] Step 5: Checkpoint — full suite green. (Real ball recall + the tiling-vs-plain benchmark are in the Task 8 acceptance run.)
Task 4: Ball post-process — single-candidate gate + capped interpolation (ball_postprocess.py)¶
The honesty engine for the ball track (§8): from a per-frame stream of best candidates, keep the most-confident candidate near the recent centroid (confidence floor + max-jump gate), then linear/Kalman interpolate across gaps capped at ≤0.5 s with interpolated=True; over-cap gaps are left empty (no fabrication); compute ball_known_fraction. Fully unit-tested on a synthetic trajectory with injected gaps + an outlier.
Files:
- Create: vision/footballcv/ball_postprocess.py, vision/test/test_ball_postprocess.py
- Extend: vision/test/conftest.py (synthetic-ball-track fixture)
Interfaces:
- Consumes: a list of per-frame (frame_ts, candidate_xy_or_None, conf) (candidate = best_ball_candidate output's xy, or None).
- Produces: gate_candidates(stream, *, conf_floor:float, max_jump_px:float) -> list[BallObs-like] (rejects sub-floor / over-jump candidates → None); interpolate_gaps(observed, frame_ts, *, max_gap_s:float) -> list[BallObs] (fills ≤cap gaps, flags interpolated=True, leaves over-cap empty); ball_known_fraction(balls:list[BallObs]) -> float; one postprocess_ball_track(stream, *, conf_floor, max_jump_px, max_gap_s) -> tuple[list[BallObs], float] orchestrator.
- [ ] Step 1: Add the synthetic-ball-track fixture
# append to vision/test/conftest.py
@pytest.fixture
def ball_track_with_gaps():
"""20 frames @10 fps. Ball moves +5px/frame in x. Frames 5-7 are MISSING (a 0.3 s gap,
under the 0.5 s cap -> interpolatable). Frames 14-19 are MISSING (a 0.6 s gap -> over cap,
left empty). Frame 10 carries a wild OUTLIER (+400 px jump) at high confidence."""
fps = 10.0
frames = []
for i in range(20):
ts = i / fps
if i in (5, 6, 7) or i >= 14:
frames.append((ts, None, 0.0))
elif i == 10:
frames.append((ts, (10.0 * i + 400.0, 50.0), 0.85)) # outlier jump
else:
frames.append((ts, (10.0 * i, 50.0), 0.7))
return {"frames": frames, "fps": fps}
- [ ] Step 2: Write the failing tests
# vision/test/test_ball_postprocess.py
import numpy as np
from footballcv.ball_postprocess import (
postprocess_ball_track, ball_known_fraction, interpolate_gaps)
def test_outlier_is_gated_out(ball_track_with_gaps):
balls, _ = postprocess_ball_track(ball_track_with_gaps["frames"],
conf_floor=0.3, max_jump_px=150.0, max_gap_s=0.5)
# frame 10's +400px jump must be rejected -> not a real detection there
assert balls[10].image_xy is None or balls[10].interpolated is True
def test_under_cap_gap_is_interpolated_and_flagged(ball_track_with_gaps):
balls, _ = postprocess_ball_track(ball_track_with_gaps["frames"],
conf_floor=0.3, max_jump_px=150.0, max_gap_s=0.5)
for i in (5, 6, 7): # the 0.3 s gap < 0.5 s cap -> filled, flagged
assert balls[i].image_xy is not None and balls[i].interpolated is True
# linearly between frame4 (x=40) and frame8 (x=80): frame6 ~= 60
assert abs(balls[6].image_xy[0] - 60.0) < 1.0
def test_over_cap_gap_left_empty(ball_track_with_gaps):
balls, _ = postprocess_ball_track(ball_track_with_gaps["frames"],
conf_floor=0.3, max_jump_px=150.0, max_gap_s=0.5)
for i in range(14, 20): # the 0.6 s tail gap > cap -> never fabricated
assert balls[i].image_xy is None and balls[i].interpolated is False
def test_known_fraction_excludes_interpolated_and_missing(ball_track_with_gaps):
balls, frac = postprocess_ball_track(ball_track_with_gaps["frames"],
conf_floor=0.3, max_jump_px=150.0, max_gap_s=0.5)
# known = real detections only (not interpolated, not missing); independently recomputed
assert abs(frac - ball_known_fraction(balls)) < 1e-9
assert 0.0 <= frac <= 1.0
# 20 frames, 9 missing/gated, 3 interpolated -> ~8 truly-known
assert 0.3 < frac < 0.6
def test_interpolate_respects_explicit_cap():
# a single 1.0 s hole between two knowns at a 1 fps timebase -> over a 0.5 s cap -> empty
from footballcv.types import BallObs
observed = [BallObs((0.0, 0.0), None, 0.7, False), None,
BallObs((20.0, 0.0), None, 0.7, False)]
ts = [0.0, 1.0, 2.0]
out = interpolate_gaps(observed, ts, max_gap_s=0.5)
assert out[1].image_xy is None and out[1].interpolated is False
-
[ ] Step 3: Run to verify it fails → FAIL (module missing).
-
[ ] Step 4: Implement
ball_postprocess.py
# vision/footballcv/ball_postprocess.py
from __future__ import annotations
import numpy as np
from footballcv.types import BallObs
def gate_candidates(stream, *, conf_floor: float, max_jump_px: float) -> list[BallObs | None]:
"""Keep the per-frame candidate only if it clears the confidence floor AND is within
max_jump_px of the last accepted position (single-candidate-near-centroid, §8). Rejected
or absent frames -> None. `stream` items are (frame_ts, xy_or_None, conf)."""
out: list[BallObs | None] = []
last_xy = None
for _ts, xy, conf in stream:
if xy is None or conf < conf_floor:
out.append(None); continue
if last_xy is not None and float(np.hypot(xy[0] - last_xy[0], xy[1] - last_xy[1])) > max_jump_px:
out.append(None); continue # max-jump gate: reject the teleport, keep last
out.append(BallObs(image_xy=(float(xy[0]), float(xy[1])), pitch_xy=None,
confidence=float(conf), interpolated=False))
last_xy = (float(xy[0]), float(xy[1]))
return out
def interpolate_gaps(observed: list[BallObs | None], frame_ts: list[float], *,
max_gap_s: float) -> list[BallObs]:
"""Linear-interpolate each None run bounded by two real detections IFF the time spanned
is <= max_gap_s (the ≤0.5 s honesty cap, §8). Filled frames are flagged interpolated=True.
Over-cap gaps (and leading/trailing Nones) stay empty — NO fabrication."""
n = len(observed)
out: list[BallObs] = [b if b is not None
else BallObs(None, None, 0.0, False) for b in observed]
i = 0
while i < n:
if observed[i] is not None:
i += 1; continue
j = i
while j < n and observed[j] is None:
j += 1
left, right = i - 1, j # bracketing real detections
if left >= 0 and right < n and (frame_ts[right] - frame_ts[left]) <= max_gap_s:
a, b = observed[left], observed[right]
span = frame_ts[right] - frame_ts[left]
for k in range(i, j):
t = (frame_ts[k] - frame_ts[left]) / span if span > 0 else 0.0
x = a.image_xy[0] + t * (b.image_xy[0] - a.image_xy[0])
y = a.image_xy[1] + t * (b.image_xy[1] - a.image_xy[1])
out[k] = BallObs(image_xy=(x, y), pitch_xy=None, confidence=0.0, interpolated=True)
# else: over-cap or unbounded -> leave the BallObs(None, ...) placeholders
i = j
return out
def ball_known_fraction(balls: list[BallObs]) -> float:
"""1 - (interpolated + missing) / frames — the honesty gate (§7.3). Only REAL detections
count as known."""
if not balls:
return 0.0
known = sum(1 for b in balls if b.image_xy is not None and not b.interpolated)
return known / len(balls)
def postprocess_ball_track(stream, *, conf_floor: float, max_jump_px: float,
max_gap_s: float = 0.5) -> tuple[list[BallObs], float]:
frame_ts = [ts for ts, _xy, _c in stream]
gated = gate_candidates(stream, conf_floor=conf_floor, max_jump_px=max_jump_px)
filled = interpolate_gaps(gated, frame_ts, max_gap_s=max_gap_s)
return filled, ball_known_fraction(filled)
The interpolator is linear by default (zero-dependency, deterministic, exactly testable). A
filterpyKalman smoother is an optional swap behind the same signature for smoother trajectories — it must produce the same cap/flag/known-fraction behaviour; absence offilterpyfalls back to this linear path (Global Constraints).
- [ ] Step 5: Run to verify it passes → PASS (5 tests). Checkpoint — full suite green.
Task 5: Top-down radar (radar.py)¶
Render the tactical board with OpenCV (not matplotlib — perf, §10) from the WorldState stream: both teams' dots + the ball on the SoccerPitchConfiguration (12000×7000 cm), with temporal smoothing. Contract/smoke-tested on synthetic WorldStates (no models).
Files:
- Create: vision/footballcv/radar.py, vision/test/test_radar.py
Interfaces:
- Consumes: a stream of WorldState (pitch-space filled).
- Produces: draw_pitch(*, width_px:int=1050, height_px:int=680) -> np.ndarray (the empty board); pitch_to_canvas(pitch_xy:tuple, canvas_wh:tuple) -> tuple (cm→px); render_radar_frame(ws:WorldState, *, smoothing:dict|None=None, team_colors:dict, canvas=None) -> np.ndarray; write_radar_video(states, out_dir:str, fps:float) -> str (returns radar.mp4 path); a small EmaSmoother for per-track temporal smoothing.
- [ ] Step 1: Write the failing tests
# vision/test/test_radar.py
import numpy as np
from footballcv.radar import draw_pitch, pitch_to_canvas, render_radar_frame, TEAM_COLORS
from footballcv.types import PlayerObs, BallObs, WorldState
def test_draw_pitch_returns_canvas_of_expected_size():
canvas = draw_pitch(width_px=1050, height_px=680)
assert canvas.shape == (680, 1050, 3)
assert canvas.sum() > 0 # pitch lines drawn
def test_pitch_to_canvas_maps_corners_and_centre():
# SoccerPitchConfiguration is 12000 x 7000 cm
assert pitch_to_canvas((0, 0), (1050, 680)) == (0, 0)
assert pitch_to_canvas((12000, 7000), (1050, 680)) == (1050, 680)
cx, cy = pitch_to_canvas((6000, 3500), (1050, 680))
assert (cx, cy) == (525, 340)
def test_render_places_team_and_ball_dots():
p0 = PlayerObs(1, "player", 0, (0, 0, 1, 1), (3000.0, 3500.0), 0.9) # left half
p1 = PlayerObs(2, "player", 1, (0, 0, 1, 1), (9000.0, 3500.0), 0.9) # right half
ball = BallObs(image_xy=(0, 0), pitch_xy=(6000.0, 3500.0), confidence=0.8, interpolated=False)
ws = WorldState(0, 0.0, "raw", [p0, p1], ball)
canvas = render_radar_frame(ws, team_colors=TEAM_COLORS)
assert canvas.shape[2] == 3 and canvas.sum() > 0
# the team-0 dot's neighbourhood carries team-0 colour
cx, cy = pitch_to_canvas((3000.0, 3500.0), (canvas.shape[1], canvas.shape[0]))
patch = canvas[max(0, cy-6):cy+6, max(0, cx-6):cx+6].reshape(-1, 3)
assert (patch == np.array(TEAM_COLORS[0])).all(axis=1).any()
def test_render_skips_players_without_pitch_xy():
p = PlayerObs(1, "player", 0, (0, 0, 1, 1), None, 0.9) # not projected yet
ws = WorldState(0, 0.0, "raw", [p], BallObs(None, None, 0.0, False))
render_radar_frame(ws, team_colors=TEAM_COLORS) # must not raise
-
[ ] Step 2: Run to verify it fails → FAIL (module missing).
-
[ ] Step 3: Implement
radar.py
# vision/footballcv/radar.py
from __future__ import annotations
from pathlib import Path
import subprocess
import numpy as np
import cv2
TEAM_COLORS = {0: (0, 122, 255), 1: (255, 64, 64), None: (180, 180, 180)} # BGR; matches report.py
BALL_COLOR = (255, 255, 255)
PITCH_LEN_CM, PITCH_WID_CM = 12000, 7000 # SoccerPitchConfiguration (§5)
def pitch_to_canvas(pitch_xy: tuple, canvas_wh: tuple) -> tuple:
"""cm on the 12000x7000 pitch -> integer px on the radar canvas (x along length)."""
w, h = canvas_wh
x = int(round(pitch_xy[0] / PITCH_LEN_CM * w))
y = int(round(pitch_xy[1] / PITCH_WID_CM * h))
return (x, y)
def draw_pitch(*, width_px: int = 1050, height_px: int = 680) -> np.ndarray:
"""Empty green board with halfway line, centre circle, and a border — OpenCV, not
matplotlib (§10). Uses SoccerPitchConfiguration proportions; the .vertices set from the
vendored config can replace these primitives for full markings."""
canvas = np.full((height_px, width_px, 3), (40, 110, 40), np.uint8)
cv2.rectangle(canvas, (1, 1), (width_px - 2, height_px - 2), (255, 255, 255), 2)
cv2.line(canvas, (width_px // 2, 0), (width_px // 2, height_px), (255, 255, 255), 2)
r = int(round(915 / PITCH_WID_CM * height_px)) # centre-circle radius 915 cm
cv2.circle(canvas, (width_px // 2, height_px // 2), r, (255, 255, 255), 2)
return canvas
class EmaSmoother:
"""Per-track exponential moving average over pitch_xy to kill single-frame jitter."""
def __init__(self, alpha: float = 0.4):
self.alpha = alpha; self._state: dict = {}
def smooth(self, key, xy: tuple) -> tuple:
prev = self._state.get(key)
out = xy if prev is None else (self.alpha * xy[0] + (1 - self.alpha) * prev[0],
self.alpha * xy[1] + (1 - self.alpha) * prev[1])
self._state[key] = out
return out
def render_radar_frame(ws, *, team_colors=TEAM_COLORS, smoother: "EmaSmoother | None" = None,
canvas=None) -> np.ndarray:
board = draw_pitch() if canvas is None else canvas.copy()
h, w = board.shape[:2]
for p in ws.players:
if p.pitch_xy is None:
continue
xy = smoother.smooth(("p", p.track_id), p.pitch_xy) if smoother else p.pitch_xy
cx, cy = pitch_to_canvas(xy, (w, h))
cx, cy = min(max(cx, 0), w - 1), min(max(cy, 0), h - 1)
cv2.circle(board, (cx, cy), 6, team_colors.get(p.team, team_colors[None]), -1)
if ws.ball.pitch_xy is not None:
bx, by = pitch_to_canvas(ws.ball.pitch_xy, (w, h))
cv2.circle(board, (min(max(bx, 0), w - 1), min(max(by, 0), h - 1)), 4, BALL_COLOR, -1)
return board
def write_radar_video(states, out_dir: str, fps: float) -> str:
out = Path(out_dir); out.mkdir(parents=True, exist_ok=True)
path = out / "radar.mp4"
smoother = EmaSmoother()
proc, board = None, draw_pitch()
for ws in states:
frame = render_radar_frame(ws, smoother=smoother, canvas=board)
if proc is None:
h, w = frame.shape[:2]
proc = _open_nvenc_writer(str(path), w, h, fps) # the SECOND encode pass (§10)
proc.stdin.write(frame.tobytes())
if proc:
proc.stdin.close(); proc.wait()
return str(path)
def _open_nvenc_writer(path, w, h, fps):
cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", "bgr24",
"-s", f"{w}x{h}", "-r", str(fps), "-i", "-",
"-c:v", "hevc_nvenc", "-pix_fmt", "yuv420p", "-vf", "scale=1920:1080", path]
return subprocess.Popen(cmd, stdin=subprocess.PIPE)
The canvas/lines are OpenCV primitives so a smoke test runs with zero deps; the vendored
SoccerPitchConfiguration.vertices(32 keypoints) can replace the hand-drawn markings for a fuller board without changing the contract. Both outputs are 1080p (thescale=1920:1080in the NVENC pass; §10). Verify NVENC flags on the 3060; fall back tolibx264behind--encoderfor Mac dev (mirrorsreport.py).
- [ ] Step 4: Run to verify it passes → PASS (4 tests). Checkpoint — full suite green.
Task 6: Wire v2 into the pipeline (pipeline.py + report.py + GK pitch-space)¶
Switch GK assignment to pitch-space centroids (H now exists) and exclude GKs from team-shape (§8/§7.3); draw the ball marker on annotated.mp4; and wire --ball/--radar into the orchestrator with the second NVENC encode pass for radar.mp4 (1080p).
Files:
- Extend: vision/footballcv/pipeline.py, vision/footballcv/report.py
- Create: vision/test/test_pipeline_v2.py
Interfaces:
- Extends report.annotate_frame to draw ws.ball.image_xy (a marker; interpolated balls drawn in a distinct style — the honesty flag is visible).
- Adds pipeline.run_v2(input, out_dir, *, device, sample_fps, imgsz, models_dir, calibration, ball, radar); main gains --ball --radar --calibration; GK assignment uses assign_goalkeeper against pitch-space team centroids; exclude_gk_from_shape(players) -> list[PlayerObs] helper (drops cls=="goalkeeper" for hull/shape, keeps them on the radar).
- [ ] Step 1: Write the failing tests (mocked models — geometry/wiring only)
# vision/test/test_pipeline_v2.py
import ast
import numpy as np
from pathlib import Path
from footballcv.types import PlayerObs, BallObs, WorldState
PIPE = Path(__file__).resolve().parents[1] / "footballcv" / "pipeline.py"
def test_pipeline_v2_still_does_not_import_fetch_models():
tree = ast.parse(PIPE.read_text())
mods = set()
for n in ast.walk(tree):
if isinstance(n, ast.Import): mods |= {a.name for a in n.names}
if isinstance(n, ast.ImportFrom): mods.add(n.module or "")
assert not any("fetch_models" in m for m in mods)
def test_gk_assigned_in_pitch_space():
from footballcv.pipeline import gk_team_pitch_space
# team-0 cluster centred on the left (small pitch_x), team-1 on the right
centroids = {0: np.array([3000.0, 3500.0]), 1: np.array([9000.0, 3500.0])}
assert gk_team_pitch_space(np.array([2800.0, 3500.0]), centroids) == 0
assert gk_team_pitch_space(np.array([9200.0, 3500.0]), centroids) == 1
def test_gk_excluded_from_team_shape_but_kept_on_radar():
from footballcv.pipeline import exclude_gk_from_shape
players = [PlayerObs(1, "player", 0, (0,0,1,1), (3000.0,3500.0), 0.9),
PlayerObs(2, "goalkeeper", 0, (0,0,1,1), (200.0,3500.0), 0.9)]
shape_players = exclude_gk_from_shape(players)
assert [p.track_id for p in shape_players] == [1] # GK dropped from shape
assert len(players) == 2 # original list untouched (radar keeps GK)
def test_annotate_draws_ball_marker():
from footballcv.report import annotate_frame, TEAM_COLORS
frame = np.zeros((96, 128, 3), np.uint8)
ws = WorldState(0, 0.0, "raw",
[PlayerObs(1, "player", 0, (5,5,20,40), None, 0.9)],
BallObs(image_xy=(64.0, 48.0), pitch_xy=None, confidence=0.8, interpolated=False))
out = annotate_frame(frame.copy(), ws, TEAM_COLORS)
# the ball marker writes pixels near (64,48)
assert out[44:52, 60:68].sum() > 0
-
[ ] Step 2: Run to verify it fails → FAIL (
gk_team_pitch_space/exclude_gk_from_shape/ ball marker missing). -
[ ] Step 3: Extend
report.py(ball marker)
# in vision/footballcv/report.py — extend annotate_frame to draw the ball
import cv2 # add at top with the other imports
def _draw_ball(frame, ball):
if ball.image_xy is None:
return frame
x, y = int(round(ball.image_xy[0])), int(round(ball.image_xy[1]))
color = (0, 255, 255) if not ball.interpolated else (0, 165, 255) # solid vs interpolated (honesty)
cv2.circle(frame, (x, y), 6, color, 2)
if ball.interpolated:
cv2.putText(frame, "~", (x + 7, y - 7), cv2.FONT_HERSHEY_SIMPLEX, 0.4, color, 1)
return frame
frame = _draw_ball(frame, ws.ball) at the end of annotate_frame (after the box/label annotators, before returning — so it works even when ws.players is empty; restructure the early-return so the ball still draws).
- [ ] Step 4: Extend
pipeline.py
# add to vision/footballcv/pipeline.py
def gk_team_pitch_space(gk_pitch_xy, team_centroids_pitch: dict) -> int:
"""GK -> nearest team centroid in PITCH space (v2; v1 used image space). §7.3/§8."""
from footballcv.teams import assign_goalkeeper
return assign_goalkeeper(np.asarray(gk_pitch_xy, float),
{t: np.asarray(c, float) for t, c in team_centroids_pitch.items()})
def exclude_gk_from_shape(players):
"""GKs distort hull/compactness (pinned to the box) -> excluded from team-shape (§7.3).
Returns a NEW list; the original (radar still shows the GK) is untouched."""
return [p for p in players if p.cls != "goalkeeper"]
def run_v2(input: str, out_dir: str, *, device="cuda", sample_fps=5.0, imgsz=1280,
models_dir="models", calibration="config/calibration.yaml",
ball=True, radar=True) -> dict:
from footballcv.models_io import load_manifest, resolve_weight
from footballcv.pitch import PitchProjector
from footballcv.report import write_annotated_video
from footballcv.radar import write_radar_video
manifest = load_manifest(Path(models_dir))
resolve_weight("players", Path(models_dir), manifest) # SHA-verified
if ball:
resolve_weight("ball", Path(models_dir), manifest) # second model, SHA-verified
projector = PitchProjector.from_calibration_file(calibration)
# Build image-space WorldStates as in v1 (detect+track+teams), then for each:
# ws = projector.project_worldstate(ws) # fills feet + ball pitch_xy
# GK team via gk_team_pitch_space against pitch-space team centroids
# The ball stream is gathered per-frame (BallDetector.best_ball_candidate) and run through
# ball_postprocess.postprocess_ball_track BEFORE projection (image-space gating + capped fill).
# Two encode passes: write_annotated_video(...) then write_radar_video(...). (§10)
# Full detect/track loop is the Task 8 acceptance integration (needs weights + a clip).
...
prov = {"detector": manifest["weights"]["players"]["model_version"],
"detector_sha256": manifest["weights"]["players"]["sha256"],
"ball_model_sha256": manifest["weights"]["ball"]["sha256"] if ball else None,
"device": device, "seed": 0, "sample_fps": sample_fps, "track_id_space": "raw"}
return {"provenance": prov, "out": out_dir}
main: ap.add_argument("--ball", action="store_true"), ap.add_argument("--radar", action="store_true"), ap.add_argument("--calibration", default="config/calibration.yaml"), and route to run_v2 when --ball or --radar is set (otherwise the v1 run_v1 path).
- [ ] Step 5: Run to verify it passes → PASS (4 tests). Checkpoint — full suite green.
Task 7: Numeric radar-anchoring gate (test_radar_anchoring.py)¶
The one numericised §7-v2 success criterion: with the ball visibly in the left third, the in-possession team's dots cluster on the left (a left/right anchoring flip still produces "clustered dots", just on the wrong side — so this is asserted numerically, not by eye). Built on synthetic pitch-space WorldStates — no models, fully deterministic.
Files:
- Create: vision/test/test_radar_anchoring.py
Interfaces:
- Consumes: synthetic pitch-space WorldStates + a tiny frac_dots_in_left_third(ws, team) -> float helper (lives in radar.py, exported for the test and reused by v3 possession later).
- [ ] Step 1: Add the helper to
radar.py
# append to vision/footballcv/radar.py
def frac_dots_in_left_third(ws, team: int) -> float:
"""Fraction of `team`'s projected players whose pitch_x is in the left third of the pitch
(< PITCH_LEN_CM/3). The §7-v2 numeric anchoring gate; reused by v3 possession-by-zone."""
pts = [p.pitch_xy for p in ws.players if p.team == team and p.pitch_xy is not None]
if not pts:
return 0.0
left = sum(1 for x, _y in pts if x < PITCH_LEN_CM / 3)
return left / len(pts)
- [ ] Step 2: Write the test (this IS the gate)
# vision/test/test_radar_anchoring.py
from footballcv.radar import frac_dots_in_left_third
from footballcv.types import PlayerObs, BallObs, WorldState
def _frame_with_left_attack(in_possession_team: int):
"""Ball in the left third; the in-possession team camped left, the other spread right."""
players = []
for i in range(6): # in-possession team: left third (x < 4000)
players.append(PlayerObs(i, "player", in_possession_team, (0,0,1,1),
(500.0 + i * 400, 3000.0 + i * 100), 0.9))
other = 1 - in_possession_team
for i in range(6): # other team: right two-thirds
players.append(PlayerObs(10 + i, "player", other, (0,0,1,1),
(7000.0 + i * 600, 3000.0 + i * 100), 0.9))
ball = BallObs(image_xy=(0, 0), pitch_xy=(1500.0, 3500.0), confidence=0.8, interpolated=False)
return WorldState(0, 0.0, "raw", players, ball)
def test_left_third_attack_clusters_in_possession_team_left():
X = 0.8 # the §7-v2 ">X%" threshold (seed)
for poss_team in (0, 1): # holds regardless of which team has the ball
ws = _frame_with_left_attack(poss_team)
# 2 frames with the ball in the left third (§7-v2) — reuse the same synthetic frame twice
for _ in range(2):
assert frac_dots_in_left_third(ws, poss_team) > X
assert frac_dots_in_left_third(ws, 1 - poss_team) < X # other team NOT left-clustered
- [ ] Step 3: Run to verify it passes → PASS. Checkpoint — full suite green.
This is the synthetic, always-runnable form of the gate. The acceptance run (Task 8) re-asserts it on two real frames of the chosen clip where the ball is visibly in the left third — the same
frac_dots_in_left_thirdagainst real projectedWorldStates.
Task 8: Acceptance run on a public clip (the §7-v2 gate)¶
Not a unit test — the human/agent acceptance gate that proves v2 works end-to-end. Run after Tasks 1–7 are green, reusing the v1 acceptance clip + a calibration for that mount.
Steps:
-
[ ] Step 1: Calibrate the mount. Run
python -m footballcv.calibrate --frame samples/<clip>.mp4 --at 00:00:30 --out config/calibration.yaml; click 6–8 well-separated, non-collinear pitch landmarks; confirm the self-grader prints GOOD (held-out reprojection error under the §7.2 seed threshold) and did not reject the set as ill-conditioned. Re-pick if RE-PICK. -
[ ] Step 2: Benchmark the ball pass (sets the default). On the 3060, run the dedicated ball model on a sample of frames plain
imgsz=1280vssv.InferenceSlicer2×2 tiling; measure recall (against a few hand-marked ball positions) and wall-clock; setBallDetector(tiling=...)default to the recall-per-second winner and record which config the §10 wall-clock now assumes (note it in the run log + ADR §10). -
[ ] Step 3: Complete the
run_v2wiring left as...in Task 6: per--sample-fpsframe run the v1 detect+track+teams loop for image-spaceWorldStates; gather the per-frame best ball candidate; release the player model and run the ball pass (second resident model);postprocess_ball_trackthe ball stream (gate + ≤0.5 s capped interpolation +ball_known_fraction);projector.project_worldstateeach frame (feet + ball →pitch_xy); assign GK viagk_team_pitch_space; then two encode passes —write_annotated_video(boxes + IDs + team colours + ball marker) andwrite_radar_video(top-down board), both 1080p. -
[ ] Step 4: Run:
cd vision && source .venv/bin/activate
python -m footballcv.pipeline --input samples/<clip>.mp4 --device cuda \
--sample-fps 5 --imgsz 1280 --calibration config/calibration.yaml \
--ball --radar --out out/clip/
out/clip/annotated.mp4 (now with a ball marker) and out/clip/radar.mp4 written.
- [ ] Step 5: Verify the §7-v2 success criteria — (a) pipeline-correctness (checkable now):
- Homography geometry (HELD-OUT, §7.2): the calibrator graded GOOD on held-out points; the reported error is stated as pixel error at a named region plus the implied metre figure (≈ ≤1.5 m near-camera, ≈ ≤4 m far-side — far side honestly worse, documented). A near-collinear/centre-clustered set is rejected (re-confirmed against the real frame).
- Radar fidelity (visual): the radar matches the video — left-wing play ⇒ left-clustered dots; both teams' shapes track the video; no per-frame jitter (constant H ⇒ none by construction; a centre-clustered landmark set can still give a stable wrong far side — that is the calibrator's job to reject, §7.2).
- Ball honesty: interpolated ball segments are visibly flagged on
annotated.mp4(distinct marker); over-cap gaps are empty (no fabrication);ball_known_fractionis reported. - Anchoring (numeric): pick 2 real frames with the ball visibly in the left third; assert > X % of the in-possession team's dots have
pitch_xin the left third (frac_dots_in_left_thirdon the real projectedWorldStates — the Task 7 gate, on real data). -
Privacy/offline:
--ball/--radarmake no run-time network calls; the v1 no-network + manifest-integrity tests still pass (the ball weight is SHA-verified on load). -
[ ] Step 6: Verify — (b) accuracy (deferred to fine-tune): ball present on the radar for the large majority of frames after interpolation ("fraction of frames with a believable radar position", not raw mAP) — explicitly deferred behind "fine-tune on the real target view once a camera + footage exist" (ADR §7 two-tier criteria, §12-Q1). Not a phase gate for v2.
-
[ ] Step 7: Append the result (clip, calibration held-out error, chosen ball config + its recall/wall-clock,
ball_known_fraction, any radar far-side caveats) to thevision/README.md"v2 acceptance" subsection. v2 is done when the (a) criteria above hold.
Self-Review¶
Run against ADR-0023 §7-v2 + the load-bearing §7.2/§8 controls:
1. Spec coverage:
- v2 deliverables (ball marked on annotated.mp4; top-down radar of both teams + ball on SoccerPitchConfiguration) → Tasks 3/6 (ball) + 2/5/6 (radar) + 8 (acceptance). ✓
- §7.2 homography held-out validation + collinearity/small-hull rejection (load-bearing) → Task 1 (grade_calibration fit-on-4/measure-on-rest + point_set_is_degenerate), unit-tested with a synthetic known homography. ✓
- §8 ball recall bottleneck + mandatory ≤0.5 s capped interpolation + interpolated=True + ball_known_fraction → Task 4, full unit tests (injected gaps + outlier). ✓
- §5 ball model football-ball-detection-rejhg, sv.InferenceSlicer 2×2 tiling benchmark-decides-default (not slow opt-in), second resident model (12 GB), vendored ViewTransformer/SoccerPitchConfiguration → Tasks 3/2/5. ✓
- §4 stage contracts (pitch/radar) + BallObs/WorldState (unchanged v1 dataclasses) → Tasks 2/5, consume the real v1 types.py fields. ✓
- §10 perf (two NVENC encode passes, both 1080p, OpenCV not matplotlib) → Tasks 5/6. ✓
- §7.3/§8 GK → pitch-space + GK excluded from team-shape → Task 6 (gk_team_pitch_space, exclude_gk_from_shape). ✓
- §7-v2 numeric anchoring gate (ball left-third ⇒ in-possession team left-clustered) → Task 7 (synthetic) + Task 8 Step 5 (real). ✓
- Out of v2 (correctly absent): analytics/possession/distance (v3), stats.json/summary.txt (v3), GoPro 360 de-warp (v4). ✓ (noted)
2. Placeholder scan: the only intentional ... is the run_v2 body completed in Task 8 Step 3 (it depends on the live Ultralytics detect+track loop, an acceptance-run integration, not unit-testable without weights) and _pick_points_interactive / _open_nvenc_writer explicitly flagged as UI/build-time pieces. No "TODO/TBD" in tested logic; every code-step ships real, runnable code with exact-value assertions.
3. Type consistency: PitchProjector.project_worldstate returns a WorldState with PlayerObs.pitch_xy/BallObs.pitch_xy filled (the real v1 fields, via dataclasses.replace); ball_postprocess emits BallObs matching types.py (image_xy/pitch_xy/confidence/interpolated); BallDetector.best_ball_candidate output feeds gate_candidates' (ts, xy, conf) stream; radar.render_radar_frame consumes WorldState/PlayerObs.team/pitch_xy and reuses report.py's TEAM_COLORS; resolve_weight("ball", ...)/load_manifest match the v1 models_io signatures; assign_goalkeeper (v1 teams.py) is reused unchanged by gk_team_pitch_space. Verified external APIs: sv.InferenceSlicer(callback, slice_wh, overlap_wh=abs-px, overlap_filter, iou_threshold) called as slicer(image); ViewTransformer(source,target).transform_points((N,2)); SoccerPitchConfiguration 12000×7000 cm + .vertices; cv2.findHomography(img, pit, cv2.RANSAC) + cv2.perspectiveTransform; sv.crop_image(image=, xyxy=). ✓
Execution Handoff¶
Plan complete and saved to docs/vision/2026-06-19-v2-implementation-plan.md. Builds on the green v1 suite. Two execution options:
1. Subagent-Driven (recommended) — dispatch a fresh subagent per task, review between tasks, fast iteration (superpowers:subagent-driven-development).
2. Inline Execution — execute tasks in this session with checkpoints (superpowers:executing-plans).
Which approach?