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, show_animation)
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 ace_condensed():
"""12 personnel: two in-line tight ends (Y right, U left), both receivers off the ball in tight splits."""
off = named_line(offense("ace"))
return [q.moved(w=-9.0) if q.key == "X" else (q.moved(w=9.0) if q.key == "Z" else q) for q in off]
def D(key, pos, d, w, label):
return Player(key, pos, "def", d, w, label=label)
def secondary(cb_w=15.0):
"""Two corners and two deep safeties (a two-high shell)."""
sw = min(9.0, cb_w - 1.5)
return [D("LCB", "CB", 6.5, -cb_w, "CB"), D("RCB", "CB", 6.5, cb_w, "CB"),
D("FS", "FS", 11.5, -sw, "FS"), D("SS", "SS", 11.5, sw, "SS")]
def even_front(playside=1, sam_w=6.6, cb_w=15.0):
"""A generic four-man even front (named in 05-02), linemen labelled by technique number:
5-technique, 1-technique, 3-technique, 5-technique, three linebackers, two deep safeties.
playside=+1 puts the 1-technique on the play side (the offense's right)."""
s = playside
return [
D("PE", "DE", 1.0, technique("5", s), "5"), # play-side end
D("PT", "DT", 1.0, technique("1", s), "1"), # play-side tackle, shaded on the center
D("BT", "DT", 1.0, technique("3", -s), "3"), # backside 3-technique
D("BE", "DE", 1.0, technique("5", -s), "5"), # backside end
D("SAM", "SAM", 4.0, s * sam_w, "S"),
D("MIKE", "MIKE", 4.5, s * 1.6, "M"),
D("WILL", "WILL", 4.5, -s * 2.6, "W"),
] + secondary(cb_w)
def front7(playside=1):
return [q for q in even_front(playside) if q.key in ("PE", "PT", "BT", "BE", "SAM", "MIKE", "WILL")]
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 or run."""
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)
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, label=None, label_at=None, lw=2.0, fontsize=8,
dash=(0, (3, 2.2)), end="arrow"):
"""A dashed 'option' path through absolute (d, w) points, with an arrowhead (or block tee)."""
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=6, 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=6))
else: # a block: dashed line ending in a T-bar
fld.ax.plot(xs, ys, color=color, lw=lw, ls=dash, zorder=6)
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=6)
if label:
x, y = _P(fld, p, *(label_at or pts[-1]))
fld.ax.text(x, y, label, fontsize=fontsize, color=color, fontweight="bold", ha="center",
va="center", zorder=9,
bbox=dict(boxstyle="round,pad=0.2", fc=style.SURFACE, ec=color, lw=0.8))
def overtake(fld, p, key, via, end, color=ZONE_GREEN, lw=2.4):
"""An overtake block (dashed green, T-bar end) from `key`'s alignment through `via` to `end`."""
q = p.players[key]
branch(fld, p, [(q.d, q.w), *via, end], color=color, end="tee", lw=lw)
def pair_oval(fld, p, d, w, width, height=2.5, color=ZONE_GREEN):
"""A faint oval around a double team (two blockers and their defender)."""
x, y = _P(fld, p, d, w)
fld.ax.add_patch(Ellipse((float(x), float(y)), width, height, fc=color, alpha=0.24, ec="none", zorder=1.5))
fld.ax.add_patch(Ellipse((float(x), float(y)), width, height, fc="none", ec=color, lw=1.8,
ls=(0, (4, 2)), zorder=1.6))
def spot(fld, p, d, w, color=style.THIRD):
"""Mark an aiming point with an X."""
x, y = _P(fld, p, d, w)
fld.ax.plot([x], [y], marker="X", ms=9, 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):
"""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=7),
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: # yard numbers, not technique labels
t.remove()
else:
t.set_clip_on(True)
def draw(p, width_in=5.8, 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 play_arrow(fld, p, d, w0, w1, text):
"""A broad 'the play goes this way' arrow at depth d, from w0 to w1."""
(x0, y0), (x1, y1) = _P(fld, p, d, w0), _P(fld, p, d, w1)
fld.ax.add_patch(FancyArrowPatch((x0, y0), (x1, y1), arrowstyle="-|>", mutation_scale=16,
color=style.OFFENSE, lw=2.2, zorder=3))
xm, ym = _P(fld, p, d + 0.9, (w0 + w1) / 2)
fld.ax.text(xm, ym, text, fontsize=7.5, color=style.OFFENSE_DARK, ha="center", va="center", zorder=4,
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 engaged
# blockers and defenders (about a yard apart) stay separate in 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=(), down=None):
"""One moment of a simulated play on `ax`: markers, trails of on-screen players, rings, cut fades."""
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"]): # trails only for players in the panel
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()
downed = [k for k, t_down in (down or {}).items() if t >= t_down]
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=9))
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:
gone = r["displayName"] in downed # cut to the ground: faded, under the blockers
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=6 if gone else 8, alpha=0.4 if gone else 1.0))
tc = style.MUTED if gone else 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 if tc != style.MUTED else 6.5)
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))
if downed: # one "cut" tag beside them (backside)
rr = cur[cur["displayName"].isin(downed)]
x, y = fld.xy(rr["x"].mean(), rr["y"].max())
ax.text(float(x) - 0.9, float(y), "cut", fontsize=7, color=style.DEFENSE_DARK, ha="right",
va="center", fontweight="bold", zorder=12,
bbox=dict(boxstyle="round,pad=0.1", fc=style.SURFACE, ec="none", alpha=0.85))
return fld
def strip(play, times, lateral=(-12, 12), window=(-8, 13), ncols=3, notes=None, rings=(("RB", style.THIRD),),
down=None, title=None, width_in=6.5):
"""Frame strip with a fixed, box-sized window so markers stay legible in print.
rings: players to circle in every frame; down: {key: t} players cut to the ground from time t."""
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.36 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)):
panel(ax, play, df, t, lateral, window, rings, down)
ax.set_title(f"{i + 1}. " + ("snap" if abs(t) < 1e-6 else f"{t:+.2f} s".rstrip("0")),
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.5, color=style.INK)
if title:
fig.suptitle(title, x=0.01, ha="left", fontsize=11, fontweight="bold", color=style.INK)
fig.tight_layout(h_pad=1.3 if notes else 0.6, w_pad=0.6)
return fig
def video(play, times, notes=None, lateral=(-12, 12), window=(-8, 13), rings=(("RB", style.THIRD),),
down=None, width_in=5.6):
"""Web version of the strip: the same panel drawn at every frame, with the strip's notes as captions
(each note shows from its frame's time on), the rings and the cut fades."""
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, down)
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 zone_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:
video(play, times, notes=notes, **kw)