Show the Python (shared drawing helpers for this chapter)
import logging
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, Patch, Rectangle, RegularPolygon
from matplotlib.lines import Line2D
from IPython.display import HTML, Markdown, display
from gridiron import Field, Play, Player, style
from gridiron.animate import animate
from gridiron.play import MARKER_R
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
LIONS, BEARS, LEAGUE = style.SERIES[0], style.SERIES[1], style.MUTED
# ---------------------------------------------------------------- building plays by hand
def O(key, pos, d, w, label=None):
return Player(key, pos, "off", d, w, label=label)
def D(key, pos, d, w, label=None):
return Player(key, pos, "def", d, w, label=label)
def line5(d=-0.7):
"""The five offensive linemen (only the center labelled)."""
return [O(k, k, d, w, label="C" if k == "C" else "") for k, w in
(("LT", -3.2), ("LG", -1.6), ("C", 0.0), ("RG", 1.6), ("RT", 3.2))]
def pre(p, key, pts):
"""Pre-snap movement through absolute (d, w) points (gridiron's motion takes offsets from the start)."""
d0, w0 = p._cur(key)
p.motion(key, pts=[(d - d0, w - w0) for d, w in pts])
def go(p, key, pts, kind="path", **kw):
"""Post-snap movement through absolute (d, w) points: kind = path, run, route or block."""
d0, w0 = p._cur(key)
rel = [(d - d0, w - w0) for d, w in pts]
if kind == "route":
return p.route(key, pts, absolute=True, **kw)
if kind == "block":
return p.block(key, pts=rel, **kw)
return {"path": p.path, "run": p.run}[kind](key, rel, **kw)
def retime(p, key, frames):
"""Replace `key`'s pre-snap keyframes with explicit (t, d, w) rows (t <= 0).
gridiron ends every pre-snap motion at the snap and starts it whenever its length requires,
so a shift, a one-second freeze and then a motion can't be expressed directly. This sets
the schedule by hand (implemented here, not in gridiron). Call it after every action is added."""
kf, t0, t1 = p._compile()
post = kf[key][kf[key][:, 0] > 1e-9]
new = np.array(frames, dtype=float)
if new[-1, 0] < 0:
new = np.vstack([new, [0.0, *new[-1, 1:]]])
kf[key] = np.vstack([new, post])
p._compiled = (kf, min(float(f[0, 0]) for f in kf.values()), t1)
# ---------------------------------------------------------------- drawing helpers
def no_numbers(ax):
"""Remove the painted yard numbers (in zoomed windows they sit where players stand)."""
for t in list(ax.texts):
if t.get_alpha() == 0.55:
t.remove()
def P_of(fld, p):
"""(d, w) in the play's frame -> plot coordinates on this field."""
return lambda d, w: tuple(float(v) for v in fld.xy(*p.to_abs(d, w)))
def say(ax, P, d, w, text, color=style.INK, fs=6.8, ha="center", ec=None):
x, y = P(d, w)
ax.text(x, y, text, fontsize=fs, color=color, ha=ha, va="center", zorder=12, linespacing=1.12,
bbox=dict(boxstyle="round,pad=0.2", fc=style.SURFACE, ec=ec or "none", lw=0.6, alpha=0.93))
def point_to(ax, P, at, to, text, color=style.INK, fs=6.6):
"""A label at `at` with a thin leader line to `to` (both (d, w))."""
ax.annotate(text, xy=P(*to), xytext=P(*at), fontsize=fs, color=color, ha="center", va="center", zorder=12,
linespacing=1.12, bbox=dict(boxstyle="round,pad=0.2", fc=style.SURFACE, ec="none", alpha=0.93),
arrowprops=dict(arrowstyle="-", color=style.INK_2, lw=0.7, shrinkA=0, shrinkB=5))
def ghost_marker(ax, x, y, label, ppy, side="def"):
"""A hollow, dashed marker: where a player stood before he moved."""
col = style.DEFENSE if side == "def" else style.OFFENSE
if side == "def":
pat = RegularPolygon((x, y), 4, radius=MARKER_R * 1.25, orientation=np.pi / 4, facecolor="none",
edgecolor=col, lw=1.0, ls=(0, (2, 1.2)), zorder=6)
else:
pat = Circle((x, y), MARKER_R, facecolor="none", edgecolor=col, lw=1.0, ls=(0, (2, 1.2)), zorder=6)
ax.add_patch(pat)
if label:
ax.text(x, y, label, ha="center", va="center", color=col, fontweight="bold", zorder=6,
fontsize=max(4.0, min(8.0, ppy * 0.72)))
# ---------------------------------------------------------------- animations and their print strips
def snapshot(play, t, ax=None, lateral=(-17, 17), window=(-9, 15), ghosts=(), ghost_t=None, trail_from=None):
"""Everyone where he is at time t, a faint trail behind each mover, and a hollow 'ghost'
where each listed player stood at `ghost_t`."""
pos = {k: play.position(k, t) for k in play.players}
moved = [pl.moved(d=float(pos[k][0]), w=float(pos[k][1])) for k, pl in play.players.items()]
q = Play([m for m in moved if m.side == "off"], [m for m in moved if m.side == "def"],
los=play.los, ball_y=play.ball_y, to_go=play.to_go)
q.highlights = dict(play.highlights)
fld = q.draw(ax=ax, lateral=lateral, window=window, title=False)
ax, ppy = fld.ax, fld.pts_per_yard()
no_numbers(ax)
P = P_of(fld, play)
start = play._compile()[1] if trail_from is None else trail_from
for k, pl in play.players.items():
if pl.pos in ("LT", "LG", "C", "RG", "RT", "DE", "DT", "NT"):
continue
pts = np.array([play.position(k, tt) for tt in np.linspace(start, t, 50)])
if np.ptp(pts[:, 0]) + np.ptp(pts[:, 1]) < 0.4:
continue
xs, ys = fld.xy(*play.to_abs(pts[:, 0], pts[:, 1]))
col = style.OFFENSE if pl.side == "off" else style.DEFENSE
ax.plot(xs, ys, color=col, lw=1.1, alpha=0.5, ls="-" if pl.side == "off" else (0, (3, 2)), zorder=3)
if ghost_t is not None:
for k in ghosts:
d0, w0 = play.position(k, ghost_t)
d1, w1 = play.position(k, t)
if abs(d1 - d0) + abs(w1 - w0) > 1.0:
ghost_marker(ax, *P(d0, w0), play.players[k].label, ppy, play.players[k].side)
bd, bw = play.ball_position(t)
carried = any(np.hypot(*(play.position(k, t) - np.array([bd, bw]))) < 0.3
for k, pl in play.players.items() if pl.side == "off" and k != "C")
bx, by = P(bd + (0.95 if carried and t > 0 else 0.0), bw)
ax.add_patch(Ellipse((bx, by), 0.75, 1.1, facecolor=style.BALL, edgecolor="white", lw=0.6, zorder=9.5))
for art in list(ax.texts) + list(ax.patches) + list(ax.lines): # nothing drawn outside the window
art.set_clip_on(True)
return fld
def strip(play, times, labels, ncols=2, width_in=6.5, legend=True, annot=None, **kw):
"""The print version of an animation: one panel per chosen moment, each with a caption.
`annot` maps a panel index to a function f(ax, P) that adds print-only labels to that panel."""
lateral, window = kw.get("lateral", (-17, 17)), kw.get("window", (-9, 15))
n = len(times)
nrows = int(np.ceil(n / ncols))
pw = width_in / ncols
h = nrows * (pw * (window[1] - window[0]) / abs(lateral[1] - lateral[0]) + 0.42) + (0.35 if legend else 0.1)
fig, axes = plt.subplots(nrows, ncols, figsize=(width_in, h), squeeze=False)
for ax in axes.flat[n:]:
ax.axis("off")
for i, (ax, t, lab) in enumerate(zip(axes.flat, times, labels)):
fld = snapshot(play, t, ax=ax, **kw)
if annot and i in annot:
annot[i](ax, P_of(fld, play))
ax.set_title(lab, fontsize=7.8, color=style.INK, loc="left", pad=3, linespacing=1.15)
fig.tight_layout(h_pad=0.6, w_pad=0.5, rect=(0, 0.05 if legend else 0, 1, 1))
if legend:
hs = [Line2D([], [], color=style.OFFENSE, lw=1.2, alpha=0.7, label="Offense: path so far"),
Line2D([], [], color=style.DEFENSE, lw=1.2, ls=(0, (3, 2)), label="Defense: path so far"),
Patch(facecolor="none", edgecolor=style.DEFENSE, lw=1.0, ls=(0, (2, 1.2)),
label="Dashed outline: where he started")]
fig.legend(handles=hs, loc="lower center", ncol=3, fontsize=7.0, frameon=False, bbox_to_anchor=(0.5, 0.0))
return fig
def show_play(play, times, labels, web_caption, annot=None, **kw):
"""Web: an animation with a caption that changes with the phase of the play.
Print: a labelled frame strip (panel titles = `labels`)."""
if PDF:
strip(play, times, labels, annot=annot, **kw)
plt.show()
return
df = play.to_tracking()
tmap = df.groupby("frameId")["time"].first()
anim = animate(df, to_go=play.to_go)
ax = anim._fig.axes[0]
cap = ax.text(0.01, 0.02, "", transform=ax.transAxes, ha="left", va="bottom", fontsize=9, color=style.INK,
zorder=21, bbox=dict(boxstyle="round,pad=0.3", fc=style.SURFACE, ec=style.THIRD, lw=1.0))
base = anim._func
def upd(fid, *a):
out = base(fid, *a)
cap.set_text(web_caption(float(tmap.get(fid, 0.0))))
return out
anim._func = upd
if shutil.which("ffmpeg"):
html = anim.to_html5_video(embed_limit=40).replace("<video ", '<video style="max-width:100%;height:auto" ')
else:
html = anim.to_jshtml(default_mode="once")
plt.close(anim._fig)
display(HTML(html))
def phases(cuts):
"""[(t_upper, caption), ...] -> function of t (seconds from the snap) for show_play."""
def f(t):
for up, txt in cuts:
if t < up:
return txt
return cuts[-1][1]
return f