import os
import logging
import shutil
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib import animation
from matplotlib.patches import Ellipse, FancyArrowPatch, Patch
from matplotlib.lines import Line2D
from gridiron import Play, Player, Field, routes as r, style, draw_marker
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
READ_GREEN = "#0f7a55" # darker step of the aqua highlight, for text and read lines
OD, DD = style.OFFENSE_DARK, style.DEFENSE_DARK
LINE_D, OFF_BALL, GUN = -0.7, -1.5, -5.0
LOS = 40 # the offense's own 30: room for deep routes in the window
FW = 160 / 3
# ---------------------------------------------------------------- players and alignments
def O(key, pos, d, w, label=None):
return Player(key, pos, "off", d, w, label=key if label is None else label)
def D(key, pos, d, w, label):
return Player(key, pos, "def", d, w, label=label)
def line5():
"""Five offensive linemen, 1.6 yards apart (only the center is lettered)."""
return [O(k, k, LINE_D, w, "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 spread(rb_w=2.0, x_w=-15.0, h_w=-9.0, y_w=9.0, z_w=15.0):
"""11 personnel, shotgun 2x2: X and Z on the line (with the five linemen, seven on the line);
H in the left slot and Y, a tight end split out, in the right slot, both a step off the ball."""
return line5() + [O("QB", "QB", GUN, 0.0, "Q"), O("RB", "RB", GUN, rb_w, "R"),
O("X", "WR", LINE_D, x_w), O("H", "WR", OFF_BALL, h_w),
O("Y", "TE", OFF_BALL, y_w), O("Z", "WR", LINE_D, z_w)]
def dline():
"""Four down linemen (a four-man rush), strength to the offense's right."""
return [D("WDE", "DE", 1.0, -5.4, "E"), D("WDT", "DT", 1.0, -0.8, "T"),
D("SDT", "DT", 1.0, 2.4, "T"), D("SDE", "DE", 1.0, 5.4, "E")]
def moved(players, extra=None):
extra = extra or {}
return [q.moved(d=extra[q.key][0], w=extra[q.key][1]) if q.key in extra else q for q in players]
def cover1_def(extra=None):
"""Nickel Cover 1 against 2x2: corners pressed with a slight outside shade (their help is
inside), the nickel ($) on H, the strong safety on the tight end, the Will on the back, the
Mike free, the free safety alone in the deep middle."""
return moved(dline() + [D("MIKE", "MIKE", 4.5, -1.8, "M"), D("WILL", "WILL", 4.5, 2.4, "W"),
D("NB", "NB", 4.0, -9.8, "$"), D("SS", "SS", 5.0, 9.8, "SS"),
D("LCB", "CB", 1.2, -15.9, "CB"), D("RCB", "CB", 1.2, 15.9, "CB"),
D("FS", "FS", 14.0, 0.0, "FS")], extra)
def zero_front():
"""Six at the line for an all-out pressure: four linemen widened a little, and both linebackers
walked up into the A gaps beside the center (a 'double A-gap mug')."""
return [D("WDE", "DE", 1.0, -5.8, "E"), D("WDT", "DT", 1.0, -3.0, "T"), D("SDT", "DT", 1.0, 3.0, "T"),
D("SDE", "DE", 1.0, 5.8, "E"), D("MIKE", "MIKE", 1.4, -0.95, "M"), D("WILL", "WILL", 1.4, 0.95, "W")]
def protect(p, rushers=("WDE", "WDT", "SDT", "SDE"), linemen=("LT", "LG", "C", "RG", "RT")):
"""Linemen set, rushers engage them: enough to say 'pass', no more."""
for k in linemen:
p.block(k, pts=[(-1.2, 0.0)])
for k in rushers:
p.rush(k, pts=[(-1.6, 0.0)], speed=2.0)
def route_to(p, key, *pts, delay=0.0, speed=None):
"""A route through absolute (d, w) points."""
start = np.array(p._cur(key))
return p.route(key, [tuple(np.array(q) - start) for q in pts], frame="field", delay=delay, speed=speed)
# ---------------------------------------------------------------- drawing helpers
def P(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def clean(ax):
"""Drop the painted yard numbers (they sit where receivers stand); clip all text to the window."""
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 ruler(fld, p, fs=6.0, step=5, top=30):
"""Depth labels (yards past the line of scrimmage) just inside the left edge of the window."""
xl = FW - fld.y1 + 0.4
for d in range(step, top + 1, step):
y = p.los + d
if fld.x0 + 1.5 <= y <= fld.x1 - 1.5:
fld.ax.text(xl, y, f"{d}" + (" yd" if d == step else ""), fontsize=fs, color=style.INK_2,
ha="left", va="center", zorder=12,
bbox=dict(boxstyle="round,pad=0.12", fc=style.SURFACE, ec="none", alpha=0.85))
def draw(p, width_in=8.3, ax=None, show_ruler=True, **kw):
fld = p.draw(width_in=width_in, ax=ax, title=False, **kw)
clean(fld.ax)
if show_ruler:
ruler(fld, p)
return fld
def say(fld, p, d, w, text, color=style.INK_2, fontsize=7.0, ha="center", weight="normal", box=True):
x, y = P(fld, p, d, w)
fld.ax.text(float(x), float(y), text, fontsize=fontsize, color=color, ha=ha, va="center", zorder=13,
fontweight=weight, linespacing=1.15,
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.9) if box else None)
def point_to(fld, p, text_at, target, text, color=style.INK_2, fontsize=7.0, ha="center"):
(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=ha, va="center", zorder=13, linespacing=1.15,
arrowprops=dict(arrowstyle="-", color=color, lw=0.7, shrinkA=2, shrinkB=7),
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.9))
def branch(fld, p, pts, color=style.DEFENSE, lw=1.5, dash=(0, (3, 2.2)), end="arrow", z=5):
"""A path through absolute (d, w) points: by default a defender's reaction (orange dashes)."""
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, ys, color=color, lw=lw, ls=dash, zorder=z, solid_capstyle="round")
if end == "arrow":
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>",
mutation_scale=10, color=color, lw=lw, zorder=z))
def oval(fld, p, center, size, color=style.DEFENSE, label=None, alpha=0.13, fs=7.0, ls=(0, (3, 2))):
"""A shaded area (depth, width) with an optional italic label at its centre."""
x, y = P(fld, p, *center)
fld.ax.add_patch(Ellipse((float(x), float(y)), size[1], size[0], fc=color, alpha=alpha, ec=color, lw=0.9,
ls=ls, zorder=1.6))
if label:
fld.ax.text(float(x), float(y), label, ha="center", va="center", fontsize=fs, color=style.INK_2,
style="italic", zorder=3)
def vdim(fld, p, w, d0, d1, text, fs=6.5, side=1):
"""A vertical dimension line at width w from depth d0 to d1, labelled beside it."""
(x0, y0), (x1, y1) = P(fld, p, d0, w), P(fld, p, d1, w)
fld.ax.annotate("", (float(x1), float(y1)), xytext=(float(x0), float(y0)),
arrowprops=dict(arrowstyle="<->", color=style.INK_2, lw=0.8), zorder=6)
say(fld, p, (d0 + d1) / 2, w + side * 2.4, text, fontsize=fs)
def hline(fld, p, d, w0, w1, color=style.INK_2, lw=0.9, ls=(0, (4, 3))):
(x0, y0), (x1, y1) = P(fld, p, d, w0), P(fld, p, d, w1)
fld.ax.plot([float(x0), float(x1)], [float(y0), float(y1)], color=color, lw=lw, ls=ls, zorder=2.5)
def number(fld, p, keys_texts, dd=-2.9):
"""Receiver numbers (#1, #2 ...) under the named receivers."""
for key, txt in keys_texts:
q = p.players[key]
say(fld, p, q.d + dd, q.w, txt, color=OD, fontsize=6.8, weight="bold")
def panel_title(ax, text, fs=8.6):
ax.set_title(text, fontsize=fs, loc="left", color=style.INK, fontweight="bold")
def under_note(ax, text, fs=6.9):
ax.text(0.5, -0.02, text, transform=ax.transAxes, ha="center", va="top", fontsize=fs, color=style.INK,
linespacing=1.2)
LEGEND = {
"route": Line2D([], [], color=style.OFFENSE, lw=1.8, label="route"),
"man": Line2D([], [], color=style.DEFENSE, lw=0.9, ls=(0, (1, 2)), label="man assignment"),
"drop": Line2D([], [], color=style.DEFENSE, lw=1.3, ls=(0, (3, 2)), label="defender's drop / path"),
"motion": Line2D([], [], color=style.OFFENSE, lw=1.3, ls=(0, (3, 2.2)), label="pre-snap motion"),
"react": Line2D([], [], color=style.DEFENSE, lw=1.4, ls=(0, (3, 2.2)), label="defender's reaction"),
"rush": Line2D([], [], color=style.DEFENSE, lw=1.8, label="rusher"),
"ring": Line2D([], [], marker="o", ls="", ms=11, mfc="none", mec=style.THIRD, mew=2, label="player to watch"),
"area": Patch(fc=style.DEFENSE, alpha=0.2, ec=style.DEFENSE, label="area a defender guards"),
"ltg": Line2D([], [], color=style.LINE_TO_GAIN, lw=2.2, label="line to gain"),
"pass": Line2D([], [], color=style.BALL, lw=1.3, ls=(0, (2, 2)), label="the throw"),
}
def field_legend(ax, keys, fs=7.0, ncol=None):
hs = [LEGEND[k] for k in keys]
ax.legend(handles=hs, loc="upper center", bbox_to_anchor=(0.5, 0.0), ncol=ncol or min(len(hs), 4),
fontsize=fs, frameon=False, handlelength=2.0, columnspacing=1.2, borderaxespad=0.2)
# ---------------------------------------------------------------- motion: keyframes, frame strips, video
def set_tracks(p, tracks, t_end=None):
"""Hand-made keyframes {key: [(t, d, w), ...]} for some players, after all routes are added.
gridiron's man() keeps a shrinking cushion that ends on top of the receiver (which hides him in
print) and cannot switch receivers, so man defenders here are keyframed by hand."""
kf, t0, t1 = p._compile()
kf = dict(kf)
for key, rows in tracks.items():
q = p.players[key]
rows = [(min(t0, -1.0), q.d, q.w), (0.0, q.d, q.w)] + [tuple(r_) for r_ in rows]
kf[key] = np.array(rows, dtype=float)
p._compiled = (kf, t0, max(t1, t_end or 0.0, *[rows[-1][0] for rows in tracks.values()]))
def chase(p, key, target, off, lag=0.25, t_on=0.7, t_end=3.6, t_start=0.1):
"""Keyframes for a man defender who settles `off` = (down, toward the offense's right) yards
from where his receiver was `lag` seconds earlier."""
q = p.players[key]
rows = []
for tt in np.round(np.arange(t_start, t_end, 0.05), 3):
f = min(1.0, max(0.0, (tt - t_start) / (t_on - t_start)))
f = f * f * (3 - 2 * f)
tgt = p.position(target, max(0.0, tt - lag)) + np.array(off)
rows.append((tt, *(np.array([q.d, q.w]) * (1 - f) + tgt * f)))
return rows
def ease(points, step=0.05):
"""Smooth keyframes through (t, d, w) waypoints (ease in and out on each leg)."""
rows = []
for (ta, da, wa), (tb, db, wb) in zip(points[:-1], points[1:]):
for tt in np.arange(ta, tb, step):
f = (tt - ta) / (tb - ta)
f = f * f * (3 - 2 * f)
rows.append((round(float(tt), 3), da + (db - da) * f, wa + (wb - wa) * f))
rows.append(tuple(points[-1]))
return rows
def frame(ax, p, t, lateral, window, rings=(), ltg=True):
"""Everyone at time t, with faint trails of where they have been and the ball's flight so far."""
ylo, yhi = sorted((p.ball_y - lateral[0], p.ball_y - lateral[1]))
fld = Field(ax=ax, orient="vertical", los=p.los, window=window, numbers=False, lateral=(ylo, yhi),
to_go=p.to_go if ltg else None)
ppy = fld.pts_per_yard()
wl, wr = sorted(lateral)
ring = dict(rings)
for k, q in p.players.items():
d, w = p.position(k, t)
if not (window[0] + 0.4 < d < window[1] - 0.4 and wl + 0.4 < w < wr - 0.4):
continue
if t > 0:
pts = np.array([p.position(k, tt) for tt in np.linspace(0.0, t, 40)])
if np.ptp(pts[:, 0]) + np.ptp(pts[:, 1]) > 0.3:
xs, ys = P(fld, p, pts[:, 0], pts[:, 1])
ax.plot(xs, ys, color=style.OFFENSE if q.side == "off" else style.DEFENSE, lw=1.0,
alpha=0.5, zorder=3)
x, y = P(fld, p, d, w)
draw_marker(ax, float(x), float(y), q.side, q.label, ppy, ring=ring.get(k))
for kind, to, te in p.ball_events:
if kind == "pass" and t > te:
ts = np.linspace(te, t, 20)
b = np.array([p.ball_position(tt) for tt in ts])
xs, ys = P(fld, p, b[:, 0], b[:, 1])
ax.plot(xs, ys, color=style.BALL, lw=1.4, ls=(0, (2, 1.5)), zorder=9)
ax.add_patch(Ellipse((float(xs[-1]) - 0.9, float(ys[-1])), 1.0, 0.62, fc=style.BALL, ec="white",
lw=0.5, zorder=11))
clean(ax)
return fld
def strip_grid(p, times, notes, lateral, window, rings=(), ncols=2, width_in=6.5, deco=None, fs=6.9):
"""Print stand-in for an animation: numbered panels, each with a sentence underneath."""
nrows = int(np.ceil(len(times) / ncols))
pw = width_in / ncols
ph = pw * (window[1] - window[0]) / abs(lateral[1] - lateral[0])
fig, axes = plt.subplots(nrows, ncols, squeeze=False, figsize=(width_in, nrows * (ph + 0.75)))
for ax in axes.flat[len(times):]:
ax.axis("off")
for i, (ax, t) in enumerate(zip(axes.flat, times)):
fld = frame(ax, p, t, lateral, window, rings)
if deco:
deco(i, fld, p)
panel_title(ax, f"{i + 1}. " + ("snap" if abs(t) < 1e-6 else f"+{t:.1f} s"))
under_note(ax, notes[i], fs=fs)
fig.tight_layout(h_pad=3.2, w_pad=0.8)
return fig
def strip_rows(plays, names, times, notes, lateral, window, rings=(), width_in=6.5, fs=6.6, deco=None):
"""One row per play, one column per moment: two versions of a play on the same clock."""
nc = len(times)
pw = width_in / nc
ph = pw * (window[1] - window[0]) / abs(lateral[1] - lateral[0])
fig, axes = plt.subplots(len(plays), nc, squeeze=False, figsize=(width_in, len(plays) * (ph + 0.8)))
for i, (p, nm) in enumerate(zip(plays, names)):
for j, t in enumerate(times):
fld = frame(axes[i, j], p, t, lateral, window, rings)
if deco:
deco(i, j, fld, p)
panel_title(axes[i, j], f"{nm}: " + ("snap" if abs(t) < 1e-6 else f"+{t:.1f} s"), fs=8)
under_note(axes[i, j], notes[i][j], fs=fs)
fig.tight_layout(h_pad=3.0, w_pad=0.6)
return fig
def video(plays, names, lateral, window, t_end, rings=(), width_in=6.0, t_start=-0.4):
"""Web version: the plays side by side, on one clock."""
from IPython.display import HTML, display
n = len(plays)
span_w, span_d = abs(lateral[1] - lateral[0]), window[1] - window[0]
fig, axes = plt.subplots(1, n, figsize=(width_in, width_in / n * span_d / span_w + 0.5), squeeze=False)
ts = np.round(np.arange(t_start, t_end + 1e-9, 0.1), 2)
def update(i):
for ax, p, nm in zip(axes[0], plays, names):
ax.clear()
frame(ax, p, max(0.0, float(ts[i])), lateral, window, rings)
panel_title(ax, (nm + ": " if nm else "") + ("snap" if ts[i] <= 0 else f"+{ts[i]:.1f} s"), fs=9)
return []
fig.tight_layout()
anim = animation.FuncAnimation(fig, update, frames=len(ts), interval=100, blit=False)
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))