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, Rectangle, RegularPolygon
from gridiron import Play, Player, Field, offense, technique, style, routes as r
from gridiron.play import draw_marker
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 te(side=1, key=None, label=None):
"""An in-line tight end: Y on the right, U on the left."""
key = key or ("Y" if side > 0 else "U")
return O(key, "TE", -0.7, side * 4.8, label or key)
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, 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.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 gap_tag(fld, p, d, w, text, color=GREEN, fontsize=8.5):
"""A gap letter 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 ring(fld, p, key, color=GREEN, r=1.25, lw=1.8, ls="-"):
"""A ring around a player at his alignment (drawn under the marker's label)."""
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 lane(fld, p, w0, w1, d0, d1, color=style.OFFENSE, alpha=0.10, ls=(0, (3, 2))):
"""Shade a rectangular lane (a run lane, a gap) from width w0 to w1 and depth d0 to d1."""
(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": # dashed all the way to a short solid head on the last 25% of the last leg
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, side="def"):
"""A faint, dotted outline where a player USED to be (before a shift)."""
x, y = _P(fld, p, d, w)
x, y = float(x), float(y)
if side == "def":
fld.ax.add_patch(RegularPolygon((x, y), 4, radius=0.75 * 1.25, orientation=np.pi / 4, fc="none",
ec=style.DEFENSE, lw=1.0, ls=(0, (1.5, 1.2)), zorder=7))
fld.ax.text(x, y, label, ha="center", va="center", fontsize=6.5, color=style.MUTED, zorder=7)
else:
fld.ax.add_patch(Circle((x, y), 0.75, fc="none", ec=style.OFFENSE, lw=1.0, ls=(0, (1.5, 1.2)), zorder=7))
def bracket(fld, p, d, w0, w1, color=style.DEFENSE_DARK, lw=1.6):
"""A bar spanning the gap(s) a player owns, with end ticks."""
(xa, ya), (xb, yb) = _P(fld, p, d, w0), _P(fld, p, d, w1)
fld.ax.plot([xa, xb], [ya, yb], color=color, lw=lw, solid_capstyle="butt", zorder=5)
for w in (w0, w1):
(x0, y0), (x1, y1) = _P(fld, p, d - 0.35, w), _P(fld, p, d + 0.35, w)
fld.ax.plot([x0, x1], [y0, y1], color=color, lw=lw, 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."""
fld = p.draw(width_in=width_in, ax=ax, **kw)
clean(fld.ax)
return fld
def title(ax, text, size=8.8):
ax.set_title(text, fontsize=size, loc="left", color=style.INK, fontweight="bold")
GAPS = [(-4.0, "C"), (-2.4, "B"), (-0.8, "A"), (0.8, "A"), (2.4, "B"), (4.0, "C"), (5.95, "D")]
def gap_row(f, p, owners, d_tag=-2.2, d_bar=-3.0, d_lab=-3.75, gaps=GAPS):
"""Gap letters under the line, and below them the player who owns each gap."""
for w, g in gaps:
gap_tag(f, p, d_tag, w, g, fontsize=7.8)
for (w, _), lab in zip(gaps, owners):
bracket(f, p, d_bar, w - 0.35, w + 0.35)
say(f, p, d_lab, w, lab, color=style.DEFENSE_DARK, fontsize=7, weight="bold")
# --- the chapter's fronts, all authored with the tight end (the strength) on the offense's RIGHT -------
def over_front(s=1, sam=True):
"""4-3 Over to side s: weak 5, 1 shaded weak, 3 strong, strong 5; Sam off the ball outside the TE,
Mike over the strong A gap, Will over the weak (uncovered) guard."""
out = [D("WE", "DE", 1.0, T("5", -s), "5"), D("NT", "DT", 1.0, T("1", -s), "1"),
D("DT", "DT", 1.0, T("3", s), "3"), D("SE", "DE", 1.0, T("5", s), "5"),
D("MIKE", "LB", 4.5, s * 0.8, "M"), D("WILL", "LB", 4.5, -s * 2.0, "W")]
if sam:
out.append(D("SAM", "LB", 4.2, s * 6.2, "S"))
return out
def under_front(s=1, sam=True):
"""4-3 Under to side s: Sam on the line outside the TE (a 9), strong 5, nose shaded strong (1),
3-technique weak, weak end in a loose 5 ("5w", a yard outside a normal 5); Mike over the strong (uncovered) guard, Will over the weak A."""
out = [D("WE", "DE", 1.0, T("5", -s) - s * 1.0, "5w"), D("DT", "DT", 1.0, T("3", -s), "3"),
D("NT", "DT", 1.0, T("1", s), "1"), D("SE", "DE", 1.0, T("5", s), "5"),
D("MIKE", "LB", 4.5, s * 1.9, "M"), D("WILL", "LB", 4.5, -s * 1.1, "W")]
if sam:
out.append(D("SAM", "LB", 1.0, T("9", s), "S"))
return out
# --- 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=(), enter=(), ring_r=0.85):
"""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)]
ids = set(first["_id"]) | {"football"}
inside = cur[cur["x"].between(play.los + window[0], play.los + window[1]) & cur["y"].between(ylo, yhi)]
ids |= set(inside["_id"]) & set(enter)
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, enter=()):
"""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.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, enter)
if after:
after(fld, play, t, i)
lab = "snap" if abs(t) < 1e-6 else f"{t:+.2f} s".replace("0 s", " 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, enter=()):
"""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, enter)
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, enter=(), 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, enter=enter)
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, enter=enter)