import os
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, FancyBboxPatch, Rectangle, RegularPolygon
from gridiron import Play, Player, Field, defense, technique, style, show_animation
from gridiron.formations import LINE_D, OFF_BALL, TE_W, WING_W, OL_SPLIT
style.apply()
RB_RING = style.THIRD # aqua ring: a running back of any kind (tailback, fullback, halfback, wingback)
TE_RING = style.LINE_TO_GAIN # gold ring: a tight end
THREAT = "#0f7a55" # darker aqua for "this back could go here" arrows (safe for text)
FAKE = style.MUTED # gray for a back's fake in the Wing-T series panels
UC, FB_D, TB_D = -1.9, -4.5, -7.0 # quarterback under center, fullback and tailback depths (yards)
GL_SPLIT = 1.25 # goal-line splits: tighter than the normal OL_SPLIT (1.6)
DL_LABELS = {"SDE": "E", "WDE": "E", "SDT": "T", "WDT": "T"}
def line5(split=OL_SPLIT):
"""The five offensive linemen; only the center is lettered."""
return [Player(k, k, "off", LINE_D, w, label=("C" if k == "C" else ""))
for k, w in [("LT", -2 * split), ("LG", -split), ("C", 0.0), ("RG", split), ("RT", 2 * split)]]
def P(key, pos, d, w, label=None):
"""An offensive skill 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=label or key)
def D(key, pos, d, w, label=None):
return Player(key, pos, "def", d, w, label=label)
def ring_by_body(p, skip=()):
"""Ring every back (aqua) and tight end (gold), as in the personnel chapter: count rings = personnel."""
for k, pl in p.players.items():
if k in skip or pl.side != "off":
continue
if pl.pos in ("RB", "FB"):
p.highlight(k, color=RB_RING)
elif pl.pos == "TE":
p.highlight(k, color=TE_RING)
return p
def hide_yard_numbers(ax):
"""Drop the faint painted field numbers (they sit where receivers 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()
QB = P("QB", "QB", UC, 0, "Q")
Y = P("Y", "TE", LINE_D, TE_W)
# The eleven classic formations, strength to the offense's right. Each is legal: seven men on the
# line (five linemen plus the two ends), only the ends of the line and the backs eligible.
# Letters: R = the running back (tailback), F = fullback, H = halfback (a third back), A/B = the
# flexbone's own back letters, W = the Wing-T wingback; Y and U = tight ends (in the goal-line set the
# third tight end, at the wing, is H, the H-back); X and Z = wide receivers; H in the singleback = slot.
# name: (typical personnel, one-line description, skill players)
FORMS = {
"T-formation": ("32", "three backs side by side", [
QB, P("LH", "RB", -4.6, -3.0, "H"), P("FB", "FB", -4.6, 0, "F"), P("RH", "RB", -4.6, 3.0, "H"),
Y, P("U", "TE", LINE_D, -TE_W)]),
"Pro set": ("21", "two backs side by side", [
QB, P("HB", "RB", -5.0, -2.2, "H"), P("FB", "FB", -5.0, 2.2, "F"),
Y, P("X", "WR", LINE_D, -14.0), P("Z", "WR", OFF_BALL, 13.0)]),
"I-formation": ("21", "two backs stacked", [
QB, P("FB", "FB", FB_D, 0, "F"), P("RB", "RB", TB_D, 0, "R"),
Y, P("X", "WR", LINE_D, -14.0), P("Z", "WR", OFF_BALL, 13.0)]),
"Offset I": ("21", "fullback cheated to one side", [
QB, P("FB", "FB", FB_D, 1.6, "F"), P("RB", "RB", TB_D, 0, "R"),
Y, P("X", "WR", LINE_D, -14.0), P("Z", "WR", OFF_BALL, 13.0)]),
"Power I": ("31", "an I plus a halfback", [
QB, P("FB", "FB", FB_D, 0, "F"), P("HB", "RB", FB_D, 2.1, "H"), P("RB", "RB", TB_D, 0, "R"),
Y, P("X", "WR", LINE_D, -14.0)]),
"Singleback": ("11", "one back, seven yards deep", [
QB, P("RB", "RB", TB_D, 0, "R"), Y, P("X", "WR", LINE_D, -14.0),
P("H", "WR", OFF_BALL, -8.5), P("Z", "WR", OFF_BALL, 13.0)]),
"Ace": ("12", "one back, a tight end each side", [
QB, P("RB", "RB", TB_D, 0, "R"), Y, P("U", "TE", LINE_D, -TE_W),
P("X", "WR", OFF_BALL, -13.0), P("Z", "WR", OFF_BALL, 13.0)]),
"Wishbone": ("31", "fullback close, halfbacks wide", [
QB, P("FB", "FB", -3.9, 0, "F"), P("LH", "RB", -5.3, -3.1, "H"), P("RH", "RB", -5.3, 3.1, "H"),
Y, P("X", "WR", LINE_D, -14.0)]),
"Flexbone": ("30", "one back, two slotbacks", [
QB, P("FB", "FB", -4.6, 0, "B"), P("LA", "RB", -1.6, -5.2, "A"), P("RA", "RB", -1.6, 5.2, "A"),
P("X", "WR", LINE_D, -12.0, "X"), P("Z", "WR", LINE_D, 12.0, "Z")]),
"Wing-T": ("31", "fullback, halfback, wingback", [
QB, P("FB", "FB", FB_D, 0, "F"), P("HB", "RB", FB_D, -3.2, "H"), P("WB", "RB", OFF_BALL, WING_W + 1.2, "W"),
Y, P("X", "WR", LINE_D, -12.0)]),
"Goal line": ("23", "three tight ends, two backs", [
QB, P("FB", "FB", FB_D, 0, "F"), P("RB", "RB", TB_D, 0, "R"),
P("Y", "TE", LINE_D, 3.9), P("U", "TE", LINE_D, -3.9), P("W", "TE", OFF_BALL, 5.2, "H")]),
}
SPLITS = {"Goal line": GL_SPLIT}
def players_of(name, mirror=False):
pl = line5(SPLITS.get(name, OL_SPLIT)) + [q.moved() for q in FORMS[name][2]]
return [q.mirrored() for q in pl] if mirror else pl
def check_legal(players):
"""Seven on the line, and every eligible receiver on the line is at an end of it."""
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 not in ("WR", "TE", "RB", "FB"), f"covered receiver {q.key}"
for _name in FORMS:
check_legal(players_of(_name))
def plate(ax, name, lateral=(-16.5, 16.5), window=(-9.0, 2.8), mirror=False, sub=True, title=None):
"""One atlas plate: the formation alone, backs ringed aqua and tight ends gold."""
code, desc, _ = FORMS[name]
p = ring_by_body(Play(players_of(name, mirror), None, los=35, to_go=None))
fld = p.draw(ax=ax, lateral=lateral, window=window, title=False)
hide_yard_numbers(ax)
ax.text(0.0, 1.03, title or name, transform=ax.transAxes, ha="left", va="bottom", fontsize=10,
fontweight="bold", color=style.INK)
if sub:
ax.text(1.0, 1.03, f"{code} personnel · {desc}", transform=ax.transAxes, ha="right",
va="bottom", fontsize=7.2, color=style.INK_2)
return p, fld
def ring_legend(fig, y=0.0, ncol=3, extra=()):
h = [Line2D([], [], marker="o", ls="", ms=9, markerfacecolor=style.OFFENSE, markeredgecolor=RB_RING,
markeredgewidth=2.2, label="aqua ring: a running back"),
Line2D([], [], marker="o", ls="", ms=9, markerfacecolor=style.OFFENSE, markeredgecolor=TE_RING,
markeredgewidth=2.2, label="gold ring: a tight end"),
Line2D([], [], marker="o", ls="", ms=9, markerfacecolor=style.OFFENSE, markeredgecolor="white",
label="no ring: receiver, QB (Q), linemen")]
h += list(extra)
fig.legend(handles=h, loc="lower center", bbox_to_anchor=(0.5, y), ncol=ncol, fontsize=7.3,
frameon=False, handletextpad=0.3, columnspacing=1.1)
def plate_pair(a, b, mirror=(False, False), height=2.05):
fig, axes = plt.subplots(1, 2, figsize=(6.6, height))
out = [plate(ax, n, mirror=m) for ax, n, m in zip(axes, (a, b), mirror)]
fig.subplots_adjust(left=0.01, right=0.99, top=0.86, bottom=0.16, wspace=0.05)
ring_legend(fig)
return fig, out
def XY(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def label(fld, p, text, at, target=None, ha="center", fs=7.3, 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 threat(fld, p, pts, color=THREAT, lw=1.7, text=None, text_at=None, fs=7.0, ls=(0, (3, 2))):
"""A dashed 'this back could go here' arrow through absolute (d, w) points."""
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[:-1], ys[:-1], color=color, lw=lw, ls=ls, zorder=6, solid_capstyle="round")
fld.ax.plot(xs[-2:], ys[-2:], color=color, lw=lw, ls=ls, zorder=6)
fld.ax.add_patch(FancyArrowPatch((xs[-2] + 0.75 * (xs[-1] - xs[-2]), ys[-2] + 0.75 * (ys[-1] - ys[-2])),
(xs[-1], ys[-1]), arrowstyle="-|>", mutation_scale=10, color=color,
lw=lw, zorder=6))
if text:
x, y = XY(fld, p, *(text_at or pts[-1]))
fld.ax.text(x, y, text, fontsize=fs, color=color, fontweight="bold", ha="center", va="center",
zorder=9, bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.92))
def base43(off, shell="two_high", fix=None):
"""A base 4-3 (four linemen, three linebackers, four defensive backs) aligned to `off`."""
dfn = defense("4-3_over", shell, off)
fix = fix or {}
dfn = [q.moved(**fix[q.key]) if q.key in fix else q for q in dfn]
return [q.moved(label=DL_LABELS.get(q.key, q.label)) for q in dfn]
# --- frame strips (PDF) and video (web), cropped to the box so the markers stay legible --------------
# gridiron's frame_strip fits all 22 players and has no rings; these panels zoom on the box, ring the
# backs the reader should follow, and put a one-line note under each frame.
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=()):
"""One moment of a simulated play: markers, trails of on-screen players, rings on chosen players."""
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": # the ball, drawn small and just ahead of whoever holds it
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 * (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.05, fc="none", ec=col, lw=1.7, zorder=7))
return fld
def strip(play, times, lateral, window, notes=None, rings=(), ncols=2, width_in=6.5):
"""Numbered frame strip with a fixed, box-sized window and a note under each frame."""
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.6 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)):
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")
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):
"""Web version of the strip: the same panel at every frame, with the strip's notes as captions."""
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)
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=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 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)