Football is fundamentally a spatial game. Every play involves 22 players competing for territory on a 100-yard field, with success determined by who controls space more effectively. While traditional statistics count outcomes, spatial analysis...
In This Chapter
- Learning Objectives
- Introduction
- 16.1 The Football Field Canvas
- 16.2 Player Position Visualization
- 16.3 Route Diagrams
- 16.4 Heat Maps and Density Plots
- 16.5 Formation Analysis
- 16.6 Tracking Data Visualization
- 16.7 Field Zone Analysis
- 16.8 Integrated Spatial Dashboard
- Chapter Summary
- Key Terms
- Practice Exercises
- Further Reading
Chapter 16: Spatial Analysis and Field Visualization
Learning Objectives
By the end of this chapter, you will be able to:
- Create accurate football field visualizations with proper dimensions
- Plot player positions and movement trajectories
- Build route diagrams for passing concepts
- Generate heat maps showing spatial tendencies
- Visualize formations and personnel alignments
- Work with tracking data to animate plays
- Create target maps and coverage visualizations
- Analyze field zones and territorial control
Introduction
Football is fundamentally a spatial game. Every play involves 22 players competing for territory on a 100-yard field, with success determined by who controls space more effectively. While traditional statistics count outcomes, spatial analysis reveals how those outcomes happen—where players line up, where routes break, where defenders concentrate, and where gaps appear.
This chapter transforms coordinate data into visual insights. We'll build the foundational tools for drawing football fields, plotting player positions, and creating the heat maps, route diagrams, and formation visualizations that professional analytics departments use daily.
The spatial dimension adds context that aggregate statistics cannot capture. A receiver's 12 targets per game means little without knowing whether those targets cluster in the slot or stretch deep downfield. A defense's third-down success rate gains meaning when we see which field zones they dominate and which they surrender.
16.1 The Football Field Canvas
Before plotting any data, we need an accurate representation of the football field itself.
Field Dimensions
A regulation football field has specific dimensions that our visualizations must respect:
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
from typing import Tuple, Optional, List, Dict
from dataclasses import dataclass
@dataclass
class FieldDimensions:
"""Standard football field dimensions in yards."""
# Field size
LENGTH: float = 120.0 # Including end zones
WIDTH: float = 53.33 # 160 feet = 53.33 yards
# End zones
ENDZONE_DEPTH: float = 10.0
# Hash marks (college vs NFL differ)
COLLEGE_HASH_WIDTH: float = 40.0 # 40 feet from sideline
NFL_HASH_WIDTH: float = 18.5 # 18.5 feet from center
# Yard line spacing
YARD_LINE_SPACING: float = 5.0
# Goal line positions
LEFT_GOAL_LINE: float = 10.0
RIGHT_GOAL_LINE: float = 110.0
@property
def field_center_y(self) -> float:
return self.WIDTH / 2
@property
def playing_field_length(self) -> float:
return 100.0 # Excluding end zones
Drawing the Basic Field
class FootballField:
"""
Create football field visualizations.
Supports both college and NFL field configurations.
"""
def __init__(self, field_type: str = 'college'):
"""
Initialize field with specified configuration.
Args:
field_type: 'college' or 'nfl' for hash mark positions
"""
self.dims = FieldDimensions()
self.field_type = field_type
# Colors
self.colors = {
'grass': '#2e5a1c',
'grass_alt': '#3d7a28', # For striped effect
'lines': 'white',
'endzone': '#264653',
'numbers': 'white',
'hash': 'white'
}
def draw(self, figsize: Tuple[int, int] = (12, 6),
show_numbers: bool = True,
show_hashes: bool = True,
vertical: bool = False) -> Tuple[plt.Figure, plt.Axes]:
"""
Draw a football field.
Args:
figsize: Figure dimensions
show_numbers: Whether to show yard numbers
show_hashes: Whether to show hash marks
vertical: If True, draw field vertically (sideline at bottom)
Returns:
Figure and Axes objects
"""
if vertical:
fig, ax = plt.subplots(figsize=(figsize[1], figsize[0]))
else:
fig, ax = plt.subplots(figsize=figsize)
# Field background
field_rect = patches.Rectangle(
(0, 0), self.dims.LENGTH, self.dims.WIDTH,
facecolor=self.colors['grass'],
edgecolor='none'
)
ax.add_patch(field_rect)
# Add grass stripes (every 5 yards)
self._draw_grass_stripes(ax)
# End zones
self._draw_endzones(ax)
# Yard lines
self._draw_yard_lines(ax)
# Hash marks
if show_hashes:
self._draw_hash_marks(ax)
# Yard numbers
if show_numbers:
self._draw_yard_numbers(ax)
# Goal lines (thicker)
ax.axvline(self.dims.LEFT_GOAL_LINE, color=self.colors['lines'],
linewidth=3)
ax.axvline(self.dims.RIGHT_GOAL_LINE, color=self.colors['lines'],
linewidth=3)
# Set limits and remove axes
ax.set_xlim(0, self.dims.LENGTH)
ax.set_ylim(0, self.dims.WIDTH)
ax.set_aspect('equal')
ax.axis('off')
if vertical:
# Rotate for vertical orientation
ax.invert_xaxis()
ax.set_xlim(self.dims.LENGTH, 0)
plt.tight_layout()
return fig, ax
def _draw_grass_stripes(self, ax: plt.Axes):
"""Draw alternating grass stripes."""
for yard in range(0, 120, 10):
if (yard // 10) % 2 == 0:
stripe = patches.Rectangle(
(yard, 0), 10, self.dims.WIDTH,
facecolor=self.colors['grass_alt'],
edgecolor='none',
alpha=0.3
)
ax.add_patch(stripe)
def _draw_endzones(self, ax: plt.Axes):
"""Draw end zones."""
# Left end zone
left_ez = patches.Rectangle(
(0, 0), self.dims.ENDZONE_DEPTH, self.dims.WIDTH,
facecolor=self.colors['endzone'],
edgecolor='none'
)
ax.add_patch(left_ez)
# Right end zone
right_ez = patches.Rectangle(
(110, 0), self.dims.ENDZONE_DEPTH, self.dims.WIDTH,
facecolor=self.colors['endzone'],
edgecolor='none'
)
ax.add_patch(right_ez)
def _draw_yard_lines(self, ax: plt.Axes):
"""Draw yard lines every 5 yards."""
for yard in range(10, 111, 5):
linewidth = 2 if yard % 10 == 0 else 1
ax.axvline(yard, color=self.colors['lines'],
linewidth=linewidth, alpha=0.8)
def _draw_hash_marks(self, ax: plt.Axes):
"""Draw hash marks."""
if self.field_type == 'college':
hash_y_bottom = (self.dims.WIDTH - self.dims.COLLEGE_HASH_WIDTH / 3) / 2
hash_y_top = self.dims.WIDTH - hash_y_bottom
else:
hash_y_bottom = self.dims.field_center_y - self.dims.NFL_HASH_WIDTH / 3
hash_y_top = self.dims.field_center_y + self.dims.NFL_HASH_WIDTH / 3
# Draw hash marks every yard
for yard in range(11, 110):
if yard % 5 != 0: # Skip yard lines
ax.plot([yard, yard], [hash_y_bottom - 0.5, hash_y_bottom + 0.5],
color=self.colors['hash'], linewidth=0.5)
ax.plot([yard, yard], [hash_y_top - 0.5, hash_y_top + 0.5],
color=self.colors['hash'], linewidth=0.5)
def _draw_yard_numbers(self, ax: plt.Axes):
"""Draw yard line numbers."""
number_y_bottom = 5
number_y_top = self.dims.WIDTH - 5
for yard in range(10, 100, 10):
display_num = yard if yard <= 50 else 100 - yard
# Bottom numbers
ax.text(yard + 10, number_y_bottom, str(display_num),
fontsize=16, color=self.colors['numbers'],
ha='center', va='center', fontweight='bold')
# Top numbers (rotated)
ax.text(yard + 10, number_y_top, str(display_num),
fontsize=16, color=self.colors['numbers'],
ha='center', va='center', fontweight='bold',
rotation=180)
def draw_half_field(self, side: str = 'right',
figsize: Tuple[int, int] = (8, 8)) -> Tuple[plt.Figure, plt.Axes]:
"""
Draw half of the field (useful for red zone analysis).
Args:
side: 'left' or 'right' half
figsize: Figure dimensions
Returns:
Figure and Axes objects
"""
fig, ax = self.draw(figsize=figsize, show_numbers=True)
if side == 'right':
ax.set_xlim(60, 120)
else:
ax.set_xlim(0, 60)
return fig, ax
Coordinate Systems
Football data comes in various coordinate systems. We need to normalize:
class CoordinateTransformer:
"""
Transform coordinates between different systems.
Common systems:
- Absolute: 0-120 yards (including end zones)
- Relative: 0-100 yards (playing field only)
- Offense-oriented: Always attacking toward 100
"""
def __init__(self):
self.dims = FieldDimensions()
def absolute_to_relative(self, x: float) -> float:
"""Convert absolute (0-120) to relative (0-100) yard line."""
return x - 10
def relative_to_absolute(self, x: float) -> float:
"""Convert relative (0-100) to absolute (0-120) yard line."""
return x + 10
def flip_field(self, x: float, y: float) -> Tuple[float, float]:
"""Flip coordinates so offense always attacks right."""
new_x = self.dims.LENGTH - x
new_y = self.dims.WIDTH - y
return new_x, new_y
def normalize_to_offense_right(self, x: float, y: float,
offense_going_right: bool) -> Tuple[float, float]:
"""
Normalize so offense is always attacking toward the right end zone.
Args:
x, y: Original coordinates
offense_going_right: Whether offense is already attacking right
Returns:
Normalized (x, y) coordinates
"""
if offense_going_right:
return x, y
else:
return self.flip_field(x, y)
16.2 Player Position Visualization
With the field drawn, we can plot player positions.
Basic Position Plotting
@dataclass
class PlayerPosition:
"""Single player position on the field."""
x: float
y: float
player_id: str
team: str # 'offense', 'defense', 'football'
position: str = ''
jersey_number: int = 0
class PlayerPlotter:
"""
Plot player positions on the field.
"""
def __init__(self):
self.colors = {
'offense': '#e76f51',
'defense': '#264653',
'football': '#8B4513',
'highlight': '#f4a261'
}
self.markers = {
'offense': 'o',
'defense': 's',
'football': 'D'
}
def plot_positions(self, ax: plt.Axes,
players: List[PlayerPosition],
show_labels: bool = True,
highlight_players: List[str] = None) -> None:
"""
Plot player positions on field.
Args:
ax: Matplotlib axes with field drawn
players: List of PlayerPosition objects
show_labels: Whether to show jersey numbers
highlight_players: Player IDs to highlight
"""
for player in players:
is_highlighted = highlight_players and player.player_id in highlight_players
color = self.colors['highlight'] if is_highlighted else self.colors[player.team]
size = 200 if is_highlighted else 150
ax.scatter(
player.x, player.y,
s=size,
c=color,
marker=self.markers.get(player.team, 'o'),
edgecolors='white',
linewidths=2,
zorder=10
)
if show_labels and player.jersey_number > 0:
ax.annotate(
str(player.jersey_number),
(player.x, player.y),
ha='center', va='center',
fontsize=8, color='white',
fontweight='bold',
zorder=11
)
def plot_formation(self, ax: plt.Axes,
offensive_positions: List[PlayerPosition],
defensive_positions: List[PlayerPosition] = None,
line_of_scrimmage: float = None) -> None:
"""
Plot a full formation with optional line of scrimmage.
Args:
ax: Matplotlib axes
offensive_positions: Offensive player positions
defensive_positions: Defensive player positions (optional)
line_of_scrimmage: Yard line for LOS (optional)
"""
# Line of scrimmage
if line_of_scrimmage is not None:
ax.axvline(line_of_scrimmage, color='blue',
linewidth=2, linestyle='--', alpha=0.7)
# Plot offense
self.plot_positions(ax, offensive_positions)
# Plot defense
if defensive_positions:
self.plot_positions(ax, defensive_positions)
Movement Trails
class MovementVisualizer:
"""
Visualize player movement over time.
"""
def __init__(self):
self.colors = {
'offense': '#e76f51',
'defense': '#264653'
}
def plot_trajectory(self, ax: plt.Axes,
positions: List[Tuple[float, float]],
team: str = 'offense',
fade: bool = True,
arrow: bool = True) -> None:
"""
Plot player movement trajectory.
Args:
ax: Matplotlib axes
positions: List of (x, y) coordinates over time
team: 'offense' or 'defense' for coloring
fade: Whether to fade earlier positions
arrow: Whether to show direction arrow
"""
if len(positions) < 2:
return
xs, ys = zip(*positions)
color = self.colors[team]
if fade:
# Gradient from transparent to solid
for i in range(len(positions) - 1):
alpha = (i + 1) / len(positions)
ax.plot(
[xs[i], xs[i+1]], [ys[i], ys[i+1]],
color=color, alpha=alpha, linewidth=2
)
else:
ax.plot(xs, ys, color=color, linewidth=2)
# Direction arrow at end
if arrow and len(positions) >= 2:
dx = xs[-1] - xs[-2]
dy = ys[-1] - ys[-2]
ax.annotate(
'', xy=(xs[-1], ys[-1]),
xytext=(xs[-2], ys[-2]),
arrowprops=dict(arrowstyle='->', color=color, lw=2)
)
# Current position marker
ax.scatter(xs[-1], ys[-1], s=100, c=color,
edgecolors='white', linewidths=2, zorder=10)
def plot_all_trajectories(self, ax: plt.Axes,
all_positions: Dict[str, List[Tuple[float, float]]],
teams: Dict[str, str]) -> None:
"""
Plot trajectories for all players.
Args:
ax: Matplotlib axes
all_positions: Dict mapping player_id to list of positions
teams: Dict mapping player_id to team
"""
for player_id, positions in all_positions.items():
team = teams.get(player_id, 'offense')
self.plot_trajectory(ax, positions, team=team)
16.3 Route Diagrams
Passing routes are fundamental to offensive design. Visualizing them helps understand concepts and tendencies.
Route Drawing
class RouteVisualizer:
"""
Create route diagrams for passing plays.
"""
def __init__(self):
self.route_colors = {
'go': '#e76f51',
'out': '#2a9d8f',
'in': '#e9c46a',
'slant': '#f4a261',
'curl': '#264653',
'comeback': '#7209b7',
'post': '#3a0ca3',
'corner': '#4361ee',
'flat': '#80b918',
'screen': '#aacc00',
'default': '#666666'
}
def draw_route(self, ax: plt.Axes,
start: Tuple[float, float],
waypoints: List[Tuple[float, float]],
route_type: str = 'default',
show_break: bool = True) -> None:
"""
Draw a single route.
Args:
ax: Matplotlib axes
start: Starting (x, y) position
waypoints: List of (x, y) waypoints defining the route
route_type: Type of route for coloring
show_break: Whether to mark the break point
"""
color = self.route_colors.get(route_type.lower(), self.route_colors['default'])
# Full path
all_points = [start] + waypoints
xs, ys = zip(*all_points)
# Draw route line
ax.plot(xs, ys, color=color, linewidth=2.5, solid_capstyle='round')
# Starting position
ax.scatter(start[0], start[1], s=150, c=color,
edgecolors='white', linewidths=2, zorder=10)
# Break point marker
if show_break and len(waypoints) >= 2:
break_point = waypoints[0]
ax.scatter(break_point[0], break_point[1], s=50, c='white',
edgecolors=color, linewidths=2, zorder=11)
# End arrow
if len(waypoints) >= 1:
end = waypoints[-1]
if len(waypoints) >= 2:
prev = waypoints[-2]
else:
prev = start
dx = end[0] - prev[0]
dy = end[1] - prev[1]
# Normalize and scale arrow
length = np.sqrt(dx**2 + dy**2)
if length > 0:
dx, dy = dx/length * 2, dy/length * 2
ax.annotate(
'', xy=(end[0], end[1]),
xytext=(end[0] - dx, end[1] - dy),
arrowprops=dict(arrowstyle='->', color=color, lw=2.5)
)
def draw_passing_concept(self, ax: plt.Axes,
routes: Dict[str, Dict],
los: float,
title: str = None) -> None:
"""
Draw a complete passing concept with multiple routes.
Args:
ax: Matplotlib axes
routes: Dict of routes with 'start', 'waypoints', 'type' keys
los: Line of scrimmage
title: Optional concept name
"""
# Draw LOS
ax.axvline(los, color='blue', linewidth=2, linestyle='--', alpha=0.5)
# Draw each route
for route_name, route_info in routes.items():
self.draw_route(
ax,
start=route_info['start'],
waypoints=route_info['waypoints'],
route_type=route_info.get('type', 'default')
)
# Label the route
end = route_info['waypoints'][-1]
ax.text(end[0] + 1, end[1], route_name,
fontsize=9, color='#264653')
if title:
ax.set_title(title, fontsize=14, fontweight='bold')
def create_common_routes() -> Dict[str, Dict]:
"""
Create sample route tree from typical formations.
"""
# Routes from 2x2 formation, ball at 70 yard line
los = 70
routes = {
'X - Post': {
'start': (los - 1, 5),
'waypoints': [(los + 10, 5), (los + 20, 20)],
'type': 'post'
},
'H - Flat': {
'start': (los - 2, 20),
'waypoints': [(los + 5, 15)],
'type': 'flat'
},
'Y - Curl': {
'start': (los - 1, 35),
'waypoints': [(los + 12, 35), (los + 10, 35)],
'type': 'curl'
},
'Z - Corner': {
'start': (los - 1, 48),
'waypoints': [(los + 10, 48), (los + 20, 53)],
'type': 'corner'
}
}
return routes
16.4 Heat Maps and Density Plots
Heat maps reveal spatial tendencies across many plays.
Target Heat Maps
from scipy import stats
class SpatialHeatMap:
"""
Create heat maps showing spatial tendencies.
"""
def __init__(self):
self.colormaps = {
'targets': 'YlOrRd',
'completions': 'Greens',
'epa': 'RdYlGn',
'tackles': 'Blues'
}
def create_target_heatmap(self, ax: plt.Axes,
target_locations: List[Tuple[float, float]],
cmap: str = 'YlOrRd',
levels: int = 20) -> None:
"""
Create heat map of target locations.
Args:
ax: Matplotlib axes (should have field drawn)
target_locations: List of (x, y) target coordinates
cmap: Colormap name
levels: Number of contour levels
"""
if len(target_locations) < 3:
return
xs, ys = zip(*target_locations)
xs, ys = np.array(xs), np.array(ys)
# Create kernel density estimate
xmin, xmax = 0, 120
ymin, ymax = 0, 53.33
xx, yy = np.mgrid[xmin:xmax:100j, ymin:ymax:50j]
positions = np.vstack([xx.ravel(), yy.ravel()])
values = np.vstack([xs, ys])
try:
kernel = stats.gaussian_kde(values)
density = np.reshape(kernel(positions).T, xx.shape)
# Plot contours
ax.contourf(xx, yy, density, levels=levels, cmap=cmap, alpha=0.6)
except:
# Fall back to scatter if KDE fails
ax.scatter(xs, ys, alpha=0.5, s=30)
def create_zone_heatmap(self, ax: plt.Axes,
zone_values: Dict[str, float],
cmap: str = 'RdYlGn') -> None:
"""
Create heat map using predefined field zones.
Args:
ax: Matplotlib axes
zone_values: Dict mapping zone names to values
cmap: Colormap name
"""
# Define zones (simplified grid)
zones = {
'deep_left': {'x': (10, 60), 'y': (0, 17.78)},
'deep_middle': {'x': (10, 60), 'y': (17.78, 35.55)},
'deep_right': {'x': (10, 60), 'y': (35.55, 53.33)},
'short_left': {'x': (60, 90), 'y': (0, 17.78)},
'short_middle': {'x': (60, 90), 'y': (17.78, 35.55)},
'short_right': {'x': (60, 90), 'y': (35.55, 53.33)},
'red_zone_left': {'x': (90, 110), 'y': (0, 17.78)},
'red_zone_middle': {'x': (90, 110), 'y': (17.78, 35.55)},
'red_zone_right': {'x': (90, 110), 'y': (35.55, 53.33)}
}
# Get colormap
cm = plt.cm.get_cmap(cmap)
# Normalize values
values = list(zone_values.values())
vmin, vmax = min(values), max(values)
for zone_name, zone_bounds in zones.items():
if zone_name in zone_values:
value = zone_values[zone_name]
norm_value = (value - vmin) / (vmax - vmin) if vmax > vmin else 0.5
rect = patches.Rectangle(
(zone_bounds['x'][0], zone_bounds['y'][0]),
zone_bounds['x'][1] - zone_bounds['x'][0],
zone_bounds['y'][1] - zone_bounds['y'][0],
facecolor=cm(norm_value),
edgecolor='white',
linewidth=1,
alpha=0.7
)
ax.add_patch(rect)
# Add value label
center_x = (zone_bounds['x'][0] + zone_bounds['x'][1]) / 2
center_y = (zone_bounds['y'][0] + zone_bounds['y'][1]) / 2
ax.text(center_x, center_y, f'{value:.1f}',
ha='center', va='center', fontsize=10,
fontweight='bold', color='black')
Defensive Coverage Maps
class CoverageVisualizer:
"""
Visualize defensive coverage tendencies.
"""
def __init__(self):
self.zone_colors = {
'deep_third_left': '#3a0ca3',
'deep_third_middle': '#4361ee',
'deep_third_right': '#4cc9f0',
'hook_curl_left': '#7209b7',
'hook_curl_right': '#b5179e',
'flat_left': '#f72585',
'flat_right': '#ff6b6b'
}
def draw_cover_3_zones(self, ax: plt.Axes, los: float = 70) -> None:
"""
Draw Cover 3 zone responsibilities.
Args:
ax: Matplotlib axes
los: Line of scrimmage
"""
width = 53.33
third = width / 3
# Deep thirds
for i, name in enumerate(['left', 'middle', 'right']):
zone = patches.Rectangle(
(los + 15, i * third),
35, third,
facecolor=self.zone_colors[f'deep_third_{name}'],
edgecolor='white',
linewidth=2,
alpha=0.4
)
ax.add_patch(zone)
ax.text(los + 32, i * third + third/2, f'Deep\n1/3',
ha='center', va='center', fontsize=9, color='white')
# Hook/curl zones
hook_curl_left = patches.Rectangle(
(los + 5, 0), 10, width/2,
facecolor=self.zone_colors['hook_curl_left'],
edgecolor='white',
linewidth=2,
alpha=0.4
)
ax.add_patch(hook_curl_left)
hook_curl_right = patches.Rectangle(
(los + 5, width/2), 10, width/2,
facecolor=self.zone_colors['hook_curl_right'],
edgecolor='white',
linewidth=2,
alpha=0.4
)
ax.add_patch(hook_curl_right)
# Flats
flat_left = patches.Rectangle(
(los, 0), 8, 10,
facecolor=self.zone_colors['flat_left'],
edgecolor='white',
linewidth=2,
alpha=0.4
)
ax.add_patch(flat_left)
flat_right = patches.Rectangle(
(los, width - 10), 8, 10,
facecolor=self.zone_colors['flat_right'],
edgecolor='white',
linewidth=2,
alpha=0.4
)
ax.add_patch(flat_right)
16.5 Formation Analysis
Formations reveal offensive and defensive philosophy.
Formation Visualization
@dataclass
class FormationTemplate:
"""Template for common formations."""
name: str
positions: Dict[str, Tuple[float, float]] # Position name to (x_offset, y)
class FormationVisualizer:
"""
Visualize offensive and defensive formations.
"""
def __init__(self):
self.offense_templates = self._create_offense_templates()
self.defense_templates = self._create_defense_templates()
def _create_offense_templates(self) -> Dict[str, FormationTemplate]:
"""Create common offensive formation templates."""
field_center = 26.67
return {
'shotgun_2x2': FormationTemplate(
name='Shotgun 2x2',
positions={
'QB': (-5, field_center),
'RB': (-6, field_center - 3),
'LT': (0, field_center - 8),
'LG': (0, field_center - 4),
'C': (0, field_center),
'RG': (0, field_center + 4),
'RT': (0, field_center + 8),
'X': (0, 5),
'H': (-1, field_center - 12),
'Y': (-1, field_center + 12),
'Z': (0, 48)
}
),
'i_formation': FormationTemplate(
name='I-Formation',
positions={
'QB': (-2, field_center),
'FB': (-5, field_center),
'TB': (-8, field_center),
'LT': (0, field_center - 8),
'LG': (0, field_center - 4),
'C': (0, field_center),
'RG': (0, field_center + 4),
'RT': (0, field_center + 8),
'TE': (0, field_center + 10),
'X': (0, 5),
'Z': (0, 48)
}
),
'empty': FormationTemplate(
name='Empty',
positions={
'QB': (-5, field_center),
'LT': (0, field_center - 8),
'LG': (0, field_center - 4),
'C': (0, field_center),
'RG': (0, field_center + 4),
'RT': (0, field_center + 8),
'X': (0, 5),
'H': (-1, field_center - 12),
'F': (-1, field_center + 6),
'Y': (-1, field_center + 16),
'Z': (0, 48)
}
)
}
def _create_defense_templates(self) -> Dict[str, FormationTemplate]:
"""Create common defensive formation templates."""
field_center = 26.67
return {
'4-3': FormationTemplate(
name='4-3 Defense',
positions={
'DE_L': (2, field_center - 12),
'DT_L': (2, field_center - 4),
'DT_R': (2, field_center + 4),
'DE_R': (2, field_center + 12),
'WLB': (5, field_center - 10),
'MLB': (5, field_center),
'SLB': (5, field_center + 10),
'CB_L': (7, 5),
'SS': (10, field_center - 8),
'FS': (15, field_center),
'CB_R': (7, 48)
}
),
'nickel': FormationTemplate(
name='Nickel Defense',
positions={
'DE_L': (2, field_center - 10),
'DT_L': (2, field_center - 3),
'DT_R': (2, field_center + 3),
'DE_R': (2, field_center + 10),
'LB_L': (5, field_center - 6),
'LB_R': (5, field_center + 6),
'CB_L': (7, 5),
'SLOT': (5, field_center - 14),
'SS': (10, field_center + 8),
'FS': (15, field_center),
'CB_R': (7, 48)
}
)
}
def draw_formation(self, ax: plt.Axes,
template: FormationTemplate,
los: float,
is_offense: bool = True,
show_labels: bool = True) -> None:
"""
Draw a formation from template.
Args:
ax: Matplotlib axes
template: Formation template
los: Line of scrimmage
is_offense: True for offense (positions behind LOS)
show_labels: Whether to show position labels
"""
color = '#e76f51' if is_offense else '#264653'
multiplier = -1 if is_offense else 1
for pos_name, (x_offset, y) in template.positions.items():
x = los + (x_offset * multiplier)
ax.scatter(x, y, s=200, c=color,
edgecolors='white', linewidths=2, zorder=10)
if show_labels:
ax.text(x, y, pos_name[:2],
ha='center', va='center',
fontsize=7, color='white',
fontweight='bold', zorder=11)
def draw_matchup(self, ax: plt.Axes,
offense_name: str,
defense_name: str,
los: float) -> None:
"""
Draw offense vs defense matchup.
Args:
ax: Matplotlib axes
offense_name: Name of offensive formation
defense_name: Name of defensive formation
los: Line of scrimmage
"""
if offense_name in self.offense_templates:
self.draw_formation(ax, self.offense_templates[offense_name],
los, is_offense=True)
if defense_name in self.defense_templates:
self.draw_formation(ax, self.defense_templates[defense_name],
los, is_offense=False)
ax.axvline(los, color='blue', linewidth=2, linestyle='--')
16.6 Tracking Data Visualization
Modern tracking data provides player positions at high frequency (10+ times per second).
Tracking Data Animation
from matplotlib.animation import FuncAnimation
from IPython.display import HTML
class TrackingAnimator:
"""
Create animations from tracking data.
"""
def __init__(self):
self.field = FootballField()
self.colors = {
'offense': '#e76f51',
'defense': '#264653',
'football': '#8B4513'
}
def create_play_animation(self,
tracking_data: pd.DataFrame,
fps: int = 10) -> FuncAnimation:
"""
Create animation of a single play.
Args:
tracking_data: DataFrame with columns:
- frame_id: Frame number
- player_id: Player identifier
- x, y: Position coordinates
- team: 'offense', 'defense', or 'football'
fps: Frames per second
Returns:
FuncAnimation object
"""
fig, ax = self.field.draw(figsize=(12, 6))
# Get unique frames
frames = sorted(tracking_data['frame_id'].unique())
# Initialize scatter plots
offense_scatter = ax.scatter([], [], s=150, c=self.colors['offense'],
edgecolors='white', linewidths=2, zorder=10)
defense_scatter = ax.scatter([], [], s=150, c=self.colors['defense'],
edgecolors='white', linewidths=2, zorder=10)
ball_scatter = ax.scatter([], [], s=100, c=self.colors['football'],
edgecolors='white', linewidths=2, zorder=11)
def init():
offense_scatter.set_offsets(np.empty((0, 2)))
defense_scatter.set_offsets(np.empty((0, 2)))
ball_scatter.set_offsets(np.empty((0, 2)))
return offense_scatter, defense_scatter, ball_scatter
def update(frame_id):
frame_data = tracking_data[tracking_data['frame_id'] == frame_id]
# Update offense
offense_data = frame_data[frame_data['team'] == 'offense']
if len(offense_data) > 0:
offense_scatter.set_offsets(offense_data[['x', 'y']].values)
# Update defense
defense_data = frame_data[frame_data['team'] == 'defense']
if len(defense_data) > 0:
defense_scatter.set_offsets(defense_data[['x', 'y']].values)
# Update ball
ball_data = frame_data[frame_data['team'] == 'football']
if len(ball_data) > 0:
ball_scatter.set_offsets(ball_data[['x', 'y']].values)
return offense_scatter, defense_scatter, ball_scatter
anim = FuncAnimation(
fig, update, frames=frames,
init_func=init, blit=True,
interval=1000/fps
)
return anim
def save_animation(self, anim: FuncAnimation,
filename: str,
fps: int = 10) -> None:
"""Save animation to file."""
anim.save(filename, writer='pillow', fps=fps)
Tracking Data Analysis
class TrackingAnalyzer:
"""
Analyze tracking data for spatial insights.
"""
def calculate_speed(self, positions: List[Tuple[float, float]],
time_interval: float = 0.1) -> List[float]:
"""
Calculate speed from position data.
Args:
positions: List of (x, y) positions
time_interval: Time between frames in seconds
Returns:
List of speeds in yards per second
"""
speeds = [0.0]
for i in range(1, len(positions)):
dx = positions[i][0] - positions[i-1][0]
dy = positions[i][1] - positions[i-1][1]
distance = np.sqrt(dx**2 + dy**2)
speed = distance / time_interval
speeds.append(speed)
return speeds
def calculate_separation(self,
receiver_pos: Tuple[float, float],
defender_positions: List[Tuple[float, float]]) -> float:
"""
Calculate receiver separation from nearest defender.
Args:
receiver_pos: (x, y) of receiver
defender_positions: List of (x, y) for defenders
Returns:
Distance to nearest defender in yards
"""
if not defender_positions:
return float('inf')
distances = []
for def_pos in defender_positions:
dx = receiver_pos[0] - def_pos[0]
dy = receiver_pos[1] - def_pos[1]
distances.append(np.sqrt(dx**2 + dy**2))
return min(distances)
def calculate_space_created(self,
positions: List[Tuple[float, float]],
grid_size: float = 5.0) -> float:
"""
Calculate area of space controlled by offense.
Uses convex hull of offensive player positions.
"""
from scipy.spatial import ConvexHull
if len(positions) < 3:
return 0.0
try:
points = np.array(positions)
hull = ConvexHull(points)
return hull.volume # Area in 2D
except:
return 0.0
16.7 Field Zone Analysis
Dividing the field into zones enables aggregate spatial analysis.
Zone Definitions
class FieldZones:
"""
Define and analyze field zones.
"""
def __init__(self):
self.width = 53.33
# Define zones
self.zones = {
# Horizontal zones (by depth)
'behind_los': {'x': (0, 0), 'description': 'Behind line of scrimmage'},
'short': {'x': (0, 10), 'description': '0-10 yards'},
'intermediate': {'x': (10, 20), 'description': '10-20 yards'},
'deep': {'x': (20, 100), 'description': '20+ yards'},
# Vertical zones (by field width)
'left_sideline': {'y': (0, 10)},
'left_hash': {'y': (10, 20)},
'middle': {'y': (20, 33.33)},
'right_hash': {'y': (33.33, 43.33)},
'right_sideline': {'y': (43.33, 53.33)}
}
def get_zone(self, x: float, y: float,
los: float) -> Dict[str, str]:
"""
Determine which zones a point falls into.
Args:
x, y: Coordinates
los: Line of scrimmage
Returns:
Dict with 'depth_zone' and 'width_zone'
"""
# Depth relative to LOS
depth = x - los
if depth < 0:
depth_zone = 'behind_los'
elif depth < 10:
depth_zone = 'short'
elif depth < 20:
depth_zone = 'intermediate'
else:
depth_zone = 'deep'
# Width zone
if y < 10:
width_zone = 'left_sideline'
elif y < 20:
width_zone = 'left_hash'
elif y < 33.33:
width_zone = 'middle'
elif y < 43.33:
width_zone = 'right_hash'
else:
width_zone = 'right_sideline'
return {'depth_zone': depth_zone, 'width_zone': width_zone}
def create_zone_summary(self, plays: pd.DataFrame,
value_col: str) -> pd.DataFrame:
"""
Summarize a metric by zone.
Args:
plays: DataFrame with 'target_x', 'target_y', 'los', and value_col
value_col: Column to aggregate
Returns:
DataFrame with zone summaries
"""
results = []
for _, play in plays.iterrows():
zones = self.get_zone(play['target_x'], play['target_y'], play['los'])
results.append({
'depth_zone': zones['depth_zone'],
'width_zone': zones['width_zone'],
'value': play[value_col]
})
results_df = pd.DataFrame(results)
return results_df.groupby(['depth_zone', 'width_zone']).agg({
'value': ['mean', 'count', 'std']
}).round(3)
16.8 Integrated Spatial Dashboard
Combining all spatial visualizations:
def create_spatial_dashboard(team_name: str,
target_data: pd.DataFrame,
formation_counts: Dict[str, int]) -> plt.Figure:
"""
Create comprehensive spatial analysis dashboard.
Args:
team_name: Team being analyzed
target_data: DataFrame with target locations
formation_counts: Dict of formation frequencies
Returns:
Figure with multiple spatial visualizations
"""
fig = plt.figure(figsize=(16, 12))
# Create grid
gs = fig.add_gridspec(2, 3, hspace=0.3, wspace=0.2)
# Panel 1: Target heat map (top left, spans 2 columns)
ax1 = fig.add_subplot(gs[0, :2])
field = FootballField()
field.draw_on_axes(ax1)
heatmap = SpatialHeatMap()
target_locations = list(zip(target_data['x'], target_data['y']))
heatmap.create_target_heatmap(ax1, target_locations)
ax1.set_title(f'{team_name} Target Locations', fontsize=12, fontweight='bold')
# Panel 2: Formation usage (top right)
ax2 = fig.add_subplot(gs[0, 2])
formations = list(formation_counts.keys())
counts = list(formation_counts.values())
ax2.barh(formations, counts, color='#264653')
ax2.set_title('Formation Usage', fontsize=12, fontweight='bold')
ax2.set_xlabel('Plays')
# Panel 3: Zone efficiency (bottom left)
ax3 = fig.add_subplot(gs[1, 0])
# Sample zone values
zone_values = {
'deep_left': 0.35,
'deep_middle': 0.28,
'deep_right': 0.42,
'short_left': 0.55,
'short_middle': 0.48,
'short_right': 0.52
}
# Would draw zone heatmap here
# Panel 4: Route tree (bottom middle)
ax4 = fig.add_subplot(gs[1, 1])
# Would draw common routes here
# Panel 5: Key stats (bottom right)
ax5 = fig.add_subplot(gs[1, 2])
ax5.axis('off')
stats_text = f"""
SPATIAL SUMMARY
Total Targets: {len(target_data)}
Avg Depth: {target_data['x'].mean():.1f} yards
Zone Breakdown:
- Short: {sum(target_data['x'] < 10) / len(target_data):.0%}
- Intermediate: {sum((target_data['x'] >= 10) & (target_data['x'] < 20)) / len(target_data):.0%}
- Deep: {sum(target_data['x'] >= 20) / len(target_data):.0%}
"""
ax5.text(0.1, 0.9, stats_text, transform=ax5.transAxes,
fontsize=10, verticalalignment='top', fontfamily='monospace')
fig.suptitle(f'{team_name} Spatial Analysis', fontsize=16, fontweight='bold')
return fig
Chapter Summary
Spatial analysis reveals patterns invisible in aggregate statistics:
- Field visualization: Accurate representation is the foundation
- Position plotting: Player locations at the snap and throughout plays
- Route diagrams: Visual language for passing concepts
- Heat maps: Density analysis across many plays
- Formation analysis: Structural tendencies on both sides
- Tracking data: High-frequency movement analysis
- Zone analysis: Aggregate spatial performance
The spatial dimension adds context that transforms numbers into football insight.
Key Terms
- Convex hull: Smallest polygon enclosing a set of points
- Heat map: Density visualization showing concentration of events
- Kernel density estimation: Statistical method for estimating probability density
- Line of scrimmage (LOS): Starting point for each play
- Tracking data: High-frequency position data (typically 10 Hz)
- Zone: Predefined region of the field for analysis
Practice Exercises
- Draw a football field with accurate dimensions and yard numbers
- Plot a play with 11 offensive and 11 defensive players
- Create a heat map of target locations for a receiver
- Animate a play using tracking data
- Build a formation comparison visualization
Further Reading
- NFL Big Data Bowl documentation
- mplsoccer library for sports field visualization
- Scipy spatial analysis documentation