import os
import shutil
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, FancyBboxPatch, RegularPolygon
from matplotlib.lines import Line2D
from gridiron import Play, Player, Field, technique, style
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
GREEN = "#0f7a55" # darker step of the aqua highlight, for text on the light field
PURPLE = style.SERIES[6] # a ring colour that is neither offense blue nor defense orange
LINE_D, OFF_D = -0.7, -1.5 # "on the line" and "a yard off it" (only the ends of the line may be receivers)
# ------------------------------------------------------------------ building plays by hand
def O(key, pos, d, w, label=None):
return Player(key, pos, "off", d, w, label=label if label is not None else key)
def D(key, pos, d, w, label):
return Player(key, pos, "def", d, w, label=label)
def line():
"""The five offensive linemen, labelled so the reader can follow the pullers."""
return [O(k, k, LINE_D, w) for k, w in (("LT", -3.2), ("LG", -1.6), ("C", 0.0), ("RG", 1.6), ("RT", 3.2))]
def check_legal(players):
"""Seven on the line, and only the two end men of the line may be receivers."""
off = [p for p in players if p.side == "off"]
on = sorted([p for p in off if p.d > -1.0], key=lambda p: p.w)
assert len(off) == 11 and len(on) == 7, f"{len(off)} players, {len(on)} on the line"
for p in on[1:-1]:
assert p.pos in ("LT", "LG", "C", "RG", "RT"), f"{p.key} is covered up but is a receiver"
return players
def front(pe=("9", 5.7), pt=("3", 2.35), bt=("1", -0.7), be=("5", -4.0), lbs=(("WILL", 4.5, -2.4, "W"),
("MIKE", 4.5, 1.6, "M")), extra=(), cb=(-12.0, 12.0), safeties=((12.0, -8.0), (12.0, 8.0))):
"""A generic four-man front, linemen labelled by technique number (03-01), plus linebackers and DBs.
Fronts get their names in Part 5; here they are just people to block."""
out = [D("DE_L", "DE", 1.0, be[1], be[0]), D("DT_L", "DT", 1.0, bt[1], bt[0]),
D("DT_R", "DT", 1.0, pt[1], pt[0]), D("DE_R", "DE", 1.0, pe[1], pe[0])]
out += [D(k, k, d, w, lab) for k, d, w, lab in lbs]
out += [D(k, pos, d, w, lab) for k, pos, d, w, lab in extra]
out += [D("LCB", "CB", 6.5, cb[0], "CB"), D("RCB", "CB", 6.5, cb[1], "CB"),
D("FS", "FS", *safeties[0], "FS"), D("SS", "SS", *safeties[1], "SS")]
return out
def go_to(p, key, end, kind="block", delay=0.0, speed=None, via=()):
"""Move `key` through absolute (d, w) points `via` to `end`, as a block, pull, path or run."""
start = np.array(p._cur(key))
pts = [tuple(np.array(q) - start) for q in (*via, end)]
if kind == "block":
return p.block(key, pts=pts, delay=delay, speed=speed)
if kind == "pull":
return p.pull(key, pts, delay=delay, speed=speed)
if kind == "route":
return p.route(key, pts, delay=delay, speed=speed, frame="field")
return {"path": p.path, "run": p.run}[kind](key, pts, delay=delay, speed=speed)
def motion_to(p, key, via, end, speed=6.0):
"""Pre-snap motion through absolute points, ending where the player is when the ball is snapped."""
start = np.array(p._cur(key))
return p.motion(key, pts=[tuple(np.array(q) - start) for q in (*via, end)], speed=speed)
# ------------------------------------------------------------------ drawing helpers
def _P(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def clean(ax):
"""Box-zoomed diagrams: drop hash ticks and yard numbers, clip text to the window."""
for ln in list(ax.lines):
xs, ys = ln.get_data()
if len(xs) == 2 and ln.get_linewidth() == 0.5 and np.hypot(xs[1] - xs[0], ys[1] - ys[0]) < 1.0:
ln.remove()
for t in list(ax.texts):
if t.get_text().isdigit() and t.get_alpha() == 0.55:
t.remove()
else:
t.set_clip_on(True)
def draw(p, width_in=6.2, ax=None, **kw):
fld = p.draw(width_in=width_in, ax=ax, **kw)
clean(fld.ax)
return fld
def say(fld, p, d, w, text, color=style.INK_2, fontsize=7.2, ha="center", weight="normal"):
"""A short on-field label at (d, w)."""
x, y = _P(fld, p, d, w)
fld.ax.text(x, y, text, fontsize=fontsize, color=color, ha=ha, va="center", zorder=12, fontweight=weight,
linespacing=1.15, bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.92))
def point_to(fld, p, text_at, target, text, color=style.INK_2, fontsize=7.0):
"""A label with a thin leader line to the player or spot it names."""
(xt, yt), (xp, yp) = _P(fld, p, *text_at), _P(fld, p, *target)
fld.ax.annotate(text, (float(xp), float(yp)), xytext=(float(xt), float(yt)), fontsize=fontsize,
color=color, ha="center", va="center", zorder=12, linespacing=1.15,
arrowprops=dict(arrowstyle="-", color=color, lw=0.7, shrinkA=2, shrinkB=6),
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.92))
def flow(fld, p, pts, color=style.DEFENSE, lw=1.6, alpha=0.9, ls=(0, (2.5, 1.8))):
"""A defender's reaction, drawn as a dashed orange arrow through absolute (d, w) points."""
xs, ys = _P(fld, p, np.array([q[0] for q in pts]), np.array([q[1] for q in pts]))
fld.ax.plot(xs[:-1], ys[:-1], color=color, lw=lw, ls=ls, zorder=5, alpha=alpha)
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>", mutation_scale=10,
color=color, lw=lw, zorder=5, alpha=alpha))
def dashed(fld, p, pts, color=style.OFFENSE, lw=1.4):
"""Hand-drawn pre-snap motion: dashed blue with a solid head."""
xs, ys = _P(fld, p, np.array([q[0] for q in pts]), np.array([q[1] for q in pts]))
fld.ax.plot(xs[:-1], ys[:-1], color=color, lw=lw, ls=(0, (3, 2)), zorder=5)
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>", mutation_scale=9,
color=color, lw=lw, zorder=5))
def ghost(fld, p, d, w, label, side="def"):
"""A hollow dashed marker: where a player was, or would have been."""
x, y = (float(v) for v in _P(fld, p, d, w))
col = style.DEFENSE if side == "def" else style.OFFENSE
if side == "def":
pat = RegularPolygon((x, y), 4, radius=0.94, orientation=np.pi / 4, fc="none", ec=col, lw=1.1,
ls=(0, (2, 1.2)), zorder=6)
else:
pat = Circle((x, y), 0.75, fc="none", ec=col, lw=1.1, ls=(0, (2, 1.2)), zorder=6)
fld.ax.add_patch(pat)
fld.ax.text(x, y, label, ha="center", va="center", color=col, fontweight="bold", zorder=6, fontsize=6.3)
# ------------------------------------------------------------------ frame strips (PDF) and video (web)
MARK_R = 0.78 # frame-strip marker radius in yards
def tracking(play):
df = play.to_tracking()
df["_id"] = df["displayName"].astype(str)
return df
def panel(ax, play, df, t, lateral, window, rings=(), ghosts=None, decorate=None):
"""One moment of a simulated play: markers, faint trails, rings, and optional hollow 'ghosts'
(players from a second version of the play, at the same moment)."""
tmap = df.groupby("frameId")["time"].first()
fid = int((tmap - t).abs().idxmin())
ylo, yhi = sorted((play.ball_y - lateral[0], play.ball_y - lateral[1]))
fld = Field(ax=ax, orient="vertical", los=play.los, window=window, numbers=False, hashes=False,
lateral=(ylo, yhi), to_go=play.to_go)
cur = df[df["frameId"] == fid]
shown = cur[cur["x"].between(play.los + window[0] + 1.0, play.los + window[1] - 1.0)
& cur["y"].between(ylo + 1.0, yhi - 1.0)]
trails = df[(df["frameId"] <= fid) & df["_id"].isin(set(shown["_id"])) & (df["side"] != "ball")]
for (_, side), g in trails.groupby(["_id", "side"]):
xs, ys = fld.xy(g["x"].values, g["y"].values)
ax.plot(xs, ys, color=style.OFFENSE if side == "off" else style.DEFENSE, lw=0.9, alpha=0.4, zorder=3)
ppy = fld.pts_per_yard()
if ghosts is not None and t > 0.05:
gplay, gkeys = ghosts
for k in gkeys:
x, y = (float(v) for v in fld.xy(*play.to_abs(*gplay.position(k, t))))
ax.add_patch(RegularPolygon((x, y), 4, radius=MARK_R * 1.3, orientation=np.pi / 4, fc="none",
ec=style.DEFENSE, lw=1.1, ls=(0, (2, 1.2)), zorder=6))
glab = str(gplay.players[k].label)
ax.text(x, y, glab, ha="center", va="center", color=style.DEFENSE, fontweight="bold", zorder=6,
fontsize=max(4.5, min(8.0, ppy * (0.95 if len(glab) <= 1 else 0.75))))
if decorate is not None:
decorate(ax, fld, t)
for _, r in shown.iterrows():
x, y = (float(v) for v in fld.xy(r["x"], r["y"]))
if r["side"] == "ball":
ax.add_patch(Ellipse((x, y + 0.45), 0.7, 0.42, fc=style.BALL, ec="white", lw=0.5, zorder=11))
continue
lab = str(r.get("label", "") or "")
if r["side"] == "off":
ax.add_patch(Circle((x, y), MARK_R, fc=style.OFFENSE, ec="white", lw=0.6, zorder=8))
tc = "white"
else:
ax.add_patch(RegularPolygon((x, y), 4, radius=MARK_R * 1.3, orientation=np.pi / 4,
fc=style.DEFENSE_TINT, ec=style.DEFENSE, lw=1.0, zorder=8))
tc = style.INK
fs = max(4.5, min(8.5, ppy * (0.95 if len(lab) <= 1 else 0.75)))
ax.text(x, y, lab, ha="center", va="center", fontsize=fs, color=tc, fontweight="bold", zorder=10)
for key, col in rings:
rr = cur[cur["displayName"] == key].iloc[0]
x, y = fld.xy(rr["x"], rr["y"])
ax.add_patch(Circle((float(x), float(y)), 1.3, fc="none", ec=col, lw=1.7, zorder=7))
return fld
def strip(play, times, notes, lateral, window, ncols=2, rings=(), ghosts=None, width_in=6.5, decorate=None):
"""The print version of an animation: numbered panels with a caption under each."""
df = tracking(play)
nrows = int(np.ceil(len(times) / ncols))
pw = width_in / ncols
span_w, span_d = lateral[1] - lateral[0], window[1] - window[0]
fig, axes = plt.subplots(nrows, ncols, squeeze=False, figsize=(width_in, nrows * (pw * span_d / span_w + 0.6)))
for ax in axes.flat[len(times):]:
ax.axis("off")
for i, (ax, t) in enumerate(zip(axes.flat, times)):
panel(ax, play, df, t, lateral, window, rings, ghosts, decorate)
ax.set_title(f"{i + 1}. " + ("snap" if abs(t) < 1e-6 else f"{t:+.1f} s"), fontsize=8.5,
color=style.INK_2, loc="left")
ax.text(0.5, -0.03, notes[i], transform=ax.transAxes, ha="center", va="top", fontsize=7.5,
color=style.INK, linespacing=1.15)
fig.tight_layout(h_pad=2.4, w_pad=0.8)
return fig
def video(play, times, notes, lateral, window, rings=(), ghosts=None, width_in=5.6, decorate=None):
"""Web version of the strip: the same panel at every frame, the strip's notes as running captions."""
from matplotlib import animation
from IPython.display import HTML, display
df = tracking(play)
span_w, span_d = lateral[1] - lateral[0], window[1] - window[0]
fig, ax = plt.subplots(figsize=(width_in, width_in * span_d / span_w + 0.4))
tmap = df.groupby("frameId")["time"].first()
def update(fid):
ax.clear()
t = float(tmap[fid])
panel(ax, play, df, t, lateral, window, rings, ghosts, decorate)
cur = [n for tt, n in zip(times, notes) if tt <= t + 1e-6]
txt = ("snap" if abs(t) < 0.05 else f"{t:+.1f} s") + ((" " + cur[-1].replace("\n", " ")) if cur else "")
ax.set_title(txt, fontsize=8, color=style.INK, loc="left")
return []
anim = animation.FuncAnimation(fig, update, frames=sorted(df["frameId"].unique()), interval=100)
html = (anim.to_html5_video(embed_limit=40).replace("<video ", '<video style="max-width:100%;height:auto" ')
if shutil.which("ffmpeg") else anim.to_jshtml(default_mode="once"))
plt.close(fig)
display(HTML(html))
def animated(play, times, notes, **kw):
"""Video on the website; a numbered, captioned frame strip in the PDF."""
if PDF:
strip(play, times, notes, **kw)
plt.show()
else:
video(play, times, notes, **kw)