File size: 7,499 Bytes
c6abe34 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | """
Spacing Engine - Analyzes offensive spacing quality.
Computes pairwise distances, paint density, and clustering to evaluate
how well offensive players are spaced on the court.
"""
from typing import Dict, Any, List
import numpy as np
from .base import BaseAnalyticsModule
class SpacingEngine(BaseAnalyticsModule):
"""Analyzes offensive spacing quality using geometric analysis."""
def __init__(
self,
clustering_threshold_m: float = 1.5,
good_spacing_threshold_m: float = 3.0,
average_spacing_threshold_m: float = 2.0,
paint_width_m: float = 4.9, # Standard NBA paint width
paint_length_m: float = 5.8, # Standard NBA paint length
):
"""
Initialize spacing engine.
Args:
clustering_threshold_m: Distance below which players are considered clustered
good_spacing_threshold_m: Average distance for "good" spacing
average_spacing_threshold_m: Average distance for "average" spacing
paint_width_m: Width of the paint area in meters
paint_length_m: Length of the paint area in meters
"""
super().__init__("spacing_engine")
self.clustering_threshold = clustering_threshold_m
self.good_threshold = good_spacing_threshold_m
self.average_threshold = average_spacing_threshold_m
self.paint_width = paint_width_m
self.paint_length = paint_length_m
def process(
self,
video_frames: List[Any],
player_tracks: List[Dict],
ball_tracks: List[Dict],
tactical_positions: List[Dict],
player_assignment: List[Dict],
ball_possession: List[int],
events: List[Dict],
shots: List[Dict],
court_keypoints: List[Dict],
speeds: List[Dict],
video_path: str,
fps: float,
**kwargs
) -> Dict[str, Any]:
"""
Analyze spacing quality across all frames.
Returns:
Dictionary with spacing metrics for each frame with possession
"""
spacing_metrics = []
for frame_idx in range(len(player_tracks)):
if frame_idx >= len(tactical_positions) or frame_idx >= len(player_assignment):
continue
if frame_idx >= len(ball_possession):
continue
possession_player = ball_possession[frame_idx]
if possession_player == -1:
continue # No possession, skip
assignment = player_assignment[frame_idx]
if possession_player not in assignment:
continue
offense_team = assignment[possession_player]
# Get offensive players' tactical positions
tactical_pos = tactical_positions[frame_idx]
offensive_positions = []
offensive_player_ids = []
for player_id, team_id in assignment.items():
if team_id == offense_team and player_id in tactical_pos:
pos = tactical_pos[player_id]
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
offensive_positions.append(pos)
offensive_player_ids.append(player_id)
if len(offensive_positions) < 2:
continue # Need at least 2 players for spacing analysis
# Compute pairwise distances
distances = []
for i in range(len(offensive_positions)):
for j in range(i + 1, len(offensive_positions)):
dist = self._euclidean_distance(
offensive_positions[i],
offensive_positions[j]
)
if dist != float('inf'):
distances.append(dist)
if not distances:
continue
avg_distance = np.mean(distances)
# Count players in paint (simplified: check if near hoop)
# Assuming tactical view has hoop at specific location
# For now, use a heuristic based on y-coordinate
paint_players = self._count_paint_players(offensive_positions)
# Detect overlaps (clustering)
overlap_count = sum(1 for d in distances if d < self.clustering_threshold)
# Classify spacing quality
if avg_distance >= self.good_threshold:
quality = "good"
elif avg_distance >= self.average_threshold:
quality = "average"
else:
quality = "poor"
spacing_metrics.append({
"frame": frame_idx,
"timestamp": self._get_frame_time(frame_idx, fps),
"spacing_quality": quality,
"avg_distance_m": float(avg_distance),
"paint_players": paint_players,
"overlap_count": overlap_count,
"player_positions": {
str(pid): pos for pid, pos in zip(offensive_player_ids, offensive_positions)
},
"offense_team": offense_team,
})
# Aggregate statistics
if spacing_metrics:
quality_counts = {"good": 0, "average": 0, "poor": 0}
for metric in spacing_metrics:
quality_counts[metric["spacing_quality"]] += 1
total = len(spacing_metrics)
summary = {
"total_frames_analyzed": total,
"good_spacing_pct": (quality_counts["good"] / total * 100) if total > 0 else 0,
"average_spacing_pct": (quality_counts["average"] / total * 100) if total > 0 else 0,
"poor_spacing_pct": (quality_counts["poor"] / total * 100) if total > 0 else 0,
"avg_distance_overall": float(np.mean([m["avg_distance_m"] for m in spacing_metrics])),
}
else:
summary = {
"total_frames_analyzed": 0,
"good_spacing_pct": 0,
"average_spacing_pct": 0,
"poor_spacing_pct": 0,
"avg_distance_overall": 0,
}
return {
"spacing_metrics": spacing_metrics,
"summary": summary,
"status": "success"
}
def _count_paint_players(self, positions: List[List[float]]) -> int:
"""
Count how many players are in the paint area.
This is a simplified heuristic. In a full implementation, you would
use court keypoints to define the exact paint boundaries.
Args:
positions: List of [x, y] positions in tactical view
Returns:
Number of players in paint
"""
# Simplified: assume tactical view has hoop at top (y=0) and paint extends downward
# This is a placeholder - real implementation should use court_keypoints
paint_count = 0
for pos in positions:
if len(pos) >= 2:
# Heuristic: if y-coordinate is in top portion of court
# (This assumes tactical view normalization)
if pos[1] < 6.0: # Within ~6m of hoop
paint_count += 1
return paint_count
|