import os
import re
import logging
import shutil
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib import animation
from matplotlib.patches import Ellipse, FancyArrowPatch, Rectangle, Patch
from matplotlib.lines import Line2D
from gridiron import Play, Player, Field, style, draw_marker
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
READ_GREEN = "#0f7a55" # darker step of the aqua highlight, for text and read lines
OD, DD = style.OFFENSE_DARK, style.DEFENSE_DARK
LINE_D, OFF_BALL, GUN, UC = -0.7, -1.5, -5.0, -1.7
FW = 160 / 3
def opp(n):
"""Line of scrimmage (BDB x) for a ball on the opponent's n-yard line. The goal line is x = 110."""
return 110 - n
# ---------------------------------------------------------------- players and alignments
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):
return Player(key, pos, "def", d, w, label=label)
def line5(split=1.6, names=False, d=LINE_D):
"""Five offensive linemen; only the center is lettered unless names=True."""
out = []
for k, m in (("LT", -2), ("LG", -1), ("C", 0), ("RG", 1), ("RT", 2)):
out.append(O(k, k, d, m * split, k if names else ("C" if k == "C" else "")))
return out
def spread(rb_w=-1.5, x_w=-15.0, h_w=-9.0, y_w=9.0, z_w=15.0, y_pos="TE"):
"""11 personnel, shotgun 2x2: X and Z on the line (seven on the line with the five linemen),
H and Y a step off the ball in the slots."""
return line5() + [O("QB", "QB", GUN, 0.0, "Q"), O("RB", "RB", GUN, rb_w, "R"),
O("X", "WR", LINE_D, x_w), O("H", "WR", OFF_BALL, h_w),
O("Y", y_pos, OFF_BALL, y_w), O("Z", "WR", LINE_D, z_w)]
def dline(w=(-5.4, -0.8, 2.4, 5.4), d=1.0):
return [D("WDE", "DE", d, w[0], "E"), D("WDT", "DT", d, w[1], "T"),
D("SDT", "DT", d, w[2], "T"), D("SDE", "DE", d, w[3], "E")]
def moved(players, extra=None):
extra = extra or {}
return [q.moved(d=extra[q.key][0], w=extra[q.key][1]) if q.key in extra else q for q in players]
def route_to(p, key, *pts, delay=0.0, speed=None):
"""A route through absolute (d, w) points."""
start = np.array(p._cur(key))
return p.route(key, [tuple(np.array(q) - start) for q in pts], frame="field", delay=delay, speed=speed)
def move_to(p, key, *pts, kind="path", delay=0.0, speed=None, target=None):
"""Any other movement through absolute (d, w) points: path, run, block or pull."""
start = np.array(p._cur(key))
rel = [tuple(np.array(q) - start) for q in pts]
if kind == "block":
return p.block(key, pts=rel, delay=delay, speed=speed)
if kind == "pull":
return p.pull(key, rel, target=target, delay=delay, speed=speed)
if kind == "rush":
return p.rush(key, pts=rel, delay=delay, speed=speed)
return {"path": p.path, "run": p.run}[kind](key, rel, delay=delay, speed=speed)
# ---------------------------------------------------------------- drawing helpers
def P(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def clean(ax, numbers=False):
"""Drop the painted yard numbers unless asked (they sit where receivers stand); clip all text."""
for t in list(ax.texts):
if not numbers and t.get_text().isdigit() and t.get_alpha() == 0.55:
t.remove()
else:
t.set_clip_on(True)
def draw(p, width_in=6.0, ax=None, numbers=False, **kw):
fld = p.draw(width_in=width_in, ax=ax, title=False, **kw)
clean(fld.ax, numbers)
return fld
def say(fld, p, d, w, text, color=style.INK_2, fontsize=7.0, ha="center", weight="normal", box=True):
x, y = P(fld, p, d, w)
fld.ax.text(float(x), float(y), text, fontsize=fontsize, color=color, ha=ha, va="center", zorder=13,
fontweight=weight, linespacing=1.15,
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.9) if box else None)
def point_to(fld, p, text_at, target, text, color=style.INK_2, fontsize=7.0, ha="center"):
(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=ha, va="center", zorder=13, linespacing=1.15,
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.9))
def branch(fld, p, pts, color=style.DEFENSE, lw=1.5, dash=(0, (3, 2.2)), end="arrow", z=5):
"""A path through absolute (d, w) points: by default a defender's reaction (orange dashes)."""
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, ys, color=color, lw=lw, ls=dash, zorder=z, solid_capstyle="round")
if end == "arrow":
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>",
mutation_scale=10, color=color, lw=lw, zorder=z))
def throw(fld, p, a, b, rad=-0.12):
"""The ball's flight from spot a to spot b (brown dashes)."""
(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=style.BALL, lw=1.4, ls=(0, (2, 2)), zorder=6,
connectionstyle=f"arc3,rad={rad}"))
def oval(fld, p, center, size, color=style.DEFENSE, label=None, alpha=0.13, fs=7.0, ls=(0, (3, 2))):
"""A shaded area (depth, width) with an optional italic label at its centre."""
x, y = P(fld, p, *center)
fld.ax.add_patch(Ellipse((float(x), float(y)), size[1], size[0], fc=color, alpha=alpha, ec=color, lw=0.9,
ls=ls, zorder=1.6))
if label:
fld.ax.text(float(x), float(y), label, ha="center", va="center", fontsize=fs, color=style.INK_2,
style="italic", zorder=3)
def vdim(fld, p, w, d0, d1, text, fs=6.5, side=1, color=style.INK_2):
"""A vertical dimension line at width w from depth d0 to d1, labelled beside it."""
(x0, y0), (x1, y1) = P(fld, p, d0, w), P(fld, p, d1, w)
fld.ax.annotate("", (float(x1), float(y1)), xytext=(float(x0), float(y0)),
arrowprops=dict(arrowstyle="<->", color=color, lw=0.9), zorder=6)
say(fld, p, (d0 + d1) / 2, w + side * 2.6, text, fontsize=fs, color=color)
def goal_marks(fld, p, w, fs=6.3, end=True):
"""Small 'goal line' / 'end line' tags at width w (only where the window shows them)."""
gl = 110 - p.los
say(fld, p, gl + 0.55, w, "goal line", fontsize=fs, color=style.MUTED, box=False)
if end and p.los + gl + 9.5 <= fld.x1:
say(fld, p, gl + 8.4, w, "end line", fontsize=fs, color=style.MUTED, box=False)
def panel_title(ax, text, fs=8.6):
ax.set_title(text, fontsize=fs, loc="left", color=style.INK, fontweight="bold")
def under_note(ax, text, fs=6.9):
ax.text(0.5, -0.02, text, transform=ax.transAxes, ha="center", va="top", fontsize=fs, color=style.INK,
linespacing=1.2)
LEGEND = {
"route": Line2D([], [], color=style.OFFENSE, lw=1.8, label="route"),
"carrier": Line2D([], [], color=style.OFFENSE_DARK, lw=2.6, label="ball carrier"),
"block": Line2D([], [], color=style.OFFENSE_DARK, lw=1.8, marker="|", ms=7, label="block"),
"man": Line2D([], [], color=style.DEFENSE, lw=0.9, ls=(0, (1, 2)), label="man assignment"),
"react": Line2D([], [], color=style.DEFENSE, lw=1.4, ls=(0, (3, 2.2)), label="defender's path"),
"rush": Line2D([], [], color=style.DEFENSE, lw=1.8, label="rusher"),
"ring": Line2D([], [], marker="o", ls="", ms=11, mfc="none", mec=style.THIRD, mew=2, label="player to watch"),
"area": Patch(fc=style.DEFENSE, alpha=0.2, ec=style.DEFENSE, label="zone a defender guards"),
"ltg": Line2D([], [], color=style.LINE_TO_GAIN, lw=2.2, label="line to gain"),
"pass": Line2D([], [], color=style.BALL, lw=1.3, ls=(0, (2, 2)), label="the throw"),
"option": Line2D([], [], color=style.THIRD, lw=1.6, ls=(0, (3, 2.2)), label="option / second choice"),
}
def field_legend(ax, keys, fs=7.0, ncol=None, y=0.0):
hs = [LEGEND[k] for k in keys]
ax.legend(handles=hs, loc="upper center", bbox_to_anchor=(0.5, y), ncol=ncol or min(len(hs), 4),
fontsize=fs, frameon=False, handlelength=2.0, columnspacing=1.2, borderaxespad=0.2)
# ---------------------------------------------------------------- motion: keyframes, frame strips, video
def set_tracks(p, tracks, t_end=None):
"""Hand-made keyframes {key: [(t, d, w), ...]} for some players, after all routes are added.
gridiron's man() ends on top of the receiver and cannot be slowed by traffic, so defenders
who get rubbed, and every body in a pile, are keyframed by hand."""
kf, t0, t1 = p._compile()
kf = dict(kf)
for key, rows in tracks.items():
q = p.players[key]
rows = [(min(t0, -1.0), q.d, q.w), (0.0, q.d, q.w)] + [tuple(r_) for r_ in rows]
kf[key] = np.array(rows, dtype=float)
p._compiled = (kf, t0, max(t1, t_end or 0.0, *[rows[-1][0] for rows in tracks.values()]))
def ease(points, step=0.05):
"""Smooth keyframes through (t, d, w) waypoints (ease in and out on each leg)."""
rows = []
for (ta, da, wa), (tb, db, wb) in zip(points[:-1], points[1:]):
for tt in np.arange(ta, tb, step):
f = (tt - ta) / (tb - ta)
f = f * f * (3 - 2 * f)
rows.append((round(float(tt), 3), da + (db - da) * f, wa + (wb - wa) * f))
rows.append(tuple(points[-1]))
return rows
def frame(ax, p, t, lateral, window, rings=(), ltg=True, numbers=False, mr=0.75):
"""Everyone at time t, with faint trails of where they have been and the ball's flight so far."""
ylo, yhi = sorted((p.ball_y - lateral[0], p.ball_y - lateral[1]))
fld = Field(ax=ax, orient="vertical", los=p.los, window=window, numbers=numbers, lateral=(ylo, yhi),
to_go=p.to_go if ltg else None)
ppy = fld.pts_per_yard()
wl, wr = sorted(lateral)
ring = dict(rings)
for k, q in p.players.items():
d, w = p.position(k, t)
if not (window[0] + 0.4 < d < window[1] - 0.4 and wl + 0.4 < w < wr - 0.4):
continue
if t > 0:
pts = np.array([p.position(k, tt) for tt in np.linspace(0.0, t, 40)])
if np.ptp(pts[:, 0]) + np.ptp(pts[:, 1]) > 0.3:
xs, ys = P(fld, p, pts[:, 0], pts[:, 1])
ax.plot(xs, ys, color=style.OFFENSE if q.side == "off" else style.DEFENSE, lw=1.0,
alpha=0.5, zorder=3)
x, y = P(fld, p, d, w)
draw_marker(ax, float(x), float(y), q.side, q.label, ppy, ring=ring.get(k), r=mr)
for kind, to, te in p.ball_events:
if kind == "pass" and t > te:
ts = np.linspace(te, t, 20)
b = np.array([p.ball_position(tt) for tt in ts])
xs, ys = P(fld, p, b[:, 0], b[:, 1])
ax.plot(xs, ys, color=style.BALL, lw=1.4, ls=(0, (2, 1.5)), zorder=9)
ax.add_patch(Ellipse((float(xs[-1]) - 0.9, float(ys[-1])), 1.0, 0.62, fc=style.BALL, ec="white",
lw=0.5, zorder=11))
clean(ax, numbers)
return fld
def strip_grid(p, times, notes, lateral, window, rings=(), ncols=2, width_in=6.5, deco=None, fs=6.9, ltg=True,
mr=0.75):
"""Print stand-in for an animation: numbered panels, each with a sentence underneath."""
nrows = int(np.ceil(len(times) / ncols))
pw = width_in / ncols
ph = pw * (window[1] - window[0]) / abs(lateral[1] - lateral[0])
fig, axes = plt.subplots(nrows, ncols, squeeze=False, figsize=(width_in, nrows * (ph + 0.8)))
for ax in axes.flat[len(times):]:
ax.axis("off")
for i, (ax, t) in enumerate(zip(axes.flat, times)):
fld = frame(ax, p, t, lateral, window, rings, ltg=ltg, mr=mr)
if deco:
deco(t, fld, p)
lab = "before the snap" if t < -1e-6 else ("snap" if abs(t) < 1e-6 else f"+{t:.1f} s")
panel_title(ax, f"{i + 1}. {lab}")
under_note(ax, notes[i], fs=fs)
fig.tight_layout(h_pad=3.2, w_pad=0.8)
return fig
def video(p, lateral, window, t_end, rings=(), width_in=5.6, t_start=-0.4, notes=None, times=None, ltg=True,
mr=0.75, deco=None):
"""Web version of a strip: the same frame drawn every tenth of a second, with the strip's
notes as a running caption."""
from IPython.display import HTML, display
span_w, span_d = abs(lateral[1] - lateral[0]), window[1] - window[0]
fig, ax = plt.subplots(figsize=(width_in, width_in * span_d / span_w + 0.5))
ts = np.round(np.arange(t_start, t_end + 1e-9, 0.1), 2)
def update(i):
ax.clear()
t = float(ts[i])
fld = frame(ax, p, max(0.0, t), lateral, window, rings, ltg=ltg, mr=mr)
if deco:
deco(max(0.0, t), fld, p)
txt = "before the snap" if t < -1e-6 else ("snap" if abs(t) < 1e-6 else f"+{t:.1f} s")
if notes and times:
cur = [n for tt, n in zip(times, notes) if tt <= t + 1e-6]
if cur:
txt += " " + cur[-1].replace("\n", " ")
ax.set_title(txt, fontsize=8, loc="left", color=style.INK)
return []
fig.tight_layout()
anim = animation.FuncAnimation(fig, update, frames=len(ts), interval=100, blit=False)
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 pct(x, nd=0):
return f"{100 * x:.{nd}f}%"