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
from matplotlib.lines import Line2D
from matplotlib.patches import Patch
from gridiron import Play, Player, Field, routes as r, style
import gridiron.play as gp
# A firm quick-game throw travels roughly 22-26 yards a second; gridiron's default (18) is an average over
# all throws and makes a five-yard slant look slow. This only changes the setting inside this chapter.
gp.BALL_SPEED = 24.0
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 and read lines on the light field
GREY = style.MUTED
LINE_D, OFF_BALL, GUN = -0.7, -1.5, -5.0
# ---------------------------------------------------------------- players and alignments
def O(key, pos, d, w, label=None):
return Player(key, pos, "off", d, w, label=label or key)
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 cover3(nb_side=1, cb_d=7.0, cb_out=1.0, x_w=-15.0, z_w=15.0, extra=None):
"""A 4-2-5 nickel showing Cover 3 against 2x2: corners off with outside leverage, one deep
safety, and a curl-flat defender walked down over each slot: the nickel ($) on side
`nb_side` (+1 = offense's right) and the strong safety on the other. extra = {key: (d, w)}."""
s = nb_side
out = [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"),
D("MIKE", "MIKE", 4.5, 1.8, "M"), D("WILL", "WILL", 4.5, -2.2, "W"),
D("LCB", "CB", cb_d, x_w - cb_out, "CB"), D("RCB", "CB", cb_d, z_w + cb_out, "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 = 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 out]
def protect(p, rushers=("WDE", "WDT", "SDT", "SDE"), depth=1.2, rush=1.6):
"""Linemen set, four rushers engage them: enough to say 'pass', no more."""
for k in ("LT", "LG", "C", "RG", "RT"):
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)
# ---------------------------------------------------------------- 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=6.2, 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.2, 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"):
"""A dashed path through absolute (d, w) points: the other branch of a read, or a defender's reaction."""
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=5, 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=5))
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=style.DEFENSE_DARK, 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)
say(fld, p, d + dy, (w0 + w1) / 2, text, color=color, fontsize=fontsize)
def vbracket(fld, p, w, d0, d1, color=style.INK_2):
"""A vertical dimension line at width w from depth d0 to d1."""
(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=color, lw=0.8), zorder=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"),
"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"),
"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"),
"react": Line2D([], [], color=style.DEFENSE, lw=1.4, ls=(0, (3, 2.2)), label="defender's reaction"),
"other": Line2D([], [], color=READ_GREEN, lw=1.6, ls=(0, (3, 2.2)), label="the other branch"),
"read": Line2D([], [], color=style.THIRD, lw=1.2, ls=(0, (4, 2)), label="QB's read"),
"window": Patch(fc=style.THIRD, alpha=0.25, ec=style.THIRD, label="window that opens"),
"triangle": Patch(fc=style.THIRD, alpha=0.15, ec=style.THIRD, ls=(0, (4, 2)), label="the triangle"),
"zone": Patch(fc=style.DEFENSE, alpha=0.13, ec=style.DEFENSE, ls=(0, (3, 2)), label="zone defender's area"),
"taken": Line2D([], [], color=GREY, lw=1.4, ls=(0, (2, 2)), marker="X", ms=7, mfc=style.DEFENSE_DARK,
mec="white", label="taken away"),
}
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 len(hs), fontsize=fs,
frameon=False, handlelength=2.0, columnspacing=1.0)
# ---------------------------------------------------------------- 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):
"""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))
ax.add_patch(Ellipse((x, y), 0.9, 0.56, 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))
return fld
def strip(play, times, notes=None, lateral=(-24, 4), window=(-8, 14), ncols=2, rings=(), throw_t=None,
width_in=6.5):
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)
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):
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)
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 trail(p, key, target, jab, lag=0.35, off=(-1.1, -0.9), t_open=0.35, t_on=0.9, t_end=4.0):
"""Hand-made keyframes for a defender chasing `target` from behind: he steps to `jab` (a
reaction to the release), then settles `off` = (down, toward the offense's right) yards from
where the receiver was `lag` seconds earlier. (gridiron's man() ends on top of the receiver.)
His depth is floored so he never ends up in the offense's backfield: while the receiver is near the
line the defender stays hip to hip with him, then the floor relaxes and he trails a step behind."""
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), (t_open, *jab)]
for tt in np.round(np.arange(t_open + 0.05, t_end, 0.05), 3):
f = min(1.0, (tt - t_open) / (t_on - t_open))
f = f * f * (3 - 2 * f)
chase = p.position(target, max(0.0, tt - lag)) + np.array(off)
now_d = float(p.position(target, tt)[0])
chase[0] = max(chase[0], 0.3, now_d - 0.6 - 0.5 * max(0.0, now_d - 1.5))
rows.append((tt, *(np.array(jab) * (1 - f) + chase * f)))
kf[key] = np.array(rows)
p._compiled = (kf, t0, max(t1, t_end))
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)) / gp.BALL_SPEED) * 10) / 10)