import logging
import os
import shutil
import textwrap
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, FancyBboxPatch, Rectangle, RegularPolygon, Patch
from matplotlib.lines import Line2D
from IPython.display import HTML, display
from gridiron import Play, Player, routes as r, 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")
# ---------------------------------------------------------------- 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 line(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 gun_2x1_te(z_w=15.0):
"""11 personnel from the gun: X on the line to the left, H in the left slot, the tight end (Y)
attached on the right and Z a step off the ball outside him. Seven on the line (five linemen, X
and Y), so Z, H, the back and the quarterback are backfield players: any one of them may go in motion."""
return line() + [O("QB", "QB", -5.0, 0.0, "Q"), O("RB", "RB", -5.0, -1.6, "R"),
O("X", "WR", -0.7, -15.0, "X"), O("H", "WR", -1.5, -9.0, "H"),
O("Y", "TE", -0.7, 4.8, "Y"), O("Z", "WR", -1.5, z_w, "Z")]
def gun_2x2_spread():
"""11 personnel 2x2 with the tight end flexed into the right slot; X and Z on the line."""
return line() + [O("QB", "QB", -5.0, 0.0, "Q"), O("RB", "RB", -5.0, -1.6, "R"),
O("X", "WR", -0.7, -15.0, "X"), O("H", "WR", -1.5, -9.0, "H"),
O("Y", "TE", -1.5, 9.0, "Y"), O("Z", "WR", -0.7, 15.0, "Z")]
def under_wing():
"""Under center: tight end on the right, a wingback (H) just outside and behind him, a tailback."""
return line() + [O("QB", "QB", -1.9, 0.0, "Q"), O("RB", "RB", -7.0, 0.0, "R"),
O("X", "WR", -0.7, -15.0, "X"), O("Y", "TE", -0.7, 4.8, "Y"),
O("H", "WR", -1.6, 6.4, "H"), O("Z", "WR", -1.5, 15.0, "Z")]
def front4(sde=4.3):
"""A four-man front against a tight end on the right (the end on that side outside the tackle)."""
return [D("WDE", "DE", 1.0, -5.0, "E"), D("WDT", "DT", 1.0, -0.7, "T"),
D("SDT", "DT", 1.0, 2.35, "T"), D("SDE", "DE", 1.0, sde, "E")]
def dashed_motion(p, key, to, head=0.5):
"""Pre-snap movement to the spot `to` (absolute d, w), drawn dashed with a short solid head.
(gridiron draws the last part of a path's final segment solid, so one long segment would
look like a route.)"""
d0, w0 = p._cur(key)
dd, dw = to[0] - d0, to[1] - w0
f = max(0.0, 1 - head / float(np.hypot(dd, dw)))
p.motion(key, pts=[(dd * f, dw * f), (dd, dw)])
def schedule(p, plan):
"""Re-time pre-snap movement. plan = {key: (t_start, t_end)} in seconds from the snap.
gridiron ends every pre-snap motion exactly at the snap and starts it whenever its length and
speed require, so two players who should move together drift apart. This stretches each listed
player's pre-snap path into his window (and holds him still until the snap if it ends early).
Implemented here rather than in gridiron; call it after every action has been added."""
kf, t0, t1 = p._compile()
for key, (a, b) in plan.items():
arr = kf[key]
pre, post = arr[arr[:, 0] <= 1e-9], arr[arr[:, 0] > 1e-9]
T0, T1 = pre[0, 0], pre[-1, 0]
new = pre.copy()
new[:, 0] = a + (pre[:, 0] - T0) / ((T1 - T0) or 1.0) * (b - a)
rows = [new] + ([np.array([[0.0, *new[-1, 1:]]])] if b < 0 else []) + [post]
kf[key] = np.vstack(rows)
p._compiled = (kf, min(float(f[0, 0]) for f in kf.values()), t1)
return p
# ---------------------------------------------------------------- drawing helpers
def no_numbers(ax):
"""Remove the painted yard numbers (in these 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 tag(ax, xy, text, ec=style.BASELINE, fs=6.8, lw=0.6, ha="center"):
ax.text(*xy, text, fontsize=fs, color=style.INK, ha=ha, va="center", zorder=12, linespacing=1.15,
bbox=dict(boxstyle="round,pad=0.22", fc=style.SURFACE, ec=ec, lw=lw))
def callout(ax, P, text, at, to, fs=6.8, ec=style.BASELINE):
"""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=style.INK, ha="center", va="center", zorder=12,
linespacing=1.15, bbox=dict(boxstyle="round,pad=0.22", fc=style.SURFACE, ec=ec, lw=0.6),
arrowprops=dict(arrowstyle="-", color=style.INK_2, lw=0.7, shrinkA=0, shrinkB=4))
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.1, ls=(0, (2, 1.2)), zorder=6)
else:
pat = Circle((x, y), MARKER_R, facecolor="none", edgecolor=col, lw=1.1, 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)))
def mpath(ax, P, pts, kind="pre"):
"""A path through (d, w) points drawn by hand. 'pre' = pre-snap motion (dashed, blue);
'post' = what he does after the snap (thin, solid, grey)."""
col = style.OFFENSE if kind == "pre" else style.INK_2
xy = [P(d, w) for d, w in pts]
xs, ys = zip(*xy)
lw = 1.6 if kind == "pre" else 1.0
cut = (xs[-2] + 0.75 * (xs[-1] - xs[-2]), ys[-2] + 0.75 * (ys[-1] - ys[-2]))
ax.plot(list(xs[:-1]) + [cut[0]], list(ys[:-1]) + [cut[1]], color=col, lw=lw,
ls=(0, (3, 2)) if kind == "pre" else "-", zorder=5, solid_capstyle="round")
head = (xs[-2] + 0.7 * (xs[-1] - xs[-2]), ys[-2] + 0.7 * (ys[-1] - ys[-2]))
ax.add_patch(FancyArrowPatch(head, xy[-1], arrowstyle="-|>", mutation_scale=8, color=col, lw=lw,
shrinkA=0, shrinkB=0, zorder=5))
def dpath(ax, P, a, b):
"""A defender's pre-snap slide, dashed, in the defense's colour."""
ax.add_patch(FancyArrowPatch(P(*a), P(*b), arrowstyle="-|>", mutation_scale=7, color=style.DEFENSE,
lw=1.2, ls=(0, (3, 2)), shrinkA=5, shrinkB=5, zorder=4))
def snap_dot(ax, P, at, dd=-1.4, dw=0.0):
"""Mark where the moving player is when the ball is snapped."""
x, y = P(*at)
ax.plot([x], [y], marker="o", ms=3.6, color=style.BALL, zorder=9)
ax.text(*P(at[0] + dd, at[1] + dw), "snap", fontsize=6.2, color=style.BALL, ha="center", va="center",
zorder=9, style="italic")
# ---------------------------------------------------------------- animations and their print strips
def snapshot(play, t, ax=None, lateral=(-21, 21), window=(-8.5, 15.5), ghosts=(), ghost_t=None, notes=()):
"""Everyone where he is at time t, a faint trail behind each mover since the first frame, 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)
for n in notes:
q.note(*n)
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]
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, 40)])
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.2, alpha=0.55, 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) # a carried ball sits just in front of him
ax.add_patch(Ellipse((bx, by), 0.75, 1.1, facecolor=style.BALL, edgecolor="white", lw=0.6, zorder=9.5))
return fld
def strip(play, times, labels, ncols=2, **kw):
"""The print version of an animation: one panel per chosen moment, each with a caption."""
lateral, window = kw.get("lateral", (-21, 21)), kw.get("window", (-8.5, 15.5))
n = len(times)
nrows = int(np.ceil(n / ncols))
pw = 6.5 / ncols
h = nrows * (pw * (window[1] - window[0]) / abs(lateral[1] - lateral[0]) + 0.36) + 0.35
fig, axes = plt.subplots(nrows, ncols, figsize=(6.5, h), squeeze=False)
for ax in axes.flat[n:]:
ax.axis("off")
for ax, t, lab in zip(axes.flat, times, labels):
snapshot(play, t, ax=ax, **kw)
ax.set_title(lab, fontsize=8.2, color=style.INK, loc="left", pad=3)
fig.tight_layout(h_pad=0.5, w_pad=0.5, rect=(0, 0.05, 1, 1))
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.1, ls=(0, (2, 1.2)), label="Dashed outline: where he started")]
fig.legend(handles=hs, loc="lower center", ncol=3, fontsize=7.2, frameon=False, bbox_to_anchor=(0.5, 0.0))
return fig
def show_play(play, times, labels, web_caption, **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, **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