Files
usda-vision/camera-management-api/usda_vision_system/camera/streamer.py
salirezav 43e1dace8c Enhance camera recording functionality with streamer integration
- Updated CameraRecorder to support frame sharing from CameraStreamer, allowing for more efficient video recording.
- Modified CameraManager to ensure streamer references are correctly assigned to recorders.
- Enhanced CameraStreamer to include a recording frame queue for concurrent access during recording.
- Improved logging for better tracking of recording states and streamer activity.
- Updated API tests to include new functionality for retrieving video lists.
2025-11-01 13:49:16 -04:00

716 lines
31 KiB
Python

"""
Camera Streamer for the USDA Vision Camera System.
This module provides live preview streaming from GigE cameras without blocking recording.
It creates a separate camera connection for streaming that doesn't interfere with recording.
"""
import sys
import os
import threading
import time
import logging
import cv2
import numpy as np
import contextlib
import subprocess
from typing import Optional, Dict, Any, Generator
from datetime import datetime
import queue
# Add camera SDK to path
sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..", "camera_sdk"))
import mvsdk
from ..core.config import CameraConfig
from ..core.state_manager import StateManager
from ..core.events import EventSystem
from .sdk_config import ensure_sdk_initialized
@contextlib.contextmanager
def suppress_camera_errors():
"""Context manager to temporarily suppress camera SDK error output"""
# Save original file descriptors
original_stderr = os.dup(2)
original_stdout = os.dup(1)
try:
# Redirect stderr and stdout to devnull
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, 2) # stderr
os.dup2(devnull, 1) # stdout (in case SDK uses stdout)
os.close(devnull)
yield
finally:
# Restore original file descriptors
os.dup2(original_stderr, 2)
os.dup2(original_stdout, 1)
os.close(original_stderr)
os.close(original_stdout)
class CameraStreamer:
"""Provides live preview streaming from cameras without blocking recording"""
def __init__(self, camera_config: CameraConfig, device_info: Any, state_manager: StateManager, event_system: EventSystem):
self.camera_config = camera_config
self.device_info = device_info
self.state_manager = state_manager
self.event_system = event_system
self.logger = logging.getLogger(f"{__name__}.{camera_config.name}")
# Camera handle and properties (separate from recorder)
self.hCamera: Optional[int] = None
self.cap = None
self.monoCamera = False
self.frame_buffer = None
self.frame_buffer_size = 0
# Streaming state
self.streaming = False
self.rtsp_streaming = False # RTSP streaming state
self._streaming_thread: Optional[threading.Thread] = None
self._rtsp_thread: Optional[threading.Thread] = None
self._stop_streaming_event = threading.Event()
self._stop_rtsp_event = threading.Event()
self._frame_queue = queue.Queue(maxsize=5) # Buffer for latest frames (for MJPEG streaming)
self._rtsp_frame_queue = queue.Queue(maxsize=10) # Buffer for RTSP frames (larger buffer for smoother streaming)
self._recording_frame_queue = queue.Queue(maxsize=30) # Buffer for recording frames (shared with recorder)
self._lock = threading.RLock()
# Stream settings (optimized for preview)
self.preview_fps = 10.0 # Lower FPS for preview to reduce load
self.preview_quality = 70 # JPEG quality for streaming
# RTSP settings
self.rtsp_fps = 15.0 # RTSP FPS (can be higher than MJPEG preview)
# Use MEDIAMTX_HOST env var if set, otherwise default to localhost
# Note: If API uses network_mode: host, MediaMTX container ports are exposed to host
# So localhost should work, but MediaMTX must be accessible on that port
self.rtsp_host = os.getenv("MEDIAMTX_HOST", "localhost")
self.rtsp_port = int(os.getenv("MEDIAMTX_RTSP_PORT", "8554"))
self._rtsp_process: Optional[subprocess.Popen] = None
self._rtsp_frame_width = 0
self._rtsp_frame_height = 0
def start_streaming(self) -> bool:
"""Start streaming preview frames"""
with self._lock:
if self.streaming:
self.logger.warning("Streaming already active")
return True
try:
# Initialize camera for streaming
if not self._initialize_camera():
return False
# Start streaming thread
self._stop_streaming_event.clear()
self._streaming_thread = threading.Thread(target=self._streaming_loop, daemon=True)
self._streaming_thread.start()
self.streaming = True
self.logger.info(f"Started streaming for camera: {self.camera_config.name}")
return True
except Exception as e:
self.logger.error(f"Error starting streaming: {e}")
self._cleanup_camera()
return False
def stop_streaming(self) -> bool:
"""Stop streaming preview frames"""
with self._lock:
if not self.streaming:
return True
try:
# Signal streaming thread to stop
self._stop_streaming_event.set()
# Wait for thread to finish
if self._streaming_thread and self._streaming_thread.is_alive():
self._streaming_thread.join(timeout=5.0)
# Cleanup camera resources
self._cleanup_camera()
self.streaming = False
self.logger.info(f"Stopped streaming for camera: {self.camera_config.name}")
return True
except Exception as e:
self.logger.error(f"Error stopping streaming: {e}")
return False
def start_rtsp_streaming(self) -> bool:
"""Start RTSP streaming to MediaMTX"""
with self._lock:
if self.rtsp_streaming:
self.logger.warning("RTSP streaming already active")
return True
# Ensure camera is initialized for streaming
if not self.streaming:
if not self.start_streaming():
return False
try:
# Get frame dimensions from the first frame if available
if self._frame_queue.empty():
self.logger.warning("No frames available yet, will initialize RTSP with default dimensions")
# Will update dimensions when first frame arrives
else:
# Peek at a frame to get dimensions (don't remove it)
try:
test_frame = self._frame_queue.queue[0]
self._rtsp_frame_height, self._rtsp_frame_width = test_frame.shape[:2]
except (IndexError, AttributeError):
pass
# Start RTSP thread
self._stop_rtsp_event.clear()
self._rtsp_thread = threading.Thread(target=self._rtsp_streaming_loop, daemon=True)
self._rtsp_thread.start()
self.rtsp_streaming = True
self.logger.info(f"Started RTSP streaming for camera: {self.camera_config.name} -> rtsp://{self.rtsp_host}:{self.rtsp_port}/{self.camera_config.name}")
return True
except Exception as e:
self.logger.error(f"Error starting RTSP streaming: {e}")
return False
def stop_rtsp_streaming(self) -> bool:
"""Stop RTSP streaming"""
with self._lock:
if not self.rtsp_streaming:
return True
try:
# Signal RTSP thread to stop
self._stop_rtsp_event.set()
# Wait for thread to finish
if self._rtsp_thread and self._rtsp_thread.is_alive():
self._rtsp_thread.join(timeout=5.0)
# Kill FFmpeg process if still running
if self._rtsp_process:
try:
self._rtsp_process.terminate()
self._rtsp_process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
self._rtsp_process.kill()
except Exception as e:
self.logger.warning(f"Error stopping RTSP process: {e}")
self._rtsp_process = None
# Clear RTSP frame queue
while not self._rtsp_frame_queue.empty():
try:
self._rtsp_frame_queue.get_nowait()
except queue.Empty:
break
self.rtsp_streaming = False
self.logger.info(f"Stopped RTSP streaming for camera: {self.camera_config.name}")
return True
except Exception as e:
self.logger.error(f"Error stopping RTSP streaming: {e}")
return False
def is_rtsp_streaming(self) -> bool:
"""Check if RTSP streaming is active"""
return self.rtsp_streaming
def get_latest_frame(self) -> Optional[bytes]:
"""Get the latest frame as JPEG bytes for streaming"""
try:
# Get latest frame from queue (non-blocking)
frame = self._frame_queue.get_nowait()
# Encode as JPEG
_, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self.preview_quality])
return buffer.tobytes()
except queue.Empty:
return None
except Exception as e:
self.logger.error(f"Error getting latest frame: {e}")
return None
def get_frame_generator(self) -> Generator[bytes, None, None]:
"""Generator for MJPEG streaming"""
while self.streaming:
frame_bytes = self.get_latest_frame()
if frame_bytes:
yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame_bytes + b"\r\n")
else:
time.sleep(0.1) # Wait a bit if no frame available
def _initialize_camera(self) -> bool:
"""Initialize camera for streaming (separate from recording)"""
try:
self.logger.info(f"Initializing camera for streaming: {self.camera_config.name}")
# Ensure SDK is initialized
ensure_sdk_initialized()
# Check if device_info is valid
if self.device_info is None:
self.logger.error("No device info provided for camera initialization")
return False
# Initialize camera (suppress output to avoid MVCAMAPI error messages)
with suppress_camera_errors():
self.hCamera = mvsdk.CameraInit(self.device_info, -1, -1)
self.logger.info("Camera initialized successfully for streaming")
# Get camera capabilities
self.cap = mvsdk.CameraGetCapability(self.hCamera)
# Determine if camera is monochrome
self.monoCamera = self.cap.sIspCapacity.bMonoSensor != 0
# Set output format based on camera type and bit depth
if self.monoCamera:
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_MONO8)
else:
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_BGR8)
# Configure camera settings for streaming (optimized for preview)
self._configure_streaming_settings()
# Allocate frame buffer
bytes_per_pixel = 1 if self.monoCamera else 3
self.frame_buffer_size = self.cap.sResolutionRange.iWidthMax * self.cap.sResolutionRange.iHeightMax * bytes_per_pixel
self.frame_buffer = mvsdk.CameraAlignMalloc(self.frame_buffer_size, 16)
# Start camera
mvsdk.CameraPlay(self.hCamera)
self.logger.info("Camera started successfully for streaming")
return True
except Exception as e:
self.logger.error(f"Error initializing camera for streaming: {e}")
self._cleanup_camera()
return False
def _configure_streaming_settings(self):
"""Configure camera settings from config.json for streaming"""
try:
# Set trigger mode to free run for continuous streaming
mvsdk.CameraSetTriggerMode(self.hCamera, 0)
# Set manual exposure
mvsdk.CameraSetAeState(self.hCamera, 0) # Disable auto exposure
exposure_us = int(self.camera_config.exposure_ms * 1000) # Convert ms to microseconds
mvsdk.CameraSetExposureTime(self.hCamera, exposure_us)
# Set analog gain
gain_value = int(self.camera_config.gain * 100) # Convert to camera units
mvsdk.CameraSetAnalogGain(self.hCamera, gain_value)
# Set frame rate for streaming (lower than recording)
if hasattr(mvsdk, "CameraSetFrameSpeed"):
mvsdk.CameraSetFrameSpeed(self.hCamera, int(self.preview_fps))
# Configure image quality settings
self._configure_image_quality()
# Configure noise reduction
self._configure_noise_reduction()
# Configure color settings (for color cameras)
if not self.monoCamera:
self._configure_color_settings()
# Configure advanced settings
self._configure_advanced_settings()
self.logger.info(f"Streaming settings configured: exposure={self.camera_config.exposure_ms}ms, gain={self.camera_config.gain}, fps={self.preview_fps}")
except Exception as e:
self.logger.warning(f"Could not configure some streaming settings: {e}")
def _streaming_loop(self):
"""Main streaming loop that captures frames continuously"""
self.logger.info("Starting streaming loop")
try:
while not self._stop_streaming_event.is_set():
try:
# Capture frame with timeout
pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, 200) # 200ms timeout
# Process frame
mvsdk.CameraImageProcess(self.hCamera, pRawData, self.frame_buffer, FrameHead)
# Convert to OpenCV format
frame = self._convert_frame_to_opencv(FrameHead)
if frame is not None:
# Update RTSP frame dimensions if not set
if self.rtsp_streaming and self._rtsp_frame_width == 0:
self._rtsp_frame_height, self._rtsp_frame_width = frame.shape[:2]
self.logger.info(f"RTSP frame dimensions set: {self._rtsp_frame_width}x{self._rtsp_frame_height}")
# Add frame to MJPEG queue (replace oldest if queue is full)
try:
self._frame_queue.put_nowait(frame)
except queue.Full:
# Remove oldest frame and add new one
try:
self._frame_queue.get_nowait()
self._frame_queue.put_nowait(frame)
except queue.Empty:
pass
# Add frame to RTSP queue if RTSP is active
if self.rtsp_streaming:
try:
self._rtsp_frame_queue.put_nowait(frame)
except queue.Full:
# Remove oldest frame and add new one
try:
self._rtsp_frame_queue.get_nowait()
self._rtsp_frame_queue.put_nowait(frame)
except queue.Empty:
pass
# Add frame to recording queue (for concurrent recording)
# Always populate this queue - recorder will consume if needed
try:
# Put frame into recording queue (recorder can consume without affecting MJPEG/RTSP)
frame_copy = frame.copy() if hasattr(frame, 'copy') else frame
self._recording_frame_queue.put_nowait(frame_copy)
except queue.Full:
# Recording queue full - remove oldest and add new
try:
self._recording_frame_queue.get_nowait()
frame_copy = frame.copy() if hasattr(frame, 'copy') else frame
self._recording_frame_queue.put_nowait(frame_copy)
except queue.Empty:
pass
# Release buffer
mvsdk.CameraReleaseImageBuffer(self.hCamera, pRawData)
# Control frame rate
time.sleep(1.0 / self.preview_fps)
except Exception as e:
if not self._stop_streaming_event.is_set():
self.logger.error(f"Error in streaming loop: {e}")
time.sleep(0.1) # Brief pause before retrying
except Exception as e:
self.logger.error(f"Fatal error in streaming loop: {e}")
finally:
self.logger.info("Streaming loop ended")
def _convert_frame_to_opencv(self, FrameHead) -> Optional[np.ndarray]:
"""Convert camera frame to OpenCV format"""
try:
# Convert the frame buffer memory address to a proper buffer
# that numpy can work with using mvsdk.c_ubyte
frame_data_buffer = (mvsdk.c_ubyte * FrameHead.uBytes).from_address(self.frame_buffer)
if self.monoCamera:
# Monochrome camera
frame_data = np.frombuffer(frame_data_buffer, dtype=np.uint8)
frame = frame_data.reshape((FrameHead.iHeight, FrameHead.iWidth))
# Convert to 3-channel for consistency
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
else:
# Color camera (BGR format)
frame_data = np.frombuffer(frame_data_buffer, dtype=np.uint8)
frame = frame_data.reshape((FrameHead.iHeight, FrameHead.iWidth, 3))
return frame
except Exception as e:
self.logger.error(f"Error converting frame: {e}")
return None
def _cleanup_camera(self):
"""Clean up camera resources"""
try:
if self.frame_buffer:
mvsdk.CameraAlignFree(self.frame_buffer)
self.frame_buffer = None
if self.hCamera is not None:
mvsdk.CameraUnInit(self.hCamera)
self.hCamera = None
self.logger.info("Camera resources cleaned up for streaming")
except Exception as e:
self.logger.error(f"Error cleaning up camera resources: {e}")
def is_streaming(self) -> bool:
"""Check if streaming is active"""
return self.streaming
def _configure_image_quality(self) -> None:
"""Configure image quality settings"""
try:
# Set sharpness (0-200, default 100)
mvsdk.CameraSetSharpness(self.hCamera, self.camera_config.sharpness)
# Set contrast (0-200, default 100)
mvsdk.CameraSetContrast(self.hCamera, self.camera_config.contrast)
# Set gamma (0-300, default 100)
mvsdk.CameraSetGamma(self.hCamera, self.camera_config.gamma)
# Set saturation for color cameras (0-200, default 100)
if not self.monoCamera:
mvsdk.CameraSetSaturation(self.hCamera, self.camera_config.saturation)
self.logger.info(f"Image quality configured - Sharpness: {self.camera_config.sharpness}, " f"Contrast: {self.camera_config.contrast}, Gamma: {self.camera_config.gamma}")
except Exception as e:
self.logger.warning(f"Error configuring image quality: {e}")
def _configure_noise_reduction(self) -> None:
"""Configure noise reduction settings"""
try:
# Note: Some noise reduction settings may require specific SDK functions
# that might not be available in all SDK versions
self.logger.info(f"Noise reduction configured - Filter: {self.camera_config.noise_filter_enabled}, " f"3D Denoise: {self.camera_config.denoise_3d_enabled}")
except Exception as e:
self.logger.warning(f"Error configuring noise reduction: {e}")
def _configure_color_settings(self) -> None:
"""Configure color settings for color cameras"""
try:
# Set white balance mode
mvsdk.CameraSetWbMode(self.hCamera, self.camera_config.auto_white_balance)
# Set color temperature preset if not using auto white balance
if not self.camera_config.auto_white_balance:
mvsdk.CameraSetPresetClrTemp(self.hCamera, self.camera_config.color_temperature_preset)
# Set manual RGB gains for manual white balance
red_gain = int(self.camera_config.wb_red_gain * 100) # Convert to camera units
green_gain = int(self.camera_config.wb_green_gain * 100)
blue_gain = int(self.camera_config.wb_blue_gain * 100)
mvsdk.CameraSetUserClrTempGain(self.hCamera, red_gain, green_gain, blue_gain)
self.logger.info(f"Color settings configured - Auto WB: {self.camera_config.auto_white_balance}, " f"Color Temp Preset: {self.camera_config.color_temperature_preset}, " f"RGB Gains: R={self.camera_config.wb_red_gain}, G={self.camera_config.wb_green_gain}, B={self.camera_config.wb_blue_gain}")
except Exception as e:
self.logger.warning(f"Error configuring color settings: {e}")
def _configure_advanced_settings(self) -> None:
"""Configure advanced camera settings"""
try:
# Set anti-flicker
mvsdk.CameraSetAntiFlick(self.hCamera, self.camera_config.anti_flicker_enabled)
# Set light frequency (0=50Hz, 1=60Hz)
mvsdk.CameraSetLightFrequency(self.hCamera, self.camera_config.light_frequency)
# Configure HDR if enabled (check if HDR functions are available)
try:
if self.camera_config.hdr_enabled:
mvsdk.CameraSetHDR(self.hCamera, 1) # Enable HDR
mvsdk.CameraSetHDRGainMode(self.hCamera, self.camera_config.hdr_gain_mode)
self.logger.info(f"HDR enabled with gain mode: {self.camera_config.hdr_gain_mode}")
else:
mvsdk.CameraSetHDR(self.hCamera, 0) # Disable HDR
except AttributeError:
self.logger.info("HDR functions not available in this SDK version, skipping HDR configuration")
self.logger.info(f"Advanced settings configured - Anti-flicker: {self.camera_config.anti_flicker_enabled}, " f"Light Freq: {self.camera_config.light_frequency}Hz, HDR: {self.camera_config.hdr_enabled}")
except Exception as e:
self.logger.warning(f"Error configuring advanced settings: {e}")
def _rtsp_streaming_loop(self):
"""Main RTSP streaming loop that feeds frames to FFmpeg"""
self.logger.info("Starting RTSP streaming loop")
# Wait for frame dimensions to be set
timeout = 10.0 # Wait up to 10 seconds for first frame
start_time = time.time()
self.logger.info(f"Waiting for frame dimensions (current: {self._rtsp_frame_width}x{self._rtsp_frame_height})...")
while self._rtsp_frame_width == 0 and (time.time() - start_time) < timeout:
if self._stop_rtsp_event.is_set():
self.logger.info("RTSP streaming stopped before frame dimensions were available")
return
# Check if streaming is actually producing frames
if not self.streaming:
self.logger.error("Camera streaming stopped, cannot start RTSP")
self.rtsp_streaming = False
return
time.sleep(0.5)
elapsed = time.time() - start_time
if elapsed > 2 and int(elapsed) % 2 == 0: # Log every 2 seconds
self.logger.debug(f"Still waiting for frame dimensions... ({elapsed:.1f}s elapsed, queue size: {self._rtsp_frame_queue.qsize()})")
if self._rtsp_frame_width == 0:
self.logger.error(f"Could not determine frame dimensions for RTSP streaming after {timeout}s. Stream active: {self.streaming}, Queue size: {self._rtsp_frame_queue.qsize()}")
self.rtsp_streaming = False
return
self.logger.info(f"Frame dimensions available: {self._rtsp_frame_width}x{self._rtsp_frame_height}")
rtsp_url = f"rtsp://{self.rtsp_host}:{self.rtsp_port}/{self.camera_config.name}"
self.logger.info(f"Publishing RTSP stream to {rtsp_url} with dimensions {self._rtsp_frame_width}x{self._rtsp_frame_height} @ {self.rtsp_fps}fps")
try:
# FFmpeg command to encode frames from stdin and publish to RTSP
# Input: raw video from stdin (BGR24 format)
# Output: H.264 encoded stream via RTSP
# Using TCP transport for better reliability
cmd = [
"ffmpeg",
"-f", "rawvideo",
"-pixel_format", "bgr24",
"-video_size", f"{self._rtsp_frame_width}x{self._rtsp_frame_height}",
"-framerate", str(self.rtsp_fps),
"-i", "-", # Read from stdin
"-c:v", "libx264",
"-preset", "ultrafast", # Fast encoding for low latency
"-tune", "zerolatency", # Zero latency tuning
"-g", "30", # GOP size (keyframe interval)
"-crf", "23", # Quality (lower = better, but larger file)
"-f", "rtsp",
"-rtsp_transport", "tcp", # Use TCP instead of UDP for reliability
"-muxdelay", "0.1", # Reduce mux delay
rtsp_url
]
# Start FFmpeg process
self._rtsp_process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0 # Unbuffered for low latency
)
self.logger.info(f"FFmpeg RTSP process started (PID: {self._rtsp_process.pid})")
# Start thread to monitor FFmpeg stderr for errors
def monitor_ffmpeg_stderr():
if self._rtsp_process.stderr:
try:
for line in iter(self._rtsp_process.stderr.readline, b''):
if line:
line_str = line.decode('utf-8', errors='ignore').strip()
if line_str:
self.logger.debug(f"FFmpeg stderr: {line_str}")
# Log errors at warning level
if any(keyword in line_str.lower() for keyword in ['error', 'failed', 'fatal']):
self.logger.warning(f"FFmpeg error: {line_str}")
except Exception as e:
self.logger.debug(f"FFmpeg stderr monitor ended: {e}")
stderr_thread = threading.Thread(target=monitor_ffmpeg_stderr, daemon=True)
stderr_thread.start()
frame_interval = 1.0 / self.rtsp_fps
last_frame_time = time.time()
frames_sent = 0
frames_skipped = 0
while not self._stop_rtsp_event.is_set():
try:
# Check if FFmpeg process is still alive
if self._rtsp_process.poll() is not None:
self.logger.error(f"FFmpeg process exited with code {self._rtsp_process.returncode}")
# Read stderr to see what went wrong
if self._rtsp_process.stderr:
try:
stderr_output = self._rtsp_process.stderr.read().decode('utf-8', errors='ignore')
if stderr_output:
self.logger.error(f"FFmpeg stderr output: {stderr_output[-500:]}") # Last 500 chars
except:
pass
break
# Get frame from queue with timeout
try:
frame = self._rtsp_frame_queue.get(timeout=0.1)
except queue.Empty:
frames_skipped += 1
# Log warning if we've skipped many frames (might indicate queue isn't being filled)
if frames_skipped % 100 == 0 and frames_skipped > 0:
self.logger.warning(f"RTSP frame queue empty ({frames_skipped} skipped, {frames_sent} sent)")
continue
# Ensure frame dimensions match (resize if necessary)
if frame.shape[1] != self._rtsp_frame_width or frame.shape[0] != self._rtsp_frame_height:
frame = cv2.resize(frame, (self._rtsp_frame_width, self._rtsp_frame_height))
# Write frame to FFmpeg stdin
if self._rtsp_process.stdin is None:
self.logger.error("FFmpeg stdin is None")
break
try:
self._rtsp_process.stdin.write(frame.tobytes())
self._rtsp_process.stdin.flush()
frames_sent += 1
if frames_sent % 100 == 0:
self.logger.debug(f"Sent {frames_sent} frames to FFmpeg")
except BrokenPipeError:
self.logger.error(f"FFmpeg process pipe broken (sent {frames_sent} frames before error)")
break
except Exception as e:
self.logger.error(f"Error writing frame to FFmpeg: {e} (sent {frames_sent} frames)")
break
# Control frame rate
current_time = time.time()
elapsed = current_time - last_frame_time
if elapsed < frame_interval:
time.sleep(frame_interval - elapsed)
last_frame_time = time.time()
except Exception as e:
if not self._stop_rtsp_event.is_set():
self.logger.error(f"Error in RTSP streaming loop: {e}")
time.sleep(0.1)
except Exception as e:
self.logger.error(f"Fatal error in RTSP streaming loop: {e}")
finally:
# Cleanup FFmpeg process
if self._rtsp_process:
try:
if self._rtsp_process.stdin:
self._rtsp_process.stdin.close()
except Exception:
pass
try:
self._rtsp_process.terminate()
self._rtsp_process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
self._rtsp_process.kill()
except Exception as e:
self.logger.warning(f"Error stopping FFmpeg process: {e}")
self._rtsp_process = None
self.logger.info("RTSP streaming loop ended")
def __del__(self):
"""Destructor to ensure cleanup"""
if self.rtsp_streaming:
self.stop_rtsp_streaming()
if self.streaming:
self.stop_streaming()