import os
import shutil
import textwrap
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, Rectangle, RegularPolygon
from gridiron import Play, Player, Field, style
from gridiron.formations import LINE_D, OFF_BALL, OL_SPLIT, TE_W
style.apply()
UC = -1.9 # quarterback under center (drawn a little off the ball)
BACK_RING = style.THIRD # aqua ring: the ball carrier or the back to watch
KEY_RING = style.DEFENSE # orange ring: the defender to watch
THREAT = "#0f7a55" # darker aqua for "the back could go here" arrows (safe for text)
def P(key, pos, d, w, label=None):
"""Offensive player at (d, w): d = yards from the ball (negative = backfield), w = yards to the offense's right."""
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 OL(key, w, label=""):
"""An offensive lineman on the line; only the center gets a letter."""
return Player(key, "OL", "off", LINE_D, w, label=label)
def line5(split=OL_SPLIT):
return [OL("LT", -2 * split), OL("LG", -split), OL("C", 0.0, "C"), OL("RG", split), OL("RT", 2 * split)]
def check_legal(players):
"""Seven offensive players on the line, and only the two at its ends may be receivers."""
off = [q for q in players if q.side == "off"]
on = sorted([q for q in off if 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.pos == "OL", f"covered receiver {q.key}"
assert len(off) == 11, len(off)
return players
def hide_yard_numbers(ax):
"""Drop the faint painted field numbers (they sit where wide players stand in zoomed plates)."""
for t in list(ax.texts):
if t.get_text().isdigit() and t.get_alpha() is not None and abs(t.get_alpha() - 0.55) < 1e-6:
t.remove()
def XY(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def draw_play(p, lateral, window, width_in=6.5):
"""Draw a play edge to edge at `width_in` (Play.draw's own figure leaves wide empty margins)."""
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))
fig.subplots_adjust(left=0.004, right=0.996, top=0.996, bottom=0.004)
fld = p.draw(ax=ax, lateral=lateral, window=window, title=False)
hide_yard_numbers(ax)
return fld
def label(fld, p, text, at, target=None, ha="center", fs=7.2, color=style.INK, va="center"):
"""A short boxed label at (d, w); with `target`, a thin leader line to that spot."""
x, y = XY(fld, p, *at)
box = dict(boxstyle="round,pad=0.22", fc=style.SURFACE, ec=style.BASELINE, lw=0.6)
if target is None:
fld.ax.text(x, y, text, fontsize=fs, ha=ha, va=va, color=color, zorder=12, bbox=box)
else:
tx, ty = XY(fld, p, *target)
fld.ax.annotate(text, xy=(tx, ty), xytext=(x, y), fontsize=fs, ha=ha, va=va, color=color, zorder=12,
bbox=box, arrowprops=dict(arrowstyle="-", color=style.INK_2, lw=0.7, shrinkA=0, shrinkB=7))
def dashed_arrow(fld, p, pts, color=THREAT, lw=1.6, z=6, head=10):
"""A dashed arrow through absolute (d, w) points: an option, a fake, or the flight of a direct snap."""
xs, ys = XY(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=(0, (3, 2)), zorder=z)
fld.ax.add_patch(FancyArrowPatch((xs[-2] + 0.7 * (xs[-1] - xs[-2]), ys[-2] + 0.7 * (ys[-1] - ys[-2])),
(xs[-1], ys[-1]), arrowstyle="-|>", mutation_scale=head, color=color,
lw=lw, zorder=z))
def gap_mark(fld, p, w, text, d=-0.15, color=style.DEFENSE_DARK):
"""A small gap letter sitting on the line of scrimmage between two blockers."""
x, y = XY(fld, p, d, w)
fld.ax.text(x, y, text, fontsize=6.6, ha="center", va="center", color=color, fontweight="bold", zorder=9,
bbox=dict(boxstyle="round,pad=0.12", fc=style.SURFACE, ec=color, lw=0.6))
# --- frame strips (PDF) and video (web), cropped to the part of the field that matters -------------
MARK_R = 0.62
def pdf_build():
return os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
def tracking(play):
df = play.to_tracking()
df["_id"] = df["displayName"].astype(str)
return df
def panel(ax, play, df, t, lateral, window, rings=(), mark_r=MARK_R):
"""One moment of a simulated play: markers, trails, optional rings (key, color)."""
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.8, play.los + window[1] - 0.8)
& cur["y"].between(ylo + 0.8, yhi - 0.8)]
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 _, 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 + 0.75, y + 0.55), 0.6, 0.36, 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 * (mark_r / MARK_R) * (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)), 1.7 * mark_r, fc="none", ec=col, lw=1.7, zorder=9))
return fld
def strip(play, times, lateral, window, notes=None, rings=(), ncols=2, width_in=6.5, mark_r=MARK_R, extra=None):
"""Numbered frame strip with a fixed window and a note under each frame.
`extra(fld, i)`, if given, draws on frame i (0-based) after the players."""
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.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)):
fld = panel(ax, play, df, t, lateral, window, rings, mark_r)
if extra is not None:
extra(fld, i)
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.2,
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.2, mark_r=MARK_R, extra=None):
"""Web version of the strip: the same panel at every frame, with the strip's notes as 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, mark_r)
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.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, **kw):
"""Video on the website; a numbered, captioned frame strip in the PDF."""
if pdf_build():
strip(play, times, **kw)
plt.show()
else:
kw.pop("ncols", None)
video(play, times, **kw)