import os
import logging
import shutil
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, Patch, RegularPolygon
from matplotlib.lines import Line2D
from gridiron import Play, Player, Field, routes as r, style
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
GREEN = "#0f7a55" # a darker step of the aqua highlight, for text and read lines on the field
PURPLE = style.SERIES[6] # a ring colour that is neither offense blue nor defense orange
GREY = style.MUTED
LINE_D, OFF_BALL, GUN, UC = -0.7, -1.5, -5.0, -1.9
OL = (("LT", -3.2), ("LG", -1.6), ("C", 0.0), ("RG", 1.6), ("RT", 3.2))
# ---------------------------------------------------------------- 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 lettered so the reader can follow him."""
return [O(k, k, LINE_D, w, k) for k, w in OL]
def ace_tight():
"""12 personnel under center: tight ends U (left) and Y (right) on the line, receivers X and Z
a step off the ball in tight splits, the back seven yards deep. Seven on the line: five
linemen plus the two tight ends, who are the ends and so eligible."""
return line5() + [O("QB", "QB", UC, 0.0, "Q"), O("RB", "RB", -7.0, 0.0, "R"),
O("U", "TE", LINE_D, -4.8), O("Y", "TE", LINE_D, 4.8),
O("X", "WR", OFF_BALL, -9.0), O("Z", "WR", OFF_BALL, 9.0)]
def i_form():
"""21 personnel I formation: X on the line wide left, Y in-line right, Z off the ball wide right."""
return line5() + [O("QB", "QB", UC, 0.0, "Q"), O("FB", "FB", -4.5, 0.0, "F"), O("RB", "RB", -7.0, 0.0, "R"),
O("Y", "TE", LINE_D, 4.8), O("X", "WR", LINE_D, -14.0), O("Z", "WR", OFF_BALL, 14.0)]
def spread(rb_w=-1.5, y_inline=False):
"""11 personnel, shotgun 2x2: X and Z on the line wide, H in the left slot and Y (a tight end)
in the right slot, both a step off the ball. y_inline=True attaches Y to the line instead and
moves Z off the ball, so there are still exactly seven on the line."""
out = line5() + [O("QB", "QB", GUN, 0.0, "Q"), O("RB", "RB", GUN, rb_w, "R"),
O("X", "WR", LINE_D, -15.0), O("H", "WR", OFF_BALL, -9.0)]
if y_inline:
return out + [O("Y", "TE", LINE_D, 4.8), O("Z", "WR", OFF_BALL, 14.0)]
return out + [O("Y", "TE", OFF_BALL, 9.0), O("Z", "WR", LINE_D, 15.0)]
def even_front(cb_w=10.0, shell="two"):
"""A four-man even front (named in 05-02) with linemen labelled by technique number, three
linebackers and either two deep safeties (shell='two') or one, with the strong safety
walked down into the box (shell='one': eight in the box)."""
out = [D("PE", "DE", 1.0, 4.0, "5"), D("PT", "DT", 1.0, 0.7, "1"),
D("BT", "DT", 1.0, -2.35, "3"), D("BE", "DE", 1.0, -4.0, "5"),
D("SAM", "SAM", 4.0, 6.6, "S"), D("MIKE", "MIKE", 4.5, 1.6, "M"), D("WILL", "WILL", 4.5, -2.6, "W"),
D("LCB", "CB", 6.5, -cb_w - 0.5, "CB"), D("RCB", "CB", 6.5, cb_w + 0.5, "CB")]
if shell == "two":
out += [D("FS", "FS", 11.5, -8.0, "FS"), D("SS", "SS", 11.5, 8.0, "SS")]
else:
out += [D("FS", "FS", 13.0, 0.0, "FS"), D("SS", "SS", 6.5, 8.5, "SS")]
return out
def nickel(extra=None, shell="three"):
"""A 4-2-5 nickel against the 2x2: corners off the outside receivers, the strong safety over H
(left slot), the nickel ($) over Y (right slot), one deep safety. shell='zero' pulls the free
safety down to eight yards over the back (no deep help) for all-out pressure looks.
extra = {key: (d, w)} moves anyone."""
out = [D("WDE", "DE", 1.0, -5.4, "E"), D("WDT", "DT", 1.0, -0.8, "T"),
D("SDT", "DT", 1.0, 2.4, "T"), D("SDE", "DE", 1.0, 5.4, "E"),
D("MIKE", "MIKE", 4.5, 1.8, "M"), D("WILL", "WILL", 4.5, -2.2, "W"),
D("LCB", "CB", 7.0, -16.0, "CB"), D("RCB", "CB", 7.0, 16.0, "CB"),
D("SS", "SS", 5.5, -8.6, "SS"), D("NB", "NB", 5.0, 8.6, "$"),
D("FS", "FS", 13.0, 0.0, "FS") if shell == "three" else D("FS", "FS", 8.0, -2.0, "FS")]
extra = extra or {}
return [q.moved(d=extra[q.key][0], w=extra[q.key][1]) if q.key in extra else q for q in out]
# ---------------------------------------------------------------- moving people
def go_to(p, key, end, kind="path", delay=0.0, speed=None, via=()):
"""Move `key` through absolute (d, w) points `via` to `end`, as a path, run, route, block, pull or rush."""
start = np.array(p._cur(key))
pts = [tuple(np.array(q) - start) for q in (*via, end)]
if kind == "rush":
return p.rush(key, pts=pts, delay=delay, speed=speed)
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 == "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 pass_set(p, keys=("LT", "LG", "C", "RG", "RT"), depth=1.2, speed=None):
"""Linemen step back into their pass sets."""
for k in keys:
p.block(k, pts=[(-depth, 0.0)], speed=speed)
def zone_steps(p, keys=("LT", "LG", "C", "RG", "RT"), step=(0.15, 1.3)):
"""Linemen take outside-zone steps to the right but stay at the line (the one-yard limit)."""
for k in keys:
p.block(k, pts=[step])
# ---------------------------------------------------------------- drawing helpers
def _P(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def clean(ax):
"""Drop the painted yard numbers (they sit where players stand); clip all text to the window."""
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.2, 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.0, 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))
DASH = (0, (3.6, 3.0)) # long enough to read as dashed at print size
def branch(fld, p, pts, color=GREEN, lw=1.7, dash=DASH, end="arrow"):
"""A dashed path through absolute (d, w) points: a defender's reaction, or the other branch of a read."""
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=5, solid_capstyle="round")
if end == "arrow": # the head sits on the last yard only, so it never paints over the dashes
seg = float(np.hypot(xs[-1] - xs[-2], ys[-1] - ys[-2]))
f = min(1.0, 0.9 / seg) if seg > 0 else 1.0
x0, y0 = xs[-1] - f * (xs[-1] - xs[-2]), ys[-1] - f * (ys[-1] - ys[-2])
fld.ax.add_patch(FancyArrowPatch((x0, y0), (xs[-1], ys[-1]), arrowstyle="-|>",
mutation_scale=10, color=color, lw=lw, ls="-", zorder=5))
elif end == "tee":
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=5)
def after_throw(fld, p, key, target):
"""A released lineman's climb to his block once the ball is in the air (dashed: it happens after the throw)."""
kf, _, _ = p._compile()
d0, w0 = kf[key][-1, 1:]
branch(fld, p, [(d0, w0), target], color=style.OFFENSE_DARK, lw=1.5, end="tee")
def ring(fld, p, d, w, color=style.DEFENSE, r_=1.25):
x, y = _P(fld, p, d, w)
fld.ax.add_patch(Circle((float(x), float(y)), r_, fc="none", ec=color, lw=1.8, zorder=7))
def opening(fld, p, center, size, color=style.THIRD, alpha=0.2):
"""A soft green oval: the space that opens. size = (depth, width) in yards."""
x, y = _P(fld, p, *center)
fld.ax.add_patch(Ellipse((float(x), float(y)), size[1], size[0], fc=color, alpha=alpha, ec=color, lw=1.0,
zorder=1.6))
def badge(fld, p, d, w, n, color=GREEN):
"""A small numbered circle: step n of a sequence."""
x, y = _P(fld, p, d, w)
fld.ax.text(float(x), float(y), str(n), fontsize=7.5, color="white", fontweight="bold", ha="center",
va="center", zorder=14, bbox=dict(boxstyle="circle,pad=0.22", fc=color, ec="white", lw=0.6))
def panel_title(ax, text, fs=9):
ax.set_title(text, fontsize=fs, loc="left", color=style.INK, fontweight="bold")
LEGEND = {
"route": Line2D([], [], color=style.OFFENSE, lw=1.8, label="route"),
"block": Line2D([], [], color=style.OFFENSE_DARK, lw=1.8, marker="|", ms=8, mew=2.2, label="block"),
"run": Line2D([], [], color=style.OFFENSE_DARK, lw=2.6, label="ball carrier"),
"fake": Line2D([], [], color=style.OFFENSE, lw=1.6, label="fake / QB path"),
"pass": Line2D([], [], color=style.BALL, lw=1.3, ls=(0, (2, 2)), label="the throw"),
"react": Line2D([], [], color=style.DEFENSE, lw=1.4, ls=DASH, label="defender's reaction"),
"rush": Line2D([], [], color=style.DEFENSE, lw=1.8, label="rush / defender's path"),
"other": Line2D([], [], color=GREEN, lw=1.6, ls=DASH, label="the other option"),
"after": Line2D([], [], color=style.OFFENSE_DARK, lw=1.5, ls=DASH, label="block after the throw"),
"window": Patch(fc=style.THIRD, alpha=0.25, ec=style.THIRD, label="space that opens"),
}
def legend(fig, keys, ncol=None, y=0.0, fs=7):
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.0, columnspacing=1.0)
# ---------------------------------------------------------------- frame strips (print) and video (web)
MARK_R = 0.7
def tracking(play):
df = play.to_tracking()
df["_id"] = df["displayName"].astype(str)
return df
def frame_panel(ax, play, df, t, lateral, window_, rings=(), throw_t=None, mark_r=MARK_R):
"""One moment of a simulated play: markers, faint trails, optional rings and the ball's flight so far."""
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=True,
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.45, zorder=3)
if throw_t is not None and t > throw_t:
b = df[(df["side"] == "ball") & (df["frameId"] <= fid)]
b = b[b["time"] >= throw_t - 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": # drawn beside the carrier's marker so it never hides his label
ax.add_patch(Ellipse((x + mark_r * 1.15, y + mark_r * 0.55), 0.95, 0.6, fc=style.BALL, ec="white",
lw=0.5, zorder=11))
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.85 if len(lab) <= 1 else 0.62) * mark_r / 0.7))
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)), mark_r * 1.7, fc="none", ec=col, lw=1.7, zorder=7))
return fld
def plan(fld, p, key, t0, color=None, lw=1.5, dashed=False):
"""The rest of a player's path from time t0 on (where he is going), ending in an arrowhead."""
kf, _, _ = p._compile()
t1 = float(kf[key][-1, 0])
if t1 <= t0 + 0.05:
return
pts = np.array([p.position(key, tt) for tt in np.linspace(t0, t1, 50)])
xs, ys = _P(fld, p, pts[:, 0], pts[:, 1])
side = p.players[key].side
col = color or (style.OFFENSE if side == "off" else style.DEFENSE)
ls = (0, (3, 2)) if dashed else "-"
fld.ax.plot(xs[:-3], ys[:-3], color=col, lw=lw, ls=ls, zorder=4, solid_capstyle="round")
fld.ax.add_patch(FancyArrowPatch((xs[-4], ys[-4]), (xs[-1], ys[-1]), arrowstyle="-|>", mutation_scale=9,
color=col, lw=lw, zorder=4))
def moment(ax, p, t, lateral, window, rings=(), plans=(), mark_r=0.8):
"""The play frozen at time t (faint trails show where everyone came from); `plans` adds
each listed player's remaining path."""
fld = frame_panel(ax, p, tracking(p), t, lateral, window, rings, mark_r=mark_r)
for key in plans:
plan(fld, p, key, t)
return fld
def throw_arrow(fld, p, a, b):
"""The throw, drawn by hand 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=9, color=style.BALL, lw=1.3, ls=(0, (2, 2)), zorder=9,
connectionstyle="arc3,rad=-0.08"))
def throw_at(p, key, t_catch):
"""Schedule the pass so the ball reaches `key` at about t_catch (ball speed 18 yd/s)."""
t = t_catch - 0.6
for _ in range(5):
a, b = p.position("QB", t), p.position(key, t_catch)
t = t_catch - float(np.hypot(*(b - a))) / 18.0
p.pass_to(key, t=round(t, 2))
return round(t, 2)
def strip(play, times, notes=None, lateral=(-24, 4), window=(-8, 14), ncols=2, rings=(), throw_t=None,
width_in=6.5, frame_rings=None):
"""frame_rings: optional list (one entry per frame) of ((key, colour), ...) rings for that frame only."""
df = tracking(play)
nrows = int(np.ceil(len(times) / ncols))
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.62 if notes else 0.25))))
for ax in axes.flat[len(times):]:
ax.axis("off")
for i, (ax, t) in enumerate(zip(axes.flat, times)):
frame_panel(ax, play, df, t, lateral, window, frame_rings[i] if frame_rings else rings, throw_t)
lab = "snap" if abs(t) < 1e-6 else f"{t:+.1f} s"
ax.set_title(f"{i + 1}. {lab}", fontsize=8.5, color=style.INK, loc="left")
if notes:
ax.text(0.5, -0.03, notes[i], transform=ax.transAxes, ha="center", va="top", fontsize=7.3,
color=style.INK, linespacing=1.2)
fig.tight_layout(h_pad=2.4 if notes else 0.6, w_pad=0.6)
return fig
def video(play, times, notes=None, lateral=(-24, 4), window=(-8, 14), rings=(), throw_t=None, width_in=5.6,
frame_rings=None):
from matplotlib import animation
from IPython.display import HTML, display
df = tracking(play)
span_w, span_d = abs(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])
if frame_rings: # the rings of the latest strip frame reached (the first frame's before it)
k = max([i for i, tt in enumerate(times) if tt <= t + 1e-6] or [0])
rings_now = frame_rings[k]
else:
rings_now = rings
frame_panel(ax, play, df, t, lateral, window, rings_now, throw_t)
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].replace("\n", " ")) if cur else ""
ax.set_title(txt, fontsize=8, 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_movie(play, times, notes=None, **kw):
"""Video on the website; a numbered, captioned frame strip in the PDF."""
if PDF:
strip(play, times, notes=notes, **kw)
plt.show()
else:
kw.pop("ncols", None)
kw.pop("width_in", None)
video(play, times, notes=notes, **kw)