- src/calibration: 3D camera calibration (solvePnP, homography) - src/physics: trajectory model with gravity/drag, close call event detector - src/streaming: camera reader + ring buffer for VAR clips - src/web: Flask app with 3-tab UI (Detection, Court, Trajectory) + Three.js - jetson/main.py: unified entry point wiring all components Court tab: one-click calibration button, 3D scene with camera positions Trajectory tab: accumulated ball path, VAR panel with snapshot + timer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
114 lines
4.1 KiB
Python
114 lines
4.1 KiB
Python
"""
|
|
Close call / event detector for referee system.
|
|
Watches the trajectory model and triggers VAR when ball lands near a line.
|
|
"""
|
|
|
|
import time
|
|
from typing import Optional, Tuple, List, Dict
|
|
from .trajectory import TrajectoryModel
|
|
|
|
# Court line definitions in meters (X=along court, Y=across court)
|
|
# Full court: 13.4 x 6.1
|
|
COURT_LENGTH = 13.4
|
|
COURT_WIDTH = 6.1
|
|
HALF = COURT_LENGTH / 2 # 6.7 (net / center line)
|
|
NVZ = 2.13 # non-volley zone (kitchen) depth from net
|
|
|
|
COURT_LINES = {
|
|
'baseline_left': {'type': 'horizontal', 'x': 0, 'y_start': 0, 'y_end': COURT_WIDTH},
|
|
'baseline_right': {'type': 'horizontal', 'x': COURT_LENGTH, 'y_start': 0, 'y_end': COURT_WIDTH},
|
|
'sideline_near': {'type': 'vertical', 'y': 0, 'x_start': 0, 'x_end': COURT_LENGTH},
|
|
'sideline_far': {'type': 'vertical', 'y': COURT_WIDTH, 'x_start': 0, 'x_end': COURT_LENGTH},
|
|
'centerline': {'type': 'horizontal', 'x': HALF, 'y_start': 0, 'y_end': COURT_WIDTH},
|
|
'kitchen_left': {'type': 'horizontal', 'x': HALF - NVZ, 'y_start': 0, 'y_end': COURT_WIDTH},
|
|
'kitchen_right': {'type': 'horizontal', 'x': HALF + NVZ, 'y_start': 0, 'y_end': COURT_WIDTH},
|
|
'center_service_left': {'type': 'vertical', 'y': COURT_WIDTH / 2,
|
|
'x_start': 0, 'x_end': HALF - NVZ},
|
|
'center_service_right': {'type': 'vertical', 'y': COURT_WIDTH / 2,
|
|
'x_start': HALF + NVZ, 'x_end': COURT_LENGTH},
|
|
}
|
|
|
|
|
|
class EventDetector:
|
|
"""Detects close calls by monitoring ball trajectory near court lines."""
|
|
|
|
def __init__(self, trigger_distance_m=0.3, cooldown_seconds=5.0):
|
|
self.trigger_distance = trigger_distance_m
|
|
self.cooldown = cooldown_seconds
|
|
self.last_trigger_time = 0.0
|
|
self.events: List[Dict] = []
|
|
|
|
def check(self, trajectory: TrajectoryModel) -> Optional[Dict]:
|
|
"""Check if current trajectory warrants a VAR trigger.
|
|
|
|
Conditions:
|
|
1. Ball is descending (vz < 0) or near ground (z < 0.3m)
|
|
2. Predicted landing point is within trigger_distance of a line
|
|
3. Cooldown has passed since last trigger
|
|
"""
|
|
now = time.time()
|
|
if now - self.last_trigger_time < self.cooldown:
|
|
return None
|
|
|
|
if not trajectory.points or trajectory.velocity is None:
|
|
return None
|
|
|
|
last = trajectory.points[-1]
|
|
vz = trajectory.velocity[2]
|
|
|
|
# Ball must be descending or near ground
|
|
if vz > 0 and last.z > 0.5:
|
|
return None
|
|
|
|
# Get predicted landing
|
|
landing = trajectory.predict_landing()
|
|
if landing is None:
|
|
return None
|
|
|
|
land_x, land_y, time_to_land = landing
|
|
|
|
# Check distance to each line
|
|
closest_line = None
|
|
closest_dist = float('inf')
|
|
|
|
for name, line in COURT_LINES.items():
|
|
dist = self._distance_to_line(land_x, land_y, line)
|
|
if dist < closest_dist:
|
|
closest_dist = dist
|
|
closest_line = name
|
|
|
|
if closest_dist <= self.trigger_distance:
|
|
self.last_trigger_time = now
|
|
event = {
|
|
'type': 'close_call',
|
|
'timestamp': now,
|
|
'line': closest_line,
|
|
'distance_m': closest_dist,
|
|
'landing_x': land_x,
|
|
'landing_y': land_y,
|
|
'time_to_land': time_to_land,
|
|
'ball_z': last.z,
|
|
'ball_speed': trajectory.get_speed(),
|
|
}
|
|
self.events.append(event)
|
|
return event
|
|
|
|
return None
|
|
|
|
def _distance_to_line(self, x: float, y: float, line: Dict) -> float:
|
|
if line['type'] == 'horizontal':
|
|
lx = line['x']
|
|
y_start, y_end = line['y_start'], line['y_end']
|
|
if y_start <= y <= y_end:
|
|
return abs(x - lx)
|
|
return float('inf')
|
|
else: # vertical
|
|
ly = line['y']
|
|
x_start, x_end = line['x_start'], line['x_end']
|
|
if x_start <= x <= x_end:
|
|
return abs(y - ly)
|
|
return float('inf')
|
|
|
|
def is_in_bounds(self, x: float, y: float) -> bool:
|
|
return 0 <= x <= COURT_LENGTH and 0 <= y <= COURT_WIDTH
|