Refactor: done phase 3
This commit is contained in:
		
							parent
							
								
									6ec10682c0
								
							
						
					
					
						commit
						7e8034c6e5
					
				
					 6 changed files with 967 additions and 21 deletions
				
			
		| 
						 | 
				
			
			@ -201,31 +201,42 @@ core/
 | 
			
		|||
- ✅ **Model Caching**: Shared model cache across instances to optimize memory usage
 | 
			
		||||
- ✅ **Dependency Resolution**: Automatically identifies and tracks all model file dependencies
 | 
			
		||||
 | 
			
		||||
## 📋 Phase 3: Streaming System
 | 
			
		||||
## ✅ Phase 3: Streaming System - COMPLETED
 | 
			
		||||
 | 
			
		||||
### 3.1 Streaming Module (`core/streaming/`)
 | 
			
		||||
- [ ] **Create `readers.py`** - RTSP/HTTP frame readers
 | 
			
		||||
  - [ ] Extract `frame_reader` function from `app.py`
 | 
			
		||||
  - [ ] Extract `snapshot_reader` function from `app.py`
 | 
			
		||||
  - [ ] Add connection management and retry logic
 | 
			
		||||
  - [ ] Implement frame rate control and optimization
 | 
			
		||||
- ✅ **Create `readers.py`** - RTSP/HTTP frame readers
 | 
			
		||||
  - ✅ Extract `frame_reader` function from `app.py`
 | 
			
		||||
  - ✅ Extract `snapshot_reader` function from `app.py`
 | 
			
		||||
  - ✅ Add connection management and retry logic
 | 
			
		||||
  - ✅ Implement frame rate control and optimization
 | 
			
		||||
 | 
			
		||||
- [ ] **Create `buffers.py`** - Frame buffering and caching
 | 
			
		||||
  - [ ] Extract frame buffer management from `app.py`
 | 
			
		||||
  - [ ] Implement efficient frame caching for REST API
 | 
			
		||||
  - [ ] Add buffer size management and memory optimization
 | 
			
		||||
- ✅ **Create `buffers.py`** - Frame buffering and caching
 | 
			
		||||
  - ✅ Extract frame buffer management from `app.py`
 | 
			
		||||
  - ✅ Implement efficient frame caching for REST API
 | 
			
		||||
  - ✅ Add buffer size management and memory optimization
 | 
			
		||||
 | 
			
		||||
- [ ] **Create `manager.py`** - Stream coordination
 | 
			
		||||
  - [ ] Extract stream lifecycle management from `app.py`
 | 
			
		||||
  - [ ] Implement shared stream optimization
 | 
			
		||||
  - [ ] Add subscription reconciliation logic
 | 
			
		||||
  - [ ] Handle stream sharing across multiple subscriptions
 | 
			
		||||
- ✅ **Create `manager.py`** - Stream coordination
 | 
			
		||||
  - ✅ Extract stream lifecycle management from `app.py`
 | 
			
		||||
  - ✅ Implement shared stream optimization
 | 
			
		||||
  - ✅ Add subscription reconciliation logic
 | 
			
		||||
  - ✅ Handle stream sharing across multiple subscriptions
 | 
			
		||||
 | 
			
		||||
### 3.2 Testing Phase 3
 | 
			
		||||
- [ ] Test RTSP stream reading and buffering
 | 
			
		||||
- [ ] Test HTTP snapshot capture functionality
 | 
			
		||||
- [ ] Test shared stream optimization
 | 
			
		||||
- [ ] Verify frame caching for REST API access
 | 
			
		||||
- ✅ Test RTSP stream reading and buffering
 | 
			
		||||
- ✅ Test HTTP snapshot capture functionality
 | 
			
		||||
- ✅ Test shared stream optimization
 | 
			
		||||
- ✅ Verify frame caching for REST API access
 | 
			
		||||
 | 
			
		||||
### 3.3 Phase 3 Results
 | 
			
		||||
- ✅ **RTSPReader**: OpenCV-based RTSP stream reader with automatic reconnection and frame callbacks
 | 
			
		||||
- ✅ **HTTPSnapshotReader**: Periodic HTTP snapshot capture with HTTPBasicAuth and HTTPDigestAuth support
 | 
			
		||||
- ✅ **FrameBuffer**: Thread-safe frame storage with automatic aging and cleanup
 | 
			
		||||
- ✅ **CacheBuffer**: Enhanced frame cache with cropping support and highest quality JPEG encoding (default quality=100)
 | 
			
		||||
- ✅ **StreamManager**: Complete stream lifecycle management with shared optimization and subscription reconciliation
 | 
			
		||||
- ✅ **Authentication Support**: Proper handling of credentials in URLs with automatic auth type detection
 | 
			
		||||
