import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, Rectangle, RegularPolygon
from gridiron import Play, Player, Field, technique, style
GREEN = "#0f7a55" # darker step of the aqua highlight, for text on the light field
PURPLE = style.SERIES[6] # a ring colour for pullers: neither offense blue nor defense orange
OL_W = {"LT": -3.2, "LG": -1.6, "C": 0.0, "RG": 1.6, "RT": 3.2}
def pdf_build():
return os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
def O(key, pos, d, w, label):
return Player(key, pos, "off", d, w, label=label)
def D(key, pos, d, w, label):
return Player(key, pos, "def", d, w, label=label)
def line():
"""The five offensive linemen, labelled LT, LG, C, RG, RT."""
return [O(k, k, -0.7, w, k) for k, w in OL_W.items()]
def te(side=1, key=None, label=None, d=-0.7):
"""An in-line tight end: Y on the right, U on the left."""
key = key or ("Y" if side > 0 else "U")
return O(key, "TE", d, side * 4.8, label or key)
def T(t, side=1):
"""Width of technique t on the offense's right (side=+1) or left (side=-1)."""
return technique(t, side)
def dl(key, t, side, label=None):
"""A defensive lineman in technique t, labelled with the number."""
return D(key, "DL", 1.0, T(t, side), label or t)
def go_to(p, key, end, kind="block", delay=0.0, speed=None, via=()):
"""Move `key` through absolute (d, w) points `via` to `end`, as a block, pull, 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 == "pull":
return p.pull(key, pts, delay=delay, speed=speed)
return {"path": p.path, "run": p.run}[kind](key, pts, delay=delay, speed=speed)
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.2, ha="center", weight="normal"):
"""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=9, fontweight=weight,
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.9))
def point_to(fld, p, text_at, target, text, color=style.INK_2, fontsize=7, shrink=7):
"""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="center", va="center", zorder=9,
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.9))
def pair_oval(fld, p, d, w, width, height=2.5, color=GREEN):
"""A faint oval around a double team (two blockers and their defender)."""
x, y = _P(fld, p, d, w)
fld.ax.add_patch(Ellipse((float(x), float(y)), width, height, fc=color, alpha=0.22, ec="none", zorder=1.5))
fld.ax.add_patch(Ellipse((float(x), float(y)), width, height, fc="none", ec=color, lw=1.6,
ls=(0, (4, 2)), zorder=1.6))
def spot(fld, p, d, w, color=style.THIRD, ms=9):
"""Mark a point (the hole, the point of attack) with an X."""
x, y = _P(fld, p, d, w)
fld.ax.plot([x], [y], marker="X", ms=ms, color=color, mec="white", mew=0.8, zorder=9)
def shade(fld, p, w0, w1, d0=-0.2, d1=6.5, color=style.THIRD, alpha=0.08):
"""Shade a rectangle from width w0 to w1 and depth d0 to d1 (the box, the play side)."""
(x0, y0), (x1, y1) = _P(fld, p, d0, w0), _P(fld, p, d1, w1)
fld.ax.add_patch(Rectangle((min(x0, x1), min(y0, y1)), abs(x1 - x0), abs(y1 - y0), fc=color, alpha=alpha,
ec=color, lw=0.9, ls=(0, (3, 2)), zorder=0.8))
def dashed(fld, p, pts, color=style.THIRD, lw=1.6, dash=(0, (3, 2.2)), end="arrow"):
"""A dashed 'option' or 'intent' path through absolute (d, w) points, ending in an arrow or a block bar."""
xs, ys = _P(fld, p, np.array([q[0] for q in pts]), np.array([q[1] for q in pts]))
if end == "arrow":
fld.ax.plot(xs[:-1], ys[:-1], color=color, lw=lw, ls=dash, zorder=6)
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>",
mutation_scale=10, color=color, lw=lw, zorder=6))
else:
fld.ax.plot(xs, ys, color=color, lw=lw, ls=dash, zorder=6)
v = np.array([xs[-1] - xs[-2], ys[-1] - ys[-2]])
perp = np.array([-v[1], v[0]]) / np.linalg.norm(v) * 0.75
fld.ax.plot([xs[-1] - perp[0], xs[-1] + perp[0]], [ys[-1] - perp[1], ys[-1] + perp[1]],
color=color, lw=lw + 0.6, zorder=6)
def clean(ax):
"""Box-zoomed diagrams: drop hash ticks and yard numbers, clip anything outside 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()
for t in list(ax.texts):
if t.get_text().isdigit() and t.get_alpha() == 0.55: # yard numbers, not technique labels
t.remove()
else:
t.set_clip_on(True)
def draw(p, width_in=5.8, ax=None, **kw):
"""Play.draw, sized for the page, cleaned for a box-sized window."""
fld = p.draw(width_in=width_in, ax=ax, **kw)
clean(fld.ax)
return fld
def subtitle(ax, text):
ax.set_title(text, fontsize=9, loc="left", color=style.INK, fontweight="bold")
# --- formations and fronts this chapter keeps coming back to -----------------------------------------
def i_form(side=1):
"""I-formation, 21 personnel, with the tight end on `side` (wide receivers outside the picture)."""
return line() + [O("QB", "QB", -1.9, 0, "Q"), O("FB", "FB", -4.9, 0, "F"), O("RB", "RB", -7.4, 0, "R"),
te(side)]
def pistol(y_side=0):
"""Pistol backfield (QB about 3.5 yards deep, RB behind him) with in-line tight ends:
y_side=+1 the Y on the right, -1 the U on the left, 0 both."""
off = line() + [O("QB", "QB", -3.5, 0, "Q"), O("RB", "RB", -7.0, 0, "R")]
if y_side >= 0:
off.append(te(+1))
if y_side <= 0:
off.append(te(-1))
return off
def over_front(side=1, safeties=True):
"""A 4-3 Over (named in 05-02), strength toward `side`: on the line a 5, a 1, a 3 and a 5, with the
Sam standing up in a wide 9 outside the tight end; the Mike over the strong A gap, the Will over the
weak B gap; two deep safeties."""
s = side
out = [dl("WE", "5", -s), dl("NT", "1", -s), dl("DT", "3", s), dl("SE", "5", s),
D("SAM", "SAM", 1.0, s * 7.0, "S"), D("MIKE", "MIKE", 4.6, s * 0.9, "M"),
D("WILL", "WILL", 4.6, -s * 2.5, "W")]
if safeties:
out += [D("FS", "FS", 10.5, -s * 7.0, "FS"), D("SS", "SS", 10.5, s * 7.0, "SS")]
return out
def four_three_base(sam=True):
"""A four-man even front, 5-1-3-5 from the offense's left, with the 3-technique to the right."""
out = [dl("BE", "5", -1), dl("NT", "1", -1), dl("DT", "3", 1), dl("PE", "5", 1),
D("WILL", "WILL", 4.6, -2.4, "W"), D("MIKE", "MIKE", 4.6, 0.9, "M")]
if sam:
out.append(D("SAM", "SAM", 4.2, 6.4, "S"))
return out
# --- frame strips (PDF) and video (web) for simulated plays ------------------------------------------
MARK_R = 0.6
def tracking(play):
df = play.to_tracking()
df["_id"] = df["displayName"].astype(str)
return df
def panel(ax, play, df, t, lateral, window, rings=()):
"""One moment of a simulated play on `ax`: markers, trails of on-screen players, rings."""
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=False,
lateral=(ylo, yhi), to_go=play.to_go)
cur = df[df["frameId"] == fid]
shown = cur[cur["x"].between(play.los + window[0] + 0.8, play.los + window[1] - 0.8)
& cur["y"].between(ylo + 0.8, yhi - 0.8)] # only players whose marker fits whole
ids = set(shown["_id"])
trails = df[(df["frameId"] <= fid) & df["_id"].isin(ids) & (df["side"] != "ball")
& df["x"].between(play.los + window[0], play.los + window[1])]
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.4, zorder=3)
ppy = fld.pts_per_yard()
for _, r in shown.iterrows():
x, y = (float(v) for v in fld.xy(r["x"], r["y"]))
if r["side"] == "ball":
ax.add_patch(Ellipse((x, y + 0.45), 0.7, 0.42, fc=style.BALL, ec="white", lw=0.5, zorder=9))
continue
lab = str(r.get("label", "") or "")
if r["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.6)))
ax.text(x, y, lab, ha="center", va="center", fontsize=fs, color=tc, fontweight="bold", zorder=10)
for key, col in rings:
r = cur[cur["displayName"] == key].iloc[0]
x, y = fld.xy(r["x"], r["y"])
ax.add_patch(Circle((float(x), float(y)), 0.95, fc="none", ec=col, lw=1.5, zorder=7))
return fld
def strip(play, times, lateral, window, notes=None, rings=(), ncols=2, width_in=6.5):
"""Numbered frame strip with a fixed, box-sized window, so markers stay legible in print."""
df = tracking(play)
nrows = int(np.ceil(len(times) / ncols))
pw = width_in / ncols
span_w, span_d = 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.55 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)):
panel(ax, play, df, t, lateral, window, rings)
ax.set_title(f"{i + 1}. " + ("snap" if abs(t) < 1e-6 else f"{t:+.2f} s".rstrip("0")),
fontsize=8.5, color=style.INK_2, loc="left")
if notes:
ax.text(0.5, -0.03, notes[i], transform=ax.transAxes, ha="center", va="top",
fontsize=7.4, color=style.INK)
fig.tight_layout(h_pad=1.3 if notes else 0.6, w_pad=0.6)
return fig
def video(play, times, lateral, window, notes=None, rings=(), width_in=5.4):
"""Web version of the strip: the same panel at every frame, with the strip's notes as captions."""
import shutil
from matplotlib import animation
from IPython.display import HTML, display
df = tracking(play)
span_w, span_d = 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])
panel(ax, play, df, t, lateral, window, rings)
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]) if cur else ""
ax.set_title(txt, fontsize=9, 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 animation_or_strip(play, times, **kw):
"""Video on the website; a numbered, captioned frame strip in the PDF."""
if pdf_build():
strip(play, times, **kw)
plt.show()
else:
kw.pop("ncols", None)
kw.pop("width_in", None)
video(play, times, **kw)