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, RegularPolygon, Patch
from matplotlib.lines import Line2D
from gridiron import Play, Player, Field, style, draw_marker, show_animation, routes as r
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")
GREEN = "#0f7a55" # darker step of the aqua highlight, for text on the light field
RED = style.SERIES[7] # free rushers and holes
PURPLE = style.SERIES[6] # droppers: a ring colour that is neither offense blue nor defense orange
DD, OD = style.DEFENSE_DARK, style.OFFENSE_DARK
SPLIT = 1.6 # center-to-guard and guard-to-tackle spacing, as in the rest of the book
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 line(labels=True):
"""The five offensive linemen. labels=False keeps only the C (wide figures overprint LT/LG/RG/RT)."""
keys = ["LT", "LG", "C", "RG", "RT"]
return [O(k, k, -0.7, (i - 2) * SPLIT, k if (labels or k == "C") else "") for i, k in enumerate(keys)]
def spread(labels=False, rb_w=-1.6):
"""11 personnel, shotgun 2x2: X and Z on the line outside, H (slot) and Y (tight end flexed as a
slot) a step off it, the back beside the quarterback. Seven on the line: five linemen, X and Z."""
return line(labels) + [O("QB", "QB", -5.0, 0.0, "Q"), O("RB", "RB", -5.0, rb_w, "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 nickel(**moves):
"""A 4-2-5 nickel against the 2x2: ends outside the tackles, a 1-technique on the center's left
shoulder, a 3-technique outside the right guard, Mike and Will at 4.5 yards, the nickel ($) over H,
corners 7 off, one deep safety (FS) and the strong safety (SS) at 8 over Y. `moves` = {key: (d, w)}."""
dfn = [D("LDE", "DE", 1.0, -5.5, "E"), D("LDT", "DT", 1.0, -0.7, "T"), D("RDT", "DT", 1.0, 2.35, "T"),
D("RDE", "DE", 1.0, 5.5, "E"), D("WILL", "WILL", 4.5, -2.4, "W"), D("MIKE", "MIKE", 4.5, 1.6, "M"),
D("NB", "NB", 5.0, -8.5, "$"), D("LCB", "CB", 7.0, -15.5, "CB"), D("RCB", "CB", 7.0, 15.5, "CB"),
D("FS", "FS", 13.0, 0.0, "FS"), D("SS", "SS", 8.0, 8.0, "SS")]
return [p.moved(d=moves[p.key][0], w=moves[p.key][1]) if p.key in moves else p for p in dfn]
def to(p, key, end, kind="path", via=(), delay=0.0, speed=None):
"""Move `key` through absolute (d, w) points `via` to `end` as a block, rush, drop-style path or run."""
start = np.array(p._cur(key))
pts = [tuple(np.array(q) - start) for q in (*via, end)]
if kind == "block":
return p.block(key, pts=pts, delay=delay, speed=speed)
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)
return {"path": p.path, "run": p.run}[kind](key, pts, delay=delay, speed=speed)
def drop_to(p, key, end, via=(), delay=0.0, speed=None):
"""A zone drop through absolute (d, w) points (gridiron's .drop goes in a straight line only)."""
start = p._cur(key)
path = [tuple(start)] + [tuple(map(float, q)) for q in (*via, end)]
return p._add("drop", key, path, speed, delay)
def timed(p, key, way, kind="path"):
"""Script a player: `way` = [(t, d, w), ...] absolute spots reached at time t (s after the snap).
Standing still between two times is allowed."""
cur = np.array(p._cur(key), dtype=float)
tc, wait = 0.0, 0.0
for t, d, w in way:
nxt = np.array([d, w], dtype=float)
dist = float(np.hypot(*(nxt - cur)))
if dist < 1e-3:
wait += t - tc
else:
p._add(kind, key, [tuple(cur), tuple(nxt)], speed=dist / (t - tc), delay=wait)
wait = 0.0
cur = nxt
tc = t
return p
def _P(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def say(fld, p, d, w, text, color=style.INK_2, fontsize=7.0, ha="center", weight="normal", box=True):
"""A short on-field label at (d, w)."""
x, y = _P(fld, p, d, w)
fld.ax.text(x, y, text, fontsize=fontsize, color=color, ha=ha, va="center", zorder=12, fontweight=weight,
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.92) if box else None)
def point_to(fld, p, text_at, target, text, color=style.INK_2, fontsize=6.8, shrink=7, ha="center"):
"""A label with a thin leader line to the player or spot it names."""
(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=12,
arrowprops=dict(arrowstyle="-", color=color, lw=0.7, shrinkA=2, shrinkB=shrink),
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.92))
def dashed(fld, p, pts, color=RED, lw=1.8, dash=(0, (3, 1.8)), z=6):
"""A dashed arrow through absolute (d, w) points: a free rusher's path."""
xs, ys = _P(fld, p, np.array([q[0] for q in pts]), np.array([q[1] for q in pts]))
xs, ys = np.asarray(xs, float), np.asarray(ys, float)
fld.ax.plot(xs, ys, color=color, lw=lw, ls=dash, zorder=z)
fld.ax.add_patch(FancyArrowPatch((xs[-2] + 0.6 * (xs[-1] - xs[-2]), ys[-2] + 0.6 * (ys[-1] - ys[-2])),
(xs[-1], ys[-1]), arrowstyle="-|>", mutation_scale=11, color=color,
lw=lw, zorder=z))
def hole(fld, p, center, size, text=None, fontsize=6.6, text_at=None):
"""A red, hatched patch: a soft spot the coverage cannot reach in time."""
cx, cy = _P(fld, p, *center)
fld.ax.add_patch(Ellipse((float(cx), float(cy)), size[1], size[0], fc=RED, alpha=0.30, ec="none", zorder=2.2))
fld.ax.add_patch(Ellipse((float(cx), float(cy)), size[1], size[0], fc="none", ec=RED, lw=1.1,
ls=(0, (2, 1.5)), zorder=2.3))
if text:
tx, ty = _P(fld, p, *(text_at or center))
fld.ax.text(float(tx), float(ty), text, fontsize=fontsize, color=RED, ha="center", va="center",
zorder=12, fontweight="bold",
bbox=dict(boxstyle="round,pad=0.15", fc=style.SURFACE, ec="none", alpha=0.85))
def zone_patch(fld, p, center, size, color=style.DEFENSE, alpha=0.10):
cx, cy = _P(fld, p, *center)
fld.ax.add_patch(Ellipse((float(cx), float(cy)), size[1], size[0], fc=color, alpha=alpha, ec=color, lw=0.8,
ls=(0, (3, 2)), zorder=2))
def no_numbers(ax):
"""Remove the painted yard-line numbers (they sit where wide receivers and safeties stand)."""
for t in list(ax.texts):
if t.get_alpha() == 0.55:
t.remove()
def clean(ax):
"""Box-zoomed diagrams: drop hash ticks and yard numbers, clip text to the window."""
for ln in list(ax.lines):
xs, ys = ln.get_data()
if len(xs) == 2 and ln.get_linewidth() == 0.5 and np.hypot(xs[1] - xs[0], ys[1] - ys[0]) < 1.0:
ln.remove()
no_numbers(ax)
def ring(fld, p, d, w, color=PURPLE, r=1.2, lw=1.8):
x, y = _P(fld, p, d, w)
fld.ax.add_patch(Circle((float(x), float(y)), r, fc="none", ec=color, lw=lw, zorder=7.5))
def ghost_marker(ax, x, y, label, ppy, color=style.DEFENSE):
"""A hollow, dashed defender: where he stood before the snap."""
ax.add_patch(RegularPolygon((x, y), 4, radius=MARKER_R * 1.25, orientation=np.pi / 4,
facecolor="none", edgecolor=color, lw=1.1, ls=(0, (2, 1.2)), zorder=6))
if label:
ax.text(x, y, label, ha="center", va="center", color=color, fontweight="bold", zorder=6,
fontsize=max(4.0, min(8.0, ppy * 0.7)))
# --------------------------------------------------------------- frame strips for print
def snapshot(play, t, ax=None, ghosts=(), ghost_t=-0.6, lateral=(-21, 21), window=(-8.5, 17.5),
width_in=6.5, notes=(), rings=(), holes=(), trail_from=None):
"""Everyone where he is at time t, with faint trails since `trail_from` (default: the first frame),
hollow markers where `ghosts` stood at `ghost_t`, purple rings on `rings` (droppers) and red
`holes` [(center, size, text)]."""
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)
ringed = {k for k, _ in rings}
q.highlights = {k: c for k, c in play.highlights.items() if t > 0 or k not in ringed}
fld = q.draw(ax=ax, lateral=lateral, window=window, width_in=width_in, title=False)
ax, ppy = fld.ax, fld.pts_per_yard()
clean(ax)
P = lambda d, w: fld.xy(*play.to_abs(d, w)) # noqa: E731
for c, s, txt in holes:
hole(fld, play, c, s, txt, fontsize=5.6)
start = trail_from if trail_from is not None else play._compile()[1]
for k, pl in play.players.items():
if pl.side == "off" and pl.pos in ("LT", "LG", "C", "RG", "RT"):
continue
pts = np.array([play.position(k, tt) for tt in np.linspace(start, t, 30)])
if np.ptp(pts[:, 0]) + np.ptp(pts[:, 1]) < 0.3:
continue
xs, ys = P(pts[:, 0], pts[:, 1])
if pl.side == "off":
ax.plot(xs, ys, color=style.OFFENSE, lw=0.9, alpha=0.45, zorder=3)
else:
ax.plot(xs, ys, color=style.DEFENSE, lw=1.1, ls=(0, (3, 2)), zorder=3)
if t > ghost_t + 0.05:
for k in ghosts:
x0, y0 = P(*play.position(k, ghost_t))
ghost_marker(ax, float(x0), float(y0), play.players[k].label, ppy)
for k, col in (rings if t > 0 else ()): # a dropper is only a dropper once he drops
x0, y0 = P(*play.position(k, t))
ax.add_patch(Circle((float(x0), float(y0)), MARKER_R * 1.75, fc="none", ec=col, lw=1.6, zorder=7.5))
bx, by = P(*play.ball_position(t))
thrown = False
for kind, _, t_rel in play.ball_events:
if kind == "pass" and t > t_rel:
thrown = True
sx, sy = P(*play.ball_position(t_rel))
ax.add_patch(FancyArrowPatch((float(sx), float(sy)), (float(bx), float(by)), arrowstyle="-|>",
mutation_scale=8, color=style.BALL, lw=1.3, ls=(0, (2, 1.5)), zorder=9))
if thrown or t <= 0: # in the quarterback's hands the ball would hide his label
draw_marker(ax, float(bx), float(by), "ball", "", ppy)
for note in notes: # (text, (d, w)) or (text, (d, w), (d, w) of the player it names)
text, (d, w) = note[0], note[1]
x, y = P(d, w)
box = dict(boxstyle="round,pad=0.2", fc=style.SURFACE, ec=style.BASELINE, lw=0.5)
if len(note) > 2:
xp, yp = P(*note[2])
ax.annotate(text, (float(xp), float(yp)), xytext=(float(x), float(y)), fontsize=6.0, color=style.INK,
ha="center", va="center", zorder=13, bbox=box,
arrowprops=dict(arrowstyle="-", color=style.INK_2, lw=0.7, shrinkA=1, shrinkB=5))
else:
ax.text(float(x), float(y), text, fontsize=6.0, color=style.INK, ha="center", va="center", zorder=13,
bbox=box)
return fld
def strip(play, times, labels, notes_at=None, holes_at=None, ncols=2, width_in=6.5, lateral=(-21, 21),
window=(-8.5, 17.5), legend=None, **kw):
"""The print version of an animation: panels at chosen moments of one simulated play."""
n = len(times)
nrows = int(np.ceil(n / ncols))
pw = width_in / ncols
h = nrows * (pw * (window[1] - window[0]) / abs(lateral[1] - lateral[0]) + 0.34)
fig, axes = plt.subplots(nrows, ncols, figsize=(width_in, h), squeeze=False)
for ax in axes.flat[n:]:
ax.axis("off")
for i, (ax, t, lab) in enumerate(zip(axes.flat, times, labels)):
snapshot(play, t, ax=ax, lateral=lateral, window=window, notes=(notes_at or {}).get(i, ()),
holes=(holes_at or {}).get(i, ()), **kw)
ax.set_title(lab, fontsize=8.2, color=style.INK, loc="left")
fig.tight_layout(h_pad=0.6, w_pad=0.4)
if legend:
fig.legend(handles=legend, loc="upper center", bbox_to_anchor=(0.5, 0.0), ncol=2, fontsize=7.3,
frameon=False)
return fig
STRIP_KEY = [Patch(facecolor="none", edgecolor=style.DEFENSE, lw=1.1, ls=(0, (2, 1.2)),
label="Dashed outline: where he stood before the snap"),
Line2D([], [], color=PURPLE, lw=0, marker="o", mfc="none", mec=PURPLE, mew=1.6, ms=9,
label="Purple ring: the dropper (a lineman in coverage)"),
Line2D([], [], color=style.DEFENSE, lw=1.1, ls=(0, (3, 2)), label="Defender's path so far"),
Line2D([], [], color=style.OFFENSE, lw=1.0, alpha=0.6, label="Offensive player's path so far"),
Line2D([], [], color=style.BALL, lw=1.3, ls=(0, (2, 1.5)), label="The pass"),
Line2D([], [], color=style.LINE_TO_GAIN, lw=2.2, label="Line to gain")]
def animation_or_strip(play, times, labels, web_times=None, **kw):
"""A video on the website; the numbered frame strip in print."""
if PDF:
strip(play, times, labels, **kw)
plt.show()
else:
show_animation(play, times=web_times or times)