Refactor: PHASE 2: Core Module Extraction
This commit is contained in:
parent
96bedae80a
commit
4e9ae6bcc4
7 changed files with 3684 additions and 0 deletions
476
detector_worker/streams/frame_reader.py
Normal file
476
detector_worker/streams/frame_reader.py
Normal file
|
@ -0,0 +1,476 @@
|
|||
"""
|
||||
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
|
Loading…
Add table
Add a link
Reference in a new issue