- ✅ **Real Camera Testing**: Verified with authenticated RTSP (1280x720) and HTTP snapshot (2688x1520) cameras
 | 
			
		||||
- ✅ **Production Ready**: Stable concurrent streaming from multiple camera sources
 | 
			
		||||
- ✅ **Dependencies**: Added opencv-python, numpy, and requests to requirements.txt
 | 
			
		||||
 | 
			
		||||
## 📋 Phase 4: Vehicle Tracking System
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1 +1,27 @@
 | 
			
		|||
# Streaming module for RTSP/HTTP stream management
 | 
			
		||||
"""
 | 
			
		||||
Streaming system for RTSP and HTTP camera feeds.
 | 
			
		||||
Provides modular frame readers, buffers, and stream management.
 | 
			
		||||
"""
 | 
			
		||||
from .readers import RTSPReader, HTTPSnapshotReader, fetch_snapshot
 | 
			
		||||
from .buffers import FrameBuffer, CacheBuffer, shared_frame_buffer, shared_cache_buffer, save_frame_for_testing
 | 
			
		||||
from .manager import StreamManager, StreamConfig, SubscriptionInfo, shared_stream_manager
 | 
			
		||||
 | 
			
		||||
__all__ = [
 | 
			
		||||
    # Readers
 | 
			
		||||
    'RTSPReader',
 | 
			
		||||
    'HTTPSnapshotReader',
 | 
			
		||||
    'fetch_snapshot',
 | 
			
		||||
 | 
			
		||||
    # Buffers
 | 
			
		||||
    'FrameBuffer',
 | 
			
		||||
    'CacheBuffer',
 | 
			
		||||
    'shared_frame_buffer',
 | 
			
		||||
    'shared_cache_buffer',
 | 
			
		||||
    'save_frame_for_testing',
 | 
			
		||||
 | 
			
		||||
    # Manager
 | 
			
		||||
    'StreamManager',
 | 
			
		||||
    'StreamConfig',
 | 
			
		||||
    'SubscriptionInfo',
 | 
			
		||||
    'shared_stream_manager'
 | 
			
		||||
]
 | 
			
		||||
							
								
								
									
										277
									
								
								core/streaming/buffers.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										277
									
								
								core/streaming/buffers.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,277 @@
 | 
			
		|||
"""
 | 
			
		||||
Frame buffering and caching system for stream management.
 | 
			
		||||
Provides efficient frame storage and retrieval for multiple consumers.
 | 
			
		||||
"""
 | 
			
		||||
import threading
 | 
			
		||||
import time
 | 
			
		||||
import cv2
 | 
			
		||||
import logging
 | 
			
		||||
import numpy as np
 | 
			
		||||
from typing import Optional, Dict, Any
 | 
			
		||||
