feat: tracking works 100%

This commit is contained in:
ziesorx 2025-09-23 23:06:03 +07:00
parent 4002febed2
commit dd401f14d7
6 changed files with 511 additions and 305 deletions

View file

@ -1,6 +1,6 @@
"""
Frame readers for RTSP streams and HTTP snapshots.
Extracted from app.py for modular architecture.
Optimized for 1280x720@6fps RTSP and 2560x1440 HTTP snapshots.
"""
import cv2
import logging
@ -8,15 +8,19 @@ import time
import threading
import requests
import numpy as np
import os
from typing import Optional, Callable
from queue import Queue
# Suppress FFMPEG/H.264 error messages if needed
# Set this environment variable to reduce noise from decoder errors
os.environ["OPENCV_LOG_LEVEL"] = "ERROR"
os.environ["OPENCV_FFMPEG_LOGLEVEL"] = "-8" # Suppress FFMPEG warnings
logger = logging.getLogger(__name__)
class RTSPReader:
"""RTSP stream frame reader using OpenCV."""
"""RTSP stream frame reader optimized for 1280x720 @ 6fps streams."""
def __init__(self, camera_id: str, rtsp_url: str, max_retries: int = 3):
self.camera_id = camera_id
@ -27,6 +31,17 @@ class RTSPReader:
self.thread = None
self.frame_callback: Optional[Callable] = None
# Expected stream specifications
self.expected_width = 1280
self.expected_height = 720
self.expected_fps = 6
# Frame processing parameters
self.frame_interval = 1.0 / self.expected_fps # ~167ms for 6fps
self.error_recovery_delay = 2.0
self.max_consecutive_errors = 10
self.stream_timeout = 30.0
def set_frame_callback(self, callback: Callable[[str, np.ndarray], None]):
"""Set callback function to handle captured frames."""
self.frame_callback = callback
@ -52,212 +67,186 @@ class RTSPReader:
logger.info(f"Stopped RTSP reader for camera {self.camera_id}")
def _read_frames(self):
"""Main frame reading loop with improved error handling and stream recovery."""
retries = 0
"""Main frame reading loop with H.264 error recovery."""
consecutive_errors = 0
frame_count = 0
last_log_time = time.time()
consecutive_errors = 0
last_successful_frame_time = time.time()
last_frame_time = 0
try:
# Initialize video capture with optimized parameters
self._initialize_capture()
while not self.stop_event.is_set():
try:
# Check if stream needs recovery
if not self.cap or not self.cap.isOpened():
logger.warning(f"Camera {self.camera_id} not open, reinitializing")
self._initialize_capture()
time.sleep(1)
while not self.stop_event.is_set():
try:
# Initialize/reinitialize capture if needed
if not self.cap or not self.cap.isOpened():
if not self._initialize_capture():
time.sleep(self.error_recovery_delay)
continue
# Check for stream timeout (no frames for 30 seconds)
if time.time() - last_successful_frame_time > 30:
logger.warning(f"Camera {self.camera_id} stream timeout, reinitializing")
self._initialize_capture()
last_successful_frame_time = time.time()
continue
ret, frame = self.cap.read()
if not ret or frame is None:
consecutive_errors += 1
logger.warning(f"Failed to read frame from camera {self.camera_id} (consecutive errors: {consecutive_errors})")
# Force stream recovery after multiple consecutive errors
if consecutive_errors >= 5:
logger.warning(f"Camera {self.camera_id}: Too many consecutive errors, reinitializing stream")
self._initialize_capture()
consecutive_errors = 0
continue
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
# Skip frame validation for now - let YOLO handle corrupted frames
# if not self._is_frame_valid(frame):
# logger.debug(f"Invalid frame detected for camera {self.camera_id}, skipping")
# consecutive_errors += 1
# if consecutive_errors >= 10: # Reinitialize after many invalid frames
# logger.warning(f"Camera {self.camera_id}: Too many invalid frames, reinitializing")
# self._initialize_capture()
# consecutive_errors = 0
# continue
# Reset counters on successful read
retries = 0
consecutive_errors = 0
frame_count += 1
last_successful_frame_time = time.time()
# Call frame callback if set
if self.frame_callback:
self.frame_callback(self.camera_id, frame)
# Check for stream timeout
if time.time() - last_successful_frame_time > self.stream_timeout:
logger.warning(f"Camera {self.camera_id}: Stream timeout, reinitializing")
self._reinitialize_capture()
last_successful_frame_time = time.time()
continue
# 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, {consecutive_errors} consecutive errors")
last_log_time = current_time
# Rate limiting for 6fps
current_time = time.time()
if current_time - last_frame_time < self.frame_interval:
time.sleep(0.01) # Small sleep to avoid busy waiting
continue
# Adaptive delay based on stream FPS and performance
if consecutive_errors == 0:
# Calculate frame delay based on actual FPS
try:
actual_fps = self.cap.get(cv2.CAP_PROP_FPS)
if actual_fps > 0 and actual_fps <= 120: # Reasonable bounds
delay = 1.0 / actual_fps
# Mock cam: 60fps -> ~16.7ms delay
# Real cam: 6fps -> ~167ms delay
else:
# Fallback for invalid FPS values
delay = 0.033 # Default 30 FPS (33ms)
except Exception as e:
logger.debug(f"Failed to get FPS for delay calculation: {e}")
delay = 0.033 # Fallback to 30 FPS
else:
delay = 0.1 # Slower when having issues (100ms)
ret, frame = self.cap.read()
time.sleep(delay)
except Exception as e:
logger.error(f"Error reading frame from camera {self.camera_id}: {e}")
if not ret or frame is None:
consecutive_errors += 1
retries += 1
# Force reinitialization on severe errors
if consecutive_errors >= 3:
logger.warning(f"Camera {self.camera_id}: Severe errors detected, reinitializing stream")
self._initialize_capture()
if consecutive_errors >= self.max_consecutive_errors:
logger.error(f"Camera {self.camera_id}: Too many consecutive errors, reinitializing")
self._reinitialize_capture()
consecutive_errors = 0
time.sleep(self.error_recovery_delay)
else:
# Skip corrupted frame and continue
logger.debug(f"Camera {self.camera_id}: Frame read failed (error {consecutive_errors})")
time.sleep(0.1)
continue
if retries > self.max_retries and self.max_retries != -1:
break
time.sleep(1)
# Validate frame dimensions
if frame.shape[1] != self.expected_width or frame.shape[0] != self.expected_height:
logger.warning(f"Camera {self.camera_id}: Unexpected frame dimensions {frame.shape[1]}x{frame.shape[0]}")
# Try to resize if dimensions are wrong
if frame.shape[1] > 0 and frame.shape[0] > 0:
frame = cv2.resize(frame, (self.expected_width, self.expected_height))
else:
consecutive_errors += 1
continue
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}")
# Check for corrupted frames (all black, all white, excessive noise)
if self._is_frame_corrupted(frame):
logger.debug(f"Camera {self.camera_id}: Corrupted frame detected, skipping")
consecutive_errors += 1
continue
def _initialize_capture(self):
"""Initialize or reinitialize video capture with optimized settings."""
# Frame is valid
consecutive_errors = 0
frame_count += 1
last_successful_frame_time = time.time()
last_frame_time = current_time
# Call frame callback
if self.frame_callback:
try:
self.frame_callback(self.camera_id, frame)
except Exception as e:
logger.error(f"Camera {self.camera_id}: Frame callback error: {e}")
# Log progress every 30 seconds
if current_time - last_log_time >= 30:
logger.info(f"Camera {self.camera_id}: {frame_count} frames processed")
last_log_time = current_time
except Exception as e:
logger.error(f"Camera {self.camera_id}: Error in frame reading loop: {e}")
consecutive_errors += 1
if consecutive_errors >= self.max_consecutive_errors:
self._reinitialize_capture()
consecutive_errors = 0
time.sleep(self.error_recovery_delay)
# Cleanup
if self.cap:
self.cap.release()
logger.info(f"RTSP reader thread ended for camera {self.camera_id}")
def _initialize_capture(self) -> bool:
"""Initialize video capture with optimized settings for 1280x720@6fps."""
try:
# Release previous capture if exists
if self.cap:
self.cap.release()
time.sleep(0.1)
time.sleep(0.5)
# Create new capture with enhanced RTSP URL parameters
enhanced_url = self._enhance_rtsp_url(self.rtsp_url)
logger.debug(f"Initializing capture for camera {self.camera_id} with URL: {enhanced_url}")
logger.info(f"Initializing capture for camera {self.camera_id}")
self.cap = cv2.VideoCapture(enhanced_url)
# Create capture with FFMPEG backend
self.cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG)
if not self.cap.isOpened():
# Try again with different backend
logger.debug(f"Retrying capture initialization with different backend for camera {self.camera_id}")
self.cap = cv2.VideoCapture(enhanced_url, cv2.CAP_FFMPEG)
if self.cap.isOpened():
# Get actual stream properties first
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)
# Adaptive buffer settings based on FPS and resolution
# Mock cam: 1920x1080@60fps, Real cam: 1280x720@6fps
if fps > 30:
# High FPS streams (like mock cam) need larger buffer
buffer_size = 5
elif fps > 15:
# Medium FPS streams
buffer_size = 3
else:
# Low FPS streams (like real cam) can use smaller buffer
buffer_size = 2
# Apply buffer size with bounds checking
try:
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, buffer_size)
actual_buffer = int(self.cap.get(cv2.CAP_PROP_BUFFERSIZE))
logger.debug(f"Camera {self.camera_id}: Buffer size set to {buffer_size}, actual: {actual_buffer}")
except Exception as e:
logger.warning(f"Failed to set buffer size for camera {self.camera_id}: {e}")
# Don't override FPS - let stream use its natural rate
# This works for both mock cam (60fps) and real cam (6fps)
logger.debug(f"Camera {self.camera_id}: Using native FPS {fps}")
# Additional optimization for high resolution streams
if width * height > 1920 * 1080:
logger.info(f"Camera {self.camera_id}: High resolution stream detected, applying optimizations")
logger.info(f"Camera {self.camera_id} initialized: {width}x{height}, FPS: {fps}, Buffer: {buffer_size}")
return True
else:
logger.error(f"Failed to initialize camera {self.camera_id}")
logger.error(f"Failed to open stream for camera {self.camera_id}")
return False
# Set capture properties for 1280x720@6fps
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.expected_width)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.expected_height)
self.cap.set(cv2.CAP_PROP_FPS, self.expected_fps)
# Set small buffer to reduce latency and avoid accumulating corrupted frames
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
# Set FFMPEG options for better H.264 handling
self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'H264'))
# Verify stream properties
actual_width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
actual_height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
actual_fps = self.cap.get(cv2.CAP_PROP_FPS)
logger.info(f"Camera {self.camera_id} initialized: {actual_width}x{actual_height} @ {actual_fps}fps")
# Read and discard first few frames to stabilize stream
for _ in range(5):
ret, _ = self.cap.read()
if not ret:
logger.warning(f"Camera {self.camera_id}: Failed to read initial frames")
time.sleep(0.1)
return True
except Exception as e:
logger.error(f"Error initializing capture for camera {self.camera_id}: {e}")
return False
def _enhance_rtsp_url(self, rtsp_url: str) -> str:
"""Use RTSP URL exactly as provided by backend without modification."""
return rtsp_url
def _reinitialize_capture(self):
"""Reinitialize capture after errors."""
logger.info(f"Reinitializing capture for camera {self.camera_id}")
if self.cap:
self.cap.release()
self.cap = None
time.sleep(1.0)
self._initialize_capture()
def _is_frame_valid(self, frame) -> bool:
"""Validate frame integrity to detect corrupted frames."""
if frame is None:
return False
def _is_frame_corrupted(self, frame: np.ndarray) -> bool:
"""Check if frame is corrupted (all black, all white, or excessive noise)."""
if frame is None or frame.size == 0:
return True
# Check frame dimensions
if frame.shape[0] < 10 or frame.shape[1] < 10:
return False
# Check mean and standard deviation
mean = np.mean(frame)
std = np.std(frame)
# Check if frame is completely black or completely white (possible corruption)
mean_val = np.mean(frame)
if mean_val < 1 or mean_val > 254:
return False
# All black or all white
if mean < 5 or mean > 250:
return True
# Check for excessive noise/corruption (very high standard deviation)
std_val = np.std(frame)
if std_val > 100: # Threshold for detecting very noisy/corrupted frames
return False
# No variation (stuck frame)
if std < 1:
return True
return True
# Excessive noise (corrupted H.264 decode)
# Calculate edge density as corruption indicator
edges = cv2.Canny(frame, 50, 150)
edge_density = np.sum(edges > 0) / edges.size
# Too many edges indicate corruption
if edge_density > 0.5:
return True
return False
class HTTPSnapshotReader:
"""HTTP snapshot reader for periodic image capture."""
"""HTTP snapshot reader optimized for 2560x1440 (2K) high quality images."""
def __init__(self, camera_id: str, snapshot_url: str, interval_ms: int = 5000, max_retries: int = 3):
self.camera_id = camera_id
@ -268,6 +257,11 @@ class HTTPSnapshotReader:
self.thread = None
self.frame_callback: Optional[Callable] = None
# Expected snapshot specifications
self.expected_width = 2560
self.expected_height = 1440
self.max_file_size = 10 * 1024 * 1024 # 10MB max for 2K image
def set_frame_callback(self, callback: Callable[[str, np.ndarray], None]):
"""Set callback function to handle captured frames."""
self.frame_callback = callback
@ -291,7 +285,7 @@ class HTTPSnapshotReader:
logger.info(f"Stopped snapshot reader for camera {self.camera_id}")
def _read_snapshots(self):
"""Main snapshot reading loop."""
"""Main snapshot reading loop for high quality 2K images."""
retries = 0
frame_count = 0
last_log_time = time.time()
@ -299,66 +293,78 @@ class HTTPSnapshotReader:
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()
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}")
if frame is None:
retries += 1
if retries > self.max_retries and self.max_retries != -1:
break
time.sleep(1)
logger.warning(f"Failed to fetch snapshot for camera {self.camera_id}, retry {retries}/{self.max_retries}")
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}")
if self.max_retries != -1 and retries > self.max_retries:
logger.error(f"Max retries reached for snapshot camera {self.camera_id}")
break
time.sleep(min(2.0, interval_seconds))
continue
# Validate image dimensions
if frame.shape[1] != self.expected_width or frame.shape[0] != self.expected_height:
logger.info(f"Camera {self.camera_id}: Snapshot dimensions {frame.shape[1]}x{frame.shape[0]} "
f"(expected {self.expected_width}x{self.expected_height})")
# Resize if needed (maintaining aspect ratio for high quality)
if frame.shape[1] > 0 and frame.shape[0] > 0:
# Only resize if significantly different
if abs(frame.shape[1] - self.expected_width) > 100:
frame = self._resize_maintain_aspect(frame, self.expected_width, self.expected_height)
# Reset retry counter on successful fetch
retries = 0
frame_count += 1
# Call frame callback
if self.frame_callback:
try:
self.frame_callback(self.camera_id, frame)
except Exception as e:
logger.error(f"Camera {self.camera_id}: Frame callback error: {e}")
# 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
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 in snapshot loop for camera {self.camera_id}: {e}")
retries += 1
if self.max_retries != -1 and retries > self.max_retries:
break
time.sleep(min(2.0, interval_seconds))
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."""
"""Fetch a single high quality snapshot from HTTP URL."""
try:
# Parse URL to extract auth credentials if present
# Parse URL for authentication
from urllib.parse import urlparse
parsed_url = urlparse(self.snapshot_url)
# Prepare headers with proper authentication
headers = {}
headers = {
'User-Agent': 'Python-Detector-Worker/1.0',
'Accept': 'image/jpeg, image/png, image/*'
}
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)
@ -370,71 +376,76 @@ class HTTPSnapshotReader:
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)
# Try Basic Auth first
response = requests.get(clean_url, auth=auth, timeout=15, headers=headers,
stream=True, verify=False)
# If Basic Auth fails, try Digest Auth (common for IP cameras)
# If Basic Auth fails, try Digest Auth
if response.status_code == 401:
auth = HTTPDigestAuth(parsed_url.username, parsed_url.password)
response = requests.get(clean_url, auth=auth, timeout=10, headers=headers)
response = requests.get(clean_url, auth=auth, timeout=15, headers=headers,
stream=True, verify=False)
else:
# No auth in URL, use as-is
response = requests.get(self.snapshot_url, timeout=10, headers=headers)
response = requests.get(self.snapshot_url, timeout=15, headers=headers,
stream=True, verify=False)
if response.status_code == 200:
# Convert bytes to numpy array
image_array = np.frombuffer(response.content, np.uint8)
# Decode as image
# Check content size
content_length = int(response.headers.get('content-length', 0))
if content_length > self.max_file_size:
logger.warning(f"Snapshot too large for camera {self.camera_id}: {content_length} bytes")
return None
# Read content
content = response.content
# Convert to numpy array
image_array = np.frombuffer(content, np.uint8)
# Decode as high quality image
frame = cv2.imdecode(image_array, cv2.IMREAD_COLOR)
if frame is None:
logger.error(f"Failed to decode snapshot for camera {self.camera_id}")
return None
logger.debug(f"Fetched snapshot for camera {self.camera_id}: {frame.shape[1]}x{frame.shape[0]}")
return frame
else:
logger.warning(f"HTTP {response.status_code} from {self.snapshot_url}")
logger.warning(f"HTTP {response.status_code} from {self.camera_id}")
return None
except requests.RequestException as e:
logger.error(f"Request error fetching snapshot: {e}")
logger.error(f"Request error fetching snapshot for {self.camera_id}: {e}")
return None
except Exception as e:
logger.error(f"Error decoding snapshot: {e}")
logger.error(f"Error decoding snapshot for {self.camera_id}: {e}")
return None
def _resize_maintain_aspect(self, frame: np.ndarray, target_width: int, target_height: int) -> np.ndarray:
"""Resize image while maintaining aspect ratio for high quality."""
h, w = frame.shape[:2]
aspect = w / h
target_aspect = target_width / target_height
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)
if aspect > target_aspect:
# Image is wider
new_width = target_width
new_height = int(target_width / aspect)
else:
# No auth in URL, use as-is
response = requests.get(url, timeout=10)
# Image is taller
new_height = target_height
new_width = int(target_height * aspect)
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
# Use INTER_LANCZOS4 for high quality downsampling
resized = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_LANCZOS4)
# Pad to target size if needed
if new_width < target_width or new_height < target_height:
top = (target_height - new_height) // 2
bottom = target_height - new_height - top
left = (target_width - new_width) // 2
right = target_width - new_width - left
resized = cv2.copyMakeBorder(resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=[0, 0, 0])
return resized