195 lines
7.4 KiB
Python
195 lines
7.4 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 = {}
|
|
self._video_start_times: Dict[str, float] = {}
|
|
self._last_frame_timestamps: Dict[str, float] = {}
|
|
self._video_fps_map: Dict[str, float] = {}
|
|
# Maximum duplicate frames to write when spacing is large (safety cap)
|
|
self._max_duplicate_frames = 1000
|
|
|
|
# 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()
|
|
# store configured fps per category
|
|
try:
|
|
self._video_fps_map[category] = float(fps)
|
|
except Exception:
|
|
self._video_fps_map[category] = float(20)
|
|
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, 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)
|
|
# Determine how many frames to write to reflect real arrival timing.
|
|
now = time.time()
|
|
last = self._last_frame_timestamps.get(category, now)
|
|
fps = float(self._video_fps_map.get(category, 20.0))
|
|
# delta seconds since last written frame
|
|
delta = max(0.0, now - last)
|
|
# compute duplicates: at least 1 frame
|
|
try:
|
|
dup = max(1, int(round(delta * fps)))
|
|
except Exception:
|
|
dup = 1
|
|
# cap duplicates to avoid runaway file sizes
|
|
if dup > self._max_duplicate_frames:
|
|
dup = self._max_duplicate_frames
|
|
for _ in range(dup):
|
|
writer.write(cv_frame)
|
|
# update last timestamp to now
|
|
self._last_frame_timestamps[category] = now
|
|
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 |