BakoAI / analytics_engine /spacing_engine.py
Okidi Norbert
Deployment fix: clean backend only
c6abe34
"""
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