Zion Boggan
repos/Pitch Tracker CV/io_titan/mlb26_gcv.py
zionboggan.com ↗
511 lines · python
History for this file →
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