""" 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 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 from .utils import suppress_camera_errors from .constants import ( MJPEG_QUEUE_MAXSIZE, RTSP_QUEUE_MAXSIZE, RECORDING_QUEUE_MAXSIZE, PREVIEW_FPS, RTSP_FPS, PREVIEW_JPEG_QUALITY, CAMERA_GET_BUFFER_TIMEOUT, CAMERA_TEST_CAPTURE_TIMEOUT, STREAMING_LOOP_SLEEP, BRIEF_PAUSE_SLEEP, ) 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, recorder=None): self.camera_config = camera_config self.device_info = device_info self.state_manager = state_manager self.event_system = event_system self.recorder = recorder # Reference to CameraRecorder for camera sharing (reverse direction) self.logger = logging.getLogger(f"{__name__}.{camera_config.name}") # Camera handle and properties (separate from recorder, or shared with recorder) self.hCamera: Optional[int] = None self.cap = None self.monoCamera = False self.frame_buffer = None self.frame_buffer_size = 0 self._using_shared_camera = False # Flag to indicate if we're sharing recorder's camera # 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=MJPEG_QUEUE_MAXSIZE) # Buffer for latest frames (for MJPEG streaming) self._rtsp_frame_queue = queue.Queue(maxsize=RTSP_QUEUE_MAXSIZE) # Buffer for RTSP frames (larger buffer for smoother streaming) self._recording_frame_queue = queue.Queue(maxsize=RECORDING_QUEUE_MAXSIZE) # Buffer for recording frames (shared with recorder) self._lock = threading.RLock() # Stream settings (optimized for preview) self.preview_fps = PREVIEW_FPS # Lower FPS for preview to reduce load self.preview_quality = PREVIEW_JPEG_QUALITY # JPEG quality for streaming # RTSP settings self.rtsp_fps = RTSP_FPS # 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(STREAMING_LOOP_SLEEP) # 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}") # Check if recorder is active and has camera open - if so, share it if self.recorder and self.recorder.hCamera and self.recorder.recording: self.logger.info("Recorder is active with camera open - will share recorder's camera connection") self.hCamera = self.recorder.hCamera # Copy camera properties from recorder self.cap = self.recorder.cap self.monoCamera = self.recorder.monoCamera self.frame_buffer = self.recorder.frame_buffer self.frame_buffer_size = self.recorder.frame_buffer_size self._using_shared_camera = True # Mark that we're using shared camera # Camera is already started by recorder, so we don't need to call CameraPlay # Also, we need to populate the frame queues from recorder's frames self.logger.info("Using recorder's camera connection for streaming - will capture frames from recorder") return True # 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(f"Starting streaming loop (using {'shared camera from recorder' if self._using_shared_camera else 'own camera'})") try: while not self._stop_streaming_event.is_set(): try: # If using shared camera, skip capture - recorder will populate queues if self._using_shared_camera: time.sleep(STREAMING_LOOP_SLEEP) # Just wait, recorder populates queues continue # Capture frame with timeout pRawData, FrameHead = mvsdk.CameraGetImageBuffer(self.hCamera, CAMERA_GET_BUFFER_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(BRIEF_PAUSE_SLEEP) # 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: # Only cleanup frame buffer if we allocated it (not sharing with recorder) if self.frame_buffer and not self._using_shared_camera: mvsdk.CameraAlignFree(self.frame_buffer) self.frame_buffer = None # Only uninitialize camera if we own it (not sharing with recorder) if self.hCamera is not None and not self._using_shared_camera: mvsdk.CameraUnInit(self.hCamera) self.hCamera = None elif self._using_shared_camera: # Just clear references, don't free shared resources self.hCamera = None self.cap = None self.frame_buffer = None self._using_shared_camera = False 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()