259 lines
8.0 KiB
Python
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
|