Refactor: done phase 3

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

View file

@ -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

View file

@ -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
View 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
View 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
View file

@ -0,0 +1,307 @@
"""
Frame readers for RTSP streams and HTTP snapshots.
Extracted from app.py for modular architecture.
"""
import cv2
import logging
import time
import threading
import requests
import numpy as np
from typing import Optional, Callable
from queue import Queue
logger = logging.getLogger(__name__)
class RTSPReader:
"""RTSP stream frame reader using OpenCV."""
def __init__(self, camera_id: str, rtsp_url: str, max_retries: int = 3):
self.camera_id = camera_id
self.rtsp_url = rtsp_url
self.max_retries = max_retries
self.cap = None
self.stop_event = threading.Event()
self.thread = None
self.frame_callback: Optional[Callable] = None
def set_frame_callback(self, callback: Callable[[str, np.ndarray], None]):
"""Set callback function to handle captured frames."""
self.frame_callback = callback
def start(self):
"""Start the RTSP reader thread."""
if self.thread and self.thread.is_alive():
logger.warning(f"RTSP reader for {self.camera_id} already running")
return
self.stop_event.clear()
self.thread = threading.Thread(target=self._read_frames, daemon=True)
self.thread.start()
logger.info(f"Started RTSP reader for camera {self.camera_id}")
def stop(self):
"""Stop the RTSP reader thread."""
self.stop_event.set()
if self.thread:
self.thread.join(timeout=5.0)
if self.cap:
self.cap.release()
logger.info(f"Stopped RTSP reader for camera {self.camera_id}")
def _read_frames(self):
"""Main frame reading loop."""
retries = 0
frame_count = 0
last_log_time = time.time()
try:
# Initialize video capture
self.cap = cv2.VideoCapture(self.rtsp_url)
# Set buffer size to 1 to get latest frames
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
if self.cap.isOpened():
width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
fps = self.cap.get(cv2.CAP_PROP_FPS)
logger.info(f"Camera {self.camera_id} opened: {width}x{height}, FPS: {fps}")
else:
logger.error(f"Camera {self.camera_id} failed to open initially")
while not self.stop_event.is_set():
try:
if not self.cap.isOpened():
logger.error(f"Camera {self.camera_id} not open, attempting to reopen")
self.cap.open(self.rtsp_url)
time.sleep(1)
continue
ret, frame = self.cap.read()
if not ret or frame is None:
logger.warning(f"Failed to read frame from camera {self.camera_id}")
retries += 1
if retries > self.max_retries and self.max_retries != -1:
logger.error(f"Max retries reached for camera {self.camera_id}")
break
time.sleep(0.1)
continue
# Reset retry counter on successful read
retries = 0
frame_count += 1
# Call frame callback if set
if self.frame_callback:
self.frame_callback(self.camera_id, frame)
# Log progress every 30 seconds
current_time = time.time()
if current_time - last_log_time >= 30:
logger.info(f"Camera {self.camera_id}: {frame_count} frames processed")
last_log_time = current_time
# Small delay to prevent CPU overload
time.sleep(0.033) # ~30 FPS
except Exception as e:
logger.error(f"Error reading frame from camera {self.camera_id}: {e}")
retries += 1
if retries > self.max_retries and self.max_retries != -1:
break
time.sleep(1)
except Exception as e:
logger.error(f"Fatal error in RTSP reader for camera {self.camera_id}: {e}")
finally:
if self.cap:
self.cap.release()
logger.info(f"RTSP reader thread ended for camera {self.camera_id}")
class HTTPSnapshotReader:
"""HTTP snapshot reader for periodic image capture."""
def __init__(self, camera_id: str, snapshot_url: str, interval_ms: int = 5000, max_retries: int = 3):
self.camera_id = camera_id
self.snapshot_url = snapshot_url
self.interval_ms = interval_ms
self.max_retries = max_retries
self.stop_event = threading.Event()
self.thread = None
self.frame_callback: Optional[Callable] = None
def set_frame_callback(self, callback: Callable[[str, np.ndarray], None]):
"""Set callback function to handle captured frames."""
self.frame_callback = callback
def start(self):
"""Start the snapshot reader thread."""
if self.thread and self.thread.is_alive():
logger.warning(f"Snapshot reader for {self.camera_id} already running")
return
self.stop_event.clear()
self.thread = threading.Thread(target=self._read_snapshots, daemon=True)
self.thread.start()
logger.info(f"Started snapshot reader for camera {self.camera_id}")
def stop(self):
"""Stop the snapshot reader thread."""
self.stop_event.set()
if self.thread:
self.thread.join(timeout=5.0)
logger.info(f"Stopped snapshot reader for camera {self.camera_id}")
def _read_snapshots(self):
"""Main snapshot reading loop."""
retries = 0
frame_count = 0
last_log_time = time.time()
interval_seconds = self.interval_ms / 1000.0
logger.info(f"Snapshot interval for camera {self.camera_id}: {interval_seconds}s")
try:
while not self.stop_event.is_set():
try:
start_time = time.time()
frame = self._fetch_snapshot()
if frame is None:
logger.warning(f"Failed to fetch snapshot for camera {self.camera_id}, retry {retries+1}/{self.max_retries}")
retries += 1
if retries > self.max_retries and self.max_retries != -1:
logger.error(f"Max retries reached for snapshot camera {self.camera_id}")
break
time.sleep(1)
continue
# Reset retry counter on successful fetch
retries = 0
frame_count += 1
# Call frame callback if set
if self.frame_callback:
self.frame_callback(self.camera_id, frame)
# Log progress every 30 seconds
current_time = time.time()
if current_time - last_log_time >= 30:
logger.info(f"Camera {self.camera_id}: {frame_count} snapshots processed")
last_log_time = current_time
# Wait for next interval, accounting for processing time
elapsed = time.time() - start_time
sleep_time = max(0, interval_seconds - elapsed)
if sleep_time > 0:
self.stop_event.wait(sleep_time)
except Exception as e:
logger.error(f"Error fetching snapshot for camera {self.camera_id}: {e}")
retries += 1
if retries > self.max_retries and self.max_retries != -1:
break
time.sleep(1)
except Exception as e:
logger.error(f"Fatal error in snapshot reader for camera {self.camera_id}: {e}")
finally:
logger.info(f"Snapshot reader thread ended for camera {self.camera_id}")
def _fetch_snapshot(self) -> Optional[np.ndarray]:
"""Fetch a single snapshot from HTTP URL."""
try:
# Parse URL to extract auth credentials if present
from urllib.parse import urlparse
parsed_url = urlparse(self.snapshot_url)
# Prepare headers with proper authentication
headers = {}
auth = None
if parsed_url.username and parsed_url.password:
# Use HTTP Basic Auth properly
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
auth = HTTPBasicAuth(parsed_url.username, parsed_url.password)
# Reconstruct URL without credentials
clean_url = f"{parsed_url.scheme}://{parsed_url.hostname}"
if parsed_url.port:
clean_url += f":{parsed_url.port}"
clean_url += parsed_url.path
if parsed_url.query:
clean_url += f"?{parsed_url.query}"
# Try with Basic Auth first
response = requests.get(clean_url, auth=auth, timeout=10, headers=headers)
# If Basic Auth fails, try Digest Auth (common for IP cameras)
if response.status_code == 401:
auth = HTTPDigestAuth(parsed_url.username, parsed_url.password)
response = requests.get(clean_url, auth=auth, timeout=10, headers=headers)
else:
# No auth in URL, use as-is
response = requests.get(self.snapshot_url, timeout=10, headers=headers)
if response.status_code == 200:
# Convert bytes to numpy array
image_array = np.frombuffer(response.content, np.uint8)
# Decode as image
frame = cv2.imdecode(image_array, cv2.IMREAD_COLOR)
return frame
else:
logger.warning(f"HTTP {response.status_code} from {self.snapshot_url}")
return None
except requests.RequestException as e:
logger.error(f"Request error fetching snapshot: {e}")
return None
except Exception as e:
logger.error(f"Error decoding snapshot: {e}")
return None
def fetch_snapshot(url: str) -> Optional[np.ndarray]:
"""Standalone function to fetch a snapshot (for compatibility)."""
try:
# Parse URL to extract auth credentials if present
from urllib.parse import urlparse
parsed_url = urlparse(url)
auth = None
if parsed_url.username and parsed_url.password:
# Use HTTP Basic Auth properly
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
auth = HTTPBasicAuth(parsed_url.username, parsed_url.password)
# Reconstruct URL without credentials
clean_url = f"{parsed_url.scheme}://{parsed_url.hostname}"
if parsed_url.port:
clean_url += f":{parsed_url.port}"
clean_url += parsed_url.path
if parsed_url.query:
clean_url += f"?{parsed_url.query}"
# Try with Basic Auth first
response = requests.get(clean_url, auth=auth, timeout=10)
# If Basic Auth fails, try Digest Auth (common for IP cameras)
if response.status_code == 401:
auth = HTTPDigestAuth(parsed_url.username, parsed_url.password)
response = requests.get(clean_url, auth=auth, timeout=10)
else:
# No auth in URL, use as-is
response = requests.get(url, timeout=10)
if response.status_code == 200:
image_array = np.frombuffer(response.content, np.uint8)
frame = cv2.imdecode(image_array, cv2.IMREAD_COLOR)
return frame
return None
except Exception as e:
logger.error(f"Error fetching snapshot from {url}: {e}")
return None

View file

@ -3,4 +3,7 @@ uvicorn
websockets
fastapi[standard]
redis
urllib3<2.0.0
urllib3<2.0.0
opencv-python
numpy
requests