Refactor: done phase 3
This commit is contained in:
parent
6ec10682c0
commit
7e8034c6e5
6 changed files with 967 additions and 21 deletions
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}")
|
Loading…
Add table
Add a link
Reference in a new issue