Spaces:
Running on Zero
Running on Zero
File size: 7,493 Bytes
8d8329b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | """Tracking-data import / export.
File format (CSV, one row per entity per time sample; this is what a real tracking feed is converted to):
play_id, t, entity, side, pos, num, x, y, z
1, 0.0, QB, O, QB, 12, 40.0, 26.7,
1, 0.0, CB1, D, CB, 24, 41.1, 8.2,
1, 0.0, ball, , , , 40.0, 26.7, 0.0
t seconds; the first sample of a play is the snap (pass snap_t to shift it)
entity any unique id per player on that play ("ball" for the ball)
side O (offense) or D (defense); blank for the ball
x yards, 0-120 end zone to end zone, the offense attacking toward LARGER x
y yards, 0-53.33, measured from the offense's LEFT sideline (top of the screen)
z ball height in yards (optional)
If the source data has the offense moving toward x=0, mirror it first (x -> 120 - x, y -> 53.33 - y).
Samples may be at any rate; the loader resamples to the viewer's 10 frames per second.
"""
from __future__ import annotations
import csv
import io
import math
from rules import FIELD_WIDTH_YD as W
FPS = 10
POS_VMAX = {"QB": 8.4, "RB": 9.8, "FB": 8.6, "WR": 10.0, "TE": 9.0, "OL": 7.5, "LT": 7.5, "LG": 7.5, "C": 7.5, "RG": 7.5,
"RT": 7.5, "DE": 8.8, "DT": 8.0, "NT": 7.8, "OLB": 9.3, "ILB": 9.0, "MLB": 9.0, "LB": 9.1, "CB": 10.3,
"S": 10.0, "FS": 10.0, "SS": 9.8, "NB": 10.2}
COLS = ["play_id", "t", "entity", "side", "pos", "num", "x", "y", "z"]
def to_csv(rep, play_id=1) -> str:
"""Write a replay in the tracking format (used to test the round trip and to hand clients a sample)."""
out = io.StringIO()
w = csv.writer(out)
w.writerow(COLS)
ids, meta = rep["ids"], {m["id"]: m for m in rep["meta"]}
seen_t = set()
for f in rep["frames"]:
key = round(f["t"], 3)
if key in seen_t:
continue
seen_t.add(key)
for k, pid in enumerate(ids):
m = meta[pid]
w.writerow([play_id, f["t"], pid, m["side"], m["pos"], m["num"], f["p"][2 * k], f["p"][2 * k + 1], ""])
w.writerow([play_id, f["t"], "ball", "", "", "", f["b"][0], f["b"][1], f["b"][2]])
return out.getvalue()
def plays_in(text) -> list:
rows = list(csv.DictReader(io.StringIO(text)))
if not rows or "play_id" not in rows[0]:
raise ValueError("Not a tracking file (no play_id column). See tracking.py for the format.")
return sorted({r["play_id"] for r in rows})
def _interp(ts, vs, t):
if t <= ts[0]:
return vs[0]
if t >= ts[-1]:
return vs[-1]
lo, hi = 0, len(ts) - 1
while hi - lo > 1:
mid = (lo + hi) // 2
if ts[mid] <= t:
lo = mid
else:
hi = mid
f = (t - ts[lo]) / max(1e-9, ts[hi] - ts[lo])
return vs[lo] + (vs[hi] - vs[lo]) * f
def load_csv(text, play_id=None, snap_t=None):
"""Parse one play from tracking CSV text into the replay structure the film viewer reads."""
rows = list(csv.DictReader(io.StringIO(text)))
missing = [c for c in ("t", "entity", "x", "y") if rows and c not in rows[0]]
if not rows or missing:
raise ValueError(f"Not a tracking file (missing columns: {missing or 'all rows'}). See tracking.py for the format.")
if play_id is None:
play_id = rows[0].get("play_id")
rows = [r for r in rows if r.get("play_id", play_id) == play_id]
tracks, info = {}, {}
for r in rows:
e = r["entity"].strip()
tracks.setdefault(e, []).append((float(r["t"]), float(r["x"]), float(r["y"]), float(r["z"]) if r.get("z") not in (None, "") else 0.0))
if e != "ball":
info.setdefault(e, dict(side=(r.get("side") or "O").strip().upper()[:1] or "O", pos=(r.get("pos") or e).strip(),
num=int(float(r["num"])) if r.get("num") not in (None, "") else 0))
if not info:
raise ValueError("No players found for that play.")
for e in tracks:
tracks[e].sort()
t0 = snap_t if snap_t is not None else min(v[0][0] for v in tracks.values())
t1 = max(v[-1][0] for v in tracks.values())
n = int(round((t1 - t0) * FPS)) + 1
ids = list(info)
if sum(1 for e in ids if info[e]["side"] == "O") < 5 or sum(1 for e in ids if info[e]["side"] == "D") < 5:
raise ValueError("Expected offense and defense players (side column O / D).")
def series(e):
tr = tracks[e]
ts = [v[0] - t0 for v in tr]
return ts, [v[1] for v in tr], [v[2] for v in tr], [v[3] for v in tr]
S = {e: series(e) for e in ids}
ball = series("ball") if "ball" in tracks else None
frames = []
for i in range(n):
t = i / FPS
p = []
for e in ids:
ts, xs, ys, _ = S[e]
p += [round(_interp(ts, xs, t), 2), round(_interp(ts, ys, t), 2)]
if ball:
ts, xs, ys, zs = ball
b = [round(_interp(ts, xs, t), 2), round(_interp(ts, ys, t), 2), round(max(0.0, _interp(ts, zs, t)), 2)]
else:
b = [p[0], p[1], 0.0]
frames.append(dict(t=round(t, 2), p=p, b=b))
# ball spot, and what the tracks say about speed and the pass
bx, by = frames[0]["b"][0], frames[0]["b"][1]
if not ball:
ox = [frames[0]["p"][2 * k] for k, e in enumerate(ids) if info[e]["side"] == "O"]
bx, by = sorted(ox)[len(ox) // 2], W / 2
meta = []
for k, e in enumerate(ids):
sp = [math.hypot(frames[j + 3]["p"][2 * k] - frames[j]["p"][2 * k],
frames[j + 3]["p"][2 * k + 1] - frames[j]["p"][2 * k + 1]) / 0.3 for j in range(max(0, n - 3))] or [8.0]
default = POS_VMAX.get(info[e]["pos"].upper(), 9.0) # a short play rarely shows a player's true top speed
vmax = round(min(11.5, max(default, max(sp) * 1.04)), 2)
meta.append(dict(id=e, side=info[e]["side"], pos=info[e]["pos"], num=info[e]["num"], vmax=vmax,
mph=round(vmax * 2.045, 1), ht=None, wt=None, forty=None, react=None))
marks = dict(throw=None, land=None, end=round(frames[-1]["t"], 2), target=None)
events = [dict(t=0.0, text="Snap", kind="play")]
if ball and n > 6:
bs = [math.hypot(frames[j + 1]["b"][0] - frames[j]["b"][0], frames[j + 1]["b"][1] - frames[j]["b"][1]) * FPS for j in range(n - 1)]
j = 0
while j < len(bs):
if bs[j] > 11:
k = j
while k < len(bs) and bs[k] > 8:
k += 1
if (k - j) / FPS >= 0.4:
marks["throw"], marks["land"] = round(j / FPS, 2), round(k / FPS, 2)
fl = frames[min(k, n - 1)]
cand = [(math.hypot(fl["p"][2 * q] - fl["b"][0], fl["p"][2 * q + 1] - fl["b"][1]), ids[q])
for q in range(len(ids)) if info[ids[q]]["side"] == "O"]
marks["target"] = min(cand)[1]
events += [dict(t=marks["throw"], text="Ball thrown", kind="play"), dict(t=marks["land"], text="Ball arrives", kind="play")]
break
j = k
j += 1
events.append(dict(t=marks["end"], text="Whistle", kind="result"))
yards = round(frames[-1]["b"][0] - bx, 1)
return dict(ids=ids, meta=meta, frames=frames, events=events, marks=marks, bx=round(bx, 2), by=round(by, 2),
result=dict(outcome="recorded", yards=yards, td=False, turnover=False, penalty=None),
summary=f"Recorded play {play_id}: ball moved {yards:+.0f} yds", source="recorded")
|