import os
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, Rectangle, RegularPolygon
from IPython.display import HTML, Markdown, display
from gridiron import Play, Player, Field, technique, style, FIELD_WIDTH
style.apply()
MINUS = "−"
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
MID = FIELD_WIDTH / 2
def pdf_build():
return os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
def pct(v, nd=0):
return f"{100 * v:.{nd}f}%"
def signed(v, nd=2):
"""+1.23 / −0.45 with a real minus sign."""
return f"{v:+.{nd}f}".replace("-", MINUS)
def ordinal(n):
n = int(n)
suf = "th" if 10 <= n % 100 <= 20 else {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
return f"{n}{suf}"
def pts(v, nd=0):
"""A difference in percentage points: 0.134 -> '13'."""
return f"{100 * v:.{nd}f}"
# ---- field zones, in yards from the opponent's goal line (nflverse `yardline_100`) ----------------
# backed up: own 1-10 | coming out: own 11-25 | open field: own 26 to opp 41 |
# four-down territory: opp 40-21 | red zone: opp 20 to goal
ZONES = [("backed up", 90, 100), ("coming out", 75, 90), ("open field", 40, 75),
("four-down\nterritory", 20, 40), ("red zone", 0, 20)]
ZONE_COL = {"backed up": style.DEFENSE, "coming out": style.LINE_TO_GAIN, "open field": style.OFFENSE,
"four-down\nterritory": style.THIRD, "red zone": style.DEFENSE_DARK}
def zone_of(y100):
for name, lo, hi in ZONES:
if lo < y100 <= hi:
return name.replace("\n", " ")
return None
def shade_zones(ax, alpha=0.07, labels=True, y=None, fs=6.6):
"""Faint zone bands on a chart whose x axis is BDB x (own goal line 10, opponent's goal line 110)."""
for name, lo, hi in ZONES:
x0, x1 = 110 - hi, 110 - lo
ax.axvspan(x0, x1, color=ZONE_COL[name], alpha=alpha, lw=0, zorder=0)
if labels:
ax.text((x0 + x1) / 2, y, name.upper().replace("\n", " ") if hi - lo > 18 else name.upper(),
ha="center", va="top", fontsize=fs, color=style.INK_2, fontweight="bold", linespacing=1.0,
zorder=1)
def yardline_axis(ax, fs=7):
ax.set_xlim(10, 110)
ax.set_xticks([10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110],
["goal", "10", "20", "30", "40", "50", "40", "30", "20", "10", "goal"], fontsize=fs)
# ---- players and on-field labels (book conventions: offense circles, defense squares) -----------
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():
"""Five offensive linemen; only the center labelled."""
return [O(k, k, -0.7, w, "C" if k == "C" else "") for k, w in
zip(["LT", "LG", "C", "RG", "RT"], (-3.2, -1.6, 0.0, 1.6, 3.2))]
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=6.8, ha="center", weight="normal"):
"""A short on-field label at (d, w), on a light patch so lines don't run through it."""
x, y = _P(fld, p, d, w)
fld.ax.text(float(x), float(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.6):
"""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 clean(ax):
"""Zoomed diagrams: drop the tiny hash ticks and painted yard numbers, 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 end_zone_marks(fld, p, w_text, goal_d, label_goal=True):
"""Label the goal line and end line on a backed-up diagram (goal_d = goal line's depth from the LOS)."""
say(fld, p, goal_d - 5.0, w_text, "OWN\nEND ZONE", color=style.INK_2, fontsize=6.4, weight="bold")
if label_goal:
say(fld, p, goal_d + 0.0, -w_text, "goal line", fontsize=6.0)
# ---- frame strips (print) and videos (web) for simulated plays --------------------------------
MARK_R = 0.95 # frame-strip marker radius in yards
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":
# a carried ball sits beside its carrier so it never covers his letter
near = cur[cur["side"] != "ball"]
held = (np.hypot(near["x"] - rr["x"], near["y"] - rr["y"]) < 1.2).any()
bx, by = (x + MARK_R * 1.25, y + MARK_R * 0.55) if held else (x, y)
ax.add_patch(Ellipse((bx, by), 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 * (1.1 if len(lab) <= 1 else 0.85)))
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)), MARK_R * 1.55, fc="none", ec=col, lw=1.7, zorder=7))
# the throw: a dashed brown line from where it was released to where the ball is now
for kind, _, te in play.ball_events:
if kind == "pass" and t > te:
(d0, w0) = play.ball_position(te)
(d1, w1) = play.ball_position(t)
xs, ys = fld.xy(*play.to_abs(np.array([d0, d1]), np.array([w0, w1])))
ax.plot(xs, ys, color=style.BALL, lw=1.3, ls=(0, (2.5, 1.8)), zorder=6)
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.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.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
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.0, 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)