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

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