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, offense, defense, style
import gridiron.play as _gp
# Slightly smaller player markers than the book default: linemen stand about a yard and a half
# apart, and this chapter's diagrams are mostly about the space between them.
_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]
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():
"""The five offensive linemen, labelled so the reader can follow each one."""
return [O(k, k, -0.7, (i - 2) * SPLIT, k) for i, k in enumerate(OL_KEYS)]
def gun(rb_w=-1.5, qb_d=-5.0):
"""Shotgun quarterback with the back beside him (rb_w=None: no back)."""
out = [O("QB", "QB", qb_d, 0.0, "Q")]
if rb_w is not None:
out.append(O("RB", "RB", qb_d, rb_w, "R"))
return out
def four_down(mike=(4.5, 1.2), will=(4.5, -2.2), lbs=True):
"""A generic four-man front: ends outside the tackles, a 1-technique shaded on the center's
left shoulder and a 3-technique on the right guard's outside shoulder, two linebackers."""
out = [D("LE", "DE", 1.0, -5.0, "E"), D("NT", "DT", 1.0, -0.7, "1"),
D("DT", "DT", 1.0, 2.35, "3"), D("RE", "DE", 1.0, 5.0, "E")]
if lbs:
out += [D("MIKE", "MIKE", mike[0], mike[1], "M"), D("WILL", "WILL", will[0], will[1], "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, route 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 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 - 0.0), 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.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=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=7.0, 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 assign(fld, p, a, b, color=GREEN, lw=1.1, via=()):
"""A thin dotted 'this is my man' line from blocker `a` to defender `b` (alignment spots), optionally
bent through absolute (d, w) points `via` so it does not run through another player."""
pa, pb = p.players[a], p.players[b]
pts = [(pa.d, pa.w), *via, (pb.d, pb.w)]
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=(0, (1, 1.6)), zorder=3.2)
def gap_oval(fld, p, w, d=0.15, width=1.25, height=1.0, color=GREEN, alpha=0.22):
"""Shade a gap on the line at width w: the area a sliding lineman owns."""
x, y = _P(fld, p, d, w)
fld.ax.add_patch(Ellipse((float(x), float(y)), width, height, fc=color, alpha=alpha, ec=color, lw=0.9,
ls=(0, (2, 1.5)), zorder=1.6))
def tag(fld, p, key, text, dd=1.05, dw=0.0, color=GREEN, fontsize=7.4):
"""A small numbered tag just upfield of a player: the protection's count."""
q = p.players[key]
x, y = _P(fld, p, q.d + dd, q.w + dw)
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.16", fc=color, ec="none"))
def free_path(fld, p, pts, color=RED, lw=2.2, label=None, label_at=None):
"""A highlighted dashed path through absolute (d, w) points (a free rusher)."""
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[:-1], ys[:-1], color=color, lw=lw, ls=(0, (3, 1.8)), zorder=6)
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>", mutation_scale=12,
color=color, lw=lw, zorder=6))
if label:
say(fld, p, *(label_at or pts[-1]), label, color=color, weight="bold")
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, cleaned for a box-sized window. ol_fs: a smaller font for the two-letter
line labels (LT, LG, RG, RT) on wide, full-field 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.6):
ax.set_title(text, loc="left", fontsize=size, fontweight="bold", color=style.INK)
def pdf_build():
return os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
# --- frame strips (print) and videos (web) for the simulated plays ---------------------------
MARK_R = 0.6 # smaller markers than the static diagrams so engaged players (about a yard apart) stay apart
def tracking(play):
df = play.to_tracking()
df["_id"] = df["displayName"].astype(str)
return df
RING_R = 0.9 # ring radius in the strips: big enough to see, small enough not to swallow a neighbour
def panel(ax, play, df, t, lateral, window, rings=(), hull=False, links=()):
"""One moment of a simulated play: markers, faint trails, rings, and optionally the pocket outline."""
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.4, play.los + window[1] - 0.4)
& cur["y"].between(ylo + 0.4, yhi - 0.4)]
ids = set(shown["_id"])
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)
if hull:
draw_hull(fld, cur)
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":
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(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 in links: # dotted 'who has whom' lines between current positions
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=GREEN, 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=4, rings=(), title=None,
width_in=6.5, hull=False, 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.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, hull, 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:
ax.text(0.5, -0.02, notes[i], transform=ax.transAxes, ha="center", va="top",
fontsize=7.0, color=style.INK, linespacing=1.15)
if title:
fig.suptitle(title, x=0.01, ha="left", fontsize=11, fontweight="bold", color=style.INK)
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, hull=False):
"""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, hull)
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 protection_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", "hull")})
# --- the pocket as a shape: convex hull of the five linemen and the quarterback ---------------
def convex_hull(pts):
"""Monotone-chain convex hull of (x, y) points, counter-clockwise."""
pts = sorted(set(map(tuple, np.round(np.asarray(pts, dtype=float), 4))))
if len(pts) < 3:
return np.array(pts)
def cross(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
lower, upper = [], []
for q in pts:
while len(lower) >= 2 and cross(lower[-2], lower[-1], q) <= 0:
lower.pop()
lower.append(q)
for q in reversed(pts):
while len(upper) >= 2 and cross(upper[-2], upper[-1], q) <= 0:
upper.pop()
upper.append(q)
return np.array(lower[:-1] + upper[:-1])
def hull_area(h):
x, y = h[:, 0], h[:, 1]
return 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1)))
def pocket_points(frame):
"""BDB (x, y) of the five linemen and the quarterback in one tracking frame."""
sel = frame[frame["displayName"].isin(OL_KEYS + ["QB"])]
return sel[["x", "y"]].to_numpy()
def draw_hull(fld, frame, color=GREEN):
h = convex_hull(pocket_points(frame))
xs, ys = fld.xy(h[:, 0], h[:, 1])
fld.ax.add_patch(Polygon(np.column_stack([xs, ys]), closed=True, fc=color, alpha=0.18, ec=color, lw=1.2,
zorder=2.5))