- 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.
716 lines
31 KiB
Python
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()
|