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

259 lines
8.0 KiB
Python

import threading
import time
import cv2
import numpy as np
from config import (
CAMERA_FROZEN_DIFF_THRESHOLD,
CAMERA_FROZEN_SECONDS,
CAMERA_CONNECT_RETRY_SECONDS,
)
class Camera:
"""
Wrapper around cv2.VideoCapture for an HTTP/MJPEG camera stream,
with a few exhibition-day survival features:
- Frame grabbing runs on its own background thread that reads as
fast as the stream delivers and keeps only the SINGLE most
recent frame. cv2.VideoCapture's own buffering on network
streams often ignores CAP_PROP_BUFFERSIZE; if the consumer is
ever briefly slower than the stream, frames queue up upstream
and every later read() returns an ever-staler frame ("the game
gets slower and slower"). Draining continuously and handing
out only the newest frame makes that impossible.
- Connecting also happens on that thread and never blocks the
caller: the game window comes up immediately (and stays
responsive to the keyboard) even if the phone/app isn't
streaming yet. It keeps retrying forever, both at startup and
after a mid-show drop.
- Every frame gets an increasing id (see read_with_id), so the
vision thread can skip a frame it has already processed
instead of redoing the same work on it.
- Frozen-frame detection: apps that pause in the background
often keep re-sending the last JPEG, which cv2.VideoCapture
happily "reads" forever. We track whether the image content
actually changes and expose is_frozen() so callers can refuse
to trust a feed that looks alive but isn't.
"""
MAX_CONSECUTIVE_FAILURES = 5
RECONNECT_BACKOFF_SECONDS = 1.0
def __init__(self, url: str):
self.url = url
self.cap = None
self._stop_event = threading.Event()
self._state_lock = threading.Lock()
self._last_thumb = None
self._last_change_time = time.monotonic()
self._frame_lock = threading.Lock()
self._latest_frame = None
self._latest_id = 0
self._latest_error = "Camera not connected yet."
self._thread = threading.Thread(
target=self._run,
name="camera-reader",
daemon=True,
)
self._thread.start()
# ========================================================
# Connection
# ========================================================
def _connect_with_retry(self):
"""
Keeps trying to open the stream until it works or the
camera is released. This runs unattended in a booth: the
phone/app may not be streaming yet, or wifi may take a
moment, and nobody may be there to relaunch anything.
"""
attempt = 0
while not self._stop_event.is_set():
attempt += 1
try:
self._open()
print("[CAMERA] connected.")
return
except Exception as error:
with self._frame_lock:
self._latest_error = (
f"Camera not connected ({error})"
)
print(
f"[CAMERA] connect attempt {attempt} failed "
f"({error}) - retrying in "
f"{CAMERA_CONNECT_RETRY_SECONDS:.1f}s..."
)
self._stop_event.wait(
CAMERA_CONNECT_RETRY_SECONDS
)
def _open(self):
if self.cap is not None:
try:
self.cap.release()
except Exception:
pass
self.cap = cv2.VideoCapture(self.url)
# Cheap extra defense on backends that honor it; the reader
# thread is what actually guarantees we never fall behind.
try:
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
except Exception:
pass
if not self.cap.isOpened():
raise RuntimeError(
f"Could not connect to camera stream: {self.url}"
)
# ========================================================
# Background thread
# ========================================================
def _run(self):
self._connect_with_retry()
consecutive_failures = 0
while not self._stop_event.is_set():
try:
success, frame = self.cap.read()
except Exception as error:
success, frame = False, None
with self._frame_lock:
self._latest_error = (
f"Camera read error: {error}"
)
if not success or frame is None:
consecutive_failures += 1
with self._frame_lock:
self._latest_error = (
"Failed to read frame from camera."
)
if (
consecutive_failures
>= self.MAX_CONSECUTIVE_FAILURES
):
self._stop_event.wait(
self.RECONNECT_BACKOFF_SECONDS
)
self._connect_with_retry()
consecutive_failures = 0
else:
# A failed read on some backends returns
# instantly; don't spin a core on it.
self._stop_event.wait(0.01)
continue
consecutive_failures = 0
self._update_freeze_state(frame)
# Publish only the newest frame; anything the consumer
# hasn't taken yet is simply replaced.
with self._frame_lock:
self._latest_frame = frame
self._latest_id += 1
self._latest_error = None
# ========================================================
# Public API
# ========================================================
def read_with_id(self):
"""Newest frame and its id; raises RuntimeError if none yet."""
with self._frame_lock:
frame = self._latest_frame
frame_id = self._latest_id
error = self._latest_error
if frame is None:
raise RuntimeError(
error or "No frame received from camera yet."
)
return frame, frame_id
def read(self):
return self.read_with_id()[0]
def is_frozen(self):
with self._state_lock:
last_change = self._last_change_time
return (
time.monotonic() - last_change
) > CAMERA_FROZEN_SECONDS
def release(self):
self._stop_event.set()
# Let the reader thread notice and finish before the
# capture object is torn down underneath it.
if self._thread.is_alive():
self._thread.join(timeout=2.0)
if self.cap is not None:
try:
self.cap.release()
except Exception:
pass
# ========================================================
# Frozen-frame detection
# ========================================================
def _update_freeze_state(self, frame):
# Shrink FIRST (INTER_AREA averages, so it also suppresses
# pixel noise) and only then convert to gray - converting a
# full frame just to throw almost all of it away is wasted
# work on the capture thread.
thumb = cv2.cvtColor(
cv2.resize(
frame,
(32, 18),
interpolation=cv2.INTER_AREA,
),
cv2.COLOR_BGR2GRAY,
).astype(np.int16)
now = time.monotonic()
with self._state_lock:
if self._last_thumb is None:
self._last_change_time = now
else:
diff = float(
np.mean(np.abs(thumb - self._last_thumb))
)
if diff > CAMERA_FROZEN_DIFF_THRESHOLD:
self._last_change_time = now
self._last_thumb = thumb