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, FancyBboxPatch, RegularPolygon
from gridiron import Play, Player, Field, offense, technique, style
OL_NAMES = {"LT": "LT", "LG": "LG", "C": "C", "RG": "RG", "RT": "RT"}
ZONE_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
def named_line(players):
"""Label the five offensive linemen so the reader can follow each one."""
return [p.moved(label=OL_NAMES[p.key]) if p.key in OL_NAMES else p for p in players]
def off21(strength="right"):
"""21 personnel from an offset I: the fullback (F) offset to the tight end's side, tight receiver
splits. Seven on the line (five linemen, the Y and the X); Z is off the ball."""
s = 1 if strength == "right" else -1
out = []
for q in named_line(offense("offset_i")):
if q.key == "X":
q = q.moved(w=-9.5)
elif q.key == "Z":
q = q.moved(w=9.5)
elif q.key == "RB":
q = q.moved(label="R")
out.append(q if q.key in OL_NAMES else q.moved(w=s * q.w)) # linemen keep their sides
return out
def D(key, pos, d, w, label):
return Player(key, pos, "def", d, w, label=label)
def bear(cb_w=10.2, s=1):
"""A bear front with a single-high safety behind it, against a tight end on side s.
Center and both guards covered (0 and two 3-techniques), a wide 5 on the backside tackle, a 9 on the
tight end; two linebackers and the strong safety in the box (eight), one deep safety, two corners."""
return [D("BE", "DE", 1.0, -s * 4.9, "E"), D("B3", "DT", 1.0, -s * technique("3"), "3"),
D("NT", "NT", 1.0, 0.0, "0"), D("P3", "DT", 1.0, s * technique("3"), "3"),
D("EDGE", "OLB", 1.0, s * 5.7, "9"),
D("MIKE", "MIKE", 4.5, s * 2.4, "M"), D("WILL", "WILL", 4.5, -s * 2.6, "W"),
D("SS", "SS", 5.6, s * 7.6, "SS"), D("FS", "FS", 13.0, 0.0, "FS"),
D("LCB", "CB", 7.0, -cb_w, "CB"), D("RCB", "CB", 7.0, cb_w, "CB")]
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 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 == "route":
return p.route(key, [*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 branch(fld, p, pts, color=style.THIRD, lw=2.0, dash=(0, (3, 2.2)), end="arrow", z=6):
"""A dashed path through absolute (d, w) points, ending in an arrowhead or a block's T-bar."""
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=z, 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=z))
else:
fld.ax.plot(xs, ys, color=color, lw=lw, ls=dash, zorder=z)
v = np.array([xs[-1] - xs[-2], ys[-1] - ys[-2]])
perp = np.array([-v[1], v[0]]) / np.linalg.norm(v) * 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 spot(fld, p, d, w, color=style.THIRD, ms=9):
"""Mark an aiming 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=9)
def say(fld, p, d, w, text, color=style.INK_2, fontsize=7.5, ha="center"):
"""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,
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 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:
t.remove()
else:
t.set_clip_on(True)
def draw(p, width_in=6.4, 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 panel_title(ax, text, fontsize=9):
ax.set_title(text, fontsize=fontsize, loc="left", color=style.INK, fontweight="bold")
def pdf_build():
return os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
MARK_R = 0.6 # frame-strip marker radius in yards, smaller than the static diagrams' so crowds stay legible
def tracking(play):
df = play.to_tracking()
df["_id"] = df["displayName"].astype(str)
return df
def panel(ax, play, df, t, lateral, window, rings=()):
"""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]
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.45), 0.7, 0.42, 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 rings:
r = cur[cur["displayName"] == key].iloc[0]
x, y = fld.xy(r["x"], r["y"])
ax.add_patch(Circle((float(x), float(y)), 1.05, fc="none", ec=col, lw=1.7, zorder=7))
return fld
def strip(play, times, lateral, window, ncols=3, notes=None, rings=(), width_in=6.5, marks=None):
"""Numbered frame strip with a fixed window, so markers stay legible in print. `marks` maps a frame
index to a function(fld, play, t) that adds that frame's on-field labels."""
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.4 if notes else 0.2))))
for ax in axes.flat[len(times):]:
ax.axis("off")
for i, (ax, t) in enumerate(zip(axes.flat, times)):
fld = panel(ax, play, df, t, lateral, window, rings)
if marks and i in marks:
marks[i](fld, play, t)
ax.set_title(f"{i + 1}. " + ("snap" if abs(t) < 1e-6 else f"{t:+.1f} s"),
fontsize=8.5, 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.4, color=style.INK)
fig.tight_layout(h_pad=1.4 if notes else 0.6, w_pad=0.6)
return fig
def video(play, times, lateral, window, notes=None, rings=(), width_in=5.6):
"""Web version of the strip: the same panel at every frame, with the strip's notes as 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 for tt, n in zip(times, notes) if tt <= t + 1e-6]
txt += (" " + cur[-1]) if cur else ""
ax.set_title(txt, fontsize=9, color=style.INK, loc="left")
return []
anim = animation.FuncAnimation(fig, update, frames=sorted(df["frameId"].unique()), 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 play_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:
kw.pop("ncols", None)
kw.pop("marks", None)
video(play, times, notes=notes, **kw)
ordinal = lambda n: f"{n}{'th' if 10 <= n % 100 <= 20 else {1: 'st', 2: 'nd', 3: 'rd'}.get(n % 10, 'th')}"
pct = lambda v: f"{100 * v:.0f}%"
WORDS = dict(enumerate(["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"]))