python-detector-worker/detector_worker/streams/frame_reader.py
2025-09-12 14:45:11 +07:00

476 lines
No EOL
20 KiB
Python

"""
Frame reading implementations for RTSP and HTTP snapshot streams.
This module provides thread-safe frame readers for different camera stream types.
"""
import cv2
import time
import queue
import logging
import requests
import threading
from typing import Optional, Any
import numpy as np
from ..core.constants import (
DEFAULT_RECONNECT_INTERVAL_SEC,
DEFAULT_MAX_RETRIES,
HTTP_SNAPSHOT_TIMEOUT,
SHARED_STREAM_BUFFER_SIZE
)
from ..core.exceptions import (
CameraConnectionError,
FrameReadError,
create_stream_error
)
logger = logging.getLogger(__name__)
def fetch_snapshot(url: str, timeout: int = HTTP_SNAPSHOT_TIMEOUT) -> Optional[np.ndarray]:
"""
Fetch a single snapshot from HTTP/HTTPS URL.
Args:
url: HTTP/HTTPS URL to fetch snapshot from
timeout: Request timeout in seconds
Returns:
Decoded image frame or None if fetch failed
"""
try:
logger.debug(f"Fetching snapshot from {url}")
response = requests.get(url, timeout=timeout, stream=True)
response.raise_for_status()
# Check if response has content
if not response.content:
logger.warning(f"Empty response from snapshot URL: {url}")
return None
# Decode image from response bytes
img_array = np.frombuffer(response.content, dtype=np.uint8)
frame = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
if frame is None:
logger.warning(f"Failed to decode image from snapshot URL: {url}")
return None
logger.debug(f"Successfully fetched snapshot from {url}, shape: {frame.shape}")
return frame
except requests.exceptions.Timeout:
logger.warning(f"Timeout fetching snapshot from {url}")
return None
except requests.exceptions.ConnectionError:
logger.warning(f"Connection error fetching snapshot from {url}")
return None
except requests.exceptions.HTTPError as e:
logger.warning(f"HTTP error fetching snapshot from {url}: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error fetching snapshot from {url}: {e}")
return None
class RTSPFrameReader:
"""Thread-safe RTSP frame reader."""
def __init__(self,
camera_id: str,
rtsp_url: str,
buffer: queue.Queue,
stop_event: threading.Event,
reconnect_interval: int = DEFAULT_RECONNECT_INTERVAL_SEC,
max_retries: int = DEFAULT_MAX_RETRIES,
connection_callback=None):
"""
Initialize RTSP frame reader.
Args:
camera_id: Unique camera identifier
rtsp_url: RTSP stream URL
buffer: Queue to put frames into
stop_event: Event to signal thread shutdown
reconnect_interval: Seconds between reconnection attempts
max_retries: Maximum retry attempts (-1 for unlimited)
connection_callback: Callback function for connection state changes
"""
self.camera_id = camera_id
self.rtsp_url = rtsp_url
self.buffer = buffer
self.stop_event = stop_event
self.reconnect_interval = reconnect_interval
self.max_retries = max_retries
self.connection_callback = connection_callback
self.cap: Optional[cv2.VideoCapture] = None
self.retries = 0
self.frame_count = 0
self.last_log_time = time.time()
def _set_connection_state(self, connected: bool, error_msg: Optional[str] = None):
"""Update connection state via callback."""
if self.connection_callback:
self.connection_callback(self.camera_id, connected, error_msg)
def _initialize_capture(self) -> bool:
"""Initialize video capture."""
try:
self.cap = cv2.VideoCapture(self.rtsp_url)
if self.cap.isOpened():
# Log camera properties
width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
fps = self.cap.get(cv2.CAP_PROP_FPS)
logger.info(f"Camera {self.camera_id} opened: {width}x{height}, FPS: {fps}")
self._set_connection_state(True)
return True
else:
logger.error(f"Camera {self.camera_id} failed to open")
self._set_connection_state(False, "Failed to open camera")
return False
except Exception as e:
error_msg = f"Failed to initialize capture: {e}"
logger.error(f"Camera {self.camera_id}: {error_msg}")
self._set_connection_state(False, error_msg)
return False
def _reconnect(self) -> bool:
"""Attempt to reconnect to RTSP stream."""
if self.cap:
self.cap.release()
self.cap = None
logger.info(f"Attempting to reconnect RTSP stream for camera: {self.camera_id}")
time.sleep(self.reconnect_interval)
return self._initialize_capture()
def _read_frame(self) -> Optional[np.ndarray]:
"""Read a single frame from the stream."""
if not self.cap or not self.cap.isOpened():
return None
try:
ret, frame = self.cap.read()
if not ret:
return None
# Update statistics
self.frame_count += 1
current_time = time.time()
# Log frame stats every 5 seconds
if current_time - self.last_log_time > 5:
elapsed = current_time - self.last_log_time
logger.info(f"Camera {self.camera_id}: Read {self.frame_count} frames in {elapsed:.1f}s")
self.frame_count = 0
self.last_log_time = current_time
return frame
except cv2.error as e:
raise FrameReadError(f"OpenCV error reading frame: {e}")
def _put_frame(self, frame: np.ndarray):
"""Put frame into buffer, removing old frame if necessary."""
# Remove old frame if buffer is full
if not self.buffer.empty():
try:
self.buffer.get_nowait()
logger.debug(f"Removed old frame from buffer for camera {self.camera_id}")
except queue.Empty:
pass
self.buffer.put(frame)
logger.debug(f"Added frame to buffer for camera {self.camera_id}, buffer size: {self.buffer.qsize()}")
def run(self):
"""Main frame reading loop."""
logger.info(f"Starting RTSP frame reader for camera {self.camera_id}")
try:
# Initialize capture
if not self._initialize_capture():
return
while not self.stop_event.is_set():
try:
frame = self._read_frame()
if frame is None:
# Connection lost
self.retries += 1
error_msg = f"Connection lost, retry {self.retries}/{self.max_retries}"
logger.warning(f"Camera {self.camera_id}: {error_msg}")
self._set_connection_state(False, error_msg)
# Check max retries
if self.retries > self.max_retries and self.max_retries != -1:
logger.error(f"Camera {self.camera_id}: Max retries reached, stopping")
self._set_connection_state(False, "Max retries reached")
break
# Attempt reconnection
if not self._reconnect():
continue
# Reset retry counter on successful reconnection
self.retries = 0
continue
# Successfully read frame
logger.debug(f"Camera {self.camera_id}: Read frame, shape: {frame.shape}")
self.retries = 0
self._set_connection_state(True)
self._put_frame(frame)
# Short sleep to avoid CPU overuse
time.sleep(0.01)
except FrameReadError as e:
self.retries += 1
error_msg = f"Frame read error: {e}"
logger.error(f"Camera {self.camera_id}: {error_msg}")
self._set_connection_state(False, error_msg)
# Check max retries
if self.retries > self.max_retries and self.max_retries != -1:
logger.error(f"Camera {self.camera_id}: Max retries reached after error")
self._set_connection_state(False, "Max retries reached after error")
break
# Attempt reconnection
if not self._reconnect():
continue
except Exception as e:
error_msg = f"Unexpected error: {str(e)}"
logger.error(f"Camera {self.camera_id}: {error_msg}", exc_info=True)
self._set_connection_state(False, error_msg)
break
except Exception as e:
logger.error(f"Error in RTSP frame reader for camera {self.camera_id}: {str(e)}", exc_info=True)
finally:
logger.info(f"RTSP frame reader for camera {self.camera_id} is exiting")
if self.cap and self.cap.isOpened():
self.cap.release()
class SnapshotFrameReader:
"""Thread-safe HTTP snapshot frame reader."""
def __init__(self,
camera_id: str,
snapshot_url: str,
snapshot_interval: int,
buffer: queue.Queue,
stop_event: threading.Event,
max_retries: int = DEFAULT_MAX_RETRIES,
connection_callback=None):
"""
Initialize snapshot frame reader.
Args:
camera_id: Unique camera identifier
snapshot_url: HTTP/HTTPS snapshot URL
snapshot_interval: Interval between snapshots in milliseconds
buffer: Queue to put frames into
stop_event: Event to signal thread shutdown
max_retries: Maximum retry attempts (-1 for unlimited)
connection_callback: Callback function for connection state changes
"""
self.camera_id = camera_id
self.snapshot_url = snapshot_url
self.snapshot_interval = snapshot_interval
self.buffer = buffer
self.stop_event = stop_event
self.max_retries = max_retries
self.connection_callback = connection_callback
self.retries = 0
self.consecutive_failures = 0
self.frame_count = 0
self.last_log_time = time.time()
def _set_connection_state(self, connected: bool, error_msg: Optional[str] = None):
"""Update connection state via callback."""
if self.connection_callback:
self.connection_callback(self.camera_id, connected, error_msg)
def _calculate_backoff_delay(self) -> float:
"""Calculate exponential backoff delay based on consecutive failures."""
interval_seconds = self.snapshot_interval / 1000.0
backoff_delay = min(30, max(1, min(2 ** min(self.consecutive_failures - 1, 6), interval_seconds * 2)))
return backoff_delay
def _test_connectivity(self):
"""Test connectivity to snapshot URL."""
if self.consecutive_failures % 5 == 1: # Every 5th failure
try:
test_response = requests.get(self.snapshot_url, timeout=(2, 5), stream=False)
logger.info(f"Camera {self.camera_id}: Connectivity test result: {test_response.status_code}")
except Exception as test_error:
logger.warning(f"Camera {self.camera_id}: Connectivity test failed: {test_error}")
def _put_frame(self, frame: np.ndarray):
"""Put frame into buffer, removing old frame if necessary."""
# Remove old frame if buffer is full
if not self.buffer.empty():
try:
self.buffer.get_nowait()
logger.debug(f"Removed old snapshot from buffer for camera {self.camera_id}")
except queue.Empty:
pass
self.buffer.put(frame)
logger.debug(f"Added snapshot to buffer for camera {self.camera_id}, buffer size: {self.buffer.qsize()}")
def run(self):
"""Main snapshot reading loop."""
logger.info(f"Starting snapshot reader for camera {self.camera_id} from {self.snapshot_url}")
interval_seconds = self.snapshot_interval / 1000.0
logger.info(f"Snapshot interval for camera {self.camera_id}: {interval_seconds}s")
# Initialize connection state
self._set_connection_state(True)
try:
while not self.stop_event.is_set():
try:
start_time = time.time()
frame = fetch_snapshot(self.snapshot_url)
if frame is None:
# Failed to fetch snapshot
self.consecutive_failures += 1
self.retries += 1
error_msg = f"Failed to fetch snapshot, consecutive failures: {self.consecutive_failures}"
logger.warning(f"Camera {self.camera_id}: {error_msg}")
self._set_connection_state(False, error_msg)
# Test connectivity periodically
self._test_connectivity()
# Check max retries
if self.retries > self.max_retries and self.max_retries != -1:
logger.error(f"Camera {self.camera_id}: Max retries reached for snapshot, stopping")
self._set_connection_state(False, "Max retries reached for snapshot")
break
# Exponential backoff
backoff_delay = self._calculate_backoff_delay()
logger.debug(f"Camera {self.camera_id}: Backing off for {backoff_delay:.1f}s")
if self.stop_event.wait(backoff_delay):
break # Exit if stop event set during backoff
continue
# Successfully fetched snapshot
self.consecutive_failures = 0 # Reset on success
self.retries = 0
self.frame_count += 1
current_time = time.time()
# Log frame stats every 5 seconds
if current_time - self.last_log_time > 5:
elapsed = current_time - self.last_log_time
logger.info(f"Camera {self.camera_id}: Fetched {self.frame_count} snapshots in {elapsed:.1f}s")
self.frame_count = 0
self.last_log_time = current_time
logger.debug(f"Camera {self.camera_id}: Fetched snapshot, shape: {frame.shape}")
self._set_connection_state(True)
self._put_frame(frame)
# Wait for interval
elapsed = time.time() - start_time
sleep_time = max(interval_seconds - elapsed, 0)
if sleep_time > 0:
if self.stop_event.wait(sleep_time):
break # Exit if stop event set during sleep
except Exception as e:
self.consecutive_failures += 1
self.retries += 1
error_msg = f"Unexpected error: {str(e)}"
logger.error(f"Camera {self.camera_id}: {error_msg}", exc_info=True)
self._set_connection_state(False, error_msg)
# Check max retries
if self.retries > self.max_retries and self.max_retries != -1:
logger.error(f"Camera {self.camera_id}: Max retries reached after error")
self._set_connection_state(False, "Max retries reached after error")
break
# Exponential backoff for exceptions too
backoff_delay = self._calculate_backoff_delay()
logger.debug(f"Camera {self.camera_id}: Exception backoff for {backoff_delay:.1f}s")
if self.stop_event.wait(backoff_delay):
break # Exit if stop event set during backoff
except Exception as e:
logger.error(f"Error in snapshot reader for camera {self.camera_id}: {str(e)}", exc_info=True)
finally:
logger.info(f"Snapshot reader for camera {self.camera_id} is exiting")
def create_frame_reader_thread(camera_id: str,
rtsp_url: Optional[str] = None,
snapshot_url: Optional[str] = None,
snapshot_interval: Optional[int] = None,
buffer: Optional[queue.Queue] = None,
stop_event: Optional[threading.Event] = None,
connection_callback=None) -> Optional[threading.Thread]:
"""
Create appropriate frame reader thread based on stream type.
Args:
camera_id: Unique camera identifier
rtsp_url: RTSP stream URL (for RTSP streams)
snapshot_url: HTTP snapshot URL (for snapshot streams)
snapshot_interval: Snapshot interval in milliseconds
buffer: Frame buffer queue
stop_event: Thread stop event
connection_callback: Connection state callback
Returns:
Configured thread ready to start, or None if invalid parameters
"""
if not buffer:
buffer = queue.Queue(maxsize=SHARED_STREAM_BUFFER_SIZE)
if not stop_event:
stop_event = threading.Event()
if snapshot_url and snapshot_interval:
# Create snapshot reader
reader = SnapshotFrameReader(
camera_id=camera_id,
snapshot_url=snapshot_url,
snapshot_interval=snapshot_interval,
buffer=buffer,
stop_event=stop_event,
connection_callback=connection_callback
)
thread = threading.Thread(target=reader.run, name=f"snapshot-{camera_id}")
elif rtsp_url:
# Create RTSP reader
reader = RTSPFrameReader(
camera_id=camera_id,
rtsp_url=rtsp_url,
buffer=buffer,
stop_event=stop_event,
connection_callback=connection_callback
)
thread = threading.Thread(target=reader.run, name=f"rtsp-{camera_id}")
else:
logger.error(f"No valid URL provided for camera {camera_id}")
return None
thread.daemon = True
return thread