| """ |
| Decision Quality Engine - Analyzes shot decision quality. |
| |
| Compares shooter's contested distance vs. teammate openness to evaluate |
| whether the shot was the best available option. |
| """ |
| from typing import Dict, Any, List |
| import numpy as np |
| from .base import BaseAnalyticsModule |
|
|
|
|
| class DecisionQualityEngine(BaseAnalyticsModule): |
| """Analyzes shot decision quality based on player openness.""" |
| |
| def __init__( |
| self, |
| open_threshold_m: float = 2.5, |
| contested_threshold_m: float = 1.5, |
| ): |
| """ |
| Initialize decision quality engine. |
| |
| Args: |
| open_threshold_m: Distance to defender for "open" classification |
| contested_threshold_m: Distance to defender for "contested" classification |
| """ |
| super().__init__("decision_quality") |
| self.open_threshold = open_threshold_m |
| self.contested_threshold = contested_threshold_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 decision quality for all shots. |
| |
| Returns: |
| Dictionary with decision analysis for each shot |
| """ |
| decision_analyses = [] |
| |
| for shot in shots: |
| frame = shot.get("start_frame", shot.get("frame", 0)) |
| if frame >= len(player_assignment) or frame >= len(tactical_positions): |
| continue |
| |
| assignment = player_assignment[frame] |
| tactical_pos = tactical_positions[frame] |
| |
| shooter_id = shot.get("player_id") |
| if not shooter_id or shooter_id not in assignment: |
| continue |
| |
| offense_team = assignment[shooter_id] |
| defense_team = 1 if offense_team == 2 else 2 |
| |
| shooter_pos = tactical_pos.get(shooter_id) |
| if not shooter_pos: |
| continue |
| |
| |
| shooter_contested_distance = float('inf') |
| for player_id, team_id in assignment.items(): |
| if team_id == defense_team and player_id in tactical_pos: |
| defender_pos = tactical_pos[player_id] |
| dist = self._euclidean_distance(shooter_pos, defender_pos) |
| if dist < shooter_contested_distance: |
| shooter_contested_distance = dist |
| |
| |
| open_teammates = 0 |
| best_teammate_openness = 0.0 |
| teammate_data = [] |
| |
| for player_id, team_id in assignment.items(): |
| if team_id == offense_team and player_id != shooter_id and player_id in tactical_pos: |
| teammate_pos = tactical_pos[player_id] |
| |
| |
| min_defender_dist = float('inf') |
| for def_id, def_team in assignment.items(): |
| if def_team == defense_team and def_id in tactical_pos: |
| def_pos = tactical_pos[def_id] |
| dist = self._euclidean_distance(teammate_pos, def_pos) |
| if dist < min_defender_dist: |
| min_defender_dist = dist |
| |
| teammate_data.append({ |
| "player_id": player_id, |
| "defender_distance": float(min_defender_dist) |
| }) |
| |
| if min_defender_dist > self.open_threshold: |
| open_teammates += 1 |
| if min_defender_dist > best_teammate_openness: |
| best_teammate_openness = min_defender_dist |
| |
| |
| shooter_is_contested = shooter_contested_distance < self.contested_threshold |
| has_open_teammate = open_teammates > 0 |
| |
| if shooter_is_contested and has_open_teammate: |
| |
| decision_quality = "low_expected_value" |
| elif not shooter_is_contested: |
| |
| decision_quality = "high_expected_value" |
| else: |
| |
| decision_quality = "acceptable" |
| |
| decision_analyses.append({ |
| "event_id": f"shot_{frame}", |
| "shot_frame": frame, |
| "shooter_track_id": shooter_id, |
| "shooter_contested_distance": float(shooter_contested_distance), |
| "open_teammates": open_teammates, |
| "best_teammate_openness": float(best_teammate_openness) if best_teammate_openness > 0 else None, |
| "decision_quality": decision_quality, |
| "teammate_positions": teammate_data, |
| "shot_outcome": shot.get("outcome", "unknown") |
| }) |
| |
| |
| if decision_analyses: |
| quality_counts = { |
| "high_expected_value": 0, |
| "acceptable": 0, |
| "low_expected_value": 0 |
| } |
| for analysis in decision_analyses: |
| quality_counts[analysis["decision_quality"]] += 1 |
| |
| total = len(decision_analyses) |
| |
| summary = { |
| "total_shots_analyzed": total, |
| "high_ev_shots": quality_counts["high_expected_value"], |
| "acceptable_shots": quality_counts["acceptable"], |
| "low_ev_shots": quality_counts["low_expected_value"], |
| "low_ev_rate": (quality_counts["low_expected_value"] / total * 100) if total > 0 else 0, |
| "avg_shooter_contested_distance": float(np.mean([ |
| a["shooter_contested_distance"] for a in decision_analyses |
| ])), |
| } |
| else: |
| summary = { |
| "total_shots_analyzed": 0, |
| "high_ev_shots": 0, |
| "acceptable_shots": 0, |
| "low_ev_shots": 0, |
| "low_ev_rate": 0, |
| "avg_shooter_contested_distance": 0, |
| } |
| |
| return { |
| "decision_analyses": decision_analyses, |
| "summary": summary, |
| "status": "success" |
| } |
|
|