import logging
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
import io
import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, Rectangle, RegularPolygon, Wedge
from gridiron import Play, Player, Field, offense, technique, style, routes as r
style.apply()
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_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 line(labels="full"):
"""The five offensive linemen, labelled LT..RT ("full") or with one letter, T G C G T ("short")."""
short = {"LT": "T", "LG": "G", "C": "C", "RG": "G", "RT": "T"}
return [p.moved(label=(p.key if labels == "full" else short[p.key])) for p in offense("singleback")
if p.key in OL_KEYS]
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 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, run, rush or route."""
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)
if kind == "rush":
return p.rush(key, pts=pts, delay=delay, speed=speed)
if kind == "route":
return p.route(key, [tuple(q) for q in (*via, end)], absolute=True, 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.5, ha="center", weight="normal", alpha=0.9):
"""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=alpha))
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 spot(fld, p, d, w, color=style.DEFENSE_DARK, ms=10, marker="X"):
"""Mark a point (a hit, a tackle) with an X."""
x, y = _P(fld, p, d, w)
fld.ax.plot([x], [y], marker=marker, ms=ms, color=color, mec="white", mew=0.8, zorder=12)
def ring(fld, p, key, color=GREEN, r=1.25, lw=1.8, ls="-"):
"""A ring around a player at his alignment."""
q = p.players[key]
x, y = _P(fld, p, q.d, q.w)
fld.ax.add_patch(Circle((float(x), float(y)), r, fc="none", ec=color, lw=lw, ls=ls, zorder=7.5))
def band(fld, p, d0, d1, w0, w1, color=GREEN, alpha=0.10, ls=(0, (3, 2)), lw=0.9):
"""Shade a rectangle from depth d0 to d1 and width w0 to w1 (alignment 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=lw, 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 'option' 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 clean(ax, numbers=True):
"""Drop hash ticks (and optionally 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 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=6.2, ax=None, numbers=True, **kw):
"""Play.draw, sized for the page, cleaned for a zoomed window."""
fld = p.draw(width_in=width_in, ax=ax, **kw)
clean(fld.ax, numbers=numbers)
return fld
def ruler(fld, p, depths=(5, 10, 15, 20, 25), w=None, fs=6.5):
"""Depth labels (yards past the line of scrimmage) along the left edge of the window."""
ax = fld.ax
x_left = (160 / 3) - fld.y1 + 0.4
for d in depths:
y = p.los + d
if fld.x0 + 0.8 <= y <= fld.x1 - 0.8:
ax.text(x_left, y, f"{d}" + (" yd" if d == depths[0] else ""), fontsize=fs, color=style.INK_2,
ha="left", va="center", zorder=12,
bbox=dict(boxstyle="round,pad=0.12", fc=style.SURFACE, ec="none", alpha=0.85))
def title(ax, text, size=9):
ax.set_title(text, fontsize=size, loc="left", color=style.INK, fontweight="bold")
# --- frame strips (PDF) and video (web) for 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.85):
"""One moment of a simulated play on `ax`: markers, trails, 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.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) & (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=6.2, after=None):
"""Numbered frame strip. `moments` is a list of (play, t, tag)."""
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.75 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(play, notes, lateral, window, rings=(), width_in=4.8, after=None):
"""Web version: the play frame by frame, with each moment's note shown as the caption."""
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.5))
df = tracking(play)
tmap = df.groupby("frameId")["time"].first()
frames = [float(tmap[f]) for f in sorted(df["frameId"].unique())]
def update(k):
t = frames[k]
ax.clear()
fld = panel(ax, play, df, t, lateral, window, rings)
if after:
after(fld, play, t, -1)
cur = [n for tt, n in notes if tt <= t + 1e-6]
ax.set_title(("snap" if abs(t) < 0.05 else f"{t:+.1f} s") + (("\n" + cur[-1].replace("\n", " ")) if cur else ""),
fontsize=8.2, 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(play, moments, lateral, window, rings=(), after=None, ncols=2, width_in=6.2):
"""Video on the website; a numbered, captioned frame strip in the PDF. moments = [(t, tag, note)]."""
if pdf_build():
strip([(play, t, tag) for t, tag, _ in moments], lateral, window, notes=[n for *_, n in moments],
rings=rings, ncols=ncols, width_in=width_in, after=after)
plt.show()
return
video(play, [(t, n) for t, _, n in moments], lateral, window, rings=rings, after=after)
def era_43(off, shell):
"""A 1980s 4-3 Over against a pro set or I (TE to the offense's right), corners 7 off."""
dfn = [D("WE", "DE", 1.0, T("5", -1), "E"), D("WT", "DT", 1.0, T("1", -1), "T"),
D("ST", "DT", 1.0, T("3"), "T"), D("SE", "DE", 1.0, T("5") + 0.2, "E"),
D("SAM", "LB", 4.0, 6.4, "S"), D("MIKE", "LB", 4.5, 0.8, "M"), D("WILL", "LB", 4.5, -3.0, "W"),
D("LCB", "CB", 6.5, -14.0, "CB"), D("RCB", "CB", 6.5, 14.0, "CB")]
if shell == "two":
dfn += [D("FS", "FS", 13.0, -8.0, "FS"), D("SS", "SS", 13.0, 8.0, "SS")]
else:
dfn += [D("FS", "FS", 13.0, 0.0, "FS"), D("SS", "SS", 8.0, 7.0, "SS")]
return dfn
def ghost_off(fld, p, d, w, label="?"):
x, y = (float(v) for v in _P(fld, p, d, w))
fld.ax.add_patch(Circle((x, y), 0.75, fc="none", ec=style.OFFENSE, lw=1.3, ls=(0, (2, 1.4)), zorder=8))
fld.ax.text(x, y, label, ha="center", va="center", fontsize=8, color=style.OFFENSE_DARK, fontweight="bold",
zorder=9)