import os
import logging
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import RegularPolygon, FancyArrowPatch, Patch
from matplotlib.lines import Line2D
from gridiron import Play, offense, defense, routes as r, show_animation, style, draw_marker
from gridiron.play import MARKER_R
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
FIELD_W = 160 / 3
# ---------------------------------------------------------------- alignments used all chapter
def pro_2x2():
"""11 personnel, shotgun 2x2 with the tight end attached: X (on the line) and H (slot, off the
ball) to the left; Y in-line on the right and Z wide right, off the ball. Seven on the line:
five linemen, X and Y. On the right #1 is Z and #2 is Y; on the left #1 is X and #2 is H."""
out = []
for p in offense("gun_2x2"):
if p.key == "Y":
p = p.moved(d=-0.7, w=4.8)
elif p.key == "Z":
p = p.moved(d=-1.5, w=15.0)
out.append(p)
return out
def spread_2x2():
"""11 personnel 2x2 with the tight end detached as the right slot (gridiron's gun_2x2)."""
return offense("gun_2x2")
QUARTERS = {"LCB": (7.5, -15.6), "RCB": (7.5, 15.6), "FS": (9.5, -6.5), "SS": (9.5, 6.5),
"NB": (4.8, -6.2), "MIKE": (4.8, 1.6), "WILL": (4.8, -2.4)}
def quarters_vs(off, moves=None, sde_9=True):
"""Nickel (4-2-5) quarters shell: corners 7-8 yards off with a slight outside shade; safeties
9-10 yards deep, a few yards inside a slot or over/just outside an attached tight end
(shallower and narrower than Cover 2 safeties); the nickel ($) at an apex over the left slot;
the Mike and Will in the box. sde_9 puts the strong-side end outside an attached tight end
(a 9 technique) so he can set the edge."""
m = dict(QUARTERS)
m.update(moves or {})
out = []
for p in defense("nickel", "two_high", off):
if p.key in m:
p = p.moved(d=m[p.key][0], w=m[p.key][1])
if p.key == "SDE" and sde_9:
p = p.moved(w=5.9)
if p.pos == "CB":
p = p.moved(label="CB")
out.append(p)
return out
def protect(p, rushers=("SDE", "SDT", "WDT", "WDE")):
"""Five linemen set, four rushers engage them: enough to show it is a pass, no more."""
for k in ("LT", "LG", "C", "RG", "RT"):
p.block(k, pts=[(-1.2, 0.0)])
for k in rushers:
p.rush(k, pts=[(-1.6, 0.0)], speed=2.0)
def no_numbers(ax):
"""Remove the painted yard-line numbers (they sit where wide receivers and safeties stand)."""
for t in list(ax.texts):
if t.get_alpha() == 0.55:
t.remove()
def ruler(fld, play, widths=False, fs=6.5):
"""Depth labels (yards past the line of scrimmage) down the left edge of the field window;
widths=True also marks the hash marks and the painted numbers along the bottom edge.
Drawn on the Field's axes: gridiron has no ruler."""
ax = fld.ax
x_left = FIELD_W - fld.y1 + 0.35
for d in range(5, 41, 5):
y = play.los + d
if fld.x0 + 0.8 <= y <= fld.x1 - 0.8:
ax.text(x_left, y, f"{d}" + (" yd" if d == 5 else ""), fontsize=fs, color=style.INK_2,
ha="left", va="center", zorder=12,
bbox=dict(boxstyle="round,pad=0.12", fc=style.SURFACE, ec="none", alpha=0.85))
if widths:
y = fld.x0 + 0.9
for yb, txt in ((23.58, "hash"), (FIELD_W - 23.58, "hash"), (13.0, "numbers"), (FIELD_W - 13.0, "numbers")):
if fld.y0 + 2.5 <= yb <= fld.y1 - 2.5:
ax.text(FIELD_W - yb, y, txt, fontsize=fs, color=style.INK_2, ha="center", va="center",
zorder=12, style="italic",
bbox=dict(boxstyle="round,pad=0.12", fc=style.SURFACE, ec="none", alpha=0.85))
def say(fld, play, text, at, fs=7.0, color=None, ha="center", weight="normal"):
"""A small boxed on-field label at (d, w), sized independently of Play.note."""
x, y = fld.xy(*play.to_abs(*at))
fld.ax.text(float(x), float(y), text, fontsize=fs, color=color or style.INK, ha=ha, va="center",
zorder=12, fontweight=weight,
bbox=dict(boxstyle="round,pad=0.2", fc=style.SURFACE, ec=style.BASELINE, lw=0.5))
def clip(fld):
"""Crop the axes to the drawn field window, so players outside a zoomed window don't
stretch the picture past the turf. (gridiron draws every player; this trims the view.)"""
fld.ax.set_xlim(FIELD_W - fld.y1, FIELD_W - fld.y0)
fld.ax.set_ylim(fld.x0, fld.x1)
for a in list(fld.ax.texts) + list(fld.ax.patches) + list(fld.ax.lines):
a.set_clip_on(True)
return fld
# ---------------------------------------------------------------- frame strips (print version of an animation)
def ghost_marker(ax, x, y, label, ppy, color=style.DEFENSE):
"""A hollow, dashed defender: where he stood at the snap."""
ax.add_patch(RegularPolygon((x, y), 4, radius=MARKER_R * 1.25, orientation=np.pi / 4,
facecolor="none", edgecolor=color, lw=1.2, ls=(0, (2, 1.2)), zorder=6))
if label:
ax.text(x, y, label, ha="center", va="center", color=color, fontweight="bold", zorder=6,
fontsize=max(4.0, min(8.5, ppy * 0.75)))
def snapshot(play, t, ax=None, ghosts=(), lateral=(-21, 21), window=(-8.5, 19.5), notes=(), show_ruler=False):
"""Everyone where he is at time t, with faint trails since the snap and dashed outlines
(`ghosts`) where the chosen defenders stood at the snap."""
pos = {k: play.position(k, t) for k in play.players}
moved = [pl.moved(d=float(pos[k][0]), w=float(pos[k][1])) for k, pl in play.players.items()]
q = Play([m for m in moved if m.side == "off"], [m for m in moved if m.side == "def"],
los=play.los, ball_y=play.ball_y, to_go=play.to_go)
q.highlights = dict(play.highlights)
for n in notes:
q.note(*n)
fld = clip(q.draw(ax=ax, lateral=lateral, window=window, title=False))
ax, ppy = fld.ax, fld.pts_per_yard()
no_numbers(ax)
if show_ruler:
ruler(fld, play, fs=5.5)
P = lambda d, w: fld.xy(*play.to_abs(d, w)) # noqa: E731
for k, pl in play.players.items():
if pl.side == "off" and pl.pos not in ("WR", "TE", "RB"):
continue
pts = np.array([play.position(k, tt) for tt in np.linspace(0, t, 30)])
if np.ptp(pts[:, 0]) + np.ptp(pts[:, 1]) < 0.3:
continue
xs, ys = P(pts[:, 0], pts[:, 1])
if pl.side == "off":
ax.plot(xs, ys, color=style.OFFENSE, lw=1.0, alpha=0.45, zorder=3)
else:
ax.plot(xs, ys, color=style.DEFENSE, lw=1.3, ls=(0, (3, 2)), zorder=3)
if t > 0.05:
for k in ghosts:
x0, y0 = P(*play.position(k, 0.0))
ghost_marker(ax, float(x0), float(y0), play.players[k].label, ppy)
bx, by = P(*play.ball_position(t))
bd = np.array(play.ball_position(t))
in_flight = any(k == "pass" and t > tr and np.hypot(*(bd - np.array(play.position(to, t)))) > 1.2
for k, to, tr in play.ball_events)
if not in_flight: # tuck a held ball beside its holder so his letter stays readable
bx, by = float(bx) + 1.0, float(by) + 0.35
for kind, _, t_rel in play.ball_events:
if kind == "pass" and t > t_rel: # a thrown ball: draw its flight so far
sx, sy = P(*play.ball_position(t_rel))
ax.add_patch(FancyArrowPatch((float(sx), float(sy)), (float(bx), float(by)), arrowstyle="-|>",
mutation_scale=8, color=style.BALL, lw=1.4, ls=(0, (2, 1.5)), zorder=9))
draw_marker(ax, float(bx), float(by), "ball", "", ppy)
return fld
def strip(play, times, labels, ghosts=(), lateral=(-21, 21), window=(-8.5, 19.5), notes_at=None,
width_in=6.5, ncols=2):
"""Panels at chosen moments of one play, ghosts kept on every post-snap panel."""
n = len(times)
nrows = int(np.ceil(n / ncols))
pw = width_in / ncols
h = nrows * (pw * (window[1] - window[0]) / abs(lateral[1] - lateral[0]) + 0.32)
fig, axes = plt.subplots(nrows, ncols, figsize=(width_in, h), squeeze=False)
for ax in axes.flat[n:]:
ax.axis("off")
for i, (ax, t, lab) in enumerate(zip(axes.flat, times, labels)):
snapshot(play, t, ax=ax, ghosts=ghosts, lateral=lateral, window=window,
notes=(notes_at or {}).get(i, ()), show_ruler=(i == 0))
ax.set_title(lab, fontsize=8.5, color=style.INK, loc="left")
fig.tight_layout(h_pad=0.6, w_pad=0.4)
h = [Patch(facecolor="none", edgecolor=style.DEFENSE, lw=1.2, ls=(0, (2, 1.2)),
label="Dashed outline: where he stood at the snap"),
Line2D([], [], color=style.DEFENSE, lw=1.3, ls=(0, (3, 2)), label="Defender's path"),
Line2D([], [], color=style.OFFENSE, lw=1.0, alpha=0.6, label="Offensive player's path")]
fig.legend(handles=h, loc="upper center", bbox_to_anchor=(0.5, 0.0), ncol=3, fontsize=7.2, frameon=False)
return fig