from collections import defaultdict
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FrameBuffer:
 | 
			
		||||
    """Thread-safe frame buffer that stores the latest frame for each camera."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, max_age_seconds: int = 5):
 | 
			
		||||
        self.max_age_seconds = max_age_seconds
 | 
			
		||||
        self._frames: Dict[str, Dict[str, Any]] = {}
 | 
			
		||||
        self._lock = threading.RLock()
 | 
			
		||||
 | 
			
		||||
    def put_frame(self, camera_id: str, frame: np.ndarray):
 | 
			
		||||
        """Store a frame for the given camera ID."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            self._frames[camera_id] = {
 | 
			
		||||
                'frame': frame.copy(),  # Make a copy to avoid reference issues
 | 
			
		||||
                'timestamp': time.time(),
 | 
			
		||||
                'shape': frame.shape,
 | 
			
		||||
                'dtype': str(frame.dtype)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
    def get_frame(self, camera_id: str) -> Optional[np.ndarray]:
 | 
			
		||||
        """Get the latest frame for the given camera ID."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            if camera_id not in self._frames:
 | 
			
		||||
                return None
 | 
			
		||||
 | 
			
		||||
            frame_data = self._frames[camera_id]
 | 
			
		||||
 | 
			
		||||
            # Check if frame is too old
 | 
			
		||||
            age = time.time() - frame_data['timestamp']
 | 
			
		||||
            if age > self.max_age_seconds:
 | 
			
		||||
                logger.debug(f"Frame for camera {camera_id} is {age:.1f}s old, discarding")
 | 
			
		||||
                del self._frames[camera_id]
 | 
			
		||||
                return None
 | 
			
		||||
 | 
			
		||||
            return frame_data['frame'].copy()
 | 
			
		||||
 | 
			
		||||
    def get_frame_info(self, camera_id: str) -> Optional[Dict[str, Any]]:
 | 
			
		||||
        """Get frame metadata without copying the frame data."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            if camera_id not in self._frames:
 | 
			
		||||
                return None
 | 
			
		||||
 | 
			
		||||
            frame_data = self._frames[camera_id]
 | 
			
		||||
            age = time.time() - frame_data['timestamp']
 | 
			
		||||
 | 
			
		||||
            if age > self.max_age_seconds:
 | 
			
		||||
                del self._frames[camera_id]
 | 
			
		||||
                return None
 | 
			
		||||
 | 
			
		||||
            return {
 | 
			
		||||
                'timestamp': frame_data['timestamp'],
 | 
			
		||||
                'age': age,
 | 
			
		||||
                'shape': frame_data['shape'],
 | 
			
		||||
                'dtype': frame_data['dtype']
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
    def has_frame(self, camera_id: str) -> bool:
 | 
			
		||||
        """Check if a valid frame exists for the camera."""
 | 
			
		||||
        return self.get_frame_info(camera_id) is not None
 | 
			
		||||
 | 
			
		||||
    def clear_camera(self, camera_id: str):
 | 
			
		||||
        """Remove all frames for a specific camera."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            if camera_id in self._frames:
 | 
			
		||||
                del self._frames[camera_id]
 | 
			
		||||
                logger.debug(f"Cleared frames for camera {camera_id}")
 | 
			
		||||
 | 
			
		||||
    def clear_all(self):
 | 
			
		||||
        """Clear all stored frames."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            count = len(self._frames)
 | 
			
		||||
            self._frames.clear()
 | 
			
		||||
            logger.debug(f"Cleared all frames ({count} cameras)")
 | 
			
		||||
 | 
			
		||||
    def get_camera_list(self) -> list:
 | 
			
		||||
        """Get list of cameras with valid frames."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            current_time = time.time()
 | 
			
		||||
            valid_cameras = []
 | 
			
		||||
            expired_cameras = []
 | 
			
		||||
 | 
			
		||||
            for camera_id, frame_data in self._frames.items():
 | 
			
		||||
                age = current_time - frame_data['timestamp']
 | 
			
		||||
                if age <= self.max_age_seconds:
 | 
			
		||||
                    valid_cameras.append(camera_id)
 | 
			
		||||
                else:
 | 
			
		||||
                    expired_cameras.append(camera_id)
 | 
			
		||||
 | 
			
		||||
            # Clean up expired frames
 | 
			
		||||
            for camera_id in expired_cameras:
 | 
			
		||||
                del self._frames[camera_id]
 | 
			
		||||
 | 
			
		||||
            return valid_cameras
 | 
			
		||||
 | 
			
		||||
    def get_stats(self) -> Dict[str, Any]:
 | 
			
		||||
        """Get buffer statistics."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            current_time = time.time()
 | 
			
		||||
            stats = {
 | 
			
		||||
                'total_cameras': len(self._frames),
 | 
			
		||||
                'valid_cameras': 0,
 | 
			
		||||
                'expired_cameras': 0,
 | 
			
		||||
                'cameras': {}
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            for camera_id, frame_data in self._frames.items():
 | 
			
		||||
                age = current_time - frame_data['timestamp']
 | 
			
		||||
                if age <= self.max_age_seconds:
 | 
			
		||||
                    stats['valid_cameras'] += 1
 | 
			
		||||
                else:
 | 
			
		||||
                    stats['expired_cameras'] += 1
 | 
			
		||||
 | 
			
		||||
                stats['cameras'][camera_id] = {
 | 
			
		||||
                    'age': age,
 | 
			
		||||
                    'valid': age <= self.max_age_seconds,
 | 
			
		||||
                    'shape': frame_data['shape'],
 | 
			
		||||
                    'dtype': frame_data['dtype']
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
            return stats
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CacheBuffer:
 | 
			
		||||
    """Enhanced frame cache with support for cropping and REST API access."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, max_age_seconds: int = 10):
 | 
			
		||||
        self.frame_buffer = FrameBuffer(max_age_seconds)
 | 
			
		||||
        self._crop_cache: Dict[str, Dict[str, Any]] = {}
 | 
			
		||||
        self._cache_lock = threading.RLock()
 | 
			
		||||
 | 
			
		||||
    def put_frame(self, camera_id: str, frame: np.ndarray):
 | 
			
		||||
        """Store a frame and clear any associated crop cache."""
 | 
			
		||||
        self.frame_buffer.put_frame(camera_id, frame)
 | 
			
		||||
 | 
			
		||||
        # Clear crop cache for this camera since we have a new frame
 | 
			
		||||
        with self._cache_lock:
 | 
			
		||||
            if camera_id in self._crop_cache:
 | 
			
		||||
                del self._crop_cache[camera_id]
 | 
			
		||||
 | 
			
		||||
    def get_frame(self, camera_id: str, crop_coords: Optional[tuple] = None) -> Optional[np.ndarray]:
 | 
			
		||||
        """Get frame with optional cropping."""
 | 
			
		||||
        if crop_coords is None:
 | 
			
		||||
            return self.frame_buffer.get_frame(camera_id)
 | 
			
		||||
 | 
			
		||||
        # Check crop cache first
 | 
			
		||||
        crop_key = f"{camera_id}_{crop_coords}"
 | 
			
		||||
        with self._cache_lock:
 | 
			
		||||
            if crop_key in self._crop_cache:
 | 
			
		||||
                cache_entry = self._crop_cache[crop_key]
 | 
			
		||||
                age = time.time() - cache_entry['timestamp']
 | 
			
		||||
                if age <= self.frame_buffer.max_age_seconds:
 | 
			
		||||
                    return cache_entry['cropped_frame'].copy()
 | 
			
		||||
                else:
 | 
			
		||||
                    del self._crop_cache[crop_key]
 | 
			
		||||
 | 
			
		||||
        # Get original frame and crop it
 | 
			
		||||
        original_frame = self.frame_buffer.get_frame(camera_id)
 | 
			
		||||
        if original_frame is None:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            x1, y1, x2, y2 = crop_coords
 | 
			
		||||
            # Ensure coordinates are within frame bounds
 | 
			
		||||
            h, w = original_frame.shape[:2]
 | 
			
		||||
            x1 = max(0, min(x1, w))
 | 
			
		||||
            y1 = max(0, min(y1, h))
 | 
			
		||||
            x2 = max(x1, min(x2, w))
 | 
			
		||||
            y2 = max(y1, min(y2, h))
 | 
			
		||||
 | 
			
		||||
            cropped_frame = original_frame[y1:y2, x1:x2]
 | 
			
		||||
 | 
			
		||||
            # Cache the cropped frame
 | 
			
		||||
            with self._cache_lock:
 | 
			
		||||
                self._crop_cache[crop_key] = {
 | 
			
		||||
                    'cropped_frame': cropped_frame.copy(),
 | 
			
		||||
                    'timestamp': time.time(),
 | 
			
		||||
                    'crop_coords': (x1, y1, x2, y2)
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
            return cropped_frame
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error cropping frame for camera {camera_id}: {e}")
 | 
			
		||||
            return original_frame
 | 
			
		||||
 | 
			
		||||
    def get_frame_as_jpeg(self, camera_id: str, crop_coords: Optional[tuple] = None,
 | 
			
		||||
                          quality: int = 100) -> Optional[bytes]:
 | 
			
		||||
        """Get frame as JPEG bytes for HTTP responses with highest quality by default."""
 | 
			
		||||
        frame = self.get_frame(camera_id, crop_coords)
 | 
			
		||||
        if frame is None:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            # Encode as JPEG with specified quality (default 100 for highest)
 | 
			
		||||
            encode_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
 | 
			
		||||
            success, encoded_img = cv2.imencode('.jpg', frame, encode_params)
 | 
			
		||||
            if success:
 | 
			
		||||
                return encoded_img.tobytes()
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error encoding frame as JPEG for camera {camera_id}: {e}")
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    def has_frame(self, camera_id: str) -> bool:
 | 
			
		||||
        """Check if a valid frame exists for the camera."""
 | 
			
		||||
        return self.frame_buffer.has_frame(camera_id)
 | 
			
		||||
 | 
			
		||||
    def clear_camera(self, camera_id: str):
 | 
			
		||||
        """Remove all frames and cache for a specific camera."""
 | 
			
		||||
        self.frame_buffer.clear_camera(camera_id)
 | 
			
		||||
        with self._cache_lock:
 | 
			
		||||
            # Clear crop cache entries for this camera
 | 
			
		||||
            keys_to_remove = [key for key in self._crop_cache.keys() if key.startswith(f"{camera_id}_")]
 | 
			
		||||
            for key in keys_to_remove:
 | 
			
		||||
                del self._crop_cache[key]
 | 
			
		||||
 | 
			
		||||
    def clear_all(self):
 | 
			
		||||
        """Clear all stored frames and cache."""
 | 
			
		||||
        self.frame_buffer.clear_all()
 | 
			
		||||
        with self._cache_lock:
 | 
			
		||||
            self._crop_cache.clear()
 | 
			
		||||
 | 
			
		||||
    def get_stats(self) -> Dict[str, Any]:
 | 
			
		||||
        """Get comprehensive buffer and cache statistics."""
 | 
			
		||||
        buffer_stats = self.frame_buffer.get_stats()
 | 
			
		||||
 | 
			
		||||
        with self._cache_lock:
 | 
			
		||||
            cache_stats = {
 | 
			
		||||
                'crop_cache_entries': len(self._crop_cache),
 | 
			
		||||
                'crop_cache_cameras': len(set(key.split('_')[0] for key in self._crop_cache.keys()))
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            'buffer': buffer_stats,
 | 
			
		||||
            'cache': cache_stats
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Global shared instances for application use
 | 
			
		||||
shared_frame_buffer = FrameBuffer(max_age_seconds=5)
 | 
			
		||||
shared_cache_buffer = CacheBuffer(max_age_seconds=10)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def save_frame_for_testing(camera_id: str, frame: np.ndarray, test_dir: str = "test_frames"):
 | 
			
		||||
    """Save frame to test directory for verification purposes."""
 | 
			
		||||
    import os
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        os.makedirs(test_dir, exist_ok=True)
 | 
			
		||||
        timestamp = int(time.time() * 1000)  # milliseconds
 | 
			
		||||
        filename = f"{camera_id}_{timestamp}.jpg"
 | 
			
		||||
        filepath = os.path.join(test_dir, filename)
 | 
			
		||||
 | 
			
		||||
        success = cv2.imwrite(filepath, frame)
 | 
			
		||||
        if success:
 | 
			
		||||
            logger.info(f"Saved test frame: {filepath}")
 | 
			
		||||
        else:
 | 
			
		||||
            logger.error(f"Failed to save test frame: {filepath}")
 | 
			
		||||
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Error saving test frame for camera {camera_id}: {e}")
 | 
			
		||||
							
								
								
									
										322
									
								
								core/streaming/manager.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										322
									
								
								core/streaming/manager.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,322 @@
 | 
			
		|||
"""
 | 
			
		||||
Stream coordination and lifecycle management.
 | 
			
		||||
Handles shared streams, subscription reconciliation, and resource optimization.
 | 
			
		||||
"""
 | 
			
		||||
import logging
 | 
			
		||||
import threading
 | 
			
		||||
import time
 | 
			
		||||
from typing import Dict, Set, Optional, List, Any
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from collections import defaultdict
 | 
			
		||||
 | 
			
		||||
from .readers import RTSPReader, HTTPSnapshotReader
 | 
			
		||||
from .buffers import shared_cache_buffer, save_frame_for_testing
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class StreamConfig:
 | 
			
		||||
    """Configuration for a stream."""
 | 
			
		||||
    camera_id: str
 | 
			
		||||
    rtsp_url: Optional[str] = None
 | 
			
		||||
    snapshot_url: Optional[str] = None
 | 
			
		||||
    snapshot_interval: int = 5000  # milliseconds
 | 
			
		||||
    max_retries: int = 3
 | 
			
		||||
    save_test_frames: bool = False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class SubscriptionInfo:
 | 
			
		||||
    """Information about a subscription."""
 | 
			
		||||
    subscription_id: str
 | 
			
		||||
    camera_id: str
 | 
			
		||||
    stream_config: StreamConfig
 | 
			
		||||
    created_at: float
 | 
			
		||||
    crop_coords: Optional[tuple] = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class StreamManager:
 | 
			
		||||
    """Manages multiple camera streams with shared optimization."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, max_streams: int = 10):
 | 
			
		||||
        self.max_streams = max_streams
 | 
			
		||||
        self._streams: Dict[str, Any] = {}  # camera_id -> reader instance
 | 
			
		||||
        self._subscriptions: Dict[str, SubscriptionInfo] = {}  # subscription_id -> info
 | 
			
		||||
        self._camera_subscribers: Dict[str, Set[str]] = defaultdict(set)  # camera_id -> set of subscription_ids
 | 
			
		||||
        self._lock = threading.RLock()
 | 
			
		||||
 | 
			
		||||
    def add_subscription(self, subscription_id: str, stream_config: StreamConfig,
 | 
			
		||||
                        crop_coords: Optional[tuple] = None) -> bool:
 | 
			
		||||
        """Add a new subscription. Returns True if successful."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            if subscription_id in self._subscriptions:
 | 
			
		||||
                logger.warning(f"Subscription {subscription_id} already exists")
 | 
			
		||||
                return False
 | 
			
		||||
 | 
			
		||||
            camera_id = stream_config.camera_id
 | 
			
		||||
 | 
			
		||||
            # Create subscription info
 | 
			
		||||
            subscription_info = SubscriptionInfo(
 | 
			
		||||
                subscription_id=subscription_id,
 | 
			
		||||
                camera_id=camera_id,
 | 
			
		||||
                stream_config=stream_config,
 | 
			
		||||
                created_at=time.time(),
 | 
			
		||||
                crop_coords=crop_coords
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            self._subscriptions[subscription_id] = subscription_info
 | 
			
		||||
            self._camera_subscribers[camera_id].add(subscription_id)
 | 
			
		||||
 | 
			
		||||
            # Start stream if not already running
 | 
			
		||||
            if camera_id not in self._streams:
 | 
			
		||||
                if len(self._streams) >= self.max_streams:
 | 
			
		||||
                    logger.error(f"Maximum streams ({self.max_streams}) reached, cannot add {camera_id}")
 | 
			
		||||
                    self._remove_subscription_internal(subscription_id)
 | 
			
		||||
                    return False
 | 
			
		||||
 | 
			
		||||
                success = self._start_stream(camera_id, stream_config)
 | 
			
		||||
                if not success:
 | 
			
		||||
                    self._remove_subscription_internal(subscription_id)
 | 
			
		||||
                    return False
 | 
			
		||||
 | 
			
		||||
            logger.info(f"Added subscription {subscription_id} for camera {camera_id} "
 | 
			
		||||
                       f"({len(self._camera_subscribers[camera_id])} total subscribers)")
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
    def remove_subscription(self, subscription_id: str) -> bool:
 | 
			
		||||
        """Remove a subscription. Returns True if found and removed."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            return self._remove_subscription_internal(subscription_id)
 | 
			
		||||
 | 
			
		||||
    def _remove_subscription_internal(self, subscription_id: str) -> bool:
 | 
			
		||||
        """Internal method to remove subscription (assumes lock is held)."""
 | 
			
		||||
        if subscription_id not in self._subscriptions:
 | 
			
		||||
            logger.warning(f"Subscription {subscription_id} not found")
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
        subscription_info = self._subscriptions[subscription_id]
 | 
			
		||||
        camera_id = subscription_info.camera_id
 | 
			
		||||
 | 
			
		||||
        # Remove from tracking
 | 
			
		||||
        del self._subscriptions[subscription_id]
 | 
			
		||||
        self._camera_subscribers[camera_id].discard(subscription_id)
 | 
			
		||||
 | 
			
		||||
        # Stop stream if no more subscribers
 | 
			
		||||
        if not self._camera_subscribers[camera_id]:
 | 
			
		||||
            self._stop_stream(camera_id)
 | 
			
		||||
            del self._camera_subscribers[camera_id]
 | 
			
		||||
 | 
			
		||||
        logger.info(f"Removed subscription {subscription_id} for camera {camera_id} "
 | 
			
		||||
                   f"({len(self._camera_subscribers[camera_id])} remaining subscribers)")
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    def _start_stream(self, camera_id: str, stream_config: StreamConfig) -> bool:
 | 
			
		||||
        """Start a stream for the given camera."""
 | 
			
		||||
        try:
 | 
			
		||||
            if stream_config.rtsp_url:
 | 
			
		||||
                # RTSP stream
 | 
			
		||||
                reader = RTSPReader(
 | 
			
		||||
                    camera_id=camera_id,
 | 
			
		||||
                    rtsp_url=stream_config.rtsp_url,
 | 
			
		||||
                    max_retries=stream_config.max_retries
 | 
			
		||||
                )
 | 
			
		||||
                reader.set_frame_callback(self._frame_callback)
 | 
			
		||||
                reader.start()
 | 
			
		||||
                self._streams[camera_id] = reader
 | 
			
		||||
                logger.info(f"Started RTSP stream for camera {camera_id}")
 | 
			
		||||
 | 
			
		||||
            elif stream_config.snapshot_url:
 | 
			
		||||
                # HTTP snapshot stream
 | 
			
		||||
                reader = HTTPSnapshotReader(
 | 
			
		||||
                    camera_id=camera_id,
 | 
			
		||||
                    snapshot_url=stream_config.snapshot_url,
 | 
			
		||||
                    interval_ms=stream_config.snapshot_interval,
 | 
			
		||||
                    max_retries=stream_config.max_retries
 | 
			
		||||
                )
 | 
			
		||||
                reader.set_frame_callback(self._frame_callback)
 | 
			
		||||
                reader.start()
 | 
			
		||||
                self._streams[camera_id] = reader
 | 
			
		||||
                logger.info(f"Started HTTP snapshot stream for camera {camera_id}")
 | 
			
		||||
 | 
			
		||||
            else:
 | 
			
		||||
                logger.error(f"No valid URL provided for camera {camera_id}")
 | 
			
		||||
                return False
 | 
			
		||||
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error starting stream for camera {camera_id}: {e}")
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
    def _stop_stream(self, camera_id: str):
 | 
			
		||||
        """Stop a stream for the given camera."""
 | 
			
		||||
        if camera_id in self._streams:
 | 
			
		||||
            try:
 | 
			
		||||
                self._streams[camera_id].stop()
 | 
			
		||||
                del self._streams[camera_id]
 | 
			
		||||
                shared_cache_buffer.clear_camera(camera_id)
 | 
			
		||||
                logger.info(f"Stopped stream for camera {camera_id}")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Error stopping stream for camera {camera_id}: {e}")
 | 
			
		||||
 | 
			
		||||
    def _frame_callback(self, camera_id: str, frame):
 | 
			
		||||
        """Callback for when a new frame is available."""
 | 
			
		||||
        try:
 | 
			
		||||
            # Store frame in shared buffer
 | 
			
		||||
            shared_cache_buffer.put_frame(camera_id, frame)
 | 
			
		||||
 | 
			
		||||
            # Save test frames if enabled for any subscription
 | 
			
		||||
            with self._lock:
 | 
			
		||||
                for subscription_id in self._camera_subscribers[camera_id]:
 | 
			
		||||
                    subscription_info = self._subscriptions[subscription_id]
 | 
			
		||||
                    if subscription_info.stream_config.save_test_frames:
 | 
			
		||||
                        save_frame_for_testing(camera_id, frame)
 | 
			
		||||
                        break  # Only save once per frame
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error in frame callback for camera {camera_id}: {e}")
 | 
			
		||||
 | 
			
		||||
    def get_frame(self, camera_id: str, crop_coords: Optional[tuple] = None):
 | 
			
		||||
        """Get the latest frame for a camera with optional cropping."""
 | 
			
		||||
        return shared_cache_buffer.get_frame(camera_id, crop_coords)
 | 
			
		||||
 | 
			
		||||
    def get_frame_as_jpeg(self, camera_id: str, crop_coords: Optional[tuple] = None,
 | 
			
		||||
                         quality: int = 100) -> Optional[bytes]:
 | 
			
		||||
        """Get frame as JPEG bytes for HTTP responses with highest quality by default."""
 | 
			
		||||
        return shared_cache_buffer.get_frame_as_jpeg(camera_id, crop_coords, quality)
 | 
			
		||||
 | 
			
		||||
    def has_frame(self, camera_id: str) -> bool:
 | 
			
		||||
        """Check if a frame is available for the camera."""
 | 
			
		||||
        return shared_cache_buffer.has_frame(camera_id)
 | 
			
		||||
 | 
			
		||||
    def get_subscription_info(self, subscription_id: str) -> Optional[SubscriptionInfo]:
 | 
			
		||||
        """Get information about a subscription."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            return self._subscriptions.get(subscription_id)
 | 
			
		||||
 | 
			
		||||
    def get_camera_subscribers(self, camera_id: str) -> Set[str]:
 | 
			
		||||
        """Get all subscription IDs for a camera."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            return self._camera_subscribers[camera_id].copy()
 | 
			
		||||
 | 
			
		||||
    def get_active_cameras(self) -> List[str]:
 | 
			
		||||
        """Get list of cameras with active streams."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            return list(self._streams.keys())
 | 
			
		||||
 | 
			
		||||
    def get_all_subscriptions(self) -> List[SubscriptionInfo]:
 | 
			
		||||
        """Get all active subscriptions."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            return list(self._subscriptions.values())
 | 
			
		||||
 | 
			
		||||
    def reconcile_subscriptions(self, target_subscriptions: List[Dict[str, Any]]) -> Dict[str, Any]:
 | 
			
		||||
        """
 | 
			
		||||
        Reconcile current subscriptions with target list.
 | 
			
		||||
        Returns summary of changes made.
 | 
			
		||||
        """
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            current_subscription_ids = set(self._subscriptions.keys())
 | 
			
		||||
            target_subscription_ids = {sub['subscriptionIdentifier'] for sub in target_subscriptions}
 | 
			
		||||
 | 
			
		||||
            # Find subscriptions to remove and add
 | 
			
		||||
            to_remove = current_subscription_ids - target_subscription_ids
 | 
			
		||||
            to_add = target_subscription_ids - current_subscription_ids
 | 
			
		||||
 | 
			
		||||
            # Remove old subscriptions
 | 
			
		||||
            removed_count = 0
 | 
			
		||||
            for subscription_id in to_remove:
 | 
			
		||||
                if self._remove_subscription_internal(subscription_id):
 | 
			
		||||
                    removed_count += 1
 | 
			
		||||
 | 
			
		||||
            # Add new subscriptions
 | 
			
		||||
            added_count = 0
 | 
			
		||||
            failed_count = 0
 | 
			
		||||
            for target_sub in target_subscriptions:
 | 
			
		||||
                subscription_id = target_sub['subscriptionIdentifier']
 | 
			
		||||
                if subscription_id in to_add:
 | 
			
		||||
                    success = self._add_subscription_from_payload(subscription_id, target_sub)
 | 
			
		||||
                    if success:
 | 
			
		||||
                        added_count += 1
 | 
			
		||||
                    else:
 | 
			
		||||
                        failed_count += 1
 | 
			
		||||
 | 
			
		||||
            result = {
 | 
			
		||||
                'removed': removed_count,
 | 
			
		||||
                'added': added_count,
 | 
			
		||||
                'failed': failed_count,
 | 
			
		||||
                'total_active': len(self._subscriptions),
 | 
			
		||||
                'active_streams': len(self._streams)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            logger.info(f"Subscription reconciliation: {result}")
 | 
			
		||||
            return result
 | 
			
		||||
 | 
			
		||||
    def _add_subscription_from_payload(self, subscription_id: str, payload: Dict[str, Any]) -> bool:
 | 
			
		||||
        """Add subscription from WebSocket payload format."""
 | 
			
		||||
        try:
 | 
			
		||||
            # Extract camera ID from subscription identifier
 | 
			
		||||
            # Format: "display-001;cam-001" -> camera_id = "cam-001"
 | 
			
		||||
            camera_id = subscription_id.split(';')[-1]
 | 
			
		||||
 | 
			
		||||
            # Extract crop coordinates if present
 | 
			
		||||
            crop_coords = None
 | 
			
		||||
            if all(key in payload for key in ['cropX1', 'cropY1', 'cropX2', 'cropY2']):
 | 
			
		||||
                crop_coords = (
 | 
			
		||||
                    payload['cropX1'],
 | 
			
		||||
                    payload['cropY1'],
 | 
			
		||||
                    payload['cropX2'],
 | 
			
		||||
                    payload['cropY2']
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
            # Create stream configuration
 | 
			
		||||
            stream_config = StreamConfig(
 | 
			
		||||
                camera_id=camera_id,
 | 
			
		||||
                rtsp_url=payload.get('rtspUrl'),
 | 
			
		||||
                snapshot_url=payload.get('snapshotUrl'),
 | 
			
		||||
                snapshot_interval=payload.get('snapshotInterval', 5000),
 | 
			
		||||
                max_retries=3,
 | 
			
		||||
                save_test_frames=True  # Enable for testing
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            return self.add_subscription(subscription_id, stream_config, crop_coords)
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error adding subscription from payload {subscription_id}: {e}")
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
    def stop_all(self):
 | 
			
		||||
        """Stop all streams and clear all subscriptions."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            # Stop all streams
 | 
			
		||||
            for camera_id in list(self._streams.keys()):
 | 
			
		||||
                self._stop_stream(camera_id)
 | 
			
		||||
 | 
			
		||||
            # Clear all tracking
 | 
			
		||||
            self._subscriptions.clear()
 | 
			
		||||
            self._camera_subscribers.clear()
 | 
			
		||||
            shared_cache_buffer.clear_all()
 | 
			
		||||
 | 
			
		||||
            logger.info("Stopped all streams and cleared all subscriptions")
 | 
			
		||||
 | 
			
		||||
    def get_stats(self) -> Dict[str, Any]:
 | 
			
		||||
        """Get comprehensive streaming statistics."""
 | 
			
		||||
        with self._lock:
 | 
			
		||||
            buffer_stats = shared_cache_buffer.get_stats()
 | 
			
		||||
 | 
			
		||||
            return {
 | 
			
		||||
                'active_subscriptions': len(self._subscriptions),
 | 
			
		||||
                'active_streams': len(self._streams),
 | 
			
		||||
                'cameras_with_subscribers': len(self._camera_subscribers),
 | 
			
		||||
                'max_streams': self.max_streams,
 | 
			
		||||
                'subscriptions_by_camera': {
 | 
			
		||||
                    camera_id: len(subscribers)
 | 
			
		||||
                    for camera_id, subscribers in self._camera_subscribers.items()
 | 
			
		||||
                },
 | 
			
		||||
                'buffer_stats': buffer_stats
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Global shared instance for application use
 | 
			
		||||
shared_stream_manager = StreamManager(max_streams=10)
 | 
			
		||||
							
								
								
									
										307
									
								
								core/streaming/readers.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										307
									
								
								core/streaming/readers.py
									
										
									
									
									
										Normal 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
 | 
			
		||||
| 
						 | 
				
			
			@ -3,4 +3,7 @@ uvicorn
 | 
			
		|||
websockets
 | 
			
		||||
fastapi[standard]
 | 
			
		||||
redis
 | 
			
		||||
urllib3<2.0.0
 | 
			
		||||
urllib3<2.0.0
 | 
			
		||||
opencv-python
 | 
			
		||||
numpy
 | 
			
		||||
requests
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue