2 min read

Player tracking technology has revolutionized football analytics by providing spatial and temporal data at unprecedented granularity. This chapter explores how computer vision and tracking systems capture player movements, and how to analyze this...

Chapter 24: Computer Vision and Tracking Data

Introduction

Player tracking technology has revolutionized football analytics by providing spatial and temporal data at unprecedented granularity. This chapter explores how computer vision and tracking systems capture player movements, and how to analyze this data to extract tactical insights, evaluate player performance, and inform game strategy.

Learning Objectives

By the end of this chapter, you will be able to:

  1. Understand player tracking data structures and sources
  2. Process and clean tracking data for analysis
  3. Calculate speed, acceleration, and movement metrics
  4. Analyze formations and player spacing
  5. Measure separation and route running quality
  6. Build visualizations of player movements

24.1 Tracking Data Fundamentals

24.1.1 Data Sources and Collection

Modern tracking systems capture player positions at high frequencies (typically 10-25 Hz), providing x,y coordinates for all players and the ball throughout each play.

import pandas as pd
import numpy as np
from typing import Dict, List, Tuple
from dataclasses import dataclass

@dataclass
class TrackingFrame:
    """Single frame of tracking data."""
    game_id: str
    play_id: str
    frame_id: int
    timestamp: float
    player_id: str
    team: str
    x: float  # Yard line (0-120)
    y: float  # Field width (0-53.3)
    speed: float  # yards/second
    acceleration: float
    direction: float  # degrees (0-360)
    orientation: float  # player facing direction


class TrackingDataLoader:
    """Load and process tracking data."""

    def __init__(self):
        self.frame_rate = 10  # Hz
        self.field_length = 120  # yards (including end zones)
        self.field_width = 53.3  # yards

    def load_play(self, filepath: str) -> pd.DataFrame:
        """Load tracking data for a single play."""
        df = pd.read_csv(filepath)

        # Standardize column names
        df = df.rename(columns={
            'x': 'x_pos',
            'y': 'y_pos',
            's': 'speed',
            'a': 'acceleration',
            'dir': 'direction',
            'o': 'orientation'
        })

        # Ensure consistent ordering
        df = df.sort_values(['frame_id', 'player_id'])

        return df

    def standardize_direction(self,
                              df: pd.DataFrame,
                              play_direction: str) -> pd.DataFrame:
        """Standardize coordinates so offense always moves left to right."""
        if play_direction == 'left':
            df['x_pos'] = self.field_length - df['x_pos']
            df['y_pos'] = self.field_width - df['y_pos']
            df['direction'] = (df['direction'] + 180) % 360
            df['orientation'] = (df['orientation'] + 180) % 360

        return df

24.1.2 Data Structure

TRACKING DATA SCHEMA
====================

Per-Frame Data:
- game_id: Unique game identifier
- play_id: Play within game
- frame_id: Frame number (1, 2, 3, ...)
- timestamp: Milliseconds from start

Player Data:
- player_id: NFL/NCAA ID
- jersey_number: Player number
- position: Position code
- team: Offense/Defense/Football

Location Data:
- x: Position along field (0-120 yards)
- y: Position across field (0-53.3 yards)
- speed: Instantaneous speed (yards/second)
- acceleration: Rate of speed change
- direction: Movement direction (degrees)
- orientation: Body facing direction (degrees)

Sample Rates:
- NFL Next Gen Stats: 10 Hz
- College tracking: varies (10-25 Hz)
- Broadcast tracking: 25-30 Hz

24.2 Movement Metrics

24.2.1 Speed and Acceleration

class MovementAnalyzer:
    """Analyze player movement from tracking data."""

    def __init__(self, frame_rate: int = 10):
        self.frame_rate = frame_rate
        self.dt = 1.0 / frame_rate

    def calculate_velocity(self, df: pd.DataFrame) -> pd.DataFrame:
        """Calculate velocity components."""
        df = df.copy()

        # Group by player
        for player_id in df['player_id'].unique():
            mask = df['player_id'] == player_id

            # Calculate velocity from position changes
            df.loc[mask, 'vx'] = df.loc[mask, 'x_pos'].diff() / self.dt
            df.loc[mask, 'vy'] = df.loc[mask, 'y_pos'].diff() / self.dt

        # Calculated speed
        df['calc_speed'] = np.sqrt(df['vx']**2 + df['vy']**2)

        return df

    def calculate_acceleration(self, df: pd.DataFrame) -> pd.DataFrame:
        """Calculate acceleration from velocity."""
        df = df.copy()

        for player_id in df['player_id'].unique():
            mask = df['player_id'] == player_id

            df.loc[mask, 'ax'] = df.loc[mask, 'vx'].diff() / self.dt
            df.loc[mask, 'ay'] = df.loc[mask, 'vy'].diff() / self.dt

        df['calc_accel'] = np.sqrt(df['ax']**2 + df['ay']**2)

        return df

    def get_max_speed(self, df: pd.DataFrame) -> pd.DataFrame:
        """Get maximum speed for each player on play."""
        return df.groupby('player_id')['speed'].max().reset_index()

    def calculate_distance_traveled(self, df: pd.DataFrame) -> pd.DataFrame:
        """Calculate total distance traveled per player."""
        df = df.copy()

        results = []
        for player_id in df['player_id'].unique():
            player_df = df[df['player_id'] == player_id].sort_values('frame_id')

            dx = player_df['x_pos'].diff()
            dy = player_df['y_pos'].diff()
            distance = np.sqrt(dx**2 + dy**2).sum()

            results.append({
                'player_id': player_id,
                'distance_traveled': distance
            })

        return pd.DataFrame(results)

24.2.2 Route Analysis

class RouteAnalyzer:
    """Analyze receiver routes from tracking data."""

    def __init__(self):
        self.route_classifications = {
            'go': self._is_go_route,
            'slant': self._is_slant,
            'out': self._is_out,
            'in': self._is_in_route,
            'curl': self._is_curl,
            'corner': self._is_corner,
            'post': self._is_post
        }

    def extract_route(self,
                      player_df: pd.DataFrame,
                      snap_frame: int) -> Dict:
        """Extract route characteristics for a receiver."""
        # Filter to post-snap frames
        route_df = player_df[player_df['frame_id'] >= snap_frame].copy()

        if len(route_df) < 5:
            return {'route_type': 'unknown', 'depth': 0}

        # Starting position
        start_x = route_df.iloc[0]['x_pos']
        start_y = route_df.iloc[0]['y_pos']

        # Deepest point
        depth = route_df['x_pos'].max() - start_x

        # Lateral movement
        lateral = route_df['y_pos'].iloc[-1] - start_y

        # Break point (max curvature)
        break_frame = self._find_break_point(route_df)

        return {
            'start_x': start_x,
            'start_y': start_y,
            'depth': depth,
            'lateral_movement': lateral,
            'break_frame': break_frame,
            'route_type': self._classify_route(route_df, depth, lateral)
        }

    def _find_break_point(self, df: pd.DataFrame) -> int:
        """Find frame where route direction changes most."""
        if len(df) < 3:
            return df['frame_id'].iloc[0]

        directions = df['direction'].values
        direction_changes = np.abs(np.diff(directions))

        # Handle wraparound
        direction_changes = np.minimum(direction_changes, 360 - direction_changes)

        break_idx = np.argmax(direction_changes)
        return df['frame_id'].iloc[break_idx]

    def _classify_route(self,
                        df: pd.DataFrame,
                        depth: float,
                        lateral: float) -> str:
        """Classify route type based on path."""
        if depth > 15 and abs(lateral) < 5:
            return 'go'
        elif depth < 8 and lateral > 5:
            return 'out'
        elif depth < 8 and lateral < -5:
            return 'in'
        elif depth > 10 and lateral > 8:
            return 'corner'
        elif depth > 10 and lateral < -8:
            return 'post'
        elif depth < 12 and abs(lateral) < 3:
            return 'curl'
        elif depth < 8 and 2 < abs(lateral) < 8:
            return 'slant'
        else:
            return 'other'

    def calculate_separation(self,
                             receiver_df: pd.DataFrame,
                             defender_df: pd.DataFrame) -> pd.DataFrame:
        """Calculate separation between receiver and nearest defender."""
        # Merge on frame
        merged = receiver_df.merge(
            defender_df,
            on='frame_id',
            suffixes=('_rec', '_def')
        )

        # Calculate distance
        merged['separation'] = np.sqrt(
            (merged['x_pos_rec'] - merged['x_pos_def'])**2 +
            (merged['y_pos_rec'] - merged['y_pos_def'])**2
        )

        return merged[['frame_id', 'separation']]

