277 lines
No EOL
9.7 KiB
Python
277 lines
No EOL
9.7 KiB
Python
"""
|
|
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}") |