import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, RegularPolygon, Rectangle
from matplotlib.lines import Line2D
import gridiron.field as gfield
from gridiron import Play, Player, Field, offense, technique, style
from gridiron.field import FIELD_WIDTH
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
OL = ("LT", "LG", "C", "RG", "RT")
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 line5():
"""Five offensive linemen, unlabelled (in this chapter 'C' and 'CB' always mean a cornerback)."""
return [q.moved(label="") for q in offense("gun_2x2") if q.key in OL]
def spread10(rb_w=-2.4, qb_d=-5.0, rb_d=-5.4):
"""Shotgun, four receivers (10 personnel): X and Z on the line, the slots H and F a step off it.
The back is offset 2.4 yards and a little deeper, so his marker never touches the quarterback's."""
return line5() + [O("QB", "QB", qb_d, 0.0, "Q"), O("RB", "RB", rb_d, rb_w, "R"),
O("X", "WR", -0.7, -16.0, "X"), O("H", "WR", -1.5, -9.0, "H"),
O("F", "WR", -1.5, 9.0, "F"), O("Z", "WR", -0.7, 16.0, "Z")]
def check_legal(players):
"""Seven offensive players on the line, and only the two at its ends may be eligible receivers."""
off = [q for q in players if q.side == "off"]
on = sorted([q for q in off if q.d > -1.0], key=lambda q: q.w)
assert len(on) == 7, [q.key for q in on]
for q in on[1:-1]:
assert q.key in OL, f"{q.key} is covered up but not a lineman"
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, path, run, route or rush."""
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 == "route":
return p.route(key, pts, delay=delay, speed=speed, frame="field")
return {"path": p.path, "run": p.run, "pull": p.pull, "rush": p.rush}[kind](key, pts, delay=delay, speed=speed)
def _P(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def branch(fld, p, pts, color=style.THIRD, label=None, label_at=None, lw=2.0, fontsize=7.5,
dash=(0, (3, 2.2)), end="arrow"):
"""A dashed 'option' path through absolute (d, w) points, with an arrowhead (or block tee)."""
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, solid_capstyle="round")
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>",
mutation_scale=11, color=color, lw=lw, zorder=6))
else:
fld.ax.plot(xs, ys, color=color, lw=lw, ls=dash, zorder=6)
if label:
x, y = _P(fld, p, *(label_at or pts[-1]))
fld.ax.text(x, y, label, fontsize=fontsize, color=color, fontweight="bold", ha="center",
va="center", zorder=9,
bbox=dict(boxstyle="round,pad=0.2", fc=style.SURFACE, ec=color, lw=0.8))
def pitch_arc(fld, p, a, b, color=style.BALL, rad=0.25):
"""A pitch or a throw: a dashed brown arc from (d, w) a to (d, w) b."""
(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=1.5, ls=(0, (2, 2)), zorder=7,
connectionstyle=f"arc3,rad={rad}"))
def read_line(fld, p, a, b, color=GREEN, num=None, at=0.5):
"""The quarterback's eyes: a thin dashed green line between two (d, w) spots, optionally numbered."""
(x0, y0), (x1, y1) = _P(fld, p, *a), _P(fld, p, *b)
fld.ax.plot([x0, x1], [y0, y1], color=color, lw=1.0, ls=(0, (2, 2)), alpha=0.85, zorder=4)
if num:
fld.ax.text(x0 + at * (x1 - x0), y0 + at * (y1 - y0), num, fontsize=7, color="white", ha="center",
va="center", fontweight="bold", zorder=9,
bbox=dict(boxstyle="circle,pad=0.18", fc=color, ec="none"))
def say(fld, p, d, w, text, color=style.INK_2, fontsize=7.5, 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 box_shade(fld, p, d0, d1, w0, w1, color=GREEN, alpha=0.07):
"""Shade a rectangle of the field (the 'box') between depths d0-d1 and widths w0-w1."""
(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.8, ls=(0, (3, 2)), zorder=1))
def clean(ax, numbers=True, hashes=True):
"""Drop hash ticks and/or painted yard numbers (box-sized windows), clip everything to the window."""
for ln in list(ax.lines):
xs, ys = ln.get_data()
if hashes and 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 numbers and 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, numbers=True, hashes=True, **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, numbers=numbers, hashes=hashes)
return fld
def panel_title(ax, text, size=9):
ax.set_title(text, fontsize=size, loc="left", color=style.INK, fontweight="bold")
class hashes_at:
"""Temporarily draw gridiron Fields with college hash marks (60 feet, 20 yards, in from each sideline).
gridiron.field reads HASH_NEAR / HASH_FAR when it draws, so we swap them and put them back."""
def __init__(self, from_sideline=20.0):
self.near, self.far = from_sideline, FIELD_WIDTH - from_sideline
def __enter__(self):
self.saved = (gfield.HASH_NEAR, gfield.HASH_FAR)
gfield.HASH_NEAR, gfield.HASH_FAR = self.near, self.far
return self
def __exit__(self, *exc):
gfield.HASH_NEAR, gfield.HASH_FAR = self.saved
return False
def pdf_build():
return os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
# ---------------------------------------------------------------- frame strips and videos (as in 03-04)
MARK_R = 0.72
def tracking(play):
df = play.to_tracking()
df["_id"] = df["displayName"].astype(str)
return df
def carrier(play, t):
"""Who holds the ball at time t (QB until the first handoff, pitch or catch)."""
holder = "QB"
for kind, to, te in sorted(play.ball_events, key=lambda e: e[2]):
if t >= te + (0.35 if kind in ("pitch", "pass") else 0.0):
holder = to
return holder
def panel(ax, play, df, t, lateral, window, rings=()):
"""One moment of a simulated play: markers, trails, the ball, rings (and a ring on the ball carrier)."""
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")
& 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.5), 0.75, 0.45, fc=style.BALL, ec="white", lw=0.5, zorder=11))
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.8 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 list(rings) + [(carrier(play, t), style.THIRD)]:
rr = cur[cur["displayName"] == key]
if len(rr):
x, y = fld.xy(rr["x"].iloc[0], rr["y"].iloc[0])
ax.add_patch(Circle((float(x), float(y)), 1.25, fc="none", ec=col, lw=1.9, zorder=7))
return fld
def strip(plays, times, lateral, window, notes=None, rows=None, rings=(), width_in=6.5, ncols=None):
"""Frame strip: one row per play (an option's branches), or one play wrapped over rows."""
dfs = [tracking(p) for p in plays]
if len(plays) > 1:
cells = [(i, t) for i in range(len(plays)) for t in times]
ncols = len(times)
else:
cells = [(0, t) for t in times]
ncols = ncols or min(3, len(times))
nrows = int(np.ceil(len(cells) / 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(cells):]:
ax.axis("off")
for k, (ax, (i, t)) in enumerate(zip(axes.flat, cells)):
panel(ax, plays[i], dfs[i], t, lateral, window, rings)
head = f"{k % len(times) + 1 if len(plays) > 1 else k + 1}. " + ("snap" if abs(t) < 1e-6 else f"{t:+.1f} s")
if rows and k % ncols == 0:
head = rows[i] + " " + head
ax.set_title(head, fontsize=8, color=style.INK_2 if not (rows and k % ncols == 0) else style.INK,
loc="left", fontweight="bold" if rows and k % ncols == 0 else "normal")
if notes:
ax.text(0.5, -0.03, notes[k], transform=ax.transAxes, ha="center", va="top",
fontsize=7.2, color=style.INK)
fig.tight_layout(h_pad=1.4 if notes else 0.6, w_pad=0.5)
return fig
def video(plays, times, lateral, window, notes=None, rows=None, rings=(), width_in=6.4):
"""Web version: the same panels drawn at every tenth of a second, side by side for branches."""
import shutil
from matplotlib import animation
from IPython.display import HTML, display
dfs = [tracking(p) for p in plays]
t0 = min(float(df["time"].min()) for df in dfs)
t1 = max(float(df["time"].max()) for df in dfs)
grid = np.round(np.arange(t0, t1 + 1e-9, 0.1), 2)
n = len(plays)
span_w, span_d = lateral[1] - lateral[0], window[1] - window[0]
pw = (width_in if n == 1 else width_in / n) * (0.85 if n == 1 else 1.0)
fig, axes = plt.subplots(1, n, squeeze=False, figsize=(pw * n, pw * span_d / span_w + 0.6))
axes = axes.flat
def update(t):
for i, ax in enumerate(axes):
ax.clear()
panel(ax, plays[i], dfs[i], t, lateral, window, rings)
txt = "snap" if abs(t) < 0.05 else f"{t:+.1f} s"
if notes:
per = len(times)
mine = notes[i * per:(i + 1) * per] if n > 1 else notes
cur = [m for tt, m in zip(times, mine) if tt <= t + 1e-6]
txt += ("\n" + cur[-1].replace("\n", " ")) if cur else ""
head = (rows[i] + ": ") if rows else ""
ax.set_title(head + txt, fontsize=7.5, color=style.INK, loc="left")
return []
anim = animation.FuncAnimation(fig, update, frames=list(grid), 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 option_animation(plays, times, lateral, window, notes=None, rows=None, rings=(), **kw):
"""Video on the website; a numbered, captioned frame strip in the PDF."""
if pdf_build():
strip(plays, times, lateral, window, notes=notes, rows=rows, rings=rings, **kw)
plt.show()
else:
video(plays, times, lateral, window, notes=notes, rows=rows, rings=rings)