import os
import logging
import shutil
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, Polygon, RegularPolygon, Patch, Rectangle
from matplotlib.lines import Line2D
import gridiron.play as gplay
from contextlib import contextmanager
from gridiron import Play, Player, Field, routes as r, style
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
READ_GREEN = "#0f7a55" # a darker step of the aqua highlight, for text, numbers and read lines
GREY = style.MUTED
OD, DD = style.OFFENSE_DARK, style.DEFENSE_DARK
LINE_D, OFF_BALL, GUN, UNDER = -0.7, -1.5, -5.0, -1.9
LOS = 40 # the offense's own 30: room to draw deep routes
# ---------------------------------------------------------------- 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():
"""The 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=-1.5, 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(s=1):
return [D("WDE", "DE", 1.0, -5.4 * s, "E"), D("WDT", "DT", 1.0, -0.8 * s, "T"),
D("SDT", "DT", 1.0, 2.4 * s, "T"), D("SDE", "DE", 1.0, 5.4 * s, "E")]
def _apply(players, extra):
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 cover3(nb_side=1, extra=None):
"""4-2-5 nickel, Cover 3 against 2x2: corners off with outside leverage, one deep safety, and a
curl-flat defender over each slot: the nickel ($) on side nb_side (+1 = offense's right), the
strong safety on the other."""
s = nb_side
return _apply(dline() + [D("MIKE", "MIKE", 4.5, 1.8, "M"), D("WILL", "WILL", 4.5, -2.2, "W"),
D("LCB", "CB", 7.0, -16.0, "CB"), D("RCB", "CB", 7.0, 16.0, "CB"),
D("NB", "NB", 5.0, s * 7.2, "$"), D("SS", "SS", 5.5, -s * 7.2, "SS"),
D("FS", "FS", 13.0, 0.0, "FS")], extra)
def cover2(extra=None):
"""4-2-5 nickel, Cover 2: corners squatting at 5 yards with outside leverage, two deep safeties."""
return _apply(dline() + [D("MIKE", "MIKE", 4.5, 1.6, "M"), D("WILL", "WILL", 4.5, -4.2, "W"),
D("NB", "NB", 4.5, 6.6, "$"),
D("LCB", "CB", 5.0, -16.3, "CB"), D("RCB", "CB", 5.0, 16.3, "CB"),
D("FS", "FS", 12.5, -8.5, "FS"), D("SS", "SS", 12.5, 8.5, "SS")], extra)
def quarters(extra=None):
"""4-2-5 nickel, Quarters (Cover 4): corners off, two safeties at about 10 yards reading the slots."""
return _apply(dline() + [D("MIKE", "MIKE", 4.5, 1.6, "M"), D("WILL", "WILL", 4.5, -4.2, "W"),
D("NB", "NB", 4.5, 6.6, "$"),
D("LCB", "CB", 7.5, -16.0, "CB"), D("RCB", "CB", 7.5, 16.0, "CB"),
D("FS", "FS", 10.0, -7.5, "FS"), D("SS", "SS", 10.0, 7.5, "SS")], extra)
def cover1(extra=None):
"""Nickel Cover 1 (man-free): corners pressed, the nickel on H, the strong safety on Y, the Will
on the back, the Mike free to rush or sit in the hole, one deep safety."""
return _apply(dline() + [D("MIKE", "MIKE", 4.2, -1.8, "M"), D("WILL", "WILL", 4.2, 1.4, "W"),
D("NB", "NB", 1.6, -8.3, "$"), D("SS", "SS", 4.5, 8.0, "SS"),
D("LCB", "CB", 1.3, -14.3, "CB"), D("RCB", "CB", 1.3, 14.3, "CB"),
D("FS", "FS", 13.5, 0.0, "FS")], extra)
def protect(p, rushers=("WDE", "WDT", "SDT", "SDE"), depth=1.2, rush=1.6, linemen=("LT", "LG", "C", "RG", "RT")):
"""Linemen set, four rushers engage them: enough to say 'pass', no more."""
for k in linemen:
p.block(k, pts=[(-depth, 0.0)])
for k in rushers:
if k in p.players:
p.rush(k, pts=[(-rush, 0.0)], speed=2.0)
def go_to(p, key, end, kind="path", delay=0.0, speed=None, via=()):
"""Move `key` through absolute (d, w) points `via` to `end`."""
start = np.array(p._cur(key))
pts = [tuple(np.array(q) - start) for q in (*via, end)]
if kind == "rush":
return p.rush(key, pts=pts, delay=delay, speed=speed)
if kind == "route":
return p.route(key, pts, frame="field", delay=delay, speed=speed)
if kind == "block":
return p.block(key, pts=pts, delay=delay, speed=speed)
return {"path": p.path, "run": p.run}[kind](key, pts, delay=delay, speed=speed)
def route_to(p, key, *pts, delay=0.0, speed=None):
"""A route through absolute (d, w) points."""
return go_to(p, key, pts[-1], kind="route", via=pts[:-1], delay=delay, speed=speed)
SWING_NUM = {-1: (-4.4, -14.6), 1: (-4.4, 14.6)}
def swing(p, side=-1, key="RB"):
"""The back checks for a blitzer, then swings wide behind the slot: the checkdown.
Returns where to put his progression number."""
route_to(p, key, (-4.8, side * 4.5), (-4.2, side * 10.5), (-3.0, side * 13.0), delay=0.4)
return SWING_NUM[side]
# ---------------------------------------------------------------- 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 = (160 / 3) - 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):
"""Draw a play without a title. A stand-alone field gets ~6.4 in of drawing width after the
default subplot margins are cropped (width_in is the figure width before cropping)."""
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, 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=READ_GREEN, lw=1.7, dash=(0, (3, 2.2)), end="arrow", z=5):
"""A dashed path through absolute (d, w) points: a defender's reaction, or the other branch of a read."""
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 ghost(fld, p, pts):
"""A route that is part of the call but not part of the read: drawn faint grey."""
branch(fld, p, pts, color="#a9a8a1", lw=1.4, dash="-", z=3.6)
def taken(fld, p, pts, lw=1.4):
"""A route the defender has taken away: grey, dashed, with an X at the end."""
branch(fld, p, pts, color=GREY, lw=lw, dash=(0, (2, 2)), end="none")
x, y = _P(fld, p, *pts[-1])
fld.ax.plot([float(x)], [float(y)], marker="X", ms=8, color=DD, mec="white", mew=0.7, zorder=9)
def opening(fld, p, center, size, color=style.THIRD):
"""A soft green oval: the throwing window that opens."""
x, y = _P(fld, p, *center)
fld.ax.add_patch(Ellipse((float(x), float(y)), size[1], size[0], fc=color, alpha=0.2, ec=color, lw=1.0,
zorder=1.6))
def triangle(fld, p, pts, color=style.THIRD):
xy = [tuple(float(v) for v in _P(fld, p, *q)) for q in pts]
fld.ax.add_patch(Polygon(xy, closed=True, fc=color, alpha=0.15, ec=color, lw=1.3, ls=(0, (4, 2)), zorder=1.7))
def bracket(fld, p, d, w0, w1, text, color=style.INK_2, fontsize=7, dy=1.2):
"""A horizontal dimension line at depth d from w0 to w1, labelled above."""
(x0, y0), (x1, y1) = _P(fld, p, d, w0), _P(fld, p, d, w1)
fld.ax.annotate("", (float(x1), float(y1)), xytext=(float(x0), float(y0)),
arrowprops=dict(arrowstyle="<->", color=color, lw=0.8), zorder=6)
if text:
say(fld, p, d + dy, (w0 + w1) / 2, text, color=color, fontsize=fontsize)
def num(fld, p, n, at, color=READ_GREEN, fs=7.6):
"""A progression number: the order of the quarterback's looks."""
x, y = _P(fld, p, *at)
fld.ax.text(float(x), float(y), str(n), ha="center", va="center", fontsize=fs, color="white",
fontweight="bold", zorder=14,
bbox=dict(boxstyle="circle,pad=0.22", fc=color, ec="white", lw=0.8))
def tag(fld, p, text, fontsize=7.4):
"""The stretch label, top right of a plate."""
fld.ax.text(0.985, 0.975, text, transform=fld.ax.transAxes, ha="right", va="top", fontsize=fontsize,
color=style.INK, fontweight="bold", zorder=15,
bbox=dict(boxstyle="round,pad=0.3", fc=style.SURFACE, ec=style.BASELINE, lw=0.6))
def panel_title(ax, text, fs=9):
ax.set_title(text, fontsize=fs, loc="left", color=style.INK, fontweight="bold")
LEGEND = {
"route": Line2D([], [], color=style.OFFENSE, lw=1.8, label="route"),
"ghost": Line2D([], [], color="#a9a8a1", lw=1.4, label="route outside the read"),
"block": Line2D([], [], color=style.OFFENSE_DARK, lw=1.8, marker="|", ms=8, mew=2.2, label="block"),
"run": Line2D([], [], color=style.OFFENSE_DARK, lw=2.6, label="ball carrier / fake"),
"pass": Line2D([], [], color=style.BALL, lw=1.3, ls=(0, (2, 2)), label="the throw"),
"drop": Line2D([], [], color=style.DEFENSE, lw=1.3, ls=(0, (3, 2)), label="defender's drop"),
"man": Line2D([], [], color=style.DEFENSE, lw=0.9, ls=(0, (1, 2)), label="man assignment"),
"react": Line2D([], [], color=style.DEFENSE, lw=1.4, ls=(0, (3, 2.2)), label="defender's reaction"),
"num": Line2D([], [], marker="o", ls="", ms=9, mfc=READ_GREEN, mec="white", label="progression (1 = first look)"),
"ring": Line2D([], [], marker="o", ls="", ms=11, mfc="none", mec=style.THIRD, mew=2, label="defender in conflict"),
"window": Patch(fc=style.THIRD, alpha=0.25, ec=style.THIRD, label="window that opens"),
"taken": Line2D([], [], color=GREY, lw=1.4, ls=(0, (2, 2)), marker="X", ms=7, mfc=DD,
mec="white", label="taken away"),
"ltg": Line2D([], [], color=style.LINE_TO_GAIN, lw=2.2, label="line to gain"),
}
def legend(fig, keys, ncol=None, y=0.0, fs=7):
hs = [LEGEND[k] for k in keys]
fig.legend(handles=hs, loc="upper center", bbox_to_anchor=(0.5, y), ncol=ncol or min(len(hs), 4), fontsize=fs,
frameon=False, handlelength=2.0, columnspacing=1.0)
PLATE_KEYS = ["route", "ghost", "drop", "num", "ring", "ltg"]
def field_legend(ax, keys, fs=7.2):
"""Legend tucked just under a single field."""
hs = [LEGEND[k] for k in keys]
ax.legend(handles=hs, loc="upper center", bbox_to_anchor=(0.5, 0.0), ncol=len(hs) if len(hs) <= 4 else 3, fontsize=fs,
frameon=False, handlelength=2.0, columnspacing=1.2, borderaxespad=0.2)
def plate(p, nums=(), ghosts=(), stretch=None, window=(-8.5, 26.5), lateral="full", keys=None, width_in=6.9):
"""A concept plate: the call, the read and the progression, in one consistent style."""
fld = draw(p, lateral=lateral, window=window, width_in=width_in)
for pts in ghosts:
ghost(fld, p, pts)
for n, at in nums:
num(fld, p, n, at)
if stretch:
tag(fld, p, stretch)
field_legend(fld.ax, keys or PLATE_KEYS)
return fld
# ---------------------------------------------------------------- frame strips (print) and video (web)
MARK_R = 0.7
def tracking(play):
df = play.to_tracking()
df["_id"] = df["displayName"].astype(str)
return df
def frame_panel(ax, play, df, t, lateral, window_, rings=(), throw_t=None, deco=None):
"""One moment of a simulated play, with faint trails, optional rings and the ball's flight so far."""
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=True,
lateral=(ylo, yhi), to_go=play.to_go)
cur = df[df["frameId"] == fid]
shown = cur[cur["x"].between(play.los + window_[0] + 0.4, play.los + window_[1] - 0.4)
& cur["y"].between(ylo + 0.4, yhi - 0.4)]
ids = set(shown["_id"])
trails = df[(df["frameId"] <= fid) & df["_id"].isin(ids) & (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.45, zorder=3)
if throw_t is not None and t > throw_t:
b = df[(df["side"] == "ball") & (df["frameId"] <= fid)]
b = b[b["time"] >= throw_t - 0.05]
xs, ys = fld.xy(b["x"].values, b["y"].values)
ax.plot(xs, ys, color=style.BALL, lw=1.3, ls=(0, (2, 1.5)), zorder=9)
ppy = fld.pts_per_yard()
for _, rr in shown.iterrows():
x, y = (float(v) for v in fld.xy(rr["x"], rr["y"]))
if rr["side"] == "ball":
if throw_t is None or t <= throw_t:
# still in the quarterback's hands: draw it at his throwing shoulder so his letter stays readable
x, y = (float(v) for v in fld.xy(rr["x"], rr["y"] - 1.0))
elif rr["x"] > play.los + 2:
# in flight or caught: nudge it beside the receiver so his letter stays readable
x, y = (float(v) for v in fld.xy(rr["x"] - 0.9, rr["y"] - 0.9))
ax.add_patch(Ellipse((x, y), 1.0, 0.62, fc=style.BALL, ec="white", lw=0.5, zorder=11))
continue
lab = str(rr.get("label", "") or "")
if rr["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.0, min(8.0, ppy * (0.85 if len(lab) <= 1 else 0.62)))
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)), MARK_R * 1.6, fc="none", ec=col, lw=1.7, zorder=7))
if deco is not None:
deco(ax, fld, cur, t)
return fld
def strip(play, times, notes=None, lateral=(-24, 4), window=(-8, 14), ncols=2, rings=(), throw_t=None,
width_in=6.5, deco=None):
df = tracking(play)
nrows = int(np.ceil(len(times) / ncols))
pw = width_in / ncols
span_w, span_d = abs(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 if notes else 0.25))))
for ax in axes.flat[len(times):]:
ax.axis("off")
for i, (ax, t) in enumerate(zip(axes.flat, times)):
frame_panel(ax, play, df, t, lateral, window, rings, throw_t, deco)
lab = "snap" if abs(t) < 1e-6 else f"{t:+.1f} s"
ax.set_title(f"{i + 1}. {lab}", fontsize=8.5, color=style.INK, loc="left")
if notes:
ax.text(0.5, -0.03, notes[i], transform=ax.transAxes, ha="center", va="top", fontsize=7.3,
color=style.INK, linespacing=1.2)
fig.tight_layout(h_pad=2.2 if notes else 0.6, w_pad=0.6)
return fig
def video(play, times, notes=None, lateral=(-24, 4), window=(-8, 14), rings=(), throw_t=None, width_in=5.6,
deco=None):
from matplotlib import animation
from IPython.display import HTML, display
df = tracking(play)
span_w, span_d = abs(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])
frame_panel(ax, play, df, t, lateral, window, rings, throw_t, deco)
txt = "snap" if abs(t) < 0.05 else f"{t:+.1f} s"
if notes:
cur = [n for tt, n in zip(times, notes) if tt <= t + 1e-6]
txt += (" " + 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 play_movie(play, times, notes=None, **kw):
"""Video on the website; a numbered, captioned frame strip in the PDF."""
if PDF:
strip(play, times, notes=notes, **kw)
plt.show()
else:
kw.pop("ncols", None)
kw.pop("width_in", None)
video(play, times, notes=notes, **kw)
def set_track(p, key, rows, t_end=None):
"""Hand-made keyframes [(t, d, w), ...] for one player (gridiron's man() ends on top of the
receiver, and we sometimes need a defender who detours around traffic). Call last."""
kf, t0, t1 = p._compile()
kf = dict(kf)
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 rows[-1][0]))
def chase(p, key, target, lag=0.3, off=(-1.0, 0.0), t_start=0.15, t_on=0.8, t_end=4.0, detour=None,
lag_after=None):
"""Keyframes for a man defender trailing `target`: he starts where he is, then settles `off` =
(down, toward the offense's right) yards from where the receiver was `lag` seconds earlier.
detour = (t0, t1, (d, w)): between t0 and t1 he is pulled toward that point (traffic)."""
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)
lg = lag
if detour is not None and lag_after is not None and tt > detour[0]:
k = min(1.0, (tt - detour[0]) / (detour[1] - detour[0]))
lg = lag + (lag_after - lag) * k
tgt = p.position(target, max(0.0, tt - lg)) + np.array(off)
pos = np.array([q.d, q.w]) * (1 - f) + tgt * f
if detour is not None:
a, b, pt = detour
if a <= tt <= b:
g = np.sin(np.pi * (tt - a) / (b - a))
pos = pos * (1 - g) + np.array(pt) * g
rows.append((tt, *pos))
return rows
def catch_time(p):
"""Seconds after the snap at which the first pass arrives (rounded up to a tenth)."""
for kind, to, te in p.ball_events:
if kind == "pass":
a, b = p.pass_target_spot()
return float(np.ceil((te + np.hypot(*(b - a)) / gplay.BALL_SPEED) * 10) / 10)
@contextmanager
def ball_speed(v):
"""Temporarily change gridiron's ball speed (yd/s): a firm deep throw travels faster than the default."""
old = gplay.BALL_SPEED
gplay.BALL_SPEED = v
try:
yield
finally:
gplay.BALL_SPEED = old