| 1 | """GCVWorker for MLB The Show 26 aim assist. |
| 2 | |
| 3 | Runs inside Gtuner IV's Computer Vision (GCV) module. Gtuner IV captures video |
| 4 | from the Monster capture card at 60fps and calls GCVWorker.process(frame) each |
| 5 | frame. This class: |
| 6 | |
| 7 | 1. Detects the baseball and PCI (Plate Coverage Indicator) using classical CV. |
| 8 | 2. Predicts plate-crossing x from a rolling trajectory fit. |
| 9 | 3. Packs an aim-assist command payload into gcvdata (≤255 bytes) for the |
| 10 | paired GPC script (mlb26_bridge.gpc) to read via gcv_ready()/gcv_read(). |
| 11 | |
| 12 | gcvdata layout: |
| 13 | offset type name notes |
| 14 | 0 fix32 aim_stick_x left-stick X deflection (-100..100) |
| 15 | 4 fix32 aim_stick_y left-stick Y deflection (-100..100) |
| 16 | 8 int16 armed 0 = passthrough, 1 = aim active |
| 17 | 10 int16 in_flight 1 = ball currently tracked |
| 18 | 12 int16 press_contact 1 = press X (contact swing) THIS FRAME |
| 19 | 14 int16 press_power 1 = press A (power swing) THIS FRAME |
| 20 | 16 int16 eta_ms predicted ms until ball crosses plate |
| 21 | 18 int16 debug_flags bit0=pci_found, bit1=ball_found, bit2=pred_good |
| 22 | |
| 23 | All fix32 values are 32-bit fixed-point (Titan Two native). We pack them as |
| 24 | big-endian 4-byte signed integers scaled by 65536 (16.16). |
| 25 | """ |
| 26 | from __future__ import annotations |
| 27 | |
| 28 | import struct |
| 29 | import time |
| 30 | from collections import deque |
| 31 | |
| 32 | import cv2 |
| 33 | import numpy as np |
| 34 | |
| 35 | try: |
| 36 | from gtuner import * |
| 37 | GTUNER_AVAILABLE = True |
| 38 | except ImportError: |
| 39 | GTUNER_AVAILABLE = False |
| 40 | |
| 41 | BALL_HSV_LOW = np.array([0, 0, 175], dtype=np.uint8) |
| 42 | BALL_HSV_HIGH = np.array([180, 70, 255], dtype=np.uint8) |
| 43 | BALL_CREAM_HSV_LOW = np.array([8, 6, 135], dtype=np.uint8) |
| 44 | BALL_CREAM_HSV_HIGH = np.array([45, 105, 255], dtype=np.uint8) |
| 45 | BALL_WHITE_HSV_LOW = np.array([0, 0, 185], dtype=np.uint8) |
| 46 | BALL_WHITE_HSV_HIGH = np.array([180, 48, 255], dtype=np.uint8) |
| 47 | BALL_MIN_R = 2.5 |
| 48 | BALL_MAX_R = 18.0 |
| 49 | BALL_MIN_CIRC = 0.50 |
| 50 | |
| 51 | BALL_SEARCH_X_FRAC = (0.396, 0.602) |
| 52 | BALL_SEARCH_Y_FRAC = (0.260, 0.746) |
| 53 | BALL_TRACK_X_FRAC = (0.30, 0.62) |
| 54 | BALL_TRACK_Y_FRAC = (0.22, 0.90) |
| 55 | BALL_ACQUIRE_X_FRAC = (0.42, 0.55) |
| 56 | BALL_ACQUIRE_Y_FRAC = (0.27, 0.43) |
| 57 | BALL_ACQUIRE_MAX_R = 8.5 |
| 58 | BALL_TRACK_MAX_STEP_PX = 115.0 |
| 59 | BALL_TRACK_LOST_MS = 140 |
| 60 | |
| 61 | PITCH_LANE_TOP_Y_FRAC = 0.24 |
| 62 | PITCH_LANE_BOTTOM_Y_FRAC = 0.93 |
| 63 | PITCH_LANE_TOP_HALF_W_FRAC = 0.10 |
| 64 | PITCH_LANE_BOTTOM_HALF_W_FRAC = 0.24 |
| 65 | PITCH_RELEASE_X_FRAC = 0.36 |
| 66 | PITCH_PLATE_X_FRAC = 0.32 |
| 67 | |
| 68 | BALL_MOG_HISTORY = 90 |
| 69 | BALL_MOG_VAR_THRESHOLD = 24 |
| 70 | BALL_MOG_LEARNING_RATE = 0.02 |
| 71 | BALL_MIN_MOTION_DELTA_PX = 4.0 |
| 72 | BALL_MOTION_MAX_GAP_MS = 220 |
| 73 | BALL_MAX_UPWARD_STEP_PX = 2.0 |
| 74 | |
| 75 | PCI_HSV_LOW = np.array([45, 140, 140], dtype=np.uint8) |
| 76 | PCI_HSV_HIGH = np.array([75, 255, 255], dtype=np.uint8) |
| 77 | PCI_SEARCH_X = (0.35, 0.65) |
| 78 | PCI_SEARCH_Y = (0.35, 0.75) |
| 79 | PCI_MIN_GREEN_PX = 150 |
| 80 | |
| 81 | SZ_CX_FRAC = 0.499 |
| 82 | SZ_CY_FRAC = 0.503 |
| 83 | SZ_W_FRAC = 0.206 |
| 84 | SZ_H_FRAC = 0.486 |
| 85 | |
| 86 | SWING_LEAD_PX = 80 |
| 87 | SWING_ZONE_X_PAD_PX = 0 |
| 88 | SWING_ZONE_Y_PAD_PX = 0 |
| 89 | SWING_BALL_MIN_R = 0.0 |
| 90 | |
| 91 | SWING_FIRE_MIN_Y_FRAC = 0.25 |
| 92 | |
| 93 | PLATE_Y_FRAC = SZ_CY_FRAC + SZ_H_FRAC / 2.0 |
| 94 | TRAJ_WINDOW_S = 0.8 |
| 95 | MIN_FIT_POINTS = 4 |
| 96 | FIT_USE_LAST_N = 8 |
| 97 | PITCH_GAP_MS = 300 |
| 98 | MAX_STEP_PX = 200 |
| 99 | PITCH_START_MAX_Y = 500 |
| 100 | STATIC_WINDOW = 3 |
| 101 | STATIC_SPREAD_PX = 3.0 |
| 102 | BAN_ZONE_MS = 600 |
| 103 | BAN_ZONE_PX = 4 |
| 104 | |
| 105 | AIM_GAIN_X = 0.35 |
| 106 | AIM_GAIN_Y = 0.30 |
| 107 | AIM_DEADZONE_PX = 2 |
| 108 | AIM_MAX_STICK = 90.0 |
| 109 | BALL_MEMORY_MS = 500 |
| 110 | BALL_SWING_MEMORY_MS = 240 |
| 111 | |
| 112 | CONTACT_TRIGGER_ETA_MS = 160 |
| 113 | CONTACT_MAX_AIM_ERR_PX = 18 |
| 114 | SWING_HOLD_MS = 450 |
| 115 | |
| 116 | DEBUG_FORCE_AIM = False |
| 117 | |
| 118 | def _fix32(value: float) -> bytes: |
| 119 | """Encode float as Titan Two fix32 (16.16 signed, BIG-endian).""" |
| 120 | v = int(round(value * 65536.0)) |
| 121 | v = max(-2**31, min(2**31 - 1, v)) |
| 122 | return struct.pack(">i", v) |
| 123 | |
| 124 | def _int16(value: int) -> bytes: |
| 125 | v = max(-32768, min(32767, int(value))) |
| 126 | return struct.pack(">h", v) |
| 127 | |
| 128 | class _Ball: |
| 129 | __slots__ = ("ts_ns", "x", "y", "r") |
| 130 | def __init__(self, ts_ns, x, y, r): |
| 131 | self.ts_ns, self.x, self.y, self.r = ts_ns, x, y, r |
| 132 | |
| 133 | class GCVWorker: |
| 134 | """Gtuner IV computer-vision worker. Called per frame. |
| 135 | |
| 136 | Gtuner IV invokes: GCVWorker(width, height). The (width, height) are the |
| 137 | captured frame dimensions (e.g. 1920, 1080). |
| 138 | """ |
| 139 | |
| 140 | def __init__(self, width, height): |
| 141 | import os |
| 142 | os.chdir(os.path.dirname(__file__)) |
| 143 | self.width = width |
| 144 | self.height = height |
| 145 | self.trail: deque = deque(maxlen=64) |
| 146 | self.banned: deque = deque(maxlen=32) |
| 147 | self.last_det_ns: int | None = None |
| 148 | self.last_pci = None |
| 149 | self.pitch_id = 0 |
| 150 | |
| 151 | self.gcvdata = bytearray(20) |
| 152 | for i in range(20): |
| 153 | self.gcvdata[i] = 0 |
| 154 | |
| 155 | self.last_ball_pos = None |
| 156 | self.last_ball_ts = 0 |
| 157 | self.last_ball_r = 0.0 |
| 158 | self.last_ball_vel = (0.0, 0.0) |
| 159 | self.ball_track_active = False |
| 160 | self.bg_sub = cv2.createBackgroundSubtractorMOG2( |
| 161 | history=BALL_MOG_HISTORY, |
| 162 | varThreshold=BALL_MOG_VAR_THRESHOLD, |
| 163 | detectShadows=False, |
| 164 | ) |
| 165 | self.prev_ball_candidate = None |
| 166 | |
| 167 | self.last_swing_ts = 0 |
| 168 | |
| 169 | self.swing_hold_until = 0 |
| 170 | |
| 171 | def __del__(self): |
| 172 | try: |
| 173 | del self.gcvdata |
| 174 | except Exception: |
| 175 | pass |
| 176 | |
| 177 | def _pitch_lane_mask(self, h, w): |
| 178 | top_y = h * PITCH_LANE_TOP_Y_FRAC |
| 179 | bottom_y = h * PITCH_LANE_BOTTOM_Y_FRAC |
| 180 | top_x = w * PITCH_RELEASE_X_FRAC |
| 181 | bottom_x = w * PITCH_PLATE_X_FRAC |
| 182 | top_half_w = w * PITCH_LANE_TOP_HALF_W_FRAC |
| 183 | bottom_half_w = w * PITCH_LANE_BOTTOM_HALF_W_FRAC |
| 184 | pts = np.array([ |
| 185 | [top_x - top_half_w, top_y], |
| 186 | [top_x + top_half_w, top_y], |
| 187 | [bottom_x + bottom_half_w, bottom_y], |
| 188 | [bottom_x - bottom_half_w, bottom_y], |
| 189 | ], dtype=np.int32) |
| 190 | lane = np.zeros((h, w), dtype=np.uint8) |
| 191 | cv2.fillConvexPoly(lane, pts, 255) |
| 192 | return lane |
| 193 | |
| 194 | def _detect_ball(self, frame, ts_ns): |
| 195 | h, w = frame.shape[:2] |
| 196 | x0, x1 = int(w * BALL_TRACK_X_FRAC[0]), int(w * BALL_TRACK_X_FRAC[1]) |
| 197 | y0, y1 = int(h * BALL_TRACK_Y_FRAC[0]), int(h * BALL_TRACK_Y_FRAC[1]) |
| 198 | x0, y0 = max(0, x0), max(0, y0) |
| 199 | x1, y1 = min(w, x1), min(h, y1) |
| 200 | if x1 <= x0 or y1 <= y0: |
| 201 | return None |
| 202 | |
| 203 | roi = frame[y0:y1, x0:x1] |
| 204 | hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) |
| 205 | cream = cv2.inRange(hsv, BALL_CREAM_HSV_LOW, BALL_CREAM_HSV_HIGH) |
| 206 | white = cv2.inRange(hsv, BALL_WHITE_HSV_LOW, BALL_WHITE_HSV_HIGH) |
| 207 | mask = cv2.bitwise_or(cream, white) |
| 208 | fgmask = self.bg_sub.apply(roi, learningRate=BALL_MOG_LEARNING_RATE) |
| 209 | _, fgmask = cv2.threshold(fgmask, 200, 255, cv2.THRESH_BINARY) |
| 210 | fgmask = cv2.morphologyEx(fgmask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8), 1) |
| 211 | fgmask = cv2.dilate(fgmask, np.ones((3, 3), np.uint8), 1) |
| 212 | mask = cv2.bitwise_and(mask, fgmask) |
| 213 | mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8), 1) |
| 214 | contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) |
| 215 | best, best_score = None, 0.0 |
| 216 | prev_for_score = self.prev_ball_candidate if self.ball_track_active else None |
| 217 | if prev_for_score is not None: |
| 218 | _, _, prev_ts = prev_for_score |
| 219 | if (ts_ns - prev_ts) > BALL_TRACK_LOST_MS * 1_000_000: |
| 220 | self.ball_track_active = False |
| 221 | self.prev_ball_candidate = None |
| 222 | self.last_ball_vel = (0.0, 0.0) |
| 223 | prev_for_score = None |
| 224 | acquire_x0 = w * BALL_ACQUIRE_X_FRAC[0] |
| 225 | acquire_x1 = w * BALL_ACQUIRE_X_FRAC[1] |
| 226 | acquire_y0 = h * BALL_ACQUIRE_Y_FRAC[0] |
| 227 | acquire_y1 = h * BALL_ACQUIRE_Y_FRAC[1] |
| 228 | for c in contours: |
| 229 | area = cv2.contourArea(c) |
| 230 | if area < np.pi * BALL_MIN_R * BALL_MIN_R * 0.5: |
| 231 | continue |
| 232 | (cx, cy), r = cv2.minEnclosingCircle(c) |
| 233 | if r < BALL_MIN_R or r > BALL_MAX_R: |
| 234 | continue |
| 235 | circ = float(area / (np.pi * r * r)) if r > 0 else 0.0 |
| 236 | if circ < BALL_MIN_CIRC: |
| 237 | continue |
| 238 | cmask = np.zeros_like(mask) |
| 239 | cv2.circle(cmask, (int(cx), int(cy)), max(1, int(r)), 255, -1) |
| 240 | mean_h, mean_s, mean_v, _ = cv2.mean(hsv, mask=cmask) |
| 241 | if mean_v < 135 or mean_s > 115: |
| 242 | continue |
| 243 | cx_g = float(cx) + x0 |
| 244 | cy_g = float(cy) + y0 |
| 245 | score = circ |
| 246 | if prev_for_score is None: |
| 247 | if not (acquire_x0 <= cx_g <= acquire_x1 and acquire_y0 <= cy_g <= acquire_y1): |
| 248 | continue |
| 249 | if r > BALL_ACQUIRE_MAX_R: |
| 250 | continue |
| 251 | else: |
| 252 | prev_x, prev_y, prev_ts = prev_for_score |
| 253 | if cy_g + BALL_MAX_UPWARD_STEP_PX < prev_y: |
| 254 | continue |
| 255 | dt = max(0.0, (ts_ns - prev_ts) / 1e9) |
| 256 | vx, vy = self.last_ball_vel |
| 257 | pred_x = prev_x + vx * dt if vy > 0 else prev_x |
| 258 | pred_y = prev_y + vy * dt if vy > 0 else prev_y |
| 259 | d = ((cx_g - pred_x) ** 2 + (cy_g - pred_y) ** 2) ** 0.5 |
| 260 | if d > BALL_TRACK_MAX_STEP_PX: |
| 261 | continue |
| 262 | score += 3.0 * max(0.0, 1.0 - d / BALL_TRACK_MAX_STEP_PX) |
| 263 | if score > best_score: |
| 264 | best_score = score |
| 265 | best = _Ball(ts_ns, cx_g, cy_g, float(r)) |
| 266 | if best is None: |
| 267 | if self.prev_ball_candidate is not None: |
| 268 | _, _, prev_ts = self.prev_ball_candidate |
| 269 | if (ts_ns - prev_ts) > BALL_TRACK_LOST_MS * 1_000_000: |
| 270 | self.prev_ball_candidate = None |
| 271 | self.ball_track_active = False |
| 272 | self.last_ball_vel = (0.0, 0.0) |
| 273 | return None |
| 274 | |
| 275 | prev = self.prev_ball_candidate |
| 276 | self.prev_ball_candidate = (best.x, best.y, ts_ns) |
| 277 | if prev is None: |
| 278 | self.ball_track_active = True |
| 279 | self.last_ball_vel = (0.0, 0.0) |
| 280 | return best |
| 281 | prev_x, prev_y, prev_ts = prev |
| 282 | if (ts_ns - prev_ts) > BALL_TRACK_LOST_MS * 1_000_000: |
| 283 | self.ball_track_active = True |
| 284 | return best |
| 285 | |
| 286 | motion_delta = ((best.x - prev_x) ** 2 + (best.y - prev_y) ** 2) ** 0.5 |
| 287 | if motion_delta < BALL_MIN_MOTION_DELTA_PX: |
| 288 | return None |
| 289 | if best.y + BALL_MAX_UPWARD_STEP_PX < prev_y: |
| 290 | return None |
| 291 | self.ball_track_active = True |
| 292 | return best |
| 293 | |
| 294 | def _detect_pci(self, frame): |
| 295 | h, w = frame.shape[:2] |
| 296 | hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) |
| 297 | mask = cv2.inRange(hsv, PCI_HSV_LOW, PCI_HSV_HIGH) |
| 298 | x0, x1 = int(w * PCI_SEARCH_X[0]), int(w * PCI_SEARCH_X[1]) |
| 299 | y0, y1 = int(h * PCI_SEARCH_Y[0]), int(h * PCI_SEARCH_Y[1]) |
| 300 | window = np.zeros_like(mask) |
| 301 | window[y0:y1, x0:x1] = 255 |
| 302 | mask = cv2.bitwise_and(mask, window) |
| 303 | mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8), 1) |
| 304 | ys, xs = np.where(mask > 0) |
| 305 | if len(xs) < PCI_MIN_GREEN_PX: |
| 306 | return None |
| 307 | return float(xs.mean()), float(ys.mean()) |
| 308 | |
| 309 | def _try_fit(self, plate_y_px: float): |
| 310 | trail = list(self.trail)[-FIT_USE_LAST_N:] |
| 311 | if len(trail) < MIN_FIT_POINTS: |
| 312 | return None |
| 313 | ys = np.array([d.y for d in trail], dtype=np.float64) |
| 314 | if ys.max() - ys.min() < 30: |
| 315 | return None |
| 316 | if (ys[-1] - ys[0]) < -30: |
| 317 | return None |
| 318 | t0 = trail[0].ts_ns |
| 319 | ts = np.array([(d.ts_ns - t0) / 1e9 for d in trail], dtype=np.float64) |
| 320 | xs = np.array([d.x for d in trail], dtype=np.float64) |
| 321 | try: |
| 322 | ay, by, cy = np.polyfit(ts, ys, 2) |
| 323 | bx, cx = np.polyfit(ts, xs, 1) |
| 324 | except Exception: |
| 325 | return None |
| 326 | disc = by * by - 4 * ay * (cy - plate_y_px) |
| 327 | if disc < 0 or abs(ay) < 1e-6: |
| 328 | return None |
| 329 | sqrt_d = float(np.sqrt(disc)) |
| 330 | candidates = [(-by + sqrt_d) / (2 * ay), (-by - sqrt_d) / (2 * ay)] |
| 331 | future = [t for t in candidates if t > ts[-1]] |
| 332 | if not future: |
| 333 | return None |
| 334 | t_cross = min(future) |
| 335 | plate_x = float(bx * t_cross + cx) |
| 336 | eta_ms = float((t_cross - ts[-1]) * 1000.0) |
| 337 | if eta_ms < 0 or eta_ms > 2000: |
| 338 | return None |
| 339 | return plate_x, eta_ms |
| 340 | |
| 341 | def process(self, frame): |
| 342 | |
| 343 | full_h, full_w = frame.shape[:2] |
| 344 | small = cv2.resize(frame, (full_w // 2, full_h // 2), interpolation=cv2.INTER_AREA) |
| 345 | h, w = small.shape[:2] |
| 346 | ts_ns = time.time_ns() |
| 347 | plate_y_px = h * PLATE_Y_FRAC |
| 348 | scale = 2.0 |
| 349 | |
| 350 | sz_cx = w * SZ_CX_FRAC |
| 351 | sz_cy = h * SZ_CY_FRAC |
| 352 | sz_w = w * SZ_W_FRAC |
| 353 | sz_h = h * SZ_H_FRAC |
| 354 | sz_left = sz_cx - sz_w / 2 |
| 355 | sz_right = sz_cx + sz_w / 2 |
| 356 | sz_top = sz_cy - sz_h / 2 |
| 357 | sz_bottom = sz_cy + sz_h / 2 |
| 358 | |
| 359 | self.last_pci = (sz_cx, sz_cy) |
| 360 | |
| 361 | ball = self._detect_ball(small, ts_ns) |
| 362 | ball_raw = ball |
| 363 | if ball_raw is not None: |
| 364 | if self.last_ball_pos is not None and self.last_ball_ts: |
| 365 | dt = (ts_ns - self.last_ball_ts) / 1e9 |
| 366 | if 0.0 < dt <= BALL_MOTION_MAX_GAP_MS / 1000.0: |
| 367 | vx = (ball_raw.x - self.last_ball_pos[0]) / dt |
| 368 | vy = (ball_raw.y - self.last_ball_pos[1]) / dt |
| 369 | self.last_ball_vel = (vx, vy) |
| 370 | self.last_ball_pos = (ball_raw.x, ball_raw.y) |
| 371 | self.last_ball_r = ball_raw.r |
| 372 | self.last_ball_ts = ts_ns |
| 373 | |
| 374 | if ball is not None: |
| 375 | if self.last_det_ns is not None and (ts_ns - self.last_det_ns) > PITCH_GAP_MS * 1e6: |
| 376 | self.trail.clear() |
| 377 | self.pitch_id += 1 |
| 378 | if self.trail: |
| 379 | last = self.trail[-1] |
| 380 | d = ((ball.x - last.x) ** 2 + (ball.y - last.y) ** 2) ** 0.5 |
| 381 | if d > MAX_STEP_PX: |
| 382 | self.trail.clear() |
| 383 | self.pitch_id += 1 |
| 384 | if not self.trail and ball.y > PITCH_START_MAX_Y: |
| 385 | ball = None |
| 386 | if ball is not None: |
| 387 | self.trail.append(ball) |
| 388 | self.last_det_ns = ts_ns |
| 389 | cutoff = ts_ns - int(TRAJ_WINDOW_S * 1e9) |
| 390 | while self.trail and self.trail[0].ts_ns < cutoff: |
| 391 | self.trail.popleft() |
| 392 | |
| 393 | pred = self._try_fit(plate_y_px) if ball is not None else None |
| 394 | |
| 395 | aim_x = 0.0 |
| 396 | aim_y = 0.0 |
| 397 | press_contact = 0 |
| 398 | press_power = 0 |
| 399 | eta_ms = 0 |
| 400 | pred_good = 0 |
| 401 | target_x = None |
| 402 | target_y = None |
| 403 | ball_ready_to_swing = False |
| 404 | pred_ready_to_swing = False |
| 405 | |
| 406 | pci_ref = self.last_pci if self.last_pci is not None else (w / 2.0, h * 0.55) |
| 407 | |
| 408 | if pred is not None: |
| 409 | plate_x, eta_ms_f = pred |
| 410 | eta_ms = int(max(0, min(32767, eta_ms_f))) |
| 411 | pred_good = 1 |
| 412 | target_x = plate_x |
| 413 | target_y = plate_y_px |
| 414 | elif self.last_ball_pos is not None: |
| 415 | if (ts_ns - self.last_ball_ts) <= BALL_MEMORY_MS * 1_000_000: |
| 416 | age_s = (ts_ns - self.last_ball_ts) / 1e9 |
| 417 | vx, vy = self.last_ball_vel |
| 418 | if vy > 0 and age_s <= BALL_SWING_MEMORY_MS / 1000.0: |
| 419 | target_x = self.last_ball_pos[0] + vx * age_s |
| 420 | target_y = self.last_ball_pos[1] + vy * age_s |
| 421 | else: |
| 422 | target_x = self.last_ball_pos[0] |
| 423 | target_y = self.last_ball_pos[1] |
| 424 | |
| 425 | if target_x is not None: |
| 426 | dx = target_x - pci_ref[0] |
| 427 | dy = target_y - pci_ref[1] |
| 428 | if abs(dx) > AIM_DEADZONE_PX: |
| 429 | aim_x = max(-AIM_MAX_STICK, min(AIM_MAX_STICK, AIM_GAIN_X * dx)) |
| 430 | if abs(dy) > AIM_DEADZONE_PX: |
| 431 | aim_y = max(-AIM_MAX_STICK, min(AIM_MAX_STICK, AIM_GAIN_Y * dy)) |
| 432 | |
| 433 | residual = (dx ** 2 + dy ** 2) ** 0.5 |
| 434 | |
| 435 | ball_x = self.last_ball_pos[0] if self.last_ball_pos is not None else 0 |
| 436 | ball_y = self.last_ball_pos[1] if self.last_ball_pos is not None else 0 |
| 437 | ball_age_s = (ts_ns - self.last_ball_ts) / 1e9 if self.last_ball_ts else 999.0 |
| 438 | vx, vy = self.last_ball_vel |
| 439 | if self.last_ball_pos is not None and vy > 0 and ball_age_s <= BALL_SWING_MEMORY_MS / 1000.0: |
| 440 | ball_x = self.last_ball_pos[0] + vx * ball_age_s |
| 441 | ball_y = self.last_ball_pos[1] + vy * ball_age_s |
| 442 | ball_recent = (self.last_ball_pos is not None and |
| 443 | (ts_ns - self.last_ball_ts) <= BALL_SWING_MEMORY_MS * 1_000_000) |
| 444 | cooled_down = (ts_ns - self.last_swing_ts) > 1_500_000_000 |
| 445 | zone_pad_x = SWING_ZONE_X_PAD_PX / scale |
| 446 | zone_pad_y = SWING_ZONE_Y_PAD_PX / scale |
| 447 | swing_lead = SWING_LEAD_PX / scale |
| 448 | in_zone_x = (sz_left - zone_pad_x) < ball_x < (sz_right + zone_pad_x) |
| 449 | in_zone_y = (sz_top - zone_pad_y) < ball_y < (sz_bottom + zone_pad_y) |
| 450 | |
| 451 | hittable_size = self.last_ball_r >= SWING_BALL_MIN_R |
| 452 | ball_ready_to_swing = self.ball_track_active and ball_recent and in_zone_x and in_zone_y and hittable_size |
| 453 | pred_ready_to_swing = False |
| 454 | if (ball_ready_to_swing or pred_ready_to_swing) and cooled_down: |
| 455 | self.last_swing_ts = ts_ns |
| 456 | self.swing_hold_until = ts_ns + SWING_HOLD_MS * 1_000_000 |
| 457 | |
| 458 | if ts_ns < self.swing_hold_until: |
| 459 | press_contact = 1 |
| 460 | |
| 461 | armed = 1 |
| 462 | in_flight = 1 if (self.trail and ball is not None) else 0 |
| 463 | debug_flags = (1 if self.last_pci else 0) | (2 if ball else 0) | (4 if pred_good else 0) |
| 464 | |
| 465 | if DEBUG_FORCE_AIM: |
| 466 | aim_x = 25.0 |
| 467 | aim_y = 0.0 |
| 468 | |
| 469 | self.gcvdata[0:4] = _fix32(aim_x) |
| 470 | self.gcvdata[4:8] = _fix32(aim_y) |
| 471 | self.gcvdata[8:10] = _int16(armed) |
| 472 | self.gcvdata[10:12] = _int16(in_flight) |
| 473 | self.gcvdata[12:14] = _int16(press_contact) |
| 474 | self.gcvdata[14:16] = _int16(press_power) |
| 475 | self.gcvdata[16:18] = _int16(eta_ms) |
| 476 | self.gcvdata[18:20] = _int16(debug_flags) |
| 477 | |
| 478 | box_color = (0, 255, 255) if press_contact else ((0, 255, 0) if ball_ready_to_swing or pred_ready_to_swing else (190, 190, 190)) |
| 479 | cv2.rectangle(frame, |
| 480 | (int(sz_left * scale), int(sz_top * scale)), |
| 481 | (int(sz_right * scale), int(sz_bottom * scale)), |
| 482 | box_color, 4) |
| 483 | if ball is not None: |
| 484 | cv2.circle(frame, (int(ball.x * scale), int(ball.y * scale)), max(12, int(ball.r * scale)), (0, 255, 255), 2) |
| 485 | if pred is not None: |
| 486 | px, _ = pred |
| 487 | cv2.drawMarker(frame, (int(px * scale), int(plate_y_px * scale)), (0, 128, 255), cv2.MARKER_CROSS, 48, 3) |
| 488 | cv2.putText(frame, f"eta {eta_ms}ms aim=({aim_x:+.1f},{aim_y:+.1f}) contact={press_contact}", |
| 489 | (20, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2, cv2.LINE_AA) |
| 490 | |
| 491 | hud = f"v41 {'SWING' if press_contact else 'READY' if (ball_ready_to_swing or pred_ready_to_swing) else 'LOCK' if self.ball_track_active else 'WAIT'}" |
| 492 | cv2.rectangle(frame, (16, full_h - 92), (430, full_h - 18), (0, 0, 0), -1) |
| 493 | cv2.putText(frame, hud, (30, full_h - 42), |
| 494 | cv2.FONT_HERSHEY_SIMPLEX, 1.4, box_color, 4, cv2.LINE_AA) |
| 495 | |
| 496 | return (frame, self.gcvdata) |
| 497 | |
| 498 | try: |
| 499 | import importlib.util as _importlib_util |
| 500 | from pathlib import Path as _Path |
| 501 | |
| 502 | _MODEL_PATH = _Path(__file__).resolve().parents[1] / "configs" / "models" / "mlb26_ball.onnx" |
| 503 | _YOLO_PATH = _Path(__file__).with_name("mlb26_gcv_yolo.py") |
| 504 | if _MODEL_PATH.exists() and _YOLO_PATH.exists(): |
| 505 | _spec = _importlib_util.spec_from_file_location("_mlb26_gcv_yolo", str(_YOLO_PATH)) |
| 506 | if _spec is not None and _spec.loader is not None: |
| 507 | _module = _importlib_util.module_from_spec(_spec) |
| 508 | _spec.loader.exec_module(_module) |
| 509 | GCVWorker = _module.GCVWorker |
| 510 | except Exception: |
| 511 | pass |