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, Polygon, RegularPolygon
from gridiron import Play, Player, Field, style
import gridiron.play as _gp
# Slightly smaller player markers than the book default: this chapter lives in the space between linemen.
_gp.draw_marker.__defaults__ = (None, 0.62)
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
RED = style.SERIES[7]
ARM = style.DEFENSE_DARK
OL_KEYS = ["LT", "LG", "C", "RG", "RT"]
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=label or key)
def D(key, pos, d, w, label):
return Player(key, pos, "def", d, w, label=label)
def line(keys=OL_KEYS):
"""Offensive linemen, labelled so the reader can follow each one."""
return [O(k, k, -0.7, (OL_KEYS.index(k) - 2) * SPLIT, k) for k in keys]
def four_down(lbs=False):
"""A generic four-man rush: a wide end on each side, a 1-technique shaded on the center's left,
a 3-technique on the right guard's outside shoulder; optionally two linebackers."""
out = [D("LE", "DE", 1.0, -5.4, "E"), D("NT", "DT", 1.0, -0.7, "1"),
D("DT", "DT", 1.0, 2.35, "3"), D("RE", "DE", 1.0, 5.4, "E")]
if lbs:
out += [D("MIKE", "MIKE", 4.6, 1.3, "M"), D("WILL", "WILL", 4.6, -2.2, "W")]
return out
def to(p, key, end, kind="block", via=(), delay=0.0, speed=None):
"""Move `key` through absolute (d, w) points `via` to `end` as a block, rush, 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)
return {"path": p.path, "run": p.run}[kind](key, pts, delay=delay, speed=speed)
def timed(p, key, way, kind="path"):
"""Script a player for the frame strips: `way` = [(t, d, w), ...] absolute spots reached at time t
(seconds 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"):
"""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))
def point_to(fld, p, text_at, target, text, color=style.INK_2, fontsize=6.8, 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=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, end="arrow", dash=(0, (3, 1.8)), z=6):
"""A dashed path through absolute (d, w) points, ending in an arrow or a block bar ('tee')."""
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)
if end == "arrow":
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))
elif end == "tee":
v = np.array([xs[-1] - xs[-2], ys[-1] - ys[-2]])
n = np.linalg.norm(v)
perp = np.array([-v[1], v[0]]) / n * 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=z)
def pushed(fld, p, a, b):
"""A blocker driven backward from a to b: dashed dark blue with a block bar."""
dashed(fld, p, [a, b], color=style.OFFENSE_DARK, lw=1.6, end="tee", dash=(0, (2.2, 1.6)), z=4.5)
def arm(fld, p, a, b, lw=3.4, color=ARM):
"""An arm (or hands) as a thick stroke from a to b, with a dot for the hand: dark orange for the rusher,
dark blue (color=style.OFFENSE_DARK) for the blocker."""
(x0, y0), (x1, y1) = _P(fld, p, *a), _P(fld, p, *b)
fld.ax.plot([x0, x1], [y0, y1], color=color, lw=lw, solid_capstyle="round", zorder=9.5)
fld.ax.plot([x1], [y1], marker="o", ms=4.2, color=color, mec="white", mew=0.6, zorder=9.6)
def curl(fld, p, a, b, rad=-0.6, color=ARM, lw=1.6):
"""A curved arrow from a to b: a hand or arm move (swim over the top, rip up through)."""
(x0, y0), (x1, y1) = _P(fld, p, *a), _P(fld, p, *b)
fld.ax.add_patch(FancyArrowPatch((float(x0), float(y0)), (float(x1), float(y1)), arrowstyle="-|>",
mutation_scale=10, color=color, lw=lw, zorder=9.5,
connectionstyle=f"arc3,rad={rad}"))
def ghost(fld, p, d, w, label="", side="def", alpha=0.45):
"""A faint marker: where a player is at a moment the static diagram does not otherwise show."""
x, y = (float(v) for v in _P(fld, p, d, w))
if side == "def":
fld.ax.add_patch(RegularPolygon((x, y), 4, radius=0.62 * 1.25, orientation=np.pi / 4, fc=style.DEFENSE_TINT,
ec=style.DEFENSE, lw=1.1, alpha=alpha, zorder=7, ls=(0, (2, 1))))
else:
fld.ax.add_patch(Circle((x, y), 0.62, fc=style.OFFENSE, ec="white", lw=0.6, alpha=alpha, zorder=7))
if label:
fld.ax.text(x, y, label, ha="center", va="center", fontsize=6.5, color=style.INK, alpha=0.8, zorder=7.5,
fontweight="bold")
def badge(fld, p, d, w, text, color=PURPLE, fontsize=7.4):
"""A small numbered badge on a path: the order in which rushers go."""
x, y = _P(fld, p, d, w)
fld.ax.text(float(x), float(y), text, fontsize=fontsize, color="white", ha="center", va="center", zorder=13,
fontweight="bold", bbox=dict(boxstyle="circle,pad=0.18", fc=color, ec="white", lw=0.6))
def lane(fld, p, pts, color=style.DEFENSE, alpha=0.11):
"""Shade a rush lane: a polygon through absolute (d, w) points."""
xy = np.array([_P(fld, p, d, w) for d, w in pts], dtype=float)
fld.ax.add_patch(Polygon(xy, closed=True, fc=color, alpha=alpha, ec=color, lw=0.8, ls=(0, (3, 2)), zorder=1.4))
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()
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 draw(p, width_in=5.8, ax=None, ol_fs=None, **kw):
"""Play.draw, sized for the page and cleaned for a box-sized window. ol_fs shrinks the two-letter line
labels (LT, LG, RG, RT) on wide figures, where the default size overprints."""
fld = p.draw(width_in=width_in, ax=ax, **kw)
clean(fld.ax)
if ol_fs:
for t in fld.ax.texts:
if t.get_text() in ("LT", "LG", "RG", "RT"):
t.set_fontsize(ol_fs)
return fld
def panel_title(ax, text, size=8.4):
ax.set_title(text, loc="left", fontsize=size, fontweight="bold", color=style.INK)
def note_below(ax, text, size=6.8):
ax.text(0.5, -0.02, text, transform=ax.transAxes, ha="center", va="top", fontsize=size, color=style.INK,
linespacing=1.2)
def pdf_build():
return os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
# --- frame strips (print) and video (web) for the simulated plays ---------------------------
MARK_R = 0.6
RING_R = 0.9
def tracking(play):
df = play.to_tracking()
df["_id"] = df["displayName"].astype(str)
return df
def panel(ax, play, df, t, lateral, window, rings=(), links=()):
"""One moment of a simulated play: markers, faint trails, rings, and 'who has whom' links."""
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.5, play.los + window[1] - 0.5)
& cur["y"].between(ylo + 0.5, yhi - 0.5) & (cur["side"] != "ball")]
ids = set(shown["_id"])
trails = df[(df["frameId"] <= fid) & df["_id"].isin(ids)]
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"]))
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(7.5, ppy * (0.8 if len(lab) <= 1 else 0.55)))
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.7, zorder=7))
for a, b, col in links:
pa = cur[cur["displayName"] == a].iloc[0]
pb = cur[cur["displayName"] == b].iloc[0]
xs, ys = fld.xy(np.array([pa["x"], pb["x"]]), np.array([pa["y"], pb["y"]]))
ax.plot(xs, ys, color=col, lw=1.5, ls=(0, (1, 1.4)), zorder=9)
return fld
def strip(play, times, notes=None, lateral=(-9, 9), window=(-9, 6), ncols=3, rings=(), width_in=6.5,
final_links=()):
"""Numbered, captioned 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.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)):
panel(ax, play, df, t, lateral, window, rings, final_links if i == len(times) - 1 else ())
ax.set_title(f"{i + 1}. " + ("snap" if abs(t) < 1e-6 else f"{t:+.1f} s"),
fontsize=8.2, color=style.INK_2, loc="left")
if notes:
note_below(ax, notes[i], size=6.9)
fig.tight_layout(h_pad=1.0 if notes else 0.5, w_pad=0.5)
return fig
def video(play, times, notes=None, lateral=(-9, 9), window=(-9, 6), rings=(), width_in=5.4):
"""Web version of the strip: every frame, with the strip's notes as running 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.replace("\n", " ") for tt, n in zip(times, notes) if tt <= t + 1e-6]
txt += (" " + cur[-1]) if cur else ""
ax.set_title(txt, fontsize=8.5, color=style.INK, loc="left")
return []
frames = [f for f in sorted(df["frameId"].unique()) if float(tmap[f]) >= -0.3]
anim = animation.FuncAnimation(fig, update, frames=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 rush_animation(play, times, notes=None, **kw):
"""Video on the website; a numbered, captioned frame strip in the PDF."""
if pdf_build():
strip(play, times, notes=notes, **kw)
plt.show()
else:
video(play, times, notes=notes, **{k: v for k, v in kw.items() if k in ("lateral", "window", "rings")})