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, FancyBboxPatch, Rectangle, RegularPolygon, Patch
from matplotlib.lines import Line2D
import gridiron.play as gplay
from gridiron import Play, Player, Field, style, draw_marker, routes as r
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
HALF = 80 / 3 # yards from the middle of the field to either sideline (26.7)
HASH_W = 18.5 / 6 # NFL hash marks: 18 ft 6 in apart, so 3.1 yards either side of the middle
NUM_W = HALF - 12.0 # the painted numbers start 12 yards in from the sideline: 14.7 from the middle
GREEN_TEXT = "#0f7a55" # a darker step of the green highlight, for text on the light field
DD = style.DEFENSE_DARK
GREY = style.MUTED
# ---------------------------------------------------------------- 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, -0.7, 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():
"""11 personnel, shotgun 2x2: X and Z on the line (with the five linemen: seven on the line),
H in the left slot and the tight end Y flexed into the right slot, both a step off the ball."""
return line5() + [O("QB", "QB", -5.0, 0.0, "Q"), O("RB", "RB", -5.0, -1.5, "R"),
O("X", "WR", -0.7, -15.0), O("H", "WR", -1.5, -9.0),
O("Y", "TE", -1.5, 9.0), O("Z", "WR", -0.7, 15.0)]
def front4():
return [D("WDE", "DE", 1.0, -5.6, "E"), D("WDT", "DT", 1.0, -0.8, "T"),
D("SDT", "DT", 1.0, 2.4, "T"), D("SDE", "DE", 1.0, 5.6, "E")]
def nickel(**spots):
"""4-2-5 nickel against the 2x2. Defaults are a neutral two-high look; pass KEY=(d, w) to move
anyone (keys: MIKE, WILL, NB, LCB, RCB, FS, SS)."""
base = {"MIKE": (4.5, 1.8), "WILL": (4.5, -2.2), "NB": (5.0, -7.4), "LCB": (7.0, -16.0),
"RCB": (7.0, 16.0), "FS": (12.5, -8.5), "SS": (12.5, 8.5)}
base.update(spots)
lab = {"MIKE": ("MIKE", "M"), "WILL": ("WILL", "W"), "NB": ("NB", "$"), "LCB": ("CB", "CB"),
"RCB": ("CB", "CB"), "FS": ("FS", "FS"), "SS": ("SS", "SS")}
return front4() + [D(k, lab[k][0], *base[k], lab[k][1]) for k in base]
def protect(p, rushers=("WDE", "WDT", "SDT", "SDE"), linemen=("LT", "LG", "C", "RG", "RT")):
"""Linemen set, four rushers engage them: enough to say 'pass', no more."""
for k in linemen:
if k in p.players:
p.block(k, pts=[(-1.2, 0.0)])
for k in rushers:
if k in p.players:
p.rush(k, pts=[(-1.6, 0.0)], speed=2.0)
# ---------------------------------------------------------------- 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 text to the window."""
for t in list(ax.texts):
if t.get_alpha() == 0.55:
t.remove()
else:
t.set_clip_on(True)
def ruler(fld, p, fs=6.0, step=5, top=40):
"""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 widths(fld, p, d, fs=6.0, which=("hash", "numbers")):
"""Italic 'hash' and 'numbers' markers at depth d, wherever they fall inside the window."""
spots = []
if "hash" in which:
spots += [(-HASH_W, "hash"), (HASH_W, "hash")]
if "numbers" in which:
spots += [(-NUM_W, "numbers"), (NUM_W, "numbers")]
for w, txt in spots:
x, y = P(fld, p, d, w)
if fld.y0 + 2.5 <= p.ball_y - w <= fld.y1 - 2.5:
fld.ax.text(float(x), float(y), txt, fontsize=fs, color=style.INK_2, ha="center", va="center",
style="italic", zorder=12,
bbox=dict(boxstyle="round,pad=0.12", fc=style.SURFACE, ec="none", alpha=0.85))
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 dim(fld, p, a, b, text, text_at, color=style.INK_2, fontsize=6.8):
"""A dimension line between two (d, w) points, labelled at text_at."""
(x0, y0), (x1, y1) = P(fld, p, *a), P(fld, p, *b)
fld.ax.annotate("", (float(x1), float(y1)), xytext=(float(x0), float(y0)),
arrowprops=dict(arrowstyle="<->", color=color, lw=0.8, shrinkA=0, shrinkB=0), zorder=6)
say(fld, p, *text_at, text, color=color, fontsize=fontsize)
def zone_box(fld, p, d0, d1, w0, w1, label="", color=style.DEFENSE, alpha=0.12, fs=7.0, ls=(0, (3, 2))):
"""A rounded, shaded rectangle from depth d0 to d1 and width w0 to w1 (yards), labelled in the middle."""
(x0, y0), (x1, y1) = P(fld, p, d0, w0), P(fld, p, d1, w1)
lo_x, lo_y = float(min(x0, x1)), float(min(y0, y1))
fld.ax.add_patch(FancyBboxPatch((lo_x, lo_y), float(abs(x1 - x0)), float(abs(y1 - y0)),
boxstyle="round,pad=0,rounding_size=1.2", fc=color, alpha=alpha,
ec="none", zorder=1.5))
fld.ax.add_patch(FancyBboxPatch((lo_x, lo_y), float(abs(x1 - x0)), float(abs(y1 - y0)),
boxstyle="round,pad=0,rounding_size=1.2", fc="none", ec=color, lw=0.9,
ls=ls, zorder=1.6))
if label:
say(fld, p, (d0 + d1) / 2, (w0 + w1) / 2, label, color=style.INK, fontsize=fs, box=False)
def path(fld, p, pts, color=style.DEFENSE, lw=1.4, dash=(0, (3, 2)), z=5):
"""A dashed arrow through absolute (d, w) points."""
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")
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>",
mutation_scale=9, color=color, lw=lw, zorder=z))
def ghost(fld, p, d, w, label="", color=style.DEFENSE):
"""A hollow, dashed defender: where he stood before he moved."""
x, y = P(fld, p, d, w)
ppy = fld.pts_per_yard()
fld.ax.add_patch(RegularPolygon((float(x), float(y)), 4, radius=gplay.MARKER_R * 1.25, orientation=np.pi / 4,
fc="none", ec=color, lw=1.1, ls=(0, (2, 1.2)), zorder=6))
if label:
fld.ax.text(float(x), float(y), label, ha="center", va="center", color=color, fontweight="bold",
zorder=6, fontsize=max(4.0, min(8.5, ppy * 0.75)))
def panel_title(ax, text, fs=8.5):
ax.set_title(text, fontsize=fs, loc="left", color=style.INK, fontweight="bold")
def draw(p, ax=None, lateral="full", window=(-8.5, 22), width_in=6.5, show_ruler=True, rfs=6.0):
fld = p.draw(ax=ax, lateral=lateral, window=window, width_in=width_in, title=False)
clean(fld.ax)
if show_ruler:
ruler(fld, p, fs=rfs)
return fld
LEGEND = {
"route": Line2D([], [], color=style.OFFENSE, lw=1.8, label="route"),
"drop": Line2D([], [], color=style.DEFENSE, lw=1.3, ls=(0, (3, 2)), label="defender's movement"),
"ghost": Patch(fc="none", ec=style.DEFENSE, lw=1.1, ls=(0, (2, 1.2)), label="where he lined up"),
"ring": Line2D([], [], marker="o", ls="", ms=11, mfc="none", mec=style.THIRD, mew=2, label="watch him"),
"zone": Patch(fc=style.DEFENSE, alpha=0.18, ec=style.DEFENSE, ls=(0, (3, 2)), label="zone"),
"pass": Line2D([], [], color=style.BALL, lw=1.3, ls=(0, (2, 2)), label="the throw"),
"ltg": Line2D([], [], color=style.LINE_TO_GAIN, lw=2.2, label="line to gain"),
}
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)
# ---------------------------------------------------------------- moments of a simulated play
def set_track(p, key, rows):
"""Hand-made keyframes [(t, d, w), ...] for one player (zone reads and man shadowing that
gridiron's straight-line drop() and man() can't express). Call after every other action."""
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(x) for x in rows]
kf[key] = np.array(rows, dtype=float)
p._compiled = (kf, t0, max(t1, rows[-1][0]))
def shadow(p, key, target, off, lag=0.3, t_on=0.9, t_from=0.0, start=None, t_end=3.0):
"""Keyframes for a defender who leaves `start` (or his alignment) at t_from and settles
`off` = (down, right) yards from where `target` was `lag` seconds earlier, fully by t_on."""
q = p.players[key]
s = np.array(start if start is not None else (q.d, q.w), dtype=float)
rows = []
for tt in np.round(np.arange(t_from + 0.05, t_end + 1e-9, 0.05), 3):
f = min(1.0, max(0.0, (tt - t_from) / max(1e-6, t_on - t_from)))
f = f * f * (3 - 2 * f)
tgt = p.position(target, max(0.0, tt - lag)) + np.array(off)
rows.append((tt, *(s * (1 - f) + tgt * f)))
return rows
def moment(ax, p, t, lateral, window, rings=(), throw_t=None, label_fs=None):
"""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=None)
ppy = fld.pts_per_yard()
inside = lambda d, w: (window[0] + 0.6 <= d <= window[1] - 0.6) and (lateral[0] + 0.6 <= w <= lateral[1] - 0.6) # noqa: E731
for k, q in p.players.items():
if q.pos in ("LT", "LG", "C", "RG", "RT", "DE", "DT"):
continue
pts = np.array([p.position(k, tt) for tt in np.linspace(0.0, max(t, 0.0), 40)])
if t > 0 and 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.55, zorder=3, ls="-" if q.side == "off" else (0, (3, 2)))
if throw_t is not None and t > throw_t:
b = np.array([p.ball_position(tt) for tt in np.linspace(throw_t, t, 25)])
xs, ys = P(fld, p, b[:, 0], b[:, 1])
ax.plot(xs, ys, color=style.BALL, lw=1.3, ls=(0, (2, 1.5)), zorder=9)
for k, q in p.players.items():
d, w = p.position(k, t)
if not inside(d, w):
continue
x, y = P(fld, p, d, w)
draw_marker(ax, float(x), float(y), q.side, q.label, ppy, ring=style.THIRD if k in rings else None)
if t > 0.05:
bd, bw = p.ball_position(t)
if inside(bd, bw):
# in the quarterback's hand: beside him; thrown: just upfield of the ball, so letters stay readable
bx, by = P(fld, p, *((bd, bw - 1.0) if t <= (throw_t or 99) else (bd + 1.2, bw)))
ax.add_patch(Ellipse((float(bx), float(by)), 1.0, 0.62, fc=style.BALL, ec="white", lw=0.5, zorder=11))
return fld
def tag(fld, p, t, text, fs=6.6):
"""A caption tucked in the bottom-left corner of a frame panel (the empty backfield)."""
fld.ax.text(0.03, 0.03, text, transform=fld.ax.transAxes, ha="left", va="bottom", fontsize=fs,
color=style.INK, zorder=15, linespacing=1.15,
bbox=dict(boxstyle="round,pad=0.25", fc=style.SURFACE, ec=style.BASELINE, lw=0.5))