Refactor: done phase 3

This commit is contained in:
ziesorx 2025-09-23 17:20:46 +07:00
parent 6ec10682c0
commit 7e8034c6e5
6 changed files with 967 additions and 21 deletions

307
core/streaming/readers.py Normal file
View file

@ -0,0 +1,307 @@
"""
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