| """ |
| Lineup Impact Engine - Analyzes performance of specific 5-player combinations. |
| |
| Segments game by unique lineups and calculates offensive/defensive ratings, |
| spacing quality, and error rates for each combination. |
| """ |
| from typing import Dict, Any, List |
| import numpy as np |
| from .base import BaseAnalyticsModule |
|
|
|
|
| class LineupImpactEngine(BaseAnalyticsModule): |
| """Analyzes performance metrics for specific player combinations.""" |
| |
| def __init__(self): |
| """Initialize lineup impact engine.""" |
| super().__init__("lineup_impact") |
| |
| 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 lineup performance across the game. |
| |
| Returns: |
| Dictionary with lineup metrics |
| """ |
| |
| spacing_metrics = kwargs.get("spacing_metrics", []) |
| defensive_reactions = kwargs.get("defensive_reactions", []) |
| |
| |
| lineup_segments = self._detect_lineup_segments( |
| player_assignment, |
| ball_possession, |
| fps |
| ) |
| |
| lineup_metrics = [] |
| |
| for segment in lineup_segments: |
| team_id = segment["team_id"] |
| lineup_hash = segment["lineup_hash"] |
| player_ids = segment["player_ids"] |
| start_frame = segment["start_frame"] |
| end_frame = segment["end_frame"] |
| |
| |
| segment_shots = [ |
| s for s in shots |
| if start_frame <= s.get("start_frame", s.get("frame", 0)) < end_frame |
| and s.get("team") == team_id |
| ] |
| |
| segment_shots_allowed = [ |
| s for s in shots |
| if start_frame <= s.get("start_frame", s.get("frame", 0)) < end_frame |
| and s.get("team") != team_id |
| ] |
| |
| |
| points_scored = sum(2 for s in segment_shots if s.get("outcome") == "made") |
| points_allowed = sum(2 for s in segment_shots_allowed if s.get("outcome") == "made") |
| |
| |
| possessions = len(segment_shots) |
| |
| |
| if possessions > 0: |
| offensive_rating = (points_scored / possessions) * 100 |
| defensive_rating = (points_allowed / possessions) * 100 |
| net_rating = offensive_rating - defensive_rating |
| else: |
| offensive_rating = 0 |
| defensive_rating = 0 |
| net_rating = 0 |
| |
| |
| segment_spacing = [ |
| m for m in spacing_metrics |
| if start_frame <= m.get("frame", 0) < end_frame |
| ] |
| |
| if segment_spacing: |
| |
| quality_scores = [] |
| for m in segment_spacing: |
| if m["spacing_quality"] == "good": |
| quality_scores.append(3.0) |
| elif m["spacing_quality"] == "average": |
| quality_scores.append(2.0) |
| else: |
| quality_scores.append(1.0) |
| avg_spacing_score = float(np.mean(quality_scores)) |
| else: |
| avg_spacing_score = 0.0 |
| |
| |
| segment_def_reactions = [ |
| r for r in defensive_reactions |
| if start_frame <= r.get("event_frame", 0) < end_frame |
| ] |
| |
| if segment_def_reactions: |
| late_closeouts = sum(1 for r in segment_def_reactions if r.get("late_closeout", False)) |
| defensive_error_rate = late_closeouts / len(segment_def_reactions) |
| else: |
| defensive_error_rate = 0.0 |
| |
| |
| segment_interceptions = [ |
| e for e in events |
| if e.get("event_type") == "interception" |
| and start_frame <= e.get("frame", 0) < end_frame |
| ] |
| turnovers = len(segment_interceptions) |
| |
| |
| duration_seconds = (end_frame - start_frame) / fps if fps > 0 else 0 |
| duration_minutes = duration_seconds / 60 |
| |
| lineup_metrics.append({ |
| "team_id": team_id, |
| "lineup_hash": lineup_hash, |
| "player_track_ids": player_ids, |
| "possessions_count": possessions, |
| "points_scored": points_scored, |
| "points_allowed": points_allowed, |
| "offensive_rating": float(offensive_rating), |
| "defensive_rating": float(defensive_rating), |
| "net_rating": float(net_rating), |
| "avg_spacing_score": avg_spacing_score, |
| "turnovers": turnovers, |
| "defensive_error_rate": float(defensive_error_rate), |
| "total_minutes": float(duration_minutes), |
| "start_frame": start_frame, |
| "end_frame": end_frame |
| }) |
| |
| |
| if lineup_metrics: |
| best_lineup = max(lineup_metrics, key=lambda x: x["net_rating"]) |
| worst_lineup = min(lineup_metrics, key=lambda x: x["net_rating"]) |
| |
| summary = { |
| "total_lineups": len(lineup_metrics), |
| "best_lineup_hash": best_lineup["lineup_hash"], |
| "best_lineup_net_rating": best_lineup["net_rating"], |
| "worst_lineup_hash": worst_lineup["lineup_hash"], |
| "worst_lineup_net_rating": worst_lineup["net_rating"], |
| "avg_net_rating": float(np.mean([m["net_rating"] for m in lineup_metrics])), |
| } |
| else: |
| summary = { |
| "total_lineups": 0, |
| "best_lineup_hash": None, |
| "best_lineup_net_rating": 0, |
| "worst_lineup_hash": None, |
| "worst_lineup_net_rating": 0, |
| "avg_net_rating": 0, |
| } |
| |
| return { |
| "lineup_metrics": lineup_metrics, |
| "summary": summary, |
| "status": "success" |
| } |
| |
| def _detect_lineup_segments( |
| self, |
| player_assignment: List[Dict], |
| ball_possession: List[int], |
| fps: float, |
| min_duration_seconds: float = 30.0 |
| ) -> List[Dict]: |
| """ |
| Detect continuous segments with the same 5-player lineup. |
| |
| Args: |
| player_assignment: Per-frame team assignments |
| ball_possession: Per-frame possession |
| fps: Frames per second |
| min_duration_seconds: Minimum segment duration to track |
| |
| Returns: |
| List of lineup segments |
| """ |
| segments = [] |
| current_lineups = {1: None, 2: None} |
| segment_starts = {1: 0, 2: 0} |
| |
| min_frames = int(min_duration_seconds * fps) |
| |
| for frame_idx in range(len(player_assignment)): |
| assignment = player_assignment[frame_idx] |
| |
| |
| team_1_players = sorted([pid for pid, team in assignment.items() if team == 1]) |
| team_2_players = sorted([pid for pid, team in assignment.items() if team == 2]) |
| |
| |
| lineup_1_hash = "_".join(map(str, team_1_players)) if len(team_1_players) >= 3 else None |
| lineup_2_hash = "_".join(map(str, team_2_players)) if len(team_2_players) >= 3 else None |
| |
| |
| for team_id, lineup_hash, players in [ |
| (1, lineup_1_hash, team_1_players), |
| (2, lineup_2_hash, team_2_players) |
| ]: |
| if lineup_hash is None: |
| continue |
| |
| if current_lineups[team_id] != lineup_hash: |
| |
| if current_lineups[team_id] is not None: |
| |
| duration = frame_idx - segment_starts[team_id] |
| if duration >= min_frames: |
| segments.append({ |
| "team_id": team_id, |
| "lineup_hash": current_lineups[team_id], |
| "player_ids": self._parse_lineup_hash(current_lineups[team_id]), |
| "start_frame": segment_starts[team_id], |
| "end_frame": frame_idx |
| }) |
| |
| |
| current_lineups[team_id] = lineup_hash |
| segment_starts[team_id] = frame_idx |
| |
| |
| for team_id in [1, 2]: |
| if current_lineups[team_id] is not None: |
| duration = len(player_assignment) - segment_starts[team_id] |
| if duration >= min_frames: |
| segments.append({ |
| "team_id": team_id, |
| "lineup_hash": current_lineups[team_id], |
| "player_ids": self._parse_lineup_hash(current_lineups[team_id]), |
| "start_frame": segment_starts[team_id], |
| "end_frame": len(player_assignment) |
| }) |
| |
| return segments |
| |
| def _parse_lineup_hash(self, lineup_hash: str) -> List[int]: |
| """Parse lineup hash back to list of player IDs.""" |
| return [int(x) for x in lineup_hash.split("_")] |
|
|