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, Rectangle, RegularPolygon, Patch
from gridiron import Play, Player, Field, technique, style, routes as r
PURPLE = style.SERIES[6] # rings: the defenders a game plan moved
RED = style.SERIES[7] # rings and badges: the matchup being hunted, or the bill for a choice
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 trips_y():
"""11 personnel, shotgun 3x1: the tight end (Y) detached as the inside receiver of three on the right.
Seven on the line: the five linemen, X and Z. Y and H are off the ball, R beside Q."""
return line() + [O("QB", "QB", -5.0, 0.0, "Q"), O("RB", "RB", -5.0, -1.6, "R"),
O("X", "WR", -0.7, -15.0, "X"), O("Y", "TE", -1.5, 6.5, "Y"),
O("H", "WR", -1.5, 11.0, "H"), O("Z", "WR", -0.7, 16.0, "Z")]
def dl_nickel():
"""Four down linemen against a formation with no attached tight end."""
return [D("LDE", "DE", 1.0, -5.0, "E"), D("LDT", "DT", 1.0, technique("1", -1), "T"),
D("RDT", "DT", 1.0, technique("3", 1), "T"), D("RDE", "DE", 1.0, 5.0, "E")]
def their_usual():
"""The opponent's usual answer to 3x1: nickel Cover 1. The Will (W) has the tight end, the third
receiver, in man; the Mike (M) has the back; the strong safety is the robber and the free safety is deep."""
return dl_nickel() + [D("WILL", "WILL", 4.5, 3.4, "W"), D("MIKE", "MIKE", 4.5, -0.6, "M"),
D("NB", "NB", 5.0, 11.0, "$"), D("SS", "SS", 9.0, 1.5, "SS"),
D("FS", "FS", 13.5, 1.0, "FS"), D("LCB", "CB", 6.0, -15.0, "CB"),
D("RCB", "CB", 6.0, 16.0, "CB")]
def game_plan_dime():
"""The game-plan answer: dime. A sixth defensive back (D) replaces the Will and takes the tight end
with outside leverage (the out-breaking routes and anything short); the strong safety sits inside
and over him at nine yards (the in-breaking routes and anything deep): an in/out bracket. The free
safety shades to the three-receiver side; the Mike takes the back. Nobody helps the cornerback on X."""
return dl_nickel() + [D("MIKE", "MIKE", 4.5, -0.6, "M"), D("DIME", "DB", 2.5, 7.3, "D"),
D("NB", "NB", 5.0, 11.0, "$"), D("SS", "SS", 9.0, 4.6, "SS"),
D("FS", "FS", 13.5, 7.0, "FS"), D("LCB", "CB", 6.5, -15.0, "CB"),
D("RCB", "CB", 6.0, 16.0, "CB")]
def go_to(p, key, end, kind="path", delay=0.0, speed=None, via=()):
"""Move `key` through absolute (d, w) points `via` to `end`, as a block, path, run or route."""
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 == "route":
return p.route(key, pts, frame="field", delay=delay, speed=speed)
if kind == "rush":
return p.rush(key, pts=pts, delay=delay, speed=speed)
return {"path": p.path, "run": p.run}[kind](key, pts, delay=delay, speed=speed)
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(ax, x, y, text, color=RED, fontsize=7.0, zorder=12):
"""A small numbered disc, in data coordinates, that ties a spot to a note elsewhere in the figure."""
ax.text(x, y, text, fontsize=fontsize, color="white", ha="center", va="center", zorder=zorder,
fontweight="bold", bbox=dict(boxstyle="circle,pad=0.18", fc=color, ec="white", lw=0.6))
def field_badge(fld, p, d, w, text, color=RED):
x, y = _P(fld, p, d, w)
badge(fld.ax, float(x), float(y), text, color=color)
def ring(fld, p, d, w, color, ls="-"):
x, y = _P(fld, p, d, w)
fld.ax.add_patch(Circle((float(x), float(y)), 1.15, fc="none", ec=color, lw=1.7, ls=ls, zorder=7))
def ghost(fld, p, pts, color=style.DEFENSE, lw=1.4, alpha=0.6, zorder=5):
"""A faint dashed arrow through absolute (d, w) points: what a player did (or will do) off the play."""
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 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 blank(ax):
ax.set_axis_off()
ax.grid(False)
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(t_after=1.6) # run past the last route so a late throw can land
df["_id"] = df["displayName"].astype(str)
return df
def panel(ax, play, df, t, lateral, window, rings=(), ball=1.0):
"""One moment of a simulated play: markers, trails of on-screen players, rings (ball = ball size factor)."""
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 * ball), 0.7 * ball, 0.42 * ball, 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, frame_rings=None):
"""Numbered, captioned frame strip: the PDF stand-in for the video.
frame_rings: optional {frame index: ((key, color), ...)} drawn on that frame only, on top of `rings`."""
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, tuple(rings) + tuple((frame_rings or {}).get(i, ())), ball=2.4)
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.6, 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, ball=1.5)
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, frame_rings=None):
"""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, frame_rings=frame_rings)
plt.show()
else:
video(play, times, notes, lateral, window, rings=rings)
def pct(x, nd=0):
return f"{100 * x:.{nd}f}%"