| """ |
| 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, |
| paint_length_m: float = 5.8, |
| ): |
| """ |
| 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 |
| |
| assignment = player_assignment[frame_idx] |
| if possession_player not in assignment: |
| continue |
| |
| offense_team = assignment[possession_player] |
| |
| |
| 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 |
| |
| |
| 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) |
| |
| |
| |
| |
| paint_players = self._count_paint_players(offensive_positions) |
| |
| |
| overlap_count = sum(1 for d in distances if d < self.clustering_threshold) |
| |
| |
| 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, |
| }) |
| |
| |
| 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 |
| """ |
| |
| |
| paint_count = 0 |
| for pos in positions: |
| if len(pos) >= 2: |
| |
| |
| if pos[1] < 6.0: |
| paint_count += 1 |
| return paint_count |
|
|