import logging
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, Rectangle, 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 pistol(extra=(), te=("Y", "U")):
"""The pistol box: five linemen, the quarterback 4 yards deep, the back 7 yards deep directly behind him,
and inline tight ends (Y on the right, U on the left). Receivers are left out unless passed in `extra`."""
tes = []
if "Y" in te:
tes.append(O("Y", "TE", -0.7, 4.8, "Y"))
if "U" in te:
tes.append(O("U", "TE", -0.7, -4.8, "U"))
return line5() + [O("QB", "QB", -4.0, 0.0, "Q"), O("RB", "RB", -7.0, 0.0, "R"), *tes, *extra]
def front62(fs=False):
"""Eight in the box against two tight ends: a 9-technique outside each tight end, a 5 on each tackle,
a 1 and a 3 inside, and two linebackers (a 6-2 look). Optional free safety in the deep middle."""
dfn = [D("BE", "OLB", 1.0, -6.3, "9"), D("B5", "DE", 1.0, -4.0, "5"), D("DT1", "DT", 1.0, -0.7, "1"),
D("DT3", "DT", 1.0, 2.4, "3"), D("P5", "DE", 1.0, 4.0, "5"), D("PE", "OLB", 1.0, 6.3, "9"),
D("MIKE", "MIKE", 4.5, 1.8, "M"), D("WILL", "WILL", 4.5, -2.0, "W")]
if fs:
dfn.append(D("FS", "FS", 12.5, 0.0, "FS"))
return dfn
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, run or pull."""
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)), zorder=6):
"""A dashed 'option' path through absolute (d, w) points, with an arrowhead."""
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[:-1], ys[:-1], color=color, lw=lw, ls=dash, zorder=zorder, 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=zorder))
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 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, weight="normal"):
"""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, fontweight=weight,
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 box_shade(fld, p, d0, d1, w0, w1, color=GREEN):
"""Shade the box (alignment coordinates) to count defenders against blockers."""
(x0, y0), (x1, y1) = _P(fld, p, d0, w0), _P(fld, p, d1, w1)
fld.ax.add_patch(Rectangle((min(x0, x1), min(y0, y1)), abs(x1 - x0), abs(y1 - y0), fc=color, alpha=0.07,
ec=color, lw=0.8, ls=(0, (3, 2)), zorder=1))
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 (as in 03-04)
MARK_R = 0.6 # frame-strip marker radius in yards
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 green 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(play, times, lateral, window, notes=None, rings=(), width_in=6.5, ncols=2):
"""Frame strip of one simulated play, wrapped over rows, with a caption line under each panel."""
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 k, (ax, t) in enumerate(zip(axes.flat, times)):
panel(ax, play, df, t, lateral, window, rings)
ax.set_title(f"{k + 1}. " + ("snap" if abs(t) < 1e-6 else f"{t:+.1f} s"), fontsize=8,
color=style.INK_2, loc="left")
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(play, times, lateral, window, notes=None, rings=(), width_in=5.4):
"""Web version: the same panel drawn every tenth of a second, with the current step's note."""
import shutil
from matplotlib import animation
from IPython.display import HTML, display
df = tracking(play)
grid = np.round(np.arange(float(df["time"].min()), float(df["time"].max()) + 1e-9, 0.1), 2)
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.6))
def update(t):
ax.clear()
panel(ax, play, df, t, lateral, window, rings)
txt = "snap" if abs(t) < 0.05 else f"{t:+.1f} s"
if notes:
cur = [m for tt, m in zip(times, notes) if tt <= t + 1e-6]
txt += ("\n" + cur[-1].replace("\n", " ")) if cur else ""
ax.set_title(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 play_animation(play, times, lateral, window, notes=None, rings=(), **kw):
"""Video on the website; a numbered, captioned frame strip in the PDF."""
if pdf_build():
strip(play, times, lateral, window, notes=notes, rings=rings, **kw)
plt.show()
else:
video(play, times, lateral, window, notes=notes, rings=rings)