import logging
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
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, offense, technique, style
GREEN = "#0f7a55" # darker step of the aqua highlight, for text on the light field
PURPLE = style.SERIES[6] # a ring colour that is neither offense blue nor defense orange
# Ring colours keep one meaning in every figure of this chapter:
FORCE_RING = PURPLE # the force (or the plus-one)
ALLEY_RING = GREEN # the alley player or another free fitter
WATCH_RING = style.DEFENSE # the defender whose technique the figure is about
BALL_RING = style.INK_2 # the ball carrier (neutral)
OL_KEYS = ("LT", "LG", "C", "RG", "RT")
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 T(t, side=1):
"""Width of technique t on the offense's right (side=+1) or left (side=-1)."""
return technique(t, side)
def line(labels="none", puller=None):
"""The five offensive linemen ("none": only the center lettered, as in the other run chapters).
`puller` letters one more lineman "G" (the guard the figure is about)."""
out = []
for p in offense("singleback"):
if p.key in OL_KEYS:
lab = p.key if labels == "full" else ("C" if p.key == "C" else ("G" if p.key == puller else ""))
out.append(p.moved(label=lab))
return out
def te(side=1, key=None, d=-0.7):
key = key or ("Y" if side > 0 else "U")
return O(key, "TE", d, side * 4.8, key)
def i_form(wrs=True, puller=None):
"""I-formation, 21 personnel, tight end (Y) on the right; X on the line left, Z off the ball right."""
out = line(puller=puller) + [O("QB", "QB", -1.9, 0, "Q"), O("FB", "FB", -4.9, 0, "F"), O("RB", "RB", -7.4, 0, "R"), te(+1)]
if wrs:
out += [O("X", "WR", -0.7, -15.0, "X"), O("Z", "WR", -1.5, 15.0, "Z")]
return out
def single_back_11(gun=False):
"""11 personnel 2x2: Y in line right, Z wide right, H in the left slot, X wide left (X on the line)."""
qb = O("QB", "QB", -5.0 if gun else -1.9, 0, "Q")
rb = O("RB", "RB", -5.0, -1.6, "R") if gun else O("RB", "RB", -7.0, 0, "R")
return line() + [qb, rb, te(+1), O("Z", "WR", -1.5, 15.0, "Z"), O("H", "WR", -1.5, -9.0, "H"),
O("X", "WR", -0.7, -15.0, "X")]
def over_front(side=1):
"""The 4-3 Over of Gap and Power Runs: 5, 1, 3, 5 on the line from the offense's left, the Sam walked up
on the line outside the tight end (a wide 9; 05-02 draws the Over's Sam off the ball), the Mike over the
strong A gap, the Will over the weak B gap."""
s = side
return [D("WE", "DL", 1.0, T("5", -s), "5"), D("NT", "DL", 1.0, T("1", -s), "1"),
D("DT", "DL", 1.0, T("3", s), "3"), D("SE", "DL", 1.0, T("5", s), "5"),
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")]
def even4(sam_w=None):
"""A four-man even front (5, 1, 3, 5 from the left; 3-technique to the right) with two or three linebackers.
The right end is a true 5 (outside shoulder of the tackle, inside the tight end)."""
out = [D("WE", "DL", 1.0, T("5", -1), "5"), D("NT", "DL", 1.0, T("1", -1), "1"),
D("DT", "DL", 1.0, T("3", 1), "3"), D("SE", "DL", 1.0, T("5", 1), "5"),
D("MIKE", "MIKE", 4.6, 1.2, "M"), D("WILL", "WILL", 4.6, -2.4, "W")]
if sam_w is not None:
out.append(D("SAM", "SAM", 4.2, sam_w, "S"))
return out
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=6.8, shrink=7, weight="normal"):
"""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, fontweight=weight,
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 gap_tag(fld, p, d, w, text, color=GREEN, fontsize=7.5):
"""A gap letter (or a short owner tag) in a small rounded box."""
x, y = _P(fld, p, d, w)
fld.ax.text(x, y, text, fontsize=fontsize, color=color, ha="center", va="center", zorder=9,
fontweight="bold", bbox=dict(boxstyle="round,pad=0.2", fc=style.SURFACE, ec=color, lw=1.0))
def spot(fld, p, d, w, color=style.DEFENSE_DARK, ms=10):
"""Mark a point (a tackle, a meeting point) 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=12)
def lane(fld, p, w0, w1, d0, d1, color=style.OFFENSE, alpha=0.10, ls=(0, (3, 2))):
"""Shade a rectangle from width w0 to w1 and depth d0 to d1 (a lane, the box, a zone of free yards)."""
(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=ls, zorder=0.8))
def dashed(fld, p, pts, color=style.THIRD, lw=1.5, dash=(0, (3, 2.2)), end="arrow"):
"""A dashed 'what if' or 'intent' path 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]))
xs, ys = np.asarray(xs, float), np.asarray(ys, float)
if end == "arrow":
hx, hy = xs[-2] + 0.75 * (xs[-1] - xs[-2]), ys[-2] + 0.75 * (ys[-1] - ys[-2])
xs = np.concatenate([xs[:-1], [hx, xs[-1]]]); ys = np.concatenate([ys[:-1], [hy, ys[-1]]])
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)
def ghost(fld, p, d, w, label="", color=style.DEFENSE):
"""A faded defender marker: where a player stood before the snap."""
x, y = _P(fld, p, d, w)
fld.ax.add_patch(RegularPolygon((float(x), float(y)), 4, radius=0.94, orientation=np.pi / 4, fc="none",
ec=color, lw=1.0, ls=(0, (2, 1.5)), alpha=0.7, zorder=5))
if label:
fld.ax.text(float(x), float(y), label, fontsize=6, color=style.MUTED, ha="center", va="center", zorder=5)
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. A diagram with its own figure
fills it edge to edge (matplotlib's default margins would shrink an equal-aspect field)."""
fld = p.draw(width_in=width_in, ax=ax, **kw)
clean(fld.ax)
if ax is None:
fld.fig.subplots_adjust(left=0.01, right=0.99, bottom=0.01,
top=0.93 if (kw.get("title", True) and p.title) else 0.99)
return fld
def subtitle(ax, text):
ax.set_title(text, fontsize=9, loc="left", color=style.INK, fontweight="bold")
# --- frame strips (PDF) and video (web) for the simulated plays --------------------------------------
MARK_R = 0.62
def tracking(play):
df = play.to_tracking()
df["_id"] = df["displayName"].astype(str)
return df
def panel(ax, play, df, t, lateral, window, rings=(), ring_r=0.9):
"""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]
snap = df[df["frameId"] == int((tmap - 0.0).abs().idxmin())]
first = snap[snap["x"].between(play.los + window[0] + 0.8, play.los + window[1] - 0.8)
& snap["y"].between(ylo + 0.8, yhi - 0.8)] # players whose marker fits at the snap
ids = set(first["_id"]) | {"football"}
shown = cur[cur["_id"].isin(ids) | (cur["side"] == "ball")]
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.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.62)))
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)), ring_r, fc="none", ec=col, lw=1.6, zorder=7))
for patch in ax.patches:
patch.set_clip_on(True)
return fld
def strip(moments, lateral, window, notes=None, rings=(), ncols=2, width_in=5.2, after=None):
"""Numbered frame strip. `moments` is a list of (play, t, tag): several plays may share one strip,
which is how two versions of the same play (same start, two endings) are shown in print."""
cache = {}
nrows = int(np.ceil(len(moments) / 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.62 if notes else 0.25))))
for ax in axes.flat[len(moments):]:
ax.axis("off")
for i, (ax, (play, t, tag)) in enumerate(zip(axes.flat, moments)):
if id(play) not in cache:
cache[id(play)] = tracking(play)
fld = panel(ax, play, cache[id(play)], t, lateral, window, rings)
if after:
after(fld, play, t, i)
lab = "snap" if abs(t) < 1e-6 else f"{t:+.1f} s"
ax.set_title(f"{i + 1}. {lab}" + (f" {tag}" if tag else ""), fontsize=8.3, 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.1, color=style.INK)
fig.tight_layout(h_pad=1.3 if notes else 0.6, w_pad=0.6)
return fig
def video(segments, lateral, window, rings=(), width_in=4.6, after=None):
"""Web version: each (play, tag, [(t, note), ...]) segment played in turn, notes as captions."""
import shutil
from matplotlib import animation
from IPython.display import HTML, display
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.45))
frames = []
for play, tag, notes in segments:
df = tracking(play)
tmap = df.groupby("frameId")["time"].first()
frames += [(play, df, float(tmap[f]), tag, notes) for f in sorted(df["frameId"].unique())]
def update(k):
play, df, t, tag, notes = frames[k]
ax.clear()
fld = panel(ax, play, df, t, lateral, window, rings)
if after:
after(fld, play, t, -1)
txt = ("snap" if abs(t) < 0.05 else f"{t:+.1f} s") + (f" {tag}" if tag else "")
cur = [n for tt, n in notes if tt <= t + 1e-6]
ax.set_title(txt + (("\n" + cur[-1].replace("\n", " ")) if cur else ""), fontsize=8.5, color=style.INK,
loc="left")
return []
anim = animation.FuncAnimation(fig, update, frames=range(len(frames)), 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(moments, notes, lateral, window, rings=(), after=None, ncols=2, width_in=5.2):
"""Video on the website; a numbered, captioned frame strip in the PDF."""
if pdf_build():
strip(moments, lateral, window, notes=notes, rings=rings, ncols=ncols, width_in=width_in, after=after)
plt.show()
return
segs = []
for play, t, tag in moments:
if not segs or segs[-1][0] is not play:
segs.append((play, tag, []))
for (play, t, tag), n in zip(moments, notes):
for s in segs:
if s[0] is play:
s[2].append((t, n))
video(segs, lateral, window, rings=rings, after=after)