import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, Rectangle, Patch, Circle
from matplotlib.lines import Line2D
from gridiron import Play, Player, Field, offense, defense, style, show_animation, draw_marker
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
GREEN = "#0f7a55" # darker step of the aqua highlight, for text on the light field
# The book's marker tags (introduced in this chapter). One rule: no tag means two things on the same
# side of the ball. (Later chapters tag a tailback T in two-back sets: a blue circle, never an orange square.)
OFF_TAGS = {"QB": "Q", "FB": "F", "RB": "R", "Y": "Y", "U": "U", "X": "X", "Z": "Z", "H": "H",
"LT": "LT", "LG": "LG", "C": "C", "RG": "RG", "RT": "RT"}
DEF_TAGS = {"SDE": "E", "WDE": "E", "SDT": "T", "WDT": "T", "NT": "N", "SAM": "S", "MIKE": "M",
"WILL": "W", "LCB": "CB", "RCB": "CB", "NB": "$", "DB": "D", "FS": "FS", "SS": "SS"}
def tagged(players):
"""Apply the book's marker tags to a list of players."""
out = []
for q in players:
table = OFF_TAGS if q.side == "off" else DEF_TAGS
out.append(q.moved(label=table.get(q.key, q.label)))
return out
def O(key, pos, d, w, label):
return Player(key, pos, "off", d, w, label=label)
def D(key, pos, d, w, label):
return Player(key, pos, "def", d, w, label=label)
def P_(fld, p, d, w):
"""(d, w) in the offense's frame -> plot coordinates of this drawn field."""
x, y = fld.xy(*p.to_abs(d, w))
return float(x), float(y)
def say(fld, p, d, w, text, color=style.INK_2, fontsize=6.6, 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=9,
fontweight=weight, linespacing=1.1,
bbox=dict(boxstyle="round,pad=0.15", fc=style.SURFACE, ec="none", alpha=0.88))
def lead(fld, p, frm, to, color=style.MUTED):
"""A thin leader line from a label at `frm` to a player at `to` (both (d, w))."""
(x0, y0), (x1, y1) = P_(fld, p, *frm), P_(fld, p, *to)
fld.ax.plot([x0, x1], [y0, y1], color=color, lw=0.7, zorder=7.5)
def bracket(fld, p, w, d0, d1, text, color=style.INK_2, fontsize=7):
"""A vertical bracket at width w spanning depths d0..d1, labelled beside it."""
(x0, y0), (x1, y1) = P_(fld, p, d0, w), P_(fld, p, d1, w)
fld.ax.plot([x0 - 0.6, x0, x1, x1 - 0.6], [y0, y0, y1, y1], color=color, lw=1.0, zorder=6)
fld.ax.text(x0 + 0.6, (y0 + y1) / 2, text, rotation=90, fontsize=fontsize, color=color,
ha="left", va="center", zorder=9, fontweight="bold")
def ghost(fld, p, d, w, side, label, alpha=0.5, ring=None):
"""A faded marker: where a player stands on a different snap."""
x, y = P_(fld, p, d, w)
for a in draw_marker(fld.ax, x, y, side, label, fld.pts_per_yard()):
a.set_alpha(alpha)
if ring:
fld.ax.add_patch(Circle((x, y), 0.75 * 1.55, fc="none", ec=ring, lw=1.6, ls=(0, (2, 1.5)), zorder=3.5))
def hide_numbers(fld):
"""Drop the painted yard numbers (they peek out from under wide receivers' markers)."""
for t in list(fld.ax.texts):
if t.get_text().isdigit() and t.get_alpha() == 0.55:
t.remove()
def box_outline(fld, p, w0, w1, d0, d1, label, color=style.INK_2):
"""A dashed rectangle in (d, w) with a small label above its top-left corner."""
(xa, ya), (xb, yb) = P_(fld, p, d0, w0), P_(fld, p, d1, w1)
fld.ax.add_patch(Rectangle((min(xa, xb), min(ya, yb)), abs(xb - xa), abs(yb - ya), fc="none", ec=color,
lw=1.0, ls=(0, (4, 2.5)), zorder=2.5))
say(fld, p, d1 + 0.8, w0 + 0.2, label, color=color, ha="left", weight="bold")
def carried_ball(df):
"""Simulated tracking with the ball drawn beside its carrier, so his tag stays readable."""
df = df.copy()
for _, g in df.groupby("frameId"):
b, pl = g[g["side"] == "ball"], g[g["side"] != "ball"]
if b.empty or g["time"].iloc[0] <= 0.2:
continue
bx, by = float(b["x"].iloc[0]), float(b["y"].iloc[0])
dist = np.hypot(pl["x"] - bx, pl["y"] - by)
if dist.min() < 0.4: # someone is carrying it: tuck it beside him,
others = pl[dist >= 0.4] # on whichever side is clearest of other players
spots = [(bx - 1.05, by), (bx, by + 1.0), (bx, by - 1.0)]
clear = [np.hypot(others["x"] - x, others["y"] - y).min() for x, y in spots]
x, y = spots[int(np.argmax(clear))]
df.loc[b.index, ["x", "y"]] = [x, y]
return df
def zoom_strip(p, times, lat=(-12.5, 12.5), window=(-8.5, 13.5), ncols=2, width_in=6.5, labels=None, ring=None):
"""A frame strip cropped to the box. (gridiron's frame_strip fits all 22 players, which makes
run-game markers tiny.) `labels` = {time: [(d, w, text), ...]} adds a note to a panel;
`ring` = {key: color} circles a player in every panel."""
from gridiron import draw_frame
df = carried_ball(p.to_tracking())
df["_id"] = df["displayName"]
ts = df.groupby("frameId")["time"].first()
nrows = int(np.ceil(len(times) / ncols))
ylo, yhi = sorted((p.ball_y - lat[0], p.ball_y - lat[1]))
pw = width_in / ncols
fig, axes = plt.subplots(nrows, ncols, squeeze=False,
figsize=(width_in, nrows * (pw * (window[1] - window[0]) / (yhi - ylo) + 0.3)))
for ax in axes.flat[len(times):]:
ax.axis("off")
for ax, t in zip(axes.flat, times):
fid = int((ts - t).abs().idxmin())
fld = Field(ax=ax, orient="vertical", los=p.los, window=window, lateral=(ylo, yhi), to_go=p.to_go,
numbers=False)
cur = df[df["frameId"] == fid]
inside = cur[(cur["y"] > ylo - 0.5) & (cur["y"] < yhi + 0.5) & (cur["x"] < p.los + window[1] - 0.5)]
trails = df[(df["frameId"] <= fid) & df["_id"].isin(set(inside["_id"]))]
draw_frame(fld, inside, trails, fld.pts_per_yard())
for k, col in (ring or {}).items():
r = cur[cur["displayName"] == k]
if len(r):
x, y = fld.xy(float(r["x"].iloc[0]), float(r["y"].iloc[0]))
ax.add_patch(Circle((float(x), float(y)), 0.75 * 1.55, fc="none", ec=col, lw=1.8, zorder=3.5))
ax.set_title("snap" if abs(t) < 1e-6 else f"{t:+.1f}s", fontsize=9, color=style.INK_2, loc="center")
for (d, w, text) in (labels or {}).get(t, []):
x, y = fld.xy(*p.to_abs(d, w))
ax.text(float(x), float(y), text, fontsize=6.6, color=style.INK, ha="center", va="center", zorder=12,
linespacing=1.1, bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec=style.BASELINE, lw=0.5))
fig.tight_layout()
return fig
# --- the chapter's two base pictures
def i_form_vs_43(te_side="right"):
"""21 personnel (I-formation: fullback and tailback stacked behind the QB) vs a base 4-3 Over."""
off = tagged(offense("i_form"))
if te_side == "left": # flip the skill players, keep the linemen's names on their own sides
off = [q if q.key in ("LT", "LG", "C", "RG", "RT") else q.mirrored() for q in off]
off = [q.moved(d=-2.2) if q.key == "QB" else q for q in off] # a hair deeper so Q doesn't sit on C
dfn = tagged(defense("4-3_over", "two_high", off))
return off, [q.moved(d=10.5) if q.key == "SS" else q for q in dfn] # strong safety a bit closer
def eleven_vs_nickel(nb_w=-8.3):
"""11 personnel from the shotgun (tight end in-line on the right, slot receiver H on the left) vs a
4-2-5 nickel. The strong end sets outside the attached tight end, so the edge is still fitted."""
off = tagged(offense("gun_2x2"))
off = [q.moved(d=-0.7, w=4.8) if q.key == "Y" else (q.moved(d=-1.5) if q.key == "Z" else q) for q in off]
dfn = tagged(defense("nickel", "two_high", off, strength="right"))
dfn = [q.moved(d=5.0, w=nb_w) if q.key == "NB" else q for q in dfn]
return off, [q.moved(w=5.6) if q.key == "SDE" else q for q in dfn]