import os
import shutil
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib import animation
from matplotlib.lines import Line2D
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, Polygon
from IPython.display import HTML, display
from gridiron import Play, Player, Field, routes as r, style, draw_marker
from gridiron.formations import LINE_D, OFF_BALL, TE_W, WING_W, OL_SPLIT
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
UC, GUN, TB_D = -1.9, -5.0, -7.0 # QB under center, QB in the shotgun, deep tailback (yards)
TE_RING = style.LINE_TO_GAIN # gold ring: a tight end (as in the personnel chapter)
RB_RING = style.THIRD # aqua ring: a running back
SIDELINE = 26.67 # yards from the middle of the field to either sideline
def line5():
"""The five offensive linemen, unlettered. (In this chapter "C" always means a cornerback, so the
center, always the middle lineman, carries no letter.)"""
return [Player(k, k, "off", LINE_D, w, label="")
for k, w in [("LT", -2 * OL_SPLIT), ("LG", -OL_SPLIT), ("C", 0.0),
("RG", OL_SPLIT), ("RT", 2 * OL_SPLIT)]]
def P(key, pos, d, w, label=None):
"""An 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=label or key)
def D(key, pos, d, w, label=None):
return Player(key, pos, "def", d, w, label=label)
def check_legal(players):
"""Seven men on the line, and only the two at its ends may be eligible receivers."""
off = [p for p in players if p.side == "off"]
line = sorted([p for p in off if abs(p.d - LINE_D) < 0.05], key=lambda p: p.w)
assert len(off) == 11, f"{len(off)} offensive players"
assert len(line) == 7, f"{len(line)} on the line: {[p.key for p in line]}"
for p in line[1:-1]:
assert p.pos not in ("WR", "TE", "RB", "FB"), f"covered receiver {p.key}"
return players
def ring_bodies(p):
"""Ring the backs (aqua) and tight ends (gold), so the personnel stays countable."""
for k, pl in p.players.items():
if pl.side == "off" and pl.pos in ("RB", "FB"):
p.highlight(k, color=RB_RING)
elif pl.side == "off" and 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 the receivers stand in these zoomed views)."""
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 pxy(play, fld, d, w):
x, y = fld.xy(*play.to_abs(d, w))
return float(x), float(y)
def put(ax, play, fld, text, d, w, size=7.5, color=style.INK, ha="center", va="center", weight="normal",
boxed=True, italic=False):
"""A short on-field label at (d, w)."""
x, y = pxy(play, fld, d, w)
kw = dict(bbox=dict(boxstyle="round,pad=0.22", fc=style.SURFACE, ec=style.BASELINE, lw=0.5)) if boxed else {}
ax.text(x, y, text, fontsize=size, color=color, ha=ha, va=va, fontweight=weight, zorder=12,
style="italic" if italic else "normal", linespacing=1.1, **kw)
def shade(ax, play, fld, pts, color, alpha=0.12, ls="-", lw=0.8, z=1.5):
"""Shade a polygon given as (d, w) points."""
xy = [pxy(play, fld, d, w) for d, w in pts]
ax.add_patch(Polygon(xy, closed=True, fc=color, ec="none", alpha=alpha, zorder=z))
ax.add_patch(Polygon(xy, closed=True, fc="none", ec=color, alpha=min(1.0, alpha * 5), lw=lw, ls=ls, zorder=z))
BOX_W, BOX_D = 6.0, 7.5 # the drawn "box": about six yards either side of the ball, 7.5 deep
def shade_box(ax, play, fld):
shade(ax, play, fld, [(0.3, -BOX_W), (BOX_D, -BOX_W), (BOX_D, BOX_W), (0.3, BOX_W)], style.DEFENSE,
alpha=0.07, ls=(0, (3, 2)))
def box_count(play):
return sum(1 for p in play.players.values()
if p.side == "def" and abs(p.w) <= BOX_W and 0 < p.d <= BOX_D)
def branch(ax, play, fld, pts, color=style.OFFENSE, ls="-", lw=1.7, z=4):
"""A route-style arrow drawn by hand through absolute (d, w) points (used for 'either/or' branches)."""
xs, ys = zip(*[pxy(play, fld, d, w) for d, w in pts])
ax.plot(xs[:-1] + (xs[-1],), ys[:-1] + (ys[-1],), color=color, lw=lw, ls=ls, zorder=z,
solid_capstyle="round")
ax.add_patch(FancyArrowPatch((xs[-2] + 0.8 * (xs[-1] - xs[-2]), ys[-2] + 0.8 * (ys[-1] - ys[-2])),
(xs[-1], ys[-1]), arrowstyle="-|>", mutation_scale=11, color=color, lw=lw,
shrinkA=0, shrinkB=0, zorder=z))
def receiver_numbers(ax, play, fld, d_lab=1.45, size=7.0, at=None):
"""Write #1, #2, #3 ... above each receiver, counting from the sideline in on each side.
Backs in the backfield (within 3 yards of the ball, behind the line) are not counted. When two
receivers share a width (a stack), the one on the line counts first. `at` = {key: (d, w)} moves
a label (for clusters, where the default spots would collide)."""
at = at or {}
rec = [p for p in play.players.values() if p.side == "off" and p.pos in ("WR", "TE", "RB", "FB")
and not (abs(p.w) < 3.0 and p.d < -2.0)]
for sgn in (-1, 1):
side = sorted([p for p in rec if p.w * sgn > 0], key=lambda p: (-round(abs(p.w), 1), -p.d))
for i, p in enumerate(side, 1):
d, w = at.get(p.key, (d_lab, p.w))
put(ax, play, fld, f"#{i}", d, w, size=size, color=style.INK_2, boxed=False, weight="bold")
def grid(nrows, ncols, lateral, window, width=6.6, extra=0.38):
pw = width / ncols
ph = pw * (window[1] - window[0]) / (lateral[1] - lateral[0]) + extra
return plt.subplots(nrows, ncols, figsize=(width, nrows * ph), squeeze=False)
def title(ax, text, sub=None):
ax.set_title(text, loc="left", fontsize=9.5, fontweight="bold", color=style.INK, pad=3)
if sub:
ax.text(1.0, 1.0, sub, transform=ax.transAxes, ha="right", va="bottom", fontsize=7.5,
color=style.INK_2)
# --- One personnel grouping (11: one back, one tight end, three receivers), eight formations. ------------
# Every alignment below is legal: check_legal() confirms seven on the line with receivers only at the ends.
def form(name):
q, rb = P("QB", "QB", GUN, 0, "Q"), P("RB", "RB", GUN, -2.2, "R")
f = {
"2x2": [q, rb, P("X", "WR", LINE_D, -15.5), P("H", "WR", OFF_BALL, -8.5),
P("Y", "TE", OFF_BALL, 8.5), P("Z", "WR", LINE_D, 15.5)],
"3x1": [q, rb, P("X", "WR", LINE_D, -15.5), P("Y", "TE", OFF_BALL, 7.0),
P("H", "WR", OFF_BALL, 11.2), P("Z", "WR", LINE_D, 15.5)],
"trey": [q, rb, P("X", "WR", LINE_D, -15.5), P("Y", "TE", LINE_D, TE_W),
P("H", "WR", OFF_BALL, 10.0), P("Z", "WR", OFF_BALL, 15.5)],
"empty": [q, P("X", "WR", LINE_D, -15.5), P("H", "WR", OFF_BALL, -8.5),
P("RB", "RB", OFF_BALL, 6.0, "R"), P("Y", "TE", OFF_BALL, 10.5), P("Z", "WR", LINE_D, 15.5)],
"bunch": [q, rb, P("X", "WR", LINE_D, -15.5), P("H", "WR", LINE_D, 8.6),
P("Y", "TE", -2.2, 7.2), P("Z", "WR", -2.2, 10.0)],
"stack": [q, rb, P("X", "WR", LINE_D, -15.5), P("H", "WR", OFF_BALL, -8.5),
P("Z", "WR", LINE_D, 11.0), P("Y", "TE", -2.5, 11.0)],
"nub": [q, P("RB", "RB", GUN, -2.2, "R"), P("Y", "TE", LINE_D, -TE_W), P("H", "WR", OFF_BALL, 7.0),
P("Z", "WR", OFF_BALL, 11.2), P("X", "WR", LINE_D, 15.5)],
"quads": [q, P("X", "WR", LINE_D, -15.5), P("RB", "RB", OFF_BALL, 5.8, "R"),
P("Y", "TE", OFF_BALL, 9.1), P("H", "WR", OFF_BALL, 12.4), P("Z", "WR", LINE_D, 15.8)],
}[name]
return check_legal(line5() + [p.moved() for p in f])
PLATE_LAT, PLATE_WIN = (-17.6, 17.6), (-7.0, 3.0)
def plate(ax, name, head, sub, numbers=True, lateral=PLATE_LAT, window=PLATE_WIN, at=None):
p = ring_bodies(Play(form(name), None, los=40, to_go=None))
fld = p.draw(ax=ax, lateral=lateral, window=window, title=False)
hide_yard_numbers(ax)
if numbers:
receiver_numbers(ax, p, fld, at=at)
title(ax, head, sub)
return p, fld
def ring_legend(fig, y=0.0):
h = [Line2D([], [], marker="o", ls="", ms=8, markerfacecolor=style.OFFENSE, markeredgecolor=RB_RING,
markeredgewidth=2.0, label="running back (R)"),
Line2D([], [], marker="o", ls="", ms=8, markerfacecolor=style.OFFENSE, markeredgecolor=TE_RING,
markeredgewidth=2.0, label="tight end (Y)"),
Line2D([], [], marker="o", ls="", ms=8, markerfacecolor=style.OFFENSE, markeredgecolor="white",
label="wide receivers X, Z, H; Q = quarterback")]
fig.legend(handles=h, loc="lower center", bbox_to_anchor=(0.5, y), ncol=3, fontsize=7.5, frameon=False,
handletextpad=0.3, columnspacing=1.2)
# --- Frame strips (PDF) and videos (web) for the two animations. ------------------------------------------
MARK_R = 0.62
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, rings."""
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=True,
lateral=(ylo, yhi), to_go=play.to_go)
cur = df[df["frameId"] == fid]
shown = cur[cur["x"].between(play.los + window[0] + 0.5, play.los + window[1] - 0.5)
& cur["y"].between(ylo + 0.5, yhi - 0.5)]
trails = df[(df["frameId"] <= fid) & df["_id"].isin(set(shown["_id"])) & (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 key, col in rings:
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)), MARK_R * 1.65, fc="none", ec=col, lw=1.7, zorder=7))
for _, row in shown.iterrows():
x, y = (float(v) for v in fld.xy(row["x"], row["y"]))
if row["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
draw_marker(ax, x, y, row["side"], row["label"], ppy, r=MARK_R)
return fld
def strip(play, times, notes, lateral, window, rings=(), ncols=2, width_in=6.5):
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.55)))
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")
ax.text(0.5, -0.02, notes[i], transform=ax.transAxes, ha="center", va="top", fontsize=7.4,
color=style.INK, linespacing=1.15)
fig.tight_layout(h_pad=2.2, w_pad=0.8)
return fig
def video(play, times, notes, lateral, window, rings=(), width_in=5.6):
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.45))
tmap = df.groupby("frameId")["time"].first()
def update(fid):
ax.clear()
t = float(tmap[fid])
panel(ax, play, df, t, lateral, window, rings)
cur = [n for tt, n in zip(times, notes) if tt <= t + 1e-6]
txt = ("snap" if abs(t) < 0.05 else f"{t:+.1f} s") + ((" " + 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 animated(play, times, notes, lateral, window, rings=()):
"""Video on the website; a numbered, captioned frame strip in the PDF."""
if PDF:
strip(play, times, notes, lateral, window, rings)
plt.show()
else:
video(play, times, notes, lateral, window, rings)