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

@ -82,6 +82,11 @@ def serialize_outgoing_message(message: OutgoingMessage) -> str:
JSON string representation JSON string representation
""" """
try: try:
# For ImageDetectionMessage, we need to include None values for abandonment detection
from .models import ImageDetectionMessage
if isinstance(message, ImageDetectionMessage):
return message.model_dump_json(exclude_none=False)
else:
return message.model_dump_json(exclude_none=True) return message.model_dump_json(exclude_none=True)
except Exception as e: except Exception as e:
logger.error(f"Failed to serialize outgoing message: {e}") logger.error(f"Failed to serialize outgoing message: {e}")

View file

@ -36,7 +36,9 @@ class CameraConnection(BaseModel):
class DetectionData(BaseModel): class DetectionData(BaseModel):
"""Detection result data structure.""" """Detection result data structure."""
detection: Dict[str, Any] = Field(..., description="Flat key-value detection results") model_config = {"json_encoders": {type(None): lambda v: None}}
detection: Optional[Dict[str, Any]] = Field(None, description="Flat key-value detection results, null for abandonment")
modelId: int modelId: int
modelName: str modelName: str

View file

@ -2,7 +2,7 @@
Streaming system for RTSP and HTTP camera feeds. Streaming system for RTSP and HTTP camera feeds.
Provides modular frame readers, buffers, and stream management. Provides modular frame readers, buffers, and stream management.
""" """
from .readers import RTSPReader, HTTPSnapshotReader, fetch_snapshot from .readers import RTSPReader, HTTPSnapshotReader
from .buffers import FrameBuffer, CacheBuffer, shared_frame_buffer, shared_cache_buffer, save_frame_for_testing from .buffers import FrameBuffer, CacheBuffer, shared_frame_buffer, shared_cache_buffer, save_frame_for_testing
from .manager import StreamManager, StreamConfig, SubscriptionInfo, shared_stream_manager from .manager import StreamManager, StreamConfig, SubscriptionInfo, shared_stream_manager
@ -10,7 +10,6 @@ __all__ = [
# Readers # Readers
'RTSPReader', 'RTSPReader',
'HTTPSnapshotReader', 'HTTPSnapshotReader',
'fetch_snapshot',
# Buffers # Buffers
'FrameBuffer', 'FrameBuffer',

View file

@ -1,37 +1,75 @@
""" """
Frame buffering and caching system for stream management. Frame buffering and caching system optimized for different stream formats.
Provides efficient frame storage and retrieval for multiple consumers. Supports 1280x720 RTSP streams and 2560x1440 HTTP snapshots.
""" """
import threading import threading
import time import time
import cv2 import cv2
import logging import logging
import numpy as np import numpy as np
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, Tuple
from collections import defaultdict from collections import defaultdict
from enum import Enum
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class StreamType(Enum):
"""Stream type enumeration."""
RTSP = "rtsp" # 1280x720 @ 6fps
HTTP = "http" # 2560x1440 high quality
class FrameBuffer: class FrameBuffer:
"""Thread-safe frame buffer that stores the latest frame for each camera.""" """Thread-safe frame buffer optimized for different stream types."""
def __init__(self, max_age_seconds: int = 5): def __init__(self, max_age_seconds: int = 5):
self.max_age_seconds = max_age_seconds self.max_age_seconds = max_age_seconds
self._frames: Dict[str, Dict[str, Any]] = {} self._frames: Dict[str, Dict[str, Any]] = {}
self._stream_types: Dict[str, StreamType] = {}
self._lock = threading.RLock() self._lock = threading.RLock()
def put_frame(self, camera_id: str, frame: np.ndarray): # Stream-specific settings
"""Store a frame for the given camera ID.""" self.rtsp_config = {
'width': 1280,
'height': 720,
'fps': 6,
'max_size_mb': 3 # 1280x720x3 bytes = ~2.6MB
}
self.http_config = {
'width': 2560,
'height': 1440,
'max_size_mb': 10
}
def put_frame(self, camera_id: str, frame: np.ndarray, stream_type: Optional[StreamType] = None):
"""Store a frame for the given camera ID with type-specific validation."""
with self._lock: with self._lock:
# Detect stream type if not provided
if stream_type is None:
stream_type = self._detect_stream_type(frame)
# Store stream type
self._stream_types[camera_id] = stream_type
# Validate frame based on stream type
if not self._validate_frame(frame, stream_type):
logger.warning(f"Frame validation failed for camera {camera_id} ({stream_type.value})")
return
self._frames[camera_id] = { self._frames[camera_id] = {
'frame': frame.copy(), # Make a copy to avoid reference issues 'frame': frame.copy(),
'timestamp': time.time(), 'timestamp': time.time(),
'shape': frame.shape, 'shape': frame.shape,
'dtype': str(frame.dtype) 'dtype': str(frame.dtype),
'stream_type': stream_type.value,
'size_mb': frame.nbytes / (1024 * 1024)
} }
logger.debug(f"Stored {stream_type.value} frame for camera {camera_id}: "
f"{frame.shape[1]}x{frame.shape[0]}, {frame.nbytes / (1024 * 1024):.2f}MB")
def get_frame(self, camera_id: str) -> Optional[np.ndarray]: def get_frame(self, camera_id: str) -> Optional[np.ndarray]:
"""Get the latest frame for the given camera ID.""" """Get the latest frame for the given camera ID."""
with self._lock: with self._lock:
@ -45,6 +83,8 @@ class FrameBuffer:
if age > self.max_age_seconds: if age > self.max_age_seconds:
logger.debug(f"Frame for camera {camera_id} is {age:.1f}s old, discarding") logger.debug(f"Frame for camera {camera_id} is {age:.1f}s old, discarding")
del self._frames[camera_id] del self._frames[camera_id]
if camera_id in self._stream_types:
del self._stream_types[camera_id]
return None return None
return frame_data['frame'].copy() return frame_data['frame'].copy()
@ -60,13 +100,17 @@ class FrameBuffer:
if age > self.max_age_seconds: if age > self.max_age_seconds:
del self._frames[camera_id] del self._frames[camera_id]
if camera_id in self._stream_types:
del self._stream_types[camera_id]
return None return None
return { return {
'timestamp': frame_data['timestamp'], 'timestamp': frame_data['timestamp'],
'age': age, 'age': age,
'shape': frame_data['shape'], 'shape': frame_data['shape'],
'dtype': frame_data['dtype'] 'dtype': frame_data['dtype'],
'stream_type': frame_data.get('stream_type', 'unknown'),
'size_mb': frame_data.get('size_mb', 0)
} }
def has_frame(self, camera_id: str) -> bool: def has_frame(self, camera_id: str) -> bool:
@ -78,6 +122,8 @@ class FrameBuffer:
with self._lock: with self._lock:
if camera_id in self._frames: if camera_id in self._frames:
del self._frames[camera_id] del self._frames[camera_id]
if camera_id in self._stream_types:
del self._stream_types[camera_id]
logger.debug(f"Cleared frames for camera {camera_id}") logger.debug(f"Cleared frames for camera {camera_id}")
def clear_all(self): def clear_all(self):
@ -85,6 +131,7 @@ class FrameBuffer:
with self._lock: with self._lock:
count = len(self._frames) count = len(self._frames)
self._frames.clear() self._frames.clear()
self._stream_types.clear()
logger.debug(f"Cleared all frames ({count} cameras)") logger.debug(f"Cleared all frames ({count} cameras)")
def get_camera_list(self) -> list: def get_camera_list(self) -> list:
@ -104,6 +151,8 @@ class FrameBuffer:
# Clean up expired frames # Clean up expired frames
for camera_id in expired_cameras: for camera_id in expired_cameras:
del self._frames[camera_id] del self._frames[camera_id]
if camera_id in self._stream_types:
del self._stream_types[camera_id]
return valid_cameras return valid_cameras
@ -115,44 +164,110 @@ class FrameBuffer:
'total_cameras': len(self._frames), 'total_cameras': len(self._frames),
'valid_cameras': 0, 'valid_cameras': 0,
'expired_cameras': 0, 'expired_cameras': 0,
'rtsp_cameras': 0,
'http_cameras': 0,
'total_memory_mb': 0,
'cameras': {} 'cameras': {}
} }
for camera_id, frame_data in self._frames.items(): for camera_id, frame_data in self._frames.items():
age = current_time - frame_data['timestamp'] age = current_time - frame_data['timestamp']
stream_type = frame_data.get('stream_type', 'unknown')
size_mb = frame_data.get('size_mb', 0)
if age <= self.max_age_seconds: if age <= self.max_age_seconds:
stats['valid_cameras'] += 1 stats['valid_cameras'] += 1
else: else:
stats['expired_cameras'] += 1 stats['expired_cameras'] += 1
if stream_type == StreamType.RTSP.value:
stats['rtsp_cameras'] += 1
elif stream_type == StreamType.HTTP.value:
stats['http_cameras'] += 1
stats['total_memory_mb'] += size_mb
stats['cameras'][camera_id] = { stats['cameras'][camera_id] = {
'age': age, 'age': age,
'valid': age <= self.max_age_seconds, 'valid': age <= self.max_age_seconds,
'shape': frame_data['shape'], 'shape': frame_data['shape'],
'dtype': frame_data['dtype'] 'dtype': frame_data['dtype'],
'stream_type': stream_type,
'size_mb': size_mb
} }
return stats return stats
def _detect_stream_type(self, frame: np.ndarray) -> StreamType:
"""Detect stream type based on frame dimensions."""
h, w = frame.shape[:2]
# Check if it matches RTSP dimensions (1280x720)
if w == self.rtsp_config['width'] and h == self.rtsp_config['height']:
return StreamType.RTSP
# Check if it matches HTTP dimensions (2560x1440) or close to it
if w >= 2000 and h >= 1000:
return StreamType.HTTP
# Default based on size
if w <= 1920 and h <= 1080:
return StreamType.RTSP
else:
return StreamType.HTTP
def _validate_frame(self, frame: np.ndarray, stream_type: StreamType) -> bool:
"""Validate frame based on stream type."""
if frame is None or frame.size == 0:
return False
h, w = frame.shape[:2]
size_mb = frame.nbytes / (1024 * 1024)
if stream_type == StreamType.RTSP:
config = self.rtsp_config
# Allow some tolerance for RTSP streams
if abs(w - config['width']) > 100 or abs(h - config['height']) > 100:
logger.warning(f"RTSP frame size mismatch: {w}x{h} (expected {config['width']}x{config['height']})")
if size_mb > config['max_size_mb']:
logger.warning(f"RTSP frame too large: {size_mb:.2f}MB (max {config['max_size_mb']}MB)")
return False
elif stream_type == StreamType.HTTP:
config = self.http_config
# More flexible for HTTP snapshots
if size_mb > config['max_size_mb']:
logger.warning(f"HTTP snapshot too large: {size_mb:.2f}MB (max {config['max_size_mb']}MB)")
return False
return True
class CacheBuffer: class CacheBuffer:
"""Enhanced frame cache with support for cropping and REST API access.""" """Enhanced frame cache with support for cropping and optimized for different formats."""
def __init__(self, max_age_seconds: int = 10): def __init__(self, max_age_seconds: int = 10):
self.frame_buffer = FrameBuffer(max_age_seconds) self.frame_buffer = FrameBuffer(max_age_seconds)
self._crop_cache: Dict[str, Dict[str, Any]] = {} self._crop_cache: Dict[str, Dict[str, Any]] = {}
self._cache_lock = threading.RLock() self._cache_lock = threading.RLock()
def put_frame(self, camera_id: str, frame: np.ndarray): # Quality settings for different stream types
self.jpeg_quality = {
StreamType.RTSP: 90, # Good quality for 720p
StreamType.HTTP: 95 # High quality for 2K
}
def put_frame(self, camera_id: str, frame: np.ndarray, stream_type: Optional[StreamType] = None):
"""Store a frame and clear any associated crop cache.""" """Store a frame and clear any associated crop cache."""
self.frame_buffer.put_frame(camera_id, frame) self.frame_buffer.put_frame(camera_id, frame, stream_type)
# Clear crop cache for this camera since we have a new frame # Clear crop cache for this camera since we have a new frame
with self._cache_lock: with self._cache_lock:
if camera_id in self._crop_cache: keys_to_remove = [key for key in self._crop_cache.keys() if key.startswith(f"{camera_id}_")]
del self._crop_cache[camera_id] for key in keys_to_remove:
del self._crop_cache[key]
def get_frame(self, camera_id: str, crop_coords: Optional[tuple] = None) -> Optional[np.ndarray]: def get_frame(self, camera_id: str, crop_coords: Optional[Tuple[int, int, int, int]] = None) -> Optional[np.ndarray]:
"""Get frame with optional cropping.""" """Get frame with optional cropping."""
if crop_coords is None: if crop_coords is None:
return self.frame_buffer.get_frame(camera_id) return self.frame_buffer.get_frame(camera_id)
@ -175,6 +290,7 @@ class CacheBuffer:
try: try:
x1, y1, x2, y2 = crop_coords x1, y1, x2, y2 = crop_coords
# Ensure coordinates are within frame bounds # Ensure coordinates are within frame bounds
h, w = original_frame.shape[:2] h, w = original_frame.shape[:2]
x1 = max(0, min(x1, w)) x1 = max(0, min(x1, w))
@ -186,6 +302,14 @@ class CacheBuffer:
# Cache the cropped frame # Cache the cropped frame
with self._cache_lock: with self._cache_lock:
# Limit cache size to prevent memory issues
if len(self._crop_cache) > 100:
# Remove oldest entries
oldest_keys = sorted(self._crop_cache.keys(),
key=lambda k: self._crop_cache[k]['timestamp'])[:50]
for key in oldest_keys:
del self._crop_cache[key]
self._crop_cache[crop_key] = { self._crop_cache[crop_key] = {
'cropped_frame': cropped_frame.copy(), 'cropped_frame': cropped_frame.copy(),
'timestamp': time.time(), 'timestamp': time.time(),
@ -198,19 +322,33 @@ class CacheBuffer:
logger.error(f"Error cropping frame for camera {camera_id}: {e}") logger.error(f"Error cropping frame for camera {camera_id}: {e}")
return original_frame return original_frame
def get_frame_as_jpeg(self, camera_id: str, crop_coords: Optional[tuple] = None, def get_frame_as_jpeg(self, camera_id: str, crop_coords: Optional[Tuple[int, int, int, int]] = None,
quality: int = 100) -> Optional[bytes]: quality: Optional[int] = None) -> Optional[bytes]:
"""Get frame as JPEG bytes for HTTP responses with highest quality by default.""" """Get frame as JPEG bytes with format-specific quality settings."""
frame = self.get_frame(camera_id, crop_coords) frame = self.get_frame(camera_id, crop_coords)
if frame is None: if frame is None:
return None return None
try: try:
# Encode as JPEG with specified quality (default 100 for highest) # Determine quality based on stream type if not specified
if quality is None:
frame_info = self.frame_buffer.get_frame_info(camera_id)
if frame_info:
stream_type_str = frame_info.get('stream_type', StreamType.RTSP.value)
stream_type = StreamType.RTSP if stream_type_str == StreamType.RTSP.value else StreamType.HTTP
quality = self.jpeg_quality[stream_type]
else:
quality = 90 # Default
# Encode as JPEG with specified quality
encode_params = [cv2.IMWRITE_JPEG_QUALITY, quality] encode_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
success, encoded_img = cv2.imencode('.jpg', frame, encode_params) success, encoded_img = cv2.imencode('.jpg', frame, encode_params)
if success: if success:
return encoded_img.tobytes() jpeg_bytes = encoded_img.tobytes()
logger.debug(f"Encoded JPEG for camera {camera_id}: quality={quality}, size={len(jpeg_bytes)} bytes")
return jpeg_bytes
return None return None
except Exception as e: except Exception as e:
@ -243,12 +381,17 @@ class CacheBuffer:
with self._cache_lock: with self._cache_lock:
cache_stats = { cache_stats = {
'crop_cache_entries': len(self._crop_cache), 'crop_cache_entries': len(self._crop_cache),
'crop_cache_cameras': len(set(key.split('_')[0] for key in self._crop_cache.keys())) 'crop_cache_cameras': len(set(key.split('_')[0] for key in self._crop_cache.keys() if '_' in key)),
'crop_cache_memory_mb': sum(
entry['cropped_frame'].nbytes / (1024 * 1024)
for entry in self._crop_cache.values()
)
} }
return { return {
'buffer': buffer_stats, 'buffer': buffer_stats,
'cache': cache_stats 'cache': cache_stats,
'total_memory_mb': buffer_stats.get('total_memory_mb', 0) + cache_stats.get('crop_cache_memory_mb', 0)
} }
@ -267,9 +410,19 @@ def save_frame_for_testing(camera_id: str, frame: np.ndarray, test_dir: str = "t
filename = f"{camera_id}_{timestamp}.jpg" filename = f"{camera_id}_{timestamp}.jpg"
filepath = os.path.join(test_dir, filename) filepath = os.path.join(test_dir, filename)
success = cv2.imwrite(filepath, frame) # Use appropriate quality based on frame size
h, w = frame.shape[:2]
if w >= 2000: # High resolution
quality = 95
else: # Standard resolution
quality = 90
encode_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
success = cv2.imwrite(filepath, frame, encode_params)
if success: if success:
logger.info(f"Saved test frame: {filepath}") size_kb = os.path.getsize(filepath) / 1024
logger.info(f"Saved test frame: {filepath} ({w}x{h}, {size_kb:.1f}KB)")
else: else:
logger.error(f"Failed to save test frame: {filepath}") logger.error(f"Failed to save test frame: {filepath}")

View file

@ -1,6 +1,6 @@
""" """
Stream coordination and lifecycle management. Stream coordination and lifecycle management.
Handles shared streams, subscription reconciliation, and resource optimization. Optimized for 1280x720@6fps RTSP and 2560x1440 HTTP snapshots.
""" """
import logging import logging
import threading import threading
@ -10,7 +10,7 @@ from dataclasses import dataclass
from collections import defaultdict from collections import defaultdict
from .readers import RTSPReader, HTTPSnapshotReader from .readers import RTSPReader, HTTPSnapshotReader
from .buffers import shared_cache_buffer, save_frame_for_testing from .buffers import shared_cache_buffer, save_frame_for_testing, StreamType
from ..tracking.integration import TrackingPipelineIntegration from ..tracking.integration import TrackingPipelineIntegration
@ -174,8 +174,11 @@ class StreamManager:
def _frame_callback(self, camera_id: str, frame): def _frame_callback(self, camera_id: str, frame):
"""Callback for when a new frame is available.""" """Callback for when a new frame is available."""
try: try:
# Store frame in shared buffer # Detect stream type based on frame dimensions
shared_cache_buffer.put_frame(camera_id, frame) stream_type = self._detect_stream_type(frame)
# Store frame in shared buffer with stream type
shared_cache_buffer.put_frame(camera_id, frame, stream_type)
# Save test frames if enabled for any subscription # Save test frames if enabled for any subscription
with self._lock: with self._lock:
@ -406,23 +409,56 @@ class StreamManager:
stats[subscription_id] = subscription_info.tracking_integration.get_statistics() stats[subscription_id] = subscription_info.tracking_integration.get_statistics()
return stats return stats
def _detect_stream_type(self, frame) -> StreamType:
"""Detect stream type based on frame dimensions."""
if frame is None:
return StreamType.RTSP # Default
h, w = frame.shape[:2]
# RTSP: 1280x720
if w == 1280 and h == 720:
return StreamType.RTSP
# HTTP: 2560x1440 or larger
if w >= 2000 and h >= 1000:
return StreamType.HTTP
# Default based on size
if w <= 1920 and h <= 1080:
return StreamType.RTSP
else:
return StreamType.HTTP
def get_stats(self) -> Dict[str, Any]: def get_stats(self) -> Dict[str, Any]:
"""Get comprehensive streaming statistics.""" """Get comprehensive streaming statistics."""
with self._lock: with self._lock:
buffer_stats = shared_cache_buffer.get_stats() buffer_stats = shared_cache_buffer.get_stats()
tracking_stats = self.get_tracking_stats() tracking_stats = self.get_tracking_stats()
# Add stream type information
stream_types = {}
for camera_id in self._streams.keys():
if isinstance(self._streams[camera_id], RTSPReader):
stream_types[camera_id] = 'rtsp'
elif isinstance(self._streams[camera_id], HTTPSnapshotReader):
stream_types[camera_id] = 'http'
else:
stream_types[camera_id] = 'unknown'
return { return {
'active_subscriptions': len(self._subscriptions), 'active_subscriptions': len(self._subscriptions),
'active_streams': len(self._streams), 'active_streams': len(self._streams),
'cameras_with_subscribers': len(self._camera_subscribers), 'cameras_with_subscribers': len(self._camera_subscribers),
'max_streams': self.max_streams, 'max_streams': self.max_streams,
'stream_types': stream_types,
'subscriptions_by_camera': { 'subscriptions_by_camera': {
camera_id: len(subscribers) camera_id: len(subscribers)
for camera_id, subscribers in self._camera_subscribers.items() for camera_id, subscribers in self._camera_subscribers.items()
}, },
'buffer_stats': buffer_stats, 'buffer_stats': buffer_stats,
'tracking_stats': tracking_stats 'tracking_stats': tracking_stats,
'memory_usage_mb': buffer_stats.get('total_memory_mb', 0)
} }

View file

@ -1,6 +1,6 @@
""" """
Frame readers for RTSP streams and HTTP snapshots. 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 cv2
import logging import logging
@ -8,15 +8,19 @@ import time
import threading import threading
import requests import requests
import numpy as np import numpy as np
import os
from typing import Optional, Callable 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__) logger = logging.getLogger(__name__)
class RTSPReader: 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): def __init__(self, camera_id: str, rtsp_url: str, max_retries: int = 3):
self.camera_id = camera_id self.camera_id = camera_id
@ -27,6 +31,17 @@ class RTSPReader:
self.thread = None self.thread = None
self.frame_callback: Optional[Callable] = 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]): def set_frame_callback(self, callback: Callable[[str, np.ndarray], None]):
"""Set callback function to handle captured frames.""" """Set callback function to handle captured frames."""
self.frame_callback = callback self.frame_callback = callback
@ -52,212 +67,186 @@ class RTSPReader:
logger.info(f"Stopped RTSP reader for camera {self.camera_id}") logger.info(f"Stopped RTSP reader for camera {self.camera_id}")
def _read_frames(self): def _read_frames(self):
"""Main frame reading loop with improved error handling and stream recovery.""" """Main frame reading loop with H.264 error recovery."""
retries = 0 consecutive_errors = 0
frame_count = 0 frame_count = 0
last_log_time = time.time() last_log_time = time.time()
consecutive_errors = 0
last_successful_frame_time = time.time() 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(): while not self.stop_event.is_set():
try: try:
# Check if stream needs recovery # Initialize/reinitialize capture if needed
if not self.cap or not self.cap.isOpened(): if not self.cap or not self.cap.isOpened():
logger.warning(f"Camera {self.camera_id} not open, reinitializing") if not self._initialize_capture():
self._initialize_capture() time.sleep(self.error_recovery_delay)
time.sleep(1) continue
last_successful_frame_time = time.time()
# 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 continue
# Check for stream timeout (no frames for 30 seconds) # Rate limiting for 6fps
if time.time() - last_successful_frame_time > 30: current_time = time.time()
logger.warning(f"Camera {self.camera_id} stream timeout, reinitializing") if current_time - last_frame_time < self.frame_interval:
self._initialize_capture() time.sleep(0.01) # Small sleep to avoid busy waiting
last_successful_frame_time = time.time()
continue continue
ret, frame = self.cap.read() ret, frame = self.cap.read()
if not ret or frame is None: if not ret or frame is None:
consecutive_errors += 1 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 >= self.max_consecutive_errors:
if consecutive_errors >= 5: logger.error(f"Camera {self.camera_id}: Too many consecutive errors, reinitializing")
logger.warning(f"Camera {self.camera_id}: Too many consecutive errors, reinitializing stream") self._reinitialize_capture()
self._initialize_capture()
consecutive_errors = 0 consecutive_errors = 0
continue time.sleep(self.error_recovery_delay)
else:
retries += 1 # Skip corrupted frame and continue
if retries > self.max_retries and self.max_retries != -1: logger.debug(f"Camera {self.camera_id}: Frame read failed (error {consecutive_errors})")
logger.error(f"Max retries reached for camera {self.camera_id}")
break
time.sleep(0.1) time.sleep(0.1)
continue continue
# Skip frame validation for now - let YOLO handle corrupted frames # Validate frame dimensions
# if not self._is_frame_valid(frame): if frame.shape[1] != self.expected_width or frame.shape[0] != self.expected_height:
# logger.debug(f"Invalid frame detected for camera {self.camera_id}, skipping") logger.warning(f"Camera {self.camera_id}: Unexpected frame dimensions {frame.shape[1]}x{frame.shape[0]}")
# consecutive_errors += 1 # Try to resize if dimensions are wrong
# if consecutive_errors >= 10: # Reinitialize after many invalid frames if frame.shape[1] > 0 and frame.shape[0] > 0:
# logger.warning(f"Camera {self.camera_id}: Too many invalid frames, reinitializing") frame = cv2.resize(frame, (self.expected_width, self.expected_height))
# self._initialize_capture() else:
# consecutive_errors = 0 consecutive_errors += 1
# continue continue
# Reset counters on successful read # Check for corrupted frames (all black, all white, excessive noise)
retries = 0 if self._is_frame_corrupted(frame):
logger.debug(f"Camera {self.camera_id}: Corrupted frame detected, skipping")
consecutive_errors += 1
continue
# Frame is valid
consecutive_errors = 0 consecutive_errors = 0
frame_count += 1 frame_count += 1
last_successful_frame_time = time.time() last_successful_frame_time = time.time()
last_frame_time = current_time
# Call frame callback if set # Call frame callback
if self.frame_callback: if self.frame_callback:
try:
self.frame_callback(self.camera_id, frame) 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 # Log progress every 30 seconds
current_time = time.time()
if current_time - last_log_time >= 30: if current_time - last_log_time >= 30:
logger.info(f"Camera {self.camera_id}: {frame_count} frames processed, {consecutive_errors} consecutive errors") logger.info(f"Camera {self.camera_id}: {frame_count} frames processed")
last_log_time = current_time last_log_time = current_time
# 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: except Exception as e:
logger.debug(f"Failed to get FPS for delay calculation: {e}") logger.error(f"Camera {self.camera_id}: Error in frame reading loop: {e}")
delay = 0.033 # Fallback to 30 FPS
else:
delay = 0.1 # Slower when having issues (100ms)
time.sleep(delay)
except Exception as e:
logger.error(f"Error reading frame from camera {self.camera_id}: {e}")
consecutive_errors += 1 consecutive_errors += 1
retries += 1 if consecutive_errors >= self.max_consecutive_errors:
self._reinitialize_capture()
# Force reinitialization on severe errors
if consecutive_errors >= 3:
logger.warning(f"Camera {self.camera_id}: Severe errors detected, reinitializing stream")
self._initialize_capture()
consecutive_errors = 0 consecutive_errors = 0
time.sleep(self.error_recovery_delay)
if retries > self.max_retries and self.max_retries != -1: # Cleanup
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: if self.cap:
self.cap.release() self.cap.release()
logger.info(f"RTSP reader thread ended for camera {self.camera_id}") logger.info(f"RTSP reader thread ended for camera {self.camera_id}")
def _initialize_capture(self): def _initialize_capture(self) -> bool:
"""Initialize or reinitialize video capture with optimized settings.""" """Initialize video capture with optimized settings for 1280x720@6fps."""
try: try:
# Release previous capture if exists # Release previous capture if exists
if self.cap: if self.cap:
self.cap.release() self.cap.release()
time.sleep(0.1) time.sleep(0.5)
# Create new capture with enhanced RTSP URL parameters logger.info(f"Initializing capture for camera {self.camera_id}")
enhanced_url = self._enhance_rtsp_url(self.rtsp_url)
logger.debug(f"Initializing capture for camera {self.camera_id} with URL: {enhanced_url}")
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(): if not self.cap.isOpened():
# Try again with different backend logger.error(f"Failed to open stream for camera {self.camera_id}")
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}")
return False 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: except Exception as e:
logger.error(f"Error initializing capture for camera {self.camera_id}: {e}") logger.error(f"Error initializing capture for camera {self.camera_id}: {e}")
return False return False
def _enhance_rtsp_url(self, rtsp_url: str) -> str: def _reinitialize_capture(self):
"""Use RTSP URL exactly as provided by backend without modification.""" """Reinitialize capture after errors."""
return rtsp_url logger.info(f"Reinitializing capture for camera {self.camera_id}")
if self.cap:
def _is_frame_valid(self, frame) -> bool: self.cap.release()
"""Validate frame integrity to detect corrupted frames.""" self.cap = None
if frame is None: time.sleep(1.0)
return False self._initialize_capture()
# Check frame dimensions
if frame.shape[0] < 10 or frame.shape[1] < 10:
return False
# 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
# 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
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 return True
# Check mean and standard deviation
mean = np.mean(frame)
std = np.std(frame)
# All black or all white
if mean < 5 or mean > 250:
return True
# No variation (stuck frame)
if std < 1:
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: 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): def __init__(self, camera_id: str, snapshot_url: str, interval_ms: int = 5000, max_retries: int = 3):
self.camera_id = camera_id self.camera_id = camera_id
@ -268,6 +257,11 @@ class HTTPSnapshotReader:
self.thread = None self.thread = None
self.frame_callback: Optional[Callable] = 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]): def set_frame_callback(self, callback: Callable[[str, np.ndarray], None]):
"""Set callback function to handle captured frames.""" """Set callback function to handle captured frames."""
self.frame_callback = callback self.frame_callback = callback
@ -291,7 +285,7 @@ class HTTPSnapshotReader:
logger.info(f"Stopped snapshot reader for camera {self.camera_id}") logger.info(f"Stopped snapshot reader for camera {self.camera_id}")
def _read_snapshots(self): def _read_snapshots(self):
"""Main snapshot reading loop.""" """Main snapshot reading loop for high quality 2K images."""
retries = 0 retries = 0
frame_count = 0 frame_count = 0
last_log_time = time.time() last_log_time = time.time()
@ -299,28 +293,42 @@ class HTTPSnapshotReader:
logger.info(f"Snapshot interval for camera {self.camera_id}: {interval_seconds}s") logger.info(f"Snapshot interval for camera {self.camera_id}: {interval_seconds}s")
try:
while not self.stop_event.is_set(): while not self.stop_event.is_set():
try: try:
start_time = time.time() start_time = time.time()
frame = self._fetch_snapshot() frame = self._fetch_snapshot()
if frame is None: if frame is None:
logger.warning(f"Failed to fetch snapshot for camera {self.camera_id}, retry {retries+1}/{self.max_retries}")
retries += 1 retries += 1
if retries > self.max_retries and self.max_retries != -1: logger.warning(f"Failed to fetch snapshot for camera {self.camera_id}, retry {retries}/{self.max_retries}")
if self.max_retries != -1 and retries > self.max_retries:
logger.error(f"Max retries reached for snapshot camera {self.camera_id}") logger.error(f"Max retries reached for snapshot camera {self.camera_id}")
break break
time.sleep(1)
time.sleep(min(2.0, interval_seconds))
continue 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 # Reset retry counter on successful fetch
retries = 0 retries = 0
frame_count += 1 frame_count += 1
# Call frame callback if set # Call frame callback
if self.frame_callback: if self.frame_callback:
try:
self.frame_callback(self.camera_id, frame) 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 # Log progress every 30 seconds
current_time = time.time() current_time = time.time()
@ -328,37 +336,35 @@ class HTTPSnapshotReader:
logger.info(f"Camera {self.camera_id}: {frame_count} snapshots processed") logger.info(f"Camera {self.camera_id}: {frame_count} snapshots processed")
last_log_time = current_time last_log_time = current_time
# Wait for next interval, accounting for processing time # Wait for next interval
elapsed = time.time() - start_time elapsed = time.time() - start_time
sleep_time = max(0, interval_seconds - elapsed) sleep_time = max(0, interval_seconds - elapsed)
if sleep_time > 0: if sleep_time > 0:
self.stop_event.wait(sleep_time) self.stop_event.wait(sleep_time)
except Exception as e: except Exception as e:
logger.error(f"Error fetching snapshot for camera {self.camera_id}: {e}") logger.error(f"Error in snapshot loop for camera {self.camera_id}: {e}")
retries += 1 retries += 1
if retries > self.max_retries and self.max_retries != -1: if self.max_retries != -1 and retries > self.max_retries:
break break
time.sleep(1) time.sleep(min(2.0, interval_seconds))
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}") logger.info(f"Snapshot reader thread ended for camera {self.camera_id}")
def _fetch_snapshot(self) -> Optional[np.ndarray]: def _fetch_snapshot(self) -> Optional[np.ndarray]:
"""Fetch a single snapshot from HTTP URL.""" """Fetch a single high quality snapshot from HTTP URL."""
try: try:
# Parse URL to extract auth credentials if present # Parse URL for authentication
from urllib.parse import urlparse from urllib.parse import urlparse
parsed_url = urlparse(self.snapshot_url) 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 auth = None
if parsed_url.username and parsed_url.password: if parsed_url.username and parsed_url.password:
# Use HTTP Basic Auth properly
from requests.auth import HTTPBasicAuth, HTTPDigestAuth from requests.auth import HTTPBasicAuth, HTTPDigestAuth
auth = HTTPBasicAuth(parsed_url.username, parsed_url.password) auth = HTTPBasicAuth(parsed_url.username, parsed_url.password)
@ -370,71 +376,76 @@ class HTTPSnapshotReader:
if parsed_url.query: if parsed_url.query:
clean_url += f"?{parsed_url.query}" clean_url += f"?{parsed_url.query}"
# Try with Basic Auth first # Try Basic Auth first
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)
# If Basic Auth fails, try Digest Auth (common for IP cameras) # If Basic Auth fails, try Digest Auth
if response.status_code == 401: if response.status_code == 401:
auth = HTTPDigestAuth(parsed_url.username, parsed_url.password) 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: else:
# No auth in URL, use as-is response = requests.get(self.snapshot_url, timeout=15, headers=headers,
response = requests.get(self.snapshot_url, timeout=10, headers=headers) stream=True, verify=False)
if response.status_code == 200: if response.status_code == 200:
# Convert bytes to numpy array # Check content size
image_array = np.frombuffer(response.content, np.uint8) content_length = int(response.headers.get('content-length', 0))
# Decode as image 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) 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 return frame
else: 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 return None
except requests.RequestException as e: 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 return None
except Exception as e: except Exception as e:
logger.error(f"Error decoding snapshot: {e}") logger.error(f"Error decoding snapshot for {self.camera_id}: {e}")
return None 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]: if aspect > target_aspect:
"""Standalone function to fetch a snapshot (for compatibility).""" # Image is wider
try: new_width = target_width
# Parse URL to extract auth credentials if present new_height = int(target_width / aspect)
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: else:
# No auth in URL, use as-is # Image is taller
response = requests.get(url, timeout=10) new_height = target_height
new_width = int(target_height * aspect)
if response.status_code == 200: # Use INTER_LANCZOS4 for high quality downsampling
image_array = np.frombuffer(response.content, np.uint8) resized = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_LANCZOS4)
frame = cv2.imdecode(image_array, cv2.IMREAD_COLOR)
return frame # Pad to target size if needed
return None if new_width < target_width or new_height < target_height:
except Exception as e: top = (target_height - new_height) // 2
logger.error(f"Error fetching snapshot from {url}: {e}") bottom = target_height - new_height - top
return None 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