Files
ball_game/vision.py
T
2026-10-31 23:04:47 +03:30

229 lines
7.0 KiB
Python

# vision.py
#
# Runs everything that looks at the camera - screen calibration and
# sticker detection - on its own background thread, so the render /
# physics loop never waits on OpenCV. (Before this, a detection pass
# ran inside the game loop and every pass was a small visible hitch.)
# OpenCV releases the GIL while it works, so the two really do run
# side by side.
#
# The game loop only ever calls snapshot(), which is a cheap lock +
# a few attribute reads. The obstacle list it returns is the SAME
# list object until something actually changed (Detector publishes a
# new list only on change), so physics/rendering can detect changes
# with a plain identity check.
import threading
import time
from config import (
DETECTION_SCALE,
MONITOR_DETECTION_INTERVAL,
OBSTACLE_DETECTION_INTERVAL,
)
class Vision:
def __init__(self, camera, calibration, detector):
self.camera = camera
self.calibration = calibration
self.detector = detector
self._lock = threading.Lock()
self._calibrated = False
self._frozen = False
self._obstacles = []
self._message = "Camera not connected yet."
# Bumped by request_reset(); a result computed against an
# older generation is discarded instead of being published.
self._generation = 0
self._reset_pending = False
self._stop_event = threading.Event()
self._thread = threading.Thread(
target=self._run,
name="vision",
daemon=True,
)
# ========================================================
# Public API (called from the game loop)
# ========================================================
def start(self):
self._thread.start()
def stop(self):
self._stop_event.set()
def request_reset(self):
"""Forget calibration and stickers; start over."""
with self._lock:
self._generation += 1
self._reset_pending = True
self._calibrated = False
self._obstacles = []
def snapshot(self):
"""(calibrated, camera_frozen, obstacles, camera_message)."""
with self._lock:
return (
self._calibrated,
self._frozen,
self._obstacles,
self._message,
)
# ========================================================
# Thread
# ========================================================
def _run(self):
last_frame_id = None
last_monitor_pass = 0.0
last_obstacle_pass = 0.0
last_error_print = 0.0
while not self._stop_event.is_set():
try:
with self._lock:
reset = self._reset_pending
self._reset_pending = False
generation = self._generation
if reset:
self.calibration.reset()
self.detector.reset()
last_frame_id = None
last_monitor_pass = 0.0
last_obstacle_pass = 0.0
try:
frame, frame_id = self.camera.read_with_id()
except RuntimeError as error:
self._publish(
generation,
message=str(error),
)
time.sleep(0.05)
continue
frozen = self.camera.is_frozen()
if frame_id == last_frame_id:
# Same frame as last time: nothing new to
# analyse (the camera delivers ~30 fps, the
# loop spins faster). Still keep the frozen
# flag fresh in case frames stopped entirely.
self._publish(generation, frozen=frozen)
time.sleep(0.004)
continue
last_frame_id = frame_id
if frozen:
# A stuck feed must not be trusted for
# calibration or stickers.
self._publish(
generation,
frozen=True,
message="",
)
continue
now = time.monotonic()
calibrated = self.calibration.is_calibrated()
# Until locked on, look at every new frame; once
# locked, only re-verify the monitor occasionally.
if (
not calibrated
or now - last_monitor_pass
>= MONITOR_DETECTION_INTERVAL
):
self.calibration.update(frame)
last_monitor_pass = now
calibrated = self.calibration.is_calibrated()
obstacles = None
if (
calibrated
and now - last_obstacle_pass
>= OBSTACLE_DETECTION_INTERVAL
):
small = self.calibration.transform_frame(
frame,
DETECTION_SCALE,
)
# Keep the sticker detector's idea of "what does
# white look like right now" in sync with what
# calibration is continuously learning off the
# ring - see Calibration.white_reference().
self.detector.set_white_reference(
self.calibration.white_reference()
)
obstacles = self.detector.detect(small)
last_obstacle_pass = time.monotonic()
self._publish(
generation,
calibrated=calibrated,
frozen=False,
message="",
obstacles=obstacles,
)
except Exception as error:
# Never let one bad frame kill the thread (and with
# it, all vision until the next restart).
now = time.monotonic()
if now - last_error_print > 5.0:
print(
"[VISION] unexpected error, "
f"continuing: {error!r}"
)
last_error_print = now
time.sleep(0.1)
def _publish(
self,
generation,
calibrated=None,
frozen=None,
message=None,
obstacles=None,
):
with self._lock:
if generation != self._generation:
# A reset happened while this pass was running; its
# results describe the old state.
return
if calibrated is not None:
self._calibrated = calibrated
if frozen is not None:
self._frozen = frozen
if message is not None:
self._message = message
if obstacles is not None:
self._obstacles = obstacles