SXXXXXXX_ControlPanel/VideoReceiverSFP/core/dump_manager.py
2026-01-16 09:37:55 +01:00

170 lines
6.3 KiB
Python

"""DumpManager: centralized dump saving, rotation and video recording for VideoReceiverSFP.
Minimal, robust implementation focusing on pruning existing files by
category based on modification time and handling real-time video encoding.
"""
from __future__ import annotations
import os
import time
import pathlib
import logging
from collections import deque
from typing import Optional, Dict
try:
import numpy as np
except Exception:
np = None
try:
from PIL import Image
except Exception:
Image = None
try:
import cv2
except Exception:
cv2 = None
logger = logging.getLogger("VideoReceiverSFP.dump_manager")
class DumpManager:
def __init__(self, dumps_dir: str, keep_count: int = 100):
self.dumps_dir = os.path.abspath(dumps_dir)
os.makedirs(self.dumps_dir, exist_ok=True)
self.keep = int(keep_count)
self._saved = {
'mfd': deque(),
'sar': deque(),
'unknown': deque(),
'bin': deque(),
'frame': deque()
}
# Video recording state
self._video_writers: Dict[str, cv2.VideoWriter] = {}
self._video_start_times: Dict[str, float] = {}
self._last_frame_timestamps: Dict[str, float] = {}
# prune existing files at startup
for cat in list(self._saved.keys()):
try:
self._prune_category(cat)
except Exception:
pass
def _timestamp(self) -> str:
"""Generate a formatted timestamp for filenames."""
now = time.time()
ms = int(now * 1000) % 1000
return time.strftime('%Y%m%d_%H%M%S') + f"_{ms:03d}"
def save_bin(self, payload: bytes, category: str = 'unknown') -> Optional[str]:
"""Save raw binary payload to disk."""
try:
ts = self._timestamp()
fname = f'VideoReceiverSFP_{category}_{ts}.bin'
path = os.path.join(self.dumps_dir, fname)
with open(path, 'wb') as fh:
fh.write(payload)
self._enqueue(path, category)
return path
except Exception:
return None
def save_preview_from_pil(self, pil_image, category: str = 'unknown', fmt: str = 'bmp') -> Optional[str]:
"""Save a PIL image to disk with rotation."""
if pil_image is None:
return None
try:
ts = self._timestamp()
ext = fmt.lstrip('.')
fname = f'VideoReceiverSFP_{category}_{ts}.{ext}'
path = os.path.join(self.dumps_dir, fname)
img = pil_image
if ext.lower() == 'png' or ext.lower() == 'bmp':
if img.mode != 'RGB' and img.mode != 'L':
img = img.convert('RGB')
img.save(path)
self._enqueue(path, category)
return path
except Exception:
return None
def start_video_record(self, category: str, width: int, height: int, fps: int = 20) -> bool:
"""Initialize an OpenCV VideoWriter for a specific category."""
if cv2 is None:
logger.error("OpenCV not available, cannot record video")
return False
try:
ts = self._timestamp()
fname = f'VideoReceiverSFP_{category}_{ts}.avi'
path = os.path.join(self.dumps_dir, fname)
# Use XVID codec for AVI container
fourcc = cv2.VideoWriter_fourcc(*'XVID')
writer = cv2.VideoWriter(path, fourcc, float(fps), (width, height))
if not writer.isOpened():
logger.error("Failed to open VideoWriter for %s", path)
return False
self._video_writers[category] = writer
self._video_start_times[category] = time.time()
self._last_frame_timestamps[category] = time.time()
logger.info("Started video recording: %s (%dx%d @ %d FPS)", path, width, height, fps)
return True
except Exception:
logger.exception("Error starting video recording")
return False
def write_video_frame(self, frame: Image.Image | np.ndarray, category: str):
"""Write a frame to the active video writer, converting from PIL/RGB if needed."""
if category not in self._video_writers:
return
try:
writer = self._video_writers[category]
# Convert PIL to numpy BGR
if Image is not None and isinstance(frame, Image.Image):
cv_frame = cv2.cvtColor(np.array(frame.convert('RGB')), cv2.COLOR_RGB2BGR)
else:
cv_frame = frame
# Ensure correct format (uint8, BGR)
if cv_frame.dtype != np.uint8:
cv_frame = cv_frame.astype(np.uint8)
writer.write(cv_frame)
except Exception:
logger.exception("Error writing frame to video %s", category)
def stop_video_record(self, category: str):
"""Release the video writer for a category."""
if category in self._video_writers:
try:
self._video_writers[category].release()
logger.info("Stopped video recording for %s", category)
except Exception:
logger.exception("Error closing video writer")
finally:
self._video_writers.pop(category, None)
self._video_start_times.pop(category, None)
self._last_frame_timestamps.pop(category, None)
def _enqueue(self, path: str, category: str) -> None:
"""Track saved file and trigger pruning."""
cat = category if category in self._saved else 'unknown'
self._saved[cat].append(path)
self._prune_category(cat)
def _prune_category(self, category: str) -> None:
"""Remove oldest files if they exceed the keep limit."""
prefix = f'VideoReceiverSFP_{category}_'
pdir = pathlib.Path(self.dumps_dir)
files = [p for p in pdir.iterdir() if p.is_file() and p.name.startswith(prefix)]
if len(files) <= self.keep:
return
# Sort by modification time (oldest first)
files_sorted = sorted(files, key=lambda p: p.stat().st_mtime)
remove_count = len(files_sorted) - self.keep
for old in files_sorted[:remove_count]:
try:
old.unlink()
except Exception:
pass