import logging
import os
import shutil
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, FancyBboxPatch, RegularPolygon, Rectangle
from matplotlib.lines import Line2D
from gridiron import Play, Player, Field, technique, style
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply() # charts: course palette, recessive grid (field diagrams switch their axes off)
PURPLE = style.SERIES[6] # rings on a player to watch: neither offense blue nor defense orange
RED = style.SERIES[7] # rings on the defender a play is built to punish
GREEN = "#0f7a55" # darker aqua for text and boxes on the light page
def O(key, pos, d, w, label=None):
return Player(key, pos, "off", d, w, label=key if label is None else label)
def D(key, pos, d, w, label=None):
return Player(key, pos, "def", d, w, label=label)
def line(labels=False):
"""Five offensive linemen; unlabeled except the center."""
names = ["LT", "LG", "C", "RG", "RT"]
return [O(k, k, -0.7, w, k if (labels or k == "C") else "")
for k, w in zip(names, (-3.2, -1.6, 0.0, 1.6, 3.2))]
def spread():
"""11 personnel, 2x2 shotgun: X and Z on the line with the five linemen (seven on the line);
H (slot receiver) and Y (tight end, flexed into the slot) off the ball."""
return line() + [O("QB", "QB", -5.0, 0.0, "Q"), O("RB", "RB", -5.0, -2.0, "R"),
O("X", "WR", -0.7, -12.5, "X"), O("H", "WR", -1.5, -7.5, "H"),
O("Y", "TE", -1.5, 7.5, "Y"), O("Z", "WR", -0.7, 12.5, "Z")]
def tight_12():
"""12 personnel under center, both tight ends attached: five linemen plus Y and U make seven on the
line; X and Z are off the ball in tight splits."""
return line() + [O("QB", "QB", -1.9, 0.0, "Q"), O("RB", "RB", -7.0, 0.0, "R"),
O("Y", "TE", -0.7, 4.8, "Y"), O("U", "TE", -0.7, -4.8, "U"),
O("X", "WR", -1.5, -11.0, "X"), O("Z", "WR", -1.5, 11.0, "Z")]
def flexed_12():
"""The same 12 personnel spread out from the shotgun: both tight ends flexed into the slots (off the
ball), X and Z on the line with the five linemen."""
return line() + [O("QB", "QB", -5.0, 0.0, "Q"), O("RB", "RB", -5.0, -2.0, "R"),
O("U", "TE", -1.5, -8.0, "U"), O("Y", "TE", -1.5, 8.0, "Y"),
O("X", "WR", -0.7, -14.0, "X"), O("Z", "WR", -0.7, 14.0, "Z")]
def base_vs_tight():
"""A 4-3 over front with one deep safety against 12 personnel (eight in the box)."""
return [D("LDE", "DE", 1.0, -5.6, "E"), D("LDT", "DT", 1.0, technique("1", -1), "T"),
D("RDT", "DT", 1.0, technique("3", 1), "T"), D("RDE", "DE", 1.0, 5.6, "E"),
D("SAM", "SAM", 3.8, 6.6, "S"), D("MIKE", "MIKE", 4.6, 1.0, "M"), D("WILL", "WILL", 4.6, -2.8, "W"),
D("SS", "SS", 7.5, -6.0, "SS"), D("FS", "FS", 13.0, 0.5, "FS"),
D("LCB", "CB", 6.5, -11.0, "CB"), D("RCB", "CB", 6.5, 11.0, "CB")]
def base_vs_flexed(lbs_out=True):
"""The same base 4-3 personnel stuck against the flexed 12: the outside linebackers either split the
difference (apex, between the box and the slot) or stay in the box."""
sam_w, will_w = (5.6, -5.6) if lbs_out else (3.2, -3.0)
return [D("LDE", "DE", 1.0, -4.6, "E"), D("LDT", "DT", 1.0, technique("1", -1), "T"),
D("RDT", "DT", 1.0, technique("3", 1), "T"), D("RDE", "DE", 1.0, 4.6, "E"),
D("SAM", "SAM", 4.6, sam_w, "S"), D("MIKE", "MIKE", 5.0, 0.4, "M"), D("WILL", "WILL", 4.6, will_w, "W"),
D("SS", "SS", 11.0, 8.0, "SS"), D("FS", "FS", 11.5, -8.0, "FS"),
D("LCB", "CB", 7.0, -14.0, "CB"), D("RCB", "CB", 7.0, 14.0, "CB")]
def nickel_2high():
"""Nickel (4-2-5) two-high against the 2x2: the nickel ($) over H, the safeties at about 12 yards."""
return [D("LDE", "DE", 1.0, -4.6, "E"), D("LDT", "DT", 1.0, -2.4, "T"), D("RDT", "DT", 1.0, 0.8, "T"),
D("RDE", "DE", 1.0, 4.6, "E"), D("MIKE", "MIKE", 4.6, 1.8, "M"), D("WILL", "WILL", 4.6, -2.2, "W"),
D("NB", "NB", 5.0, -7.8, "$"), D("SS", "SS", 12.0, 8.0, "SS"), D("FS", "FS", 12.0, -8.0, "FS"),
D("LCB", "CB", 7.0, -12.5, "CB"), D("RCB", "CB", 7.0, 12.5, "CB")]
def cover1_look():
"""Single-high man look against the 2x2: the strong safety down over Y, the free safety in the middle."""
return [D("LDE", "DE", 1.0, -4.6, "E"), D("LDT", "DT", 1.0, -2.4, "T"), D("RDT", "DT", 1.0, 0.8, "T"),
D("RDE", "DE", 1.0, 4.6, "E"), D("MIKE", "MIKE", 4.6, 1.2, "M"), D("WILL", "WILL", 4.6, -2.2, "W"),
D("NB", "NB", 5.5, -7.8, "$"), D("SS", "SS", 6.0, 7.8, "SS"), D("FS", "FS", 13.5, 0.0, "FS"),
D("LCB", "CB", 6.0, -12.5, "CB"), D("RCB", "CB", 6.0, 12.5, "CB")]
def _P(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def go_to(p, key, end, kind="route", delay=0.0, speed=None, via=()):
"""Move `key` through absolute (d, w) points `via` to `end`, as a route, path, run, block or drop."""
start = np.array(p._cur(key))
pts = [tuple(np.array(q) - start) for q in (*via, end)]
if kind == "route":
return p.route(key, pts, frame="field", delay=delay, speed=speed)
if kind == "block":
return p.block(key, pts=pts, delay=delay, speed=speed)
if kind == "drop":
return p.drop(key, end, delay=delay, speed=speed)
return {"path": p.path, "run": p.run}[kind](key, pts, delay=delay, speed=speed)
def say(fld, p, d, w, text, color=style.INK_2, fontsize=7.0, 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=11, fontweight=weight,
linespacing=1.1, 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=6.8):
"""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=11, linespacing=1.1,
arrowprops=dict(arrowstyle="-", color=color, lw=0.7, shrinkA=2, shrinkB=7),
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.92))
def threat(fld, p, *keys):
"""Dashed brown rings: defenders threatening to blitz before the snap."""
for k in keys:
x, y = _P(fld, p, p.players[k].d, p.players[k].w)
fld.ax.add_patch(Circle((float(x), float(y)), 1.25, fc="none", ec=style.BALL, lw=1.5, ls=(0, (2.2, 1.4)),
zorder=7))
def ghost(fld, p, pts, color=style.DEFENSE, lw=1.4, alpha=0.55, zorder=5):
"""A faint dashed arrow through absolute (d, w) points: what a player did on earlier snaps."""
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, 2)), alpha=alpha, zorder=zorder)
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>", mutation_scale=10,
color=color, lw=lw, alpha=alpha, ls=(0, (3, 2)), zorder=zorder))
def clean(ax):
"""Zoomed diagrams: drop hash ticks and yard numbers, and 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, ax=None, width_in=5.8, **kw):
fld = p.draw(ax=ax, width_in=width_in, title=False, **kw)
clean(fld.ax)
return fld
def panel_title(ax, text, fontsize=8.6):
ax.set_title(text, fontsize=fontsize, loc="left", color=style.INK, fontweight="bold", pad=3)
def pdf_build():
return os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
# ---- frame strips (print) and videos (web) for simulated plays
MARK_R = 0.6 # frame-strip marker radius in yards, smaller than the static diagrams'
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: 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")]
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 _, rr in shown.iterrows():
x, y = (float(v) for v in fld.xy(rr["x"], rr["y"]))
if rr["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(rr.get("label", "") or "")
if rr["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:
rr = cur[cur["displayName"] == key].iloc[0]
x, y = fld.xy(rr["x"], rr["y"])
ax.add_patch(Circle((float(x), float(y)), 1.05, fc="none", ec=col, lw=1.7, zorder=7))
# once a pass is in the air: the throw as a dashed brown arrow, and a brown ring on the target
passes = [(k, tt) for kind, k, tt in play.ball_events if kind == "pass"]
if passes and t >= passes[0][1] + 0.05:
target, t_rel = passes[0]
b = df[(df["side"] == "ball") & (df["time"] >= t_rel - 1e-6) & (df["time"] <= t + 1e-6)]
if len(b) >= 2:
xs, ys = fld.xy(b["x"].values, b["y"].values)
ax.plot(xs[:-1], ys[:-1], color=style.BALL, lw=1.3, ls=(0, (3, 2)), zorder=8.5)
ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>", mutation_scale=8,
color=style.BALL, lw=1.3, zorder=8.5))
rr = cur[cur["displayName"] == target].iloc[0]
x, y = fld.xy(rr["x"], rr["y"])
ax.add_patch(Circle((float(x), float(y)), 1.05, fc="none", ec=style.BALL, lw=1.5, zorder=7))
return fld
def strip(play, times, notes, lateral, window, ncols=2, rings=(), width_in=6.4):
"""Numbered, captioned frame strip: the PDF stand-in for the video."""
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)))
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)
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")
ax.text(0.5, -0.03, notes[i], transform=ax.transAxes, ha="center", va="top", fontsize=7.4,
color=style.INK, linespacing=1.15)
fig.tight_layout(h_pad=1.6, w_pad=0.6)
return fig
def video(play, times, notes, lateral, window, rings=(), width_in=5.6):
"""Web version of the strip: every frame, with the strip's notes as running captions."""
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)
cur = [n.replace("\n", " ") for tt, n in zip(times, notes) if tt <= t + 1e-6]
ax.set_title(("snap" if abs(t) < 0.05 else f"{t:+.1f} s") + ((" " + cur[-1]) if cur else ""),
fontsize=8.5, 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 animation_or_strip(play, times, notes, lateral, window, rings=(), ncols=2):
"""Video on the website; a numbered, captioned frame strip in the PDF."""
if pdf_build():
strip(play, times, notes, lateral, window, ncols=ncols, rings=rings)
plt.show()
else:
video(play, times, notes, lateral, window, rings=rings)
def canvas(fig):
"""A full-figure axes in 0-100 x 0-100 units for schematic drawings."""
ax = fig.add_axes([0, 0, 1, 1])
ax.set_xlim(0, 100); ax.set_ylim(0, 100); ax.axis("off")
return ax
def box(ax, x, y, w, h, title, body="", fc=style.SURFACE, ec=style.BASELINE, lw=1.1, tsize=8.0, bsize=6.7,
tcolor=style.INK, gap=3.6):
"""A rounded box centred at (x, y) with a bold title and a smaller body."""
ax.add_patch(FancyBboxPatch((x - w / 2, y - h / 2), w, h, boxstyle="round,pad=0.3,rounding_size=1.2",
fc=fc, ec=ec, lw=lw, zorder=2))
ax.text(x - w / 2 + 1.2, y + h / 2 - 1.3, title, ha="left", va="top", fontsize=tsize, fontweight="bold",
color=tcolor, zorder=3)
if body:
ax.text(x - w / 2 + 1.2, y + h / 2 - 1.3 - gap, body, ha="left", va="top", fontsize=bsize,
color=style.INK_2, zorder=3, linespacing=1.22)
def arrow(ax, x0, y0, x1, y1, color=style.INK_2, lw=1.2, rad=0.0, text=None, tx=None, ty=None, tsize=6.4):
ax.add_patch(FancyArrowPatch((x0, y0), (x1, y1), arrowstyle="-|>", mutation_scale=10, color=color, lw=lw,
connectionstyle=f"arc3,rad={rad}", zorder=1))
if text:
ax.text(tx if tx is not None else (x0 + x1) / 2, ty if ty is not None else (y0 + y1) / 2, text,
fontsize=tsize, color=color, ha="center", va="center", style="italic", zorder=3,
bbox=dict(boxstyle="round,pad=0.15", fc=style.SURFACE, ec="none"))