import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, RegularPolygon
from gridiron import Play, Player, Field, offense, technique, style
OL_NAMES = {"LT": "LT", "LG": "LG", "C": "C", "RG": "RG", "RT": "RT"}
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 O(key, pos, d, w, label):
return Player(key, pos, "off", d, w, label=label)
def D(key, pos, d, w, label):
return Player(key, pos, "def", d, w, label=label)
def line5():
return [q for q in named_line(offense("gun_2x2")) if q.key in OL_NAMES]
def gun(rb_w=-1.6, extra=()):
"""Shotgun box: five linemen, the quarterback 5 yards deep and a back beside him (receivers left out)."""
return line5() + [O("QB", "QB", -5.0, 0.0, "Q"), O("RB", "RB", -5.0, rb_w, "R"), *extra]
def flexbone():
"""The flexbone of 02-03: B-back behind the QB, two A-backs (slotbacks) off the line, X and Z receivers on it.
The QB is drawn at gridiron's under-center depth (a little off the center, for legibility)."""
return line5() + [O("QB", "QB", -1.9, 0.0, "Q"), O("FB", "FB", -4.6, 0.0, "B"),
O("LA", "RB", -1.6, -5.2, "A"), O("RA", "RB", -1.6, 5.2, "A"),
O("LW", "WR", -0.7, -12.0, "X"), O("RW", "WR", -0.7, 12.0, "Z")]
def check_legal(players):
"""Seven offensive players on the line, and only the two at its ends may be eligible 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.key in OL_NAMES, f"{q.key} is covered up but not a lineman"
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, "pull": p.pull}[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=7.5,
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:
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 pitch_arc(fld, p, a, b, color=style.BALL):
"""The pitch: a dashed brown arc 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="arc3,rad=0.25"))
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 say(fld, p, d, w, text, color=style.INK_2, fontsize=7.5, ha="center", weight="normal"):
"""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, fontweight=weight,
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, 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="center", va="center", zorder=9,
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 tackle_x(fld, p, d, w, color=style.DEFENSE_DARK):
x, y = _P(fld, p, d, w)
fld.ax.plot([x], [y], marker="X", ms=10, color=color, mec="white", mew=0.8, zorder=12)
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:
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 pdf_build():
return os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
# ---------------------------------------------------------------- frame strips and videos
MARK_R = 0.6 # frame-strip marker radius in yards (smaller than static diagrams, so engaged players stay apart)
def tracking(play):
df = play.to_tracking()
df["_id"] = df["displayName"].astype(str)
return df
def carrier(play, t):
"""Who holds the ball at time t (QB until the first handoff or pitch arrives)."""
holder = "QB"
for kind, to, te in sorted(play.ball_events, key=lambda e: e[2]):
if t >= te + (0.25 if kind == "pitch" else 0.0):
holder = to
return holder
def panel(ax, play, df, t, lateral, window, rings=()):
"""One moment of a simulated play: markers, trails, the ball, rings (and a ring on the ball carrier)."""
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"]):
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, y + 0.5), 0.75, 0.45, 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.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)
for key, col in list(rings) + [(carrier(play, t), style.THIRD)]:
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, ncols=None):
"""Frame strip: one row per play (an option's branches), or one play wrapped over rows."""
dfs = [tracking(p) for p in plays]
if len(plays) > 1:
cells = [(i, t) for i in range(len(plays)) for t in times]
ncols = len(times)
else:
cells = [(0, t) for t in times]
ncols = ncols or min(3, len(times))
nrows = int(np.ceil(len(cells) / 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.55 if notes else 0.25))))
for ax in axes.flat[len(cells):]:
ax.axis("off")
for k, (ax, (i, t)) in enumerate(zip(axes.flat, cells)):
panel(ax, plays[i], dfs[i], t, lateral, window, rings)
head = f"{k % len(times) + 1 if len(plays) > 1 else k + 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_2 if not (rows and k % ncols == 0) else 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.2, color=style.INK)
fig.tight_layout(h_pad=1.4 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 same panels drawn at every tenth of a second, side by side for branches."""
import shutil
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(t0, t1 + 1e-9, 0.1), 2)
n = len(plays)
span_w, span_d = lateral[1] - lateral[0], window[1] - window[0]
pw = (width_in if n == 1 else width_in / n) * (0.85 if n == 1 else 1.0)
fig, axes = plt.subplots(1, n, squeeze=False, figsize=(pw * n, pw * span_d / span_w + 0.6))
axes = 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:
per = len(times)
mine = notes[i * per:(i + 1) * per] if n > 1 else notes
cur = [m for tt, m in zip(times, mine) if tt <= t + 1e-6]
txt += ("\n" + cur[-1].replace("\n", " ")) if cur else ""
head = (rows[i] + ": ") if rows else ""
ax.set_title(head + 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 option_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)
# ---------------------------------------------------------------- the generic even front of 03-02
def front7(sam=True):
"""5-technique and 1-technique to the offense's right, 3 and 5 to its left; Sam, Mike, Will."""
dl = [D("PE", "DE", 1.0, technique("5", 1), "5"), D("PT", "DT", 1.0, technique("1", 1), "1"),
D("BT", "DT", 1.0, technique("3", -1), "3"), D("BE", "DE", 1.0, technique("5", -1), "5")]
lbs = [D("MIKE", "MIKE", 4.5, 1.6, "M"), D("WILL", "WILL", 4.5, -2.6, "W")]
if sam:
lbs.append(D("SAM", "SAM", 4.0, 6.6, "S"))
return dl + lbs