24.3 Formation Analysis

24.3.1 Pre-Snap Formations

class FormationAnalyzer:
    """Analyze offensive and defensive formations."""

    def __init__(self):
        self.los_margin = 1.0  # yards from LOS to consider on line

    def identify_offensive_formation(self,
                                      offense_df: pd.DataFrame,
                                      snap_frame: int) -> Dict:
        """Identify offensive formation at snap."""
        pre_snap = offense_df[offense_df['frame_id'] == snap_frame - 1]

        # Find line of scrimmage (ball position)
        ball_x = pre_snap[pre_snap['position'] == 'QB']['x_pos'].iloc[0]

        # Count players in backfield
        backfield = pre_snap[pre_snap['x_pos'] < ball_x - 3]
        rb_count = len(backfield[backfield['position'].isin(['RB', 'FB'])])

        # Count receivers
        receivers = pre_snap[pre_snap['position'].isin(['WR', 'TE'])]
        left_receivers = len(receivers[receivers['y_pos'] < 26.65])
        right_receivers = len(receivers[receivers['y_pos'] > 26.65])

        # Determine formation
        if rb_count == 2:
            backfield_type = 'I-Form' if self._is_i_formation(backfield) else 'Split Back'
        elif rb_count == 1:
            backfield_type = 'Single Back'
        else:
            backfield_type = 'Empty'

        return {
            'backfield': backfield_type,
            'rb_count': rb_count,
            'left_receivers': left_receivers,
            'right_receivers': right_receivers,
            'formation_strength': 'Left' if left_receivers > right_receivers else 'Right'
        }

    def _is_i_formation(self, backfield_df: pd.DataFrame) -> bool:
        """Check if backfield is in I-formation."""
        if len(backfield_df) < 2:
            return False
        y_positions = backfield_df['y_pos'].values
        return abs(y_positions[0] - y_positions[1]) < 2

    def calculate_box_count(self,
                            defense_df: pd.DataFrame,
                            los_x: float) -> int:
        """Count defenders in the box."""
        box_defenders = defense_df[
            (defense_df['x_pos'] > los_x - 5) &
            (defense_df['x_pos'] < los_x + 3) &
            (defense_df['y_pos'] > 20) &
            (defense_df['y_pos'] < 33)
        ]
        return len(box_defenders)

    def calculate_offensive_spacing(self,
                                     offense_df: pd.DataFrame) -> Dict:
        """Calculate offensive spacing metrics."""
        positions = offense_df[['x_pos', 'y_pos']].values

        # Average distance between players
        distances = []
        for i in range(len(positions)):
            for j in range(i+1, len(positions)):
                dist = np.sqrt(
                    (positions[i,0] - positions[j,0])**2 +
                    (positions[i,1] - positions[j,1])**2
                )
                distances.append(dist)

        return {
            'avg_spacing': np.mean(distances),
            'min_spacing': np.min(distances),
            'max_spacing': np.max(distances),
            'spread_width': offense_df['y_pos'].max() - offense_df['y_pos'].min()
        }

