""" 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