""" Frame readers for RTSP streams and HTTP snapshots. Extracted from app.py for modular architecture. """ import cv2 import logging import time import threading import requests import numpy as np from typing import Optional, Callable from queue import Queue logger = logging.getLogger(__name__) class RTSPReader: """RTSP stream frame reader using OpenCV.""" def __init__(self, camera_id: str, rtsp_url: str, max_retries: int = 3): self.camera_id = camera_id self.rtsp_url = rtsp_url self.max_retries = max_retries self.cap = None self.stop_event = threading.Event() self.thread = None self.frame_callback: Optional[Callable] = None def set_frame_callback(self, callback: Callable[[str, np.ndarray], None]): """Set callback function to handle captured frames.""" self.frame_callback = callback def start(self): """Start the RTSP reader thread.""" if self.thread and self.thread.is_alive(): logger.warning(f"RTSP reader for {self.camera_id} already running") return self.stop_event.clear() self.thread = threading.Thread(target=self._read_frames, daemon=True) self.thread.start() logger.info(f"Started RTSP reader for camera {self.camera_id}") def stop(self): """Stop the RTSP reader thread.""" self.stop_event.set() if self.thread: self.thread.join(timeout=5.0) if self.cap: self.cap.release() logger.info(f"Stopped RTSP reader for camera {self.camera_id}") def _read_frames(self): """Main frame reading loop.""" retries = 0 frame_count = 0 last_log_time = time.time() try: # Initialize video capture self.cap = cv2.VideoCapture(self.rtsp_url) # Set buffer size to 1 to get latest frames self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) if self.cap.isOpened(): 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}") else: logger.error(f"Camera {self.camera_id} failed to open initially") while not self.stop_event.is_set(): try: if not self.cap.isOpened(): logger.error(f"Camera {self.camera_id} not open, attempting to reopen") self.cap.open(self.rtsp_url) time.sleep(1) continue ret, frame = self.cap.read() if not ret or frame is None: logger.warning(f"Failed to read frame from camera {self.camera_id}") retries += 1 if retries > self.max_retries and self.max_retries != -1: logger.error(f"Max retries reached for camera {self.camera_id}") break time.sleep(0.1) continue # Reset retry counter on successful read retries = 0 frame_count += 1 # Call frame callback if set if self.frame_callback: self.frame_callback(self.camera_id, frame) # Log progress every 30 seconds current_time = time.time() if current_time - last_log_time >= 30: logger.info(f"Camera {self.camera_id}: {frame_count} frames processed") last_log_time = current_time # Small delay to prevent CPU overload time.sleep(0.033) # ~30 FPS except Exception as e: logger.error(f"Error reading frame from camera {self.camera_id}: {e}") retries += 1 if retries > self.max_retries and self.max_retries != -1: break time.sleep(1) except Exception as e: logger.error(f"Fatal error in RTSP reader for camera {self.camera_id}: {e}") finally: if self.cap: self.cap.release() logger.info(f"RTSP reader thread ended for camera {self.camera_id}") class HTTPSnapshotReader: """HTTP snapshot reader for periodic image capture.""" def __init__(self, camera_id: str, snapshot_url: str, interval_ms: int = 5000, max_retries: int = 3): self.camera_id = camera_id self.snapshot_url = snapshot_url self.interval_ms = interval_ms self.max_retries = max_retries self.stop_event = threading.Event() self.thread = None self.frame_callback: Optional[Callable] = None def set_frame_callback(self, callback: Callable[[str, np.ndarray], None]): """Set callback function to handle captured frames.""" self.frame_callback = callback def start(self): """Start the snapshot reader thread.""" if self.thread and self.thread.is_alive(): logger.warning(f"Snapshot reader for {self.camera_id} already running") return self.stop_event.clear() self.thread = threading.Thread(target=self._read_snapshots, daemon=True) self.thread.start() logger.info(f"Started snapshot reader for camera {self.camera_id}") def stop(self): """Stop the snapshot reader thread.""" self.stop_event.set() if self.thread: self.thread.join(timeout=5.0) logger.info(f"Stopped snapshot reader for camera {self.camera_id}") def _read_snapshots(self): """Main snapshot reading loop.""" retries = 0 frame_count = 0 last_log_time = time.time() interval_seconds = self.interval_ms / 1000.0 logger.info(f"Snapshot interval for camera {self.camera_id}: {interval_seconds}s") try: while not self.stop_event.is_set(): try: start_time = time.time() frame = self._fetch_snapshot() if frame is None: logger.warning(f"Failed to fetch snapshot for camera {self.camera_id}, retry {retries+1}/{self.max_retries}") retries += 1 if retries > self.max_retries and self.max_retries != -1: logger.error(f"Max retries reached for snapshot camera {self.camera_id}") break time.sleep(1) continue # Reset retry counter on successful fetch retries = 0 frame_count += 1 # Call frame callback if set if self.frame_callback: self.frame_callback(self.camera_id, frame) # Log progress every 30 seconds current_time = time.time() if current_time - last_log_time >= 30: logger.info(f"Camera {self.camera_id}: {frame_count} snapshots processed") last_log_time = current_time # Wait for next interval, accounting for processing time elapsed = time.time() - start_time sleep_time = max(0, interval_seconds - elapsed) if sleep_time > 0: self.stop_event.wait(sleep_time) except Exception as e: logger.error(f"Error fetching snapshot for camera {self.camera_id}: {e}") retries += 1 if retries > self.max_retries and self.max_retries != -1: break time.sleep(1) except Exception as e: logger.error(f"Fatal error in snapshot reader for camera {self.camera_id}: {e}") finally: logger.info(f"Snapshot reader thread ended for camera {self.camera_id}") def _fetch_snapshot(self) -> Optional[np.ndarray]: """Fetch a single snapshot from HTTP URL.""" try: # Parse URL to extract auth credentials if present from urllib.parse import urlparse parsed_url = urlparse(self.snapshot_url) # Prepare headers with proper authentication headers = {} auth = None if parsed_url.username and parsed_url.password: # Use HTTP Basic Auth properly from requests.auth import HTTPBasicAuth, HTTPDigestAuth auth = HTTPBasicAuth(parsed_url.username, parsed_url.password) # Reconstruct URL without credentials clean_url = f"{parsed_url.scheme}://{parsed_url.hostname}" if parsed_url.port: clean_url += f":{parsed_url.port}" clean_url += parsed_url.path if parsed_url.query: clean_url += f"?{parsed_url.query}" # Try with Basic Auth first response = requests.get(clean_url, auth=auth, timeout=10, headers=headers) # If Basic Auth fails, try Digest Auth (common for IP cameras) if response.status_code == 401: auth = HTTPDigestAuth(parsed_url.username, parsed_url.password) response = requests.get(clean_url, auth=auth, timeout=10, headers=headers) else: # No auth in URL, use as-is response = requests.get(self.snapshot_url, timeout=10, headers=headers) if response.status_code == 200: # Convert bytes to numpy array image_array = np.frombuffer(response.content, np.uint8) # Decode as image frame = cv2.imdecode(image_array, cv2.IMREAD_COLOR) return frame else: logger.warning(f"HTTP {response.status_code} from {self.snapshot_url}") return None except requests.RequestException as e: logger.error(f"Request error fetching snapshot: {e}") return None except Exception as e: logger.error(f"Error decoding snapshot: {e}") return None def fetch_snapshot(url: str) -> Optional[np.ndarray]: """Standalone function to fetch a snapshot (for compatibility).""" try: # Parse URL to extract auth credentials if present from urllib.parse import urlparse parsed_url = urlparse(url) auth = None if parsed_url.username and parsed_url.password: # Use HTTP Basic Auth properly from requests.auth import HTTPBasicAuth, HTTPDigestAuth auth = HTTPBasicAuth(parsed_url.username, parsed_url.password) # Reconstruct URL without credentials clean_url = f"{parsed_url.scheme}://{parsed_url.hostname}" if parsed_url.port: clean_url += f":{parsed_url.port}" clean_url += parsed_url.path if parsed_url.query: clean_url += f"?{parsed_url.query}" # Try with Basic Auth first response = requests.get(clean_url, auth=auth, timeout=10) # If Basic Auth fails, try Digest Auth (common for IP cameras) if response.status_code == 401: auth = HTTPDigestAuth(parsed_url.username, parsed_url.password) response = requests.get(clean_url, auth=auth, timeout=10) else: # No auth in URL, use as-is response = requests.get(url, timeout=10) if response.status_code == 200: image_array = np.frombuffer(response.content, np.uint8) frame = cv2.imdecode(image_array, cv2.IMREAD_COLOR) return frame return None except Exception as e: logger.error(f"Error fetching snapshot from {url}: {e}") return None