import os
import re
import shutil
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, FancyBboxPatch, RegularPolygon
from gridiron import Play, Player, Field, technique, style, routes as r
PURPLE = style.SERIES[6] # a ring colour that is neither offense blue nor defense orange
RED = style.SERIES[7] # rings on the defender a play is built to punish
GREEN = "#0f7a55" # darker aqua, for text and boxes on the light page
def O(key, pos, d, w, label=None):
return Player(key, pos, "off", d, w, label=key if label is None else label)
def D(key, pos, d, w, label=None):
return Player(key, pos, "def", d, w, label=label)
def line(labels=False):
"""Five offensive linemen; unlabeled except the center unless labels=True."""
names = ["LT", "LG", "C", "RG", "RT"]
return [O(k, k, -0.7, w, k if (labels or k == "C") else "")
for k, w in zip(names, (-3.2, -1.6, 0.0, 1.6, 3.2))]
def offset_i(labels=False):
"""21 personnel, offset I, tight end right, receivers in reduced (tight) splits. Seven on the line:
five linemen, Y and X; Z is off the ball, and Q, F and R are backs."""
return line(labels) + [O("QB", "QB", -1.9, 0.0, "Q"), O("FB", "FB", -4.5, 1.5, "F"),
O("RB", "RB", -7.0, 0.0, "R"), O("Y", "TE", -0.7, 4.8, "Y"),
O("X", "WR", -0.7, -9.5, "X"), O("Z", "WR", -1.5, 9.5, "Z")]
def base_43():
"""A 4-3 over front with one deep safety (eight in the box) against the offset I."""
return [D("WDE", "DE", 1.0, technique("5", -1), "E"), D("WDT", "DT", 1.0, technique("1", -1), "T"),
D("SDT", "DT", 1.0, technique("3", 1), "T"), D("SDE", "DE", 1.0, technique("9", 1), "E"),
D("SAM", "SAM", 4.0, 5.2, "S"), D("MIKE", "MIKE", 4.6, 1.0, "M"), D("WILL", "WILL", 4.6, -2.6, "W"),
D("SS", "SS", 7.5, 7.0, "SS"), D("FS", "FS", 13.0, -0.5, "FS"),
D("LCB", "CB", 6.5, -9.5, "CB"), D("RCB", "CB", 6.5, 9.5, "CB")]
def spread():
"""11 personnel, 2x2 shotgun in tight splits: X and Z on the line with the five linemen; H and Y off the ball."""
return line() + [O("QB", "QB", -5.0, 0.0, "Q"), O("RB", "RB", -5.0, -2.0, "R"),
O("X", "WR", -0.7, -12.5, "X"), O("H", "WR", -1.5, -7.5, "H"),
O("Y", "TE", -1.5, 7.5, "Y"), O("Z", "WR", -0.7, 12.5, "Z")]
def mug_look():
"""Nickel pressure look: four linemen, both linebackers mugged in the A gaps, the nickel and strong safety
walked up as edge threats (eight possible rushers), one deep safety."""
return [D("LDE", "DE", 1.0, -4.6, "E"), D("LDT", "DT", 1.0, -2.6, "T"), D("RDT", "DT", 1.0, 2.6, "T"),
D("RDE", "DE", 1.0, 4.6, "E"), D("MIKE", "MIKE", 2.2, 0.85, "M"), D("WILL", "WILL", 2.2, -0.85, "W"),
D("NB", "NB", 3.0, -6.6, "$"), D("SS", "SS", 4.0, 6.6, "SS"), D("FS", "FS", 13.0, -1.0, "FS"),
D("LCB", "CB", 7.0, -12.5, "CB"), D("RCB", "CB", 7.0, 12.5, "CB")]
def threat(fld, p, *keys):
"""Dashed brown rings: defenders threatening to blitz before the snap (not the play's target)."""
for k in keys:
x, y = _P(fld, p, p.players[k].d, p.players[k].w)
fld.ax.add_patch(Circle((float(x), float(y)), 1.25, fc="none", ec=style.BALL, lw=1.5, ls=(0, (2.2, 1.4)),
zorder=7))
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, route 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)
if kind == "pull":
return p.pull(key, pts=pts, delay=delay, speed=speed)
if kind == "route":
return p.route(key, pts, frame="field", delay=delay, speed=speed)
return {"path": p.path, "run": p.run}[kind](key, pts, delay=delay, speed=speed)
def oz_first_second(p, fake=False):
"""The shared first second of the outside-zone family: every lineman steps right, Q opens right
(and on a fake the back runs the outside-zone track without the ball)."""
for k in ("LT", "LG", "C", "RG", "RT"):
p.block(k, pts=[(0.2, 1.4)])
go_to(p, "QB", (-3.3, 1.7), "path", speed=3.0)
if fake:
go_to(p, "RB", (-1.2, 4.6), "path", via=[(-5.6, 1.6), (-2.6, 3.8)])
def _P(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def say(fld, p, d, w, text, color=style.INK_2, fontsize=7.0, 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=11, fontweight=weight,
linespacing=1.1, bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.92))
def point_to(fld, p, text_at, target, text, color=style.INK_2, fontsize=6.8):
"""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=11, linespacing=1.1,
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.92))
def badge(fld, p, d, w, text, color=RED, fontsize=7.0):
"""A small numbered disc that ties a defender to a box elsewhere in the figure."""
x, y = _P(fld, p, d, w)
fld.ax.text(x, y, text, fontsize=fontsize, color="white", ha="center", va="center", zorder=12,
fontweight="bold", bbox=dict(boxstyle="circle,pad=0.18", fc=color, ec="white", lw=0.6))
def ghost(fld, p, pts, color=style.DEFENSE, lw=1.4, alpha=0.55, zorder=5):
"""A faint dashed arrow through absolute (d, w) points: what a player did on earlier snaps."""
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=(0, (3, 2)), alpha=alpha, zorder=zorder)
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>", mutation_scale=10,
color=color, lw=lw, alpha=alpha, ls=(0, (3, 2)), zorder=zorder))
def was_here(fld, p, d, w, to=None):
"""A faint dashed outline where a defender started, and (optionally) a dashed arrow to where he is now."""
x0, y0 = _P(fld, p, d, w)
fld.ax.add_patch(RegularPolygon((float(x0), float(y0)), 4, radius=0.95, orientation=np.pi / 4, fc="none",
ec=style.DEFENSE, lw=0.9, ls=(0, (2, 1.5)), alpha=0.8, zorder=6))
if to is not None:
ghost(fld, p, [(d, w), to], alpha=0.9, lw=1.5)
def shifted(defense, **spots):
"""The defense with some players drawn where they have moved to: shifted(base_43(), WILL=(4.6, 0.2))."""
return [q.moved(d=spots[q.key][0], w=spots[q.key][1]) if q.key in spots else q for q in defense]
def clean(ax):
"""Zoomed diagrams: drop hash ticks and yard numbers, and clip text to 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, ax=None, width_in=5.8, **kw):
fld = p.draw(ax=ax, width_in=width_in, title=False, **kw)
clean(fld.ax)
return fld
def panel_title(ax, text, fontsize=8.6):
ax.set_title(text, fontsize=fontsize, loc="left", color=style.INK, fontweight="bold", pad=3)
def pdf_build():
return os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
# ---- frame strips (print) and videos (web) for simulated plays
MARK_R = 0.6 # frame-strip marker radius in yards, smaller than the static diagrams'
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."""
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")]
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 _, rr in shown.iterrows():
x, y = (float(v) for v in fld.xy(rr["x"], rr["y"]))
if rr["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(rr.get("label", "") or "")
if rr["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 rings:
rr = cur[cur["displayName"] == key].iloc[0]
x, y = fld.xy(rr["x"], rr["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, notes, lateral, window, ncols=2, rings=(), width_in=6.4):
"""Numbered, captioned frame strip: the PDF stand-in for the video."""
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.5)))
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.03, notes[i], transform=ax.transAxes, ha="center", va="top", fontsize=7.5,
color=style.INK, linespacing=1.15)
fig.tight_layout(h_pad=1.4, w_pad=0.6)
return fig
def video(play, times, notes, lateral, window, rings=(), width_in=5.6):
"""Web version of the strip: every frame, with the strip's notes as running captions."""
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)
cur = [n.replace("\n", " ") for tt, n in zip(times, notes) if tt <= t + 1e-6]
ax.set_title(("snap" if abs(t) < 0.05 else f"{t:+.1f} s") + ((" " + cur[-1]) if cur else ""),
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 animation_or_strip(play, times, notes, lateral, window, rings=(), ncols=2):
"""Video on the website; a numbered, captioned frame strip in the PDF."""
if pdf_build():
strip(play, times, notes, lateral, window, ncols=ncols, rings=rings)
plt.show()
else:
video(play, times, notes, lateral, window, rings=rings)