24.4 Visualization

24.4.1 Animated Play Visualization

import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle, Circle
from matplotlib.animation import FuncAnimation

class PlayVisualizer:
    """Visualize tracking data as animated plays."""

    FIELD_LENGTH = 120
    FIELD_WIDTH = 53.3

    def __init__(self):
        self.colors = {
            'offense': '#e74c3c',
            'defense': '#3498db',
            'football': '#8B4513'
        }

    def create_field(self, ax: plt.Axes) -> plt.Axes:
        """Create football field visualization."""
        # Field background
        ax.set_facecolor('#228B22')

        # Yard lines
        for yard in range(0, 121, 10):
            ax.axvline(x=yard, color='white', alpha=0.5, linewidth=1)

        # Hash marks
        for yard in range(0, 121, 1):
            ax.plot([yard, yard], [22.9, 23.5], color='white', linewidth=0.5)
            ax.plot([yard, yard], [29.8, 30.4], color='white', linewidth=0.5)

        # End zones
        ax.add_patch(Rectangle((0, 0), 10, self.FIELD_WIDTH,
                                color='#1a5c1a', alpha=0.5))
        ax.add_patch(Rectangle((110, 0), 10, self.FIELD_WIDTH,
                                color='#1a5c1a', alpha=0.5))

        # Field boundaries
        ax.set_xlim(0, self.FIELD_LENGTH)
        ax.set_ylim(0, self.FIELD_WIDTH)
        ax.set_aspect('equal')

        return ax

    def plot_frame(self,
                   ax: plt.Axes,
                   frame_df: pd.DataFrame) -> List:
        """Plot single frame of tracking data."""
        artists = []

        for _, player in frame_df.iterrows():
            color = self.colors.get(player['team'], 'gray')

            # Player position
            circle = Circle(
                (player['x_pos'], player['y_pos']),
                radius=0.8,
                color=color,
                alpha=0.8
            )
            ax.add_patch(circle)
            artists.append(circle)

            # Jersey number
            text = ax.text(
                player['x_pos'], player['y_pos'],
                str(int(player.get('jersey_number', 0))),
                ha='center', va='center',
                fontsize=6, color='white', fontweight='bold'
            )
            artists.append(text)

        return artists

    def animate_play(self,
                     play_df: pd.DataFrame,
                     filepath: str = None) -> FuncAnimation:
        """Create animated visualization of a play."""
        fig, ax = plt.subplots(figsize=(14, 7))
        self.create_field(ax)

        frames = play_df['frame_id'].unique()

        def update(frame_num):
            ax.clear()
            self.create_field(ax)
            frame_df = play_df[play_df['frame_id'] == frames[frame_num]]
            self.plot_frame(ax, frame_df)
            ax.set_title(f'Frame {frame_num}')

        anim = FuncAnimation(
            fig, update,
            frames=len(frames),
            interval=100,
            blit=False
        )

        if filepath:
            anim.save(filepath, writer='pillow', fps=10)

        return anim

Summary

Computer vision and tracking data provide unprecedented insight into player movements and spatial relationships in football. Key applications include:

  1. Movement Analysis: Speed, acceleration, and distance metrics quantify player athleticism and effort
  2. Route Analysis: Detailed route tracking enables separation analysis and route classification
  3. Formation Recognition: Pre-snap formations can be automatically identified and categorized
  4. Tactical Insights: Spacing and positioning reveal strategic tendencies

The challenge lies in processing large volumes of high-frequency data and extracting meaningful football insights from raw coordinates.

Key Takeaways

  • Tracking data captures x,y positions at 10-25 Hz for all players
  • Speed and acceleration metrics derived from position changes
  • Route analysis quantifies receiver skill and separation
  • Formation analysis reveals tactical tendencies
  • Visualization is essential for communicating spatial insights