""" 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}")