import os
import shutil
import logging
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, Rectangle, RegularPolygon
from matplotlib.lines import Line2D
from gridiron import Play, Player, Field, routes as r, style
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
GREEN = "#0f7a55" # a darker step of the aqua highlight, for text and branches on the light field
RED = style.DEFENSE_DARK
OL_NAMES = ("LT", "LG", "C", "RG", "RT")
LINE_D, OFF_BALL, GUN = -0.7, -1.5, -5.0
def pdf_build():
return os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
# ---------------------------------------------------------------- players and alignments
def O(key, pos, d, w, label=None):
return Player(key, pos, "off", d, w, label=label or key)
def D(key, pos, d, w, label):
return Player(key, pos, "def", d, w, label=label)
def line5():
"""The five offensive linemen, 1.6 yards apart, each labelled so the reader can follow him."""
return [O(k, k, LINE_D, w, k) for k, w in zip(OL_NAMES, (-3.2, -1.6, 0.0, 1.6, 3.2))]
def trips(rb_w=-1.6, x_w=-15.0, y_w=4.8, h_w=10.0, z_w=16.0):
"""11 personnel, shotgun, 3x1: X alone on the left (on the line), the tight end Y attached on the right
(on the line), H in the slot and Z wide on the right, both a step off the ball. Seven on the line:
the five linemen plus X and Y, so X and Y are the ends of the line and everyone else is a back."""
return line5() + [O("QB", "QB", GUN, 0.0, "Q"), O("RB", "RB", GUN, rb_w, "R"),
O("X", "WR", LINE_D, x_w), O("Y", "TE", LINE_D, y_w),
O("H", "WR", OFF_BALL, h_w), O("Z", "WR", OFF_BALL, z_w)]
def nickel(shell="two_high", **moves):
"""A 4-2-5 nickel against trips right. Defensive linemen are labelled by technique: a 5 on the left
tackle's outside shoulder, a 3 on the left guard's outside shoulder, a 1 shading the center, and a 7
on the tight end's inside shoulder. Mike and Will linebackers 4 1/2 yards deep; the nickel back ($)
apexed between the tight end and H. shell="two_high": two safeties 12 yards deep.
shell="single_high": the free safety in the deep middle and the strong safety walked down as the
overhang defender on the single-receiver side. moves = {key: (d, w)} adjusts anyone."""
out = [D("BE", "DE", 1.0, -4.0, "5"), D("BT", "DT", 1.0, -2.35, "3"),
D("PT", "DT", 1.0, 0.7, "1"), D("PE", "DE", 1.0, 4.2, "7"),
D("MIKE", "MIKE", 4.5, 1.8, "M"), D("WILL", "WILL", 4.5, -2.2, "W"),
D("NB", "NB", 5.0, 7.4, "$"), D("LCB", "CB", 7.0, -15.8, "CB"), D("RCB", "CB", 7.0, 16.8, "CB")]
if shell == "two_high":
out += [D("SS", "SS", 12.0, 9.0, "SS"), D("FS", "FS", 12.5, -8.5, "FS")]
else:
out += [D("SS", "SS", 5.5, -9.0, "SS"), D("FS", "FS", 13.0, -0.5, "FS")]
return [q.moved(d=moves[q.key][0], w=moves[q.key][1]) if q.key in moves else q for q in out]
def only(players, lo, hi, dmax=30.0):
"""Keep the players inside a lateral window (w from lo to hi) for a zoomed static diagram."""
return [q for q in players if lo <= q.w <= hi and q.d <= dmax]
def check_legal(players):
"""Seven offensive players on the line, and only the two at its ends may be eligible receivers."""
on = sorted([q for q in players if q.side == "off" and 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_NAMES, f"{q.key} is covered up but not a lineman"
check_legal(trips())
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, pts, frame="field", delay=delay, speed=speed)
return {"path": p.path, "run": p.run, "pull": p.pull}[kind](key, pts, delay=delay, speed=speed)
# ---------------------------------------------------------------- drawing helpers
def _P(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def clean(ax):
"""Zoomed diagrams: drop hash ticks and painted 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 t.get_text().isdigit() and t.get_alpha() == 0.55:
t.remove()
else:
t.set_clip_on(True)
def draw(p, width_in=6.0, ax=None, **kw):
fld = p.draw(width_in=width_in, ax=ax, title=False, **kw)
clean(fld.ax)
return fld
def say(fld, p, d, w, text, color=style.INK_2, fontsize=7.2, ha="center", weight="normal", box=True):
"""A short on-field label at (d, w)."""
x, y = _P(fld, p, d, w)
fld.ax.text(float(x), float(y), text, fontsize=fontsize, color=color, ha=ha, va="center", zorder=13,
fontweight=weight, linespacing=1.15,
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.9) if box else None)
def point_to(fld, p, text_at, target, text, color=style.INK_2, fontsize=7, ha="center", 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=ha, va="center", zorder=13, linespacing=1.15,
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 branch(fld, p, pts, color=GREEN, lw=1.8, dash=(0, (3, 2.2)), end="arrow", z=6):
"""A dashed path through absolute (d, w) points: one branch of a read, or a defender's reaction."""
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, ys, color=color, lw=lw, ls=dash, zorder=z, solid_capstyle="round")
if end == "arrow":
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>",
mutation_scale=10, color=color, lw=lw, zorder=z))
def throw(fld, p, a, b, color=style.BALL, rad=-0.08):
"""The pass: a dashed brown arrow 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=style.THIRD):
"""The quarterback's eyes: a dashed green line between two (d, w) spots."""
(x0, y0), (x1, y1) = _P(fld, p, *a), _P(fld, p, *b)
fld.ax.plot([x0, x1], [y0, y1], color=color, lw=1.3, ls=(0, (4, 2)), zorder=4)
def tackle_x(fld, p, d, w, color=RED, ms=10):
x, y = _P(fld, p, d, w)
fld.ax.plot([float(x)], [float(y)], marker="X", ms=ms, color=color, mec="white", mew=0.8, zorder=12)
def region(fld, p, d0, d1, w0, w1, color, alpha=0.12, ls="-", lw=0.9, fill=True):
"""A rectangle in (d, w): the box, or a rule's legal zone."""
(xa, ya), (xb, yb) = _P(fld, p, d0, w0), _P(fld, p, d1, w1)
x, y = min(float(xa), float(xb)), min(float(ya), float(yb))
fld.ax.add_patch(Rectangle((x, y), abs(float(xb) - float(xa)), abs(float(yb) - float(ya)),
fc=color if fill else "none", alpha=alpha if fill else 1.0, ec="none", zorder=1.5))
fld.ax.add_patch(Rectangle((x, y), abs(float(xb) - float(xa)), abs(float(yb) - float(ya)),
fc="none", ec=color, lw=lw, ls=ls, zorder=1.6))
def panel_title(ax, text, fs=9):
ax.set_title(text, fontsize=fs, loc="left", color=style.INK, fontweight="bold")
LEGEND = {
"block": Line2D([], [], color=style.OFFENSE_DARK, lw=1.8, marker="|", ms=8, mew=2.2, label="block"),
"route": Line2D([], [], color=style.OFFENSE, lw=1.8, label="route"),
"give": Line2D([], [], color=style.OFFENSE_DARK, lw=2.0, ls=(0, (3, 2.2)), label="run branch"),
"keep": Line2D([], [], color=GREEN, lw=2.0, ls=(0, (3, 2.2)), label="QB keep"),
"pass": Line2D([], [], color=style.BALL, lw=1.4, ls=(0, (2, 2)), label="pass branch"),
"read": Line2D([], [], color=style.THIRD, lw=1.3, ls=(0, (4, 2)), label="QB's eyes"),
"react": Line2D([], [], color=RED, lw=1.6, ls=(0, (3, 2.2)), label="defender's choice"),
"fake": Line2D([], [], color=style.OFFENSE, lw=1.6, label="fake / path"),
}
def legend(fig, keys, y=0.02, fs=7, ncol=None):
hs = [LEGEND[k] for k in keys]
fig.legend(handles=hs, loc="upper center", bbox_to_anchor=(0.5, y), ncol=ncol or len(hs), fontsize=fs,
frameon=False, handlelength=2.2, columnspacing=1.2)
# ---------------------------------------------------------------- frame strips (print) and video (web)
MARK_R = 0.72
def tracking(play):
df = play.to_tracking()
df["_id"] = df["displayName"].astype(str)
return df
def catch_time(play):
"""Seconds after the snap at which the first pass arrives."""
for kind, to, te in play.ball_events:
if kind == "pass":
a, b = play.pass_target_spot()
return te + float(np.hypot(*(b - a))) / 18.0
return None
def carrier(play, t):
"""Who holds the ball at time t (None while a pass is in the air)."""
holder = "QB"
for kind, to, te in sorted(play.ball_events, key=lambda e: e[2]):
if kind == "handoff" and t >= te:
holder = to
if kind == "pass" and t >= te:
holder = to if t >= catch_time(play) else None
return holder
def panel(ax, play, df, t, lateral, window, rings=()):
"""One moment of a simulated play: markers, faint trails, the ball (and its flight), 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")]
who_now = carrier(play, t)
for (pid, side), g in trails.groupby(["_id", "side"]):
xs, ys = fld.xy(g["x"].values, g["y"].values)
if pid == who_now and pid != "QB": # the ball carrier's path, drawn so it reads in print
ax.plot(xs, ys, color=style.OFFENSE_DARK, lw=1.8, alpha=0.9, zorder=3.5)
continue
ax.plot(xs, ys, color=style.OFFENSE if side == "off" else style.DEFENSE, lw=0.9, alpha=0.4, zorder=3)
passes = [te for kind, _, te in play.ball_events if kind == "pass"]
if passes and t > passes[0]:
b = df[(df["side"] == "ball") & (df["frameId"] <= fid) & (df["time"] >= passes[0] - 0.05)]
xs, ys = fld.xy(b["x"].values, b["y"].values)
ax.plot(xs, ys, color=style.BALL, lw=1.3, ls=(0, (2, 1.5)), zorder=9)
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.8, 0.5, fc=style.BALL, ec="white", lw=0.5, zorder=11))
continue
lab = str(rr.get("label", "") or "")
if lab in OL_NAMES and lab != "C":
lab = "" # too small to read in a strip; the center keeps his letter
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.2, min(7.5, ppy * (0.95 if len(lab) <= 1 else 0.68)))
ax.text(x, y, lab, ha="center", va="center", fontsize=fs, color=tc, fontweight="bold", zorder=10)
who = carrier(play, t)
for key, col in list(rings) + ([(who, style.THIRD)] if who else []):
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.05, fc="none", ec=col, lw=1.7, zorder=7))
return fld
def strip(plays, times, lateral, window, notes=None, rows=None, rings=(), width_in=6.5):
"""Frame strip: one row per branch of the read, the same moments in each row."""
dfs = [tracking(p) for p in plays]
cells = [(i, t) for i in range(len(plays)) for t in times]
ncols = len(times)
nrows = len(plays)
pw = width_in / ncols
span_w, span_d = abs(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.3))))
for k, (ax, (i, t)) in enumerate(zip(axes.flat, cells)):
panel(ax, plays[i], dfs[i], t, lateral, window, rings)
head = f"{k % ncols + 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, 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.0,
color=style.INK, linespacing=1.2)
fig.tight_layout(h_pad=1.6 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 branches side by side, drawn every tenth of a second."""
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(max(t0, -0.5), t1 + 1e-9, 0.1), 2)
n = len(plays)
span_w, span_d = abs(lateral[1] - lateral[0]), window[1] - window[0]
pw = width_in / n
fig, axes = plt.subplots(1, n, squeeze=False, figsize=(pw * n, pw * span_d / span_w + 0.7))
axes = list(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:
mine = notes[i * len(times):(i + 1) * len(times)]
cur = [m for tt, m in zip(times, mine) if tt <= t + 1e-6]
txt += ("\n" + cur[-1].replace("\n", " ")) if cur else ""
ax.set_title((rows[i] + ": " if rows else "") + 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 rpo_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)