Merge pull request 'dev' (#17) from dev into main
All checks were successful
Build Worker Base and Application Images / check-base-changes (push) Successful in 8s
Build Worker Base and Application Images / build-base (push) Successful in 9m7s
Build Worker Base and Application Images / build-docker (push) Successful in 4m3s
Build Worker Base and Application Images / deploy-stack (push) Successful in 29s

Reviewed-on: #17
This commit is contained in:
Siwat Sirichai 2025-09-25 19:59:49 +00:00
commit d663aaa446
11 changed files with 871 additions and 218 deletions

View file

@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(dir:*)",
"WebSearch"
],
"deny": [],
"ask": []
}
}

View file

@ -1,21 +1,123 @@
# Base image with all ML dependencies
# Base image with complete ML and hardware acceleration stack
FROM pytorch/pytorch:2.8.0-cuda12.6-cudnn9-runtime
# Install system dependencies
RUN apt update && apt install -y \
libgl1 \
# Install build dependencies and system libraries
RUN apt-get update && apt-get install -y \
# Build tools
build-essential \
cmake \
git \
pkg-config \
wget \
unzip \
yasm \
nasm \
# Additional dependencies for FFmpeg/NVIDIA build
libtool \
libc6 \
libc6-dev \
libnuma1 \
libnuma-dev \
# Essential compilation libraries
gcc \
g++ \
libc6-dev \
linux-libc-dev \
# System libraries
libgl1-mesa-glx \
libglib2.0-0 \
libgstreamer1.0-0 \
libgtk-3-0 \
libavcodec58 \
libavformat58 \
libswscale5 \
libgomp1 \
# Core media libraries (essential ones only)
libjpeg-dev \
libpng-dev \
libx264-dev \
libx265-dev \
libvpx-dev \
libmp3lame-dev \
libv4l-dev \
# TurboJPEG for fast JPEG encoding
libturbojpeg0-dev \
# Python development
python3-dev \
python3-numpy \
&& rm -rf /var/lib/apt/lists/*
# Copy and install base requirements (ML dependencies that rarely change)
# Add NVIDIA CUDA repository and install minimal development tools
RUN apt-get update && apt-get install -y wget gnupg && \
wget -O - https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/3bf863cc.pub | apt-key add - && \
echo "deb https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64 /" > /etc/apt/sources.list.d/cuda.list && \
apt-get update && \
apt-get install -y \
cuda-nvcc-12-6 \
cuda-cudart-dev-12-6 \
libnpp-dev-12-6 \
&& apt-get remove -y wget gnupg && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
# Ensure CUDA paths are available
ENV PATH="/usr/local/cuda/bin:${PATH}"
ENV LD_LIBRARY_PATH="/usr/local/cuda/lib64:${LD_LIBRARY_PATH}"
# Install NVIDIA Video Codec SDK headers (official method)
RUN cd /tmp && \
git clone https://git.videolan.org/git/ffmpeg/nv-codec-headers.git && \
cd nv-codec-headers && \
make install && \
cd / && rm -rf /tmp/*
# Build FFmpeg from source with NVIDIA CUDA support
RUN cd /tmp && \
echo "Building FFmpeg with NVIDIA CUDA support..." && \
# Download FFmpeg source (official method)
git clone https://git.ffmpeg.org/ffmpeg.git ffmpeg/ && \
cd ffmpeg && \
# Configure with NVIDIA support (simplified to avoid configure issues)
./configure \
--prefix=/usr/local \
--enable-shared \
--disable-static \
--enable-nonfree \
--enable-gpl \
--enable-cuda-nvcc \
--enable-cuvid \
--enable-nvdec \
--enable-nvenc \
--enable-libnpp \
--extra-cflags=-I/usr/local/cuda/include \
--extra-ldflags=-L/usr/local/cuda/lib64 \
--enable-libx264 \
--enable-libx265 \
--enable-libvpx \
--enable-libmp3lame && \
# Build and install
make -j$(nproc) && \
make install && \
ldconfig && \
# Verify CUVID decoders are available
echo "=== Verifying FFmpeg CUVID Support ===" && \
(ffmpeg -hide_banner -decoders 2>/dev/null | grep cuvid || echo "No CUVID decoders found") && \
echo "=== Verifying FFmpeg NVENC Support ===" && \
(ffmpeg -hide_banner -encoders 2>/dev/null | grep nvenc || echo "No NVENC encoders found") && \
cd / && rm -rf /tmp/*
# Set environment variables for maximum hardware acceleration
ENV LD_LIBRARY_PATH="/usr/local/cuda/lib64:/usr/local/lib:${LD_LIBRARY_PATH}"
ENV PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:${PKG_CONFIG_PATH}"
ENV PYTHONPATH="/usr/local/lib/python3.10/dist-packages:${PYTHONPATH}"
# Optimized environment variables for hardware acceleration
ENV OPENCV_FFMPEG_CAPTURE_OPTIONS="rtsp_transport;tcp|hwaccel;cuda|hwaccel_device;0|video_codec;h264_cuvid|hwaccel_output_format;cuda"
ENV OPENCV_FFMPEG_WRITER_OPTIONS="video_codec;h264_nvenc|preset;fast|tune;zerolatency|gpu;0"
ENV CUDA_VISIBLE_DEVICES=0
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=compute,video,utility
# Copy and install base requirements (exclude opencv-python since we built from source)
COPY requirements.base.txt .
RUN pip install --no-cache-dir -r requirements.base.txt
RUN grep -v opencv-python requirements.base.txt > requirements.tmp && \
mv requirements.tmp requirements.base.txt && \
pip install --no-cache-dir -r requirements.base.txt
# Set working directory
WORKDIR /app

View file

@ -6,7 +6,7 @@ import json
import logging
import os
import cv2
from datetime import datetime
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional
from fastapi import WebSocket, WebSocketDisconnect
@ -483,8 +483,8 @@ class WebSocketHandler:
images_dir.mkdir(exist_ok=True)
# Generate filename with timestamp and session ID
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{display_identifier}_{session_id}_{timestamp}.jpg"
timestamp = datetime.now(tz=timezone(timedelta(hours=7))).strftime("%Y%m%d_%H%M%S")
filename = f"{session_id}_{display_identifier}_{timestamp}.jpg"
filepath = images_dir / filename
# Use existing HTTPSnapshotReader to fetch snapshot

View file

@ -2,7 +2,7 @@
Streaming system for RTSP and HTTP camera feeds.
Provides modular frame readers, buffers, and stream management.
"""
from .readers import RTSPReader, HTTPSnapshotReader
from .readers import RTSPReader, HTTPSnapshotReader, FFmpegRTSPReader
from .buffers import FrameBuffer, CacheBuffer, shared_frame_buffer, shared_cache_buffer
from .manager import StreamManager, StreamConfig, SubscriptionInfo, shared_stream_manager, initialize_stream_manager
@ -10,6 +10,7 @@ __all__ = [
# Readers
'RTSPReader',
'HTTPSnapshotReader',
'FFmpegRTSPReader',
# Buffers
'FrameBuffer',

View file

@ -9,53 +9,25 @@ import logging
import numpy as np
from typing import Optional, Dict, Any, Tuple
from collections import defaultdict
from enum import Enum
logger = logging.getLogger(__name__)
class StreamType(Enum):
"""Stream type enumeration."""
RTSP = "rtsp" # 1280x720 @ 6fps
HTTP = "http" # 2560x1440 high quality
class FrameBuffer:
"""Thread-safe frame buffer optimized for different stream types."""
"""Thread-safe frame buffer for all camera streams."""
def __init__(self, max_age_seconds: int = 5):
self.max_age_seconds = max_age_seconds
self._frames: Dict[str, Dict[str, Any]] = {}
self._stream_types: Dict[str, StreamType] = {}
self._lock = threading.RLock()
# Stream-specific settings
self.rtsp_config = {
'width': 1280,
'height': 720,
'fps': 6,
'max_size_mb': 3 # 1280x720x3 bytes = ~2.6MB
}
self.http_config = {
'width': 2560,
'height': 1440,
'max_size_mb': 10
}
def put_frame(self, camera_id: str, frame: np.ndarray, stream_type: Optional[StreamType] = None):
"""Store a frame for the given camera ID with type-specific validation."""
def put_frame(self, camera_id: str, frame: np.ndarray):
"""Store a frame for the given camera ID."""
with self._lock:
# Detect stream type if not provided
if stream_type is None:
stream_type = self._detect_stream_type(frame)
# Store stream type
self._stream_types[camera_id] = stream_type
# Validate frame based on stream type
if not self._validate_frame(frame, stream_type):
logger.warning(f"Frame validation failed for camera {camera_id} ({stream_type.value})")
# Validate frame
if not self._validate_frame(frame):
logger.warning(f"Frame validation failed for camera {camera_id}")
return
self._frames[camera_id] = {
@ -63,14 +35,9 @@ class FrameBuffer:
'timestamp': time.time(),
'shape': frame.shape,
'dtype': str(frame.dtype),
'stream_type': stream_type.value,
'size_mb': frame.nbytes / (1024 * 1024)
}
# Commented out verbose frame storage logging
# logger.debug(f"Stored {stream_type.value} frame for camera {camera_id}: "
# f"{frame.shape[1]}x{frame.shape[0]}, {frame.nbytes / (1024 * 1024):.2f}MB")
def get_frame(self, camera_id: str) -> Optional[np.ndarray]:
"""Get the latest frame for the given camera ID."""
with self._lock:
@ -84,8 +51,6 @@ class FrameBuffer:
if age > self.max_age_seconds:
logger.debug(f"Frame for camera {camera_id} is {age:.1f}s old, discarding")
del self._frames[camera_id]
if camera_id in self._stream_types:
del self._stream_types[camera_id]
return None
return frame_data['frame'].copy()
@ -101,8 +66,6 @@ class FrameBuffer:
if age > self.max_age_seconds:
del self._frames[camera_id]
if camera_id in self._stream_types:
del self._stream_types[camera_id]
return None
return {
@ -110,7 +73,6 @@ class FrameBuffer:
'age': age,
'shape': frame_data['shape'],
'dtype': frame_data['dtype'],
'stream_type': frame_data.get('stream_type', 'unknown'),
'size_mb': frame_data.get('size_mb', 0)
}
@ -123,8 +85,6 @@ class FrameBuffer:
with self._lock:
if camera_id in self._frames:
del self._frames[camera_id]
if camera_id in self._stream_types:
del self._stream_types[camera_id]
logger.debug(f"Cleared frames for camera {camera_id}")
def clear_all(self):
@ -132,7 +92,6 @@ class FrameBuffer:
with self._lock:
count = len(self._frames)
self._frames.clear()
self._stream_types.clear()
logger.debug(f"Cleared all frames ({count} cameras)")
def get_camera_list(self) -> list:
@ -152,8 +111,6 @@ class FrameBuffer:
# Clean up expired frames
for camera_id in expired_cameras:
del self._frames[camera_id]
if camera_id in self._stream_types:
del self._stream_types[camera_id]
return valid_cameras
@ -165,15 +122,12 @@ class FrameBuffer:
'total_cameras': len(self._frames),
'valid_cameras': 0,
'expired_cameras': 0,
'rtsp_cameras': 0,
'http_cameras': 0,
'total_memory_mb': 0,
'cameras': {}
}
for camera_id, frame_data in self._frames.items():
age = current_time - frame_data['timestamp']
stream_type = frame_data.get('stream_type', 'unknown')
size_mb = frame_data.get('size_mb', 0)
if age <= self.max_age_seconds:
@ -181,11 +135,6 @@ class FrameBuffer:
else:
stats['expired_cameras'] += 1
if stream_type == StreamType.RTSP.value:
stats['rtsp_cameras'] += 1
elif stream_type == StreamType.HTTP.value:
stats['http_cameras'] += 1
stats['total_memory_mb'] += size_mb
stats['cameras'][camera_id] = {
@ -193,74 +142,45 @@ class FrameBuffer:
'valid': age <= self.max_age_seconds,
'shape': frame_data['shape'],
'dtype': frame_data['dtype'],
'stream_type': stream_type,
'size_mb': size_mb
}
return stats
def _detect_stream_type(self, frame: np.ndarray) -> StreamType:
"""Detect stream type based on frame dimensions."""
h, w = frame.shape[:2]
# Check if it matches RTSP dimensions (1280x720)
if w == self.rtsp_config['width'] and h == self.rtsp_config['height']:
return StreamType.RTSP
# Check if it matches HTTP dimensions (2560x1440) or close to it
if w >= 2000 and h >= 1000:
return StreamType.HTTP
# Default based on size
if w <= 1920 and h <= 1080:
return StreamType.RTSP
else:
return StreamType.HTTP
def _validate_frame(self, frame: np.ndarray, stream_type: StreamType) -> bool:
"""Validate frame based on stream type."""
def _validate_frame(self, frame: np.ndarray) -> bool:
"""Validate frame - basic validation for any stream type."""
if frame is None or frame.size == 0:
return False
h, w = frame.shape[:2]
size_mb = frame.nbytes / (1024 * 1024)
if stream_type == StreamType.RTSP:
config = self.rtsp_config
# Allow some tolerance for RTSP streams
if abs(w - config['width']) > 100 or abs(h - config['height']) > 100:
logger.warning(f"RTSP frame size mismatch: {w}x{h} (expected {config['width']}x{config['height']})")
if size_mb > config['max_size_mb']:
logger.warning(f"RTSP frame too large: {size_mb:.2f}MB (max {config['max_size_mb']}MB)")
# Basic size validation - reject extremely large frames regardless of type
max_size_mb = 50 # Generous limit for any frame type
if size_mb > max_size_mb:
logger.warning(f"Frame too large: {size_mb:.2f}MB (max {max_size_mb}MB) for {w}x{h}")
return False
elif stream_type == StreamType.HTTP:
config = self.http_config
# More flexible for HTTP snapshots
if size_mb > config['max_size_mb']:
logger.warning(f"HTTP snapshot too large: {size_mb:.2f}MB (max {config['max_size_mb']}MB)")
# Basic dimension validation
if w < 100 or h < 100:
logger.warning(f"Frame too small: {w}x{h}")
return False
return True
class CacheBuffer:
"""Enhanced frame cache with support for cropping and optimized for different formats."""
"""Enhanced frame cache with support for cropping."""
def __init__(self, max_age_seconds: int = 10):
self.frame_buffer = FrameBuffer(max_age_seconds)
self._crop_cache: Dict[str, Dict[str, Any]] = {}
self._cache_lock = threading.RLock()
self.jpeg_quality = 95 # High quality for all frames
# Quality settings for different stream types
self.jpeg_quality = {
StreamType.RTSP: 90, # Good quality for 720p
StreamType.HTTP: 95 # High quality for 2K
}
def put_frame(self, camera_id: str, frame: np.ndarray, stream_type: Optional[StreamType] = None):
def put_frame(self, camera_id: str, frame: np.ndarray):
"""Store a frame and clear any associated crop cache."""
self.frame_buffer.put_frame(camera_id, frame, stream_type)
self.frame_buffer.put_frame(camera_id, frame)
# Clear crop cache for this camera since we have a new frame
with self._cache_lock:
@ -325,21 +245,15 @@ class CacheBuffer:
def get_frame_as_jpeg(self, camera_id: str, crop_coords: Optional[Tuple[int, int, int, int]] = None,
quality: Optional[int] = None) -> Optional[bytes]:
"""Get frame as JPEG bytes with format-specific quality settings."""
"""Get frame as JPEG bytes."""
frame = self.get_frame(camera_id, crop_coords)
if frame is None:
return None
try:
# Determine quality based on stream type if not specified
# Use specified quality or default
if quality is None:
frame_info = self.frame_buffer.get_frame_info(camera_id)
if frame_info:
stream_type_str = frame_info.get('stream_type', StreamType.RTSP.value)
stream_type = StreamType.RTSP if stream_type_str == StreamType.RTSP.value else StreamType.HTTP
quality = self.jpeg_quality[stream_type]
else:
quality = 90 # Default
quality = self.jpeg_quality
# Encode as JPEG with specified quality
encode_params = [cv2.IMWRITE_JPEG_QUALITY, quality]

View file

@ -9,8 +9,8 @@ from typing import Dict, Set, Optional, List, Any
from dataclasses import dataclass
from collections import defaultdict
from .readers import RTSPReader, HTTPSnapshotReader
from .buffers import shared_cache_buffer, StreamType
from .readers import RTSPReader, HTTPSnapshotReader, FFmpegRTSPReader
from .buffers import shared_cache_buffer
from ..tracking.integration import TrackingPipelineIntegration
@ -129,8 +129,8 @@ class StreamManager:
"""Start a stream for the given camera."""
try:
if stream_config.rtsp_url:
# RTSP stream
reader = RTSPReader(
# RTSP stream using FFmpeg subprocess with CUDA acceleration
reader = FFmpegRTSPReader(
camera_id=camera_id,
rtsp_url=stream_config.rtsp_url,
max_retries=stream_config.max_retries
@ -138,7 +138,7 @@ class StreamManager:
reader.set_frame_callback(self._frame_callback)
reader.start()
self._streams[camera_id] = reader
logger.info(f"Started RTSP stream for camera {camera_id}")
logger.info(f"Started FFmpeg RTSP stream for camera {camera_id}")
elif stream_config.snapshot_url:
# HTTP snapshot stream
@ -177,12 +177,8 @@ class StreamManager:
def _frame_callback(self, camera_id: str, frame):
"""Callback for when a new frame is available."""
try:
# Detect stream type based on frame dimensions
stream_type = self._detect_stream_type(frame)
# Store frame in shared buffer with stream type
shared_cache_buffer.put_frame(camera_id, frame, stream_type)
# Store frame in shared buffer
shared_cache_buffer.put_frame(camera_id, frame)
# Process tracking for subscriptions with tracking integration
self._process_tracking_for_camera(camera_id, frame)
@ -404,26 +400,6 @@ class StreamManager:
stats[subscription_id] = subscription_info.tracking_integration.get_statistics()
return stats
def _detect_stream_type(self, frame) -> StreamType:
"""Detect stream type based on frame dimensions."""
if frame is None:
return StreamType.RTSP # Default
h, w = frame.shape[:2]
# RTSP: 1280x720
if w == 1280 and h == 720:
return StreamType.RTSP
# HTTP: 2560x1440 or larger
if w >= 2000 and h >= 1000:
return StreamType.HTTP
# Default based on size
if w <= 1920 and h <= 1080:
return StreamType.RTSP
else:
return StreamType.HTTP
def get_stats(self) -> Dict[str, Any]:
"""Get comprehensive streaming statistics."""
@ -431,22 +407,11 @@ class StreamManager:
buffer_stats = shared_cache_buffer.get_stats()
tracking_stats = self.get_tracking_stats()
# Add stream type information
stream_types = {}
for camera_id in self._streams.keys():
if isinstance(self._streams[camera_id], RTSPReader):
stream_types[camera_id] = 'rtsp'
elif isinstance(self._streams[camera_id], HTTPSnapshotReader):
stream_types[camera_id] = 'http'
else:
stream_types[camera_id] = 'unknown'
return {
'active_subscriptions': len(self._subscriptions),
'active_streams': len(self._streams),
'cameras_with_subscribers': len(self._camera_subscribers),
'max_streams': self.max_streams,
'stream_types': stream_types,
'subscriptions_by_camera': {
camera_id: len(subscribers)
for camera_id, subscribers in self._camera_subscribers.items()

View file

@ -9,7 +9,10 @@ import threading
import requests
import numpy as np
import os
import subprocess
from typing import Optional, Callable
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
# Suppress FFMPEG/H.264 error messages if needed
# Set this environment variable to reduce noise from decoder errors
@ -18,6 +21,208 @@ os.environ["OPENCV_FFMPEG_LOGLEVEL"] = "-8" # Suppress FFMPEG warnings
logger = logging.getLogger(__name__)
# Suppress noisy watchdog debug logs
logging.getLogger('watchdog.observers.inotify_buffer').setLevel(logging.CRITICAL)
class FrameFileHandler(FileSystemEventHandler):
"""File system event handler for frame file changes."""
def __init__(self, callback):
self.callback = callback
self.last_modified = 0
def on_modified(self, event):
if event.is_directory:
return
# Debounce rapid file changes
current_time = time.time()
if current_time - self.last_modified > 0.01: # 10ms debounce
self.last_modified = current_time
self.callback()
class FFmpegRTSPReader:
"""RTSP stream reader using subprocess FFmpeg with CUDA hardware acceleration and file watching."""
def __init__(self, camera_id: str, rtsp_url: str, max_retries: int = 3):
self.camera_id = camera_id
self.rtsp_url = rtsp_url
self.max_retries = max_retries
self.process = None
self.stop_event = threading.Event()
self.thread = None
self.frame_callback: Optional[Callable] = None
self.observer = None
self.frame_ready_event = threading.Event()
# Stream specs
self.width = 1280
self.height = 720
def set_frame_callback(self, callback: Callable[[str, np.ndarray], None]):
"""Set callback function to handle captured frames."""
self.frame_callback = callback
def start(self):
"""Start the FFmpeg subprocess reader."""
if self.thread and self.thread.is_alive():
logger.warning(f"FFmpeg reader for {self.camera_id} already running")
return
self.stop_event.clear()
self.thread = threading.Thread(target=self._read_frames, daemon=True)
self.thread.start()
logger.info(f"Started FFmpeg reader for camera {self.camera_id}")
def stop(self):
"""Stop the FFmpeg subprocess reader."""
self.stop_event.set()
if self.process:
self.process.terminate()
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
if self.thread:
self.thread.join(timeout=5.0)
logger.info(f"Stopped FFmpeg reader for camera {self.camera_id}")
def _start_ffmpeg_process(self):
"""Start FFmpeg subprocess with CUDA hardware acceleration writing to temp file."""
# Create temp file path for this camera
self.temp_file = f"/tmp/claude/camera_{self.camera_id.replace(' ', '_')}.raw"
os.makedirs("/tmp/claude", exist_ok=True)
# Use PPM format - uncompressed with header, supports -update 1
self.temp_file = f"/tmp/claude/camera_{self.camera_id.replace(' ', '_')}.ppm"
cmd = [
'ffmpeg',
'-hwaccel', 'cuda',
'-hwaccel_device', '0',
'-rtsp_transport', 'tcp',
'-i', self.rtsp_url,
'-f', 'image2',
'-update', '1', # Works with image2 format
'-pix_fmt', 'rgb24', # PPM uses RGB not BGR
'-an', # No audio
'-y', # Overwrite output file
self.temp_file
]
try:
# Start FFmpeg detached - we don't need to communicate with it
self.process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
logger.info(f"Started FFmpeg process PID {self.process.pid} for camera {self.camera_id} -> {self.temp_file}")
return True
except Exception as e:
logger.error(f"Failed to start FFmpeg for camera {self.camera_id}: {e}")
return False
def _setup_file_watcher(self):
"""Setup file system watcher for temp file."""
if not os.path.exists(self.temp_file):
return
# Setup file watcher
handler = FrameFileHandler(self._on_file_changed)
self.observer = Observer()
self.observer.schedule(handler, os.path.dirname(self.temp_file), recursive=False)
self.observer.start()
logger.info(f"Started file watcher for {self.temp_file}")
def _on_file_changed(self):
"""Called when temp file is modified."""
if os.path.basename(self.temp_file) in str(self.temp_file):
self.frame_ready_event.set()
def _read_frames(self):
"""Reactively read frames when file changes."""
frame_count = 0
last_log_time = time.time()
bytes_per_frame = self.width * self.height * 3
restart_check_interval = 10 # Check FFmpeg status every 10 seconds
while not self.stop_event.is_set():
try:
# Start FFmpeg if not running
if not self.process or self.process.poll() is not None:
if self.process and self.process.poll() is not None:
logger.warning(f"FFmpeg process died for camera {self.camera_id}, restarting...")
if not self._start_ffmpeg_process():
time.sleep(5.0)
continue
# Wait for temp file to be created
wait_count = 0
while not os.path.exists(self.temp_file) and wait_count < 30:
time.sleep(1.0)
wait_count += 1
if not os.path.exists(self.temp_file):
logger.error(f"Temp file not created after 30s for {self.camera_id}")
continue
# Setup file watcher
self._setup_file_watcher()
# Wait for file change event (or timeout for health check)
if self.frame_ready_event.wait(timeout=restart_check_interval):
self.frame_ready_event.clear()
# Read PPM frame (uncompressed with header)
try:
if os.path.exists(self.temp_file):
# Read PPM with OpenCV (handles RGB->BGR conversion automatically)
frame = cv2.imread(self.temp_file)
if frame is not None and frame.shape == (self.height, self.width, 3):
# Call frame callback directly
if self.frame_callback:
self.frame_callback(self.camera_id, frame)
frame_count += 1
# Log progress
current_time = time.time()
if current_time - last_log_time >= 30:
logger.info(f"Camera {self.camera_id}: {frame_count} PPM frames processed reactively")
last_log_time = current_time
else:
logger.debug(f"Camera {self.camera_id}: Invalid PPM frame")
else:
logger.debug(f"Camera {self.camera_id}: PPM file not found yet")
except (IOError, OSError) as e:
logger.debug(f"Camera {self.camera_id}: File read error: {e}")
except Exception as e:
logger.error(f"Camera {self.camera_id}: Error in reactive frame reading: {e}")
time.sleep(1.0)
# Cleanup
if self.observer:
self.observer.stop()
self.observer.join()
if self.process:
self.process.terminate()
# Clean up temp file
try:
if hasattr(self, 'temp_file') and os.path.exists(self.temp_file):
os.remove(self.temp_file)
except:
pass
logger.info(f"Reactive FFmpeg reader ended for camera {self.camera_id}")
logger = logging.getLogger(__name__)
class RTSPReader:
"""RTSP stream frame reader optimized for 1280x720 @ 6fps streams."""
@ -37,7 +242,6 @@ class RTSPReader:
self.expected_fps = 6
# Frame processing parameters
self.frame_interval = 1.0 / self.expected_fps # ~167ms for 6fps
self.error_recovery_delay = 5.0 # Increased from 2.0 for stability
self.max_consecutive_errors = 30 # Increased from 10 to handle network jitter
self.stream_timeout = 30.0
@ -72,7 +276,6 @@ class RTSPReader:
frame_count = 0
last_log_time = time.time()
last_successful_frame_time = time.time()
last_frame_time = 0
while not self.stop_event.is_set():
try:
@ -90,19 +293,35 @@ class RTSPReader:
last_successful_frame_time = time.time()
continue
# Rate limiting for 6fps
current_time = time.time()
if current_time - last_frame_time < self.frame_interval:
time.sleep(0.01) # Small sleep to avoid busy waiting
continue
# Read frame immediately without rate limiting for minimum latency
try:
ret, frame = self.cap.read()
if ret and frame is None:
# Grab succeeded but retrieve failed - decoder issue
logger.error(f"Camera {self.camera_id}: Frame grab OK but decode failed")
except Exception as read_error:
logger.error(f"Camera {self.camera_id}: cap.read() threw exception: {type(read_error).__name__}: {read_error}")
ret, frame = False, None
if not ret or frame is None:
consecutive_errors += 1
# Enhanced logging to diagnose the issue
logger.error(f"Camera {self.camera_id}: cap.read() failed - ret={ret}, frame={frame is not None}")
# Try to get more info from the capture
try:
if self.cap and self.cap.isOpened():
backend = self.cap.getBackendName()
pos_frames = self.cap.get(cv2.CAP_PROP_POS_FRAMES)
logger.error(f"Camera {self.camera_id}: Capture open, backend: {backend}, pos_frames: {pos_frames}")
else:
logger.error(f"Camera {self.camera_id}: Capture is closed or None!")
except Exception as info_error:
logger.error(f"Camera {self.camera_id}: Error getting capture info: {type(info_error).__name__}: {info_error}")
if consecutive_errors >= self.max_consecutive_errors:
logger.error(f"Camera {self.camera_id}: Too many consecutive errors, reinitializing")
logger.error(f"Camera {self.camera_id}: Too many consecutive errors ({consecutive_errors}), reinitializing")
self._reinitialize_capture()
consecutive_errors = 0
time.sleep(self.error_recovery_delay)
@ -118,13 +337,8 @@ class RTSPReader:
time.sleep(sleep_time)
continue
# Validate frame dimensions
if frame.shape[1] != self.expected_width or frame.shape[0] != self.expected_height:
logger.warning(f"Camera {self.camera_id}: Unexpected frame dimensions {frame.shape[1]}x{frame.shape[0]}")
# Try to resize if dimensions are wrong
if frame.shape[1] > 0 and frame.shape[0] > 0:
frame = cv2.resize(frame, (self.expected_width, self.expected_height))
else:
# Accept any valid frame dimensions - don't force specific resolution
if frame.shape[1] <= 0 or frame.shape[0] <= 0:
consecutive_errors += 1
continue
@ -138,7 +352,6 @@ class RTSPReader:
consecutive_errors = 0
frame_count += 1
last_successful_frame_time = time.time()
last_frame_time = current_time
# Call frame callback
if self.frame_callback:
@ -148,6 +361,7 @@ class RTSPReader:
logger.error(f"Camera {self.camera_id}: Frame callback error: {e}")
# Log progress every 30 seconds
current_time = time.time()
if current_time - last_log_time >= 30:
logger.info(f"Camera {self.camera_id}: {frame_count} frames processed")
last_log_time = current_time
@ -166,41 +380,104 @@ class RTSPReader:
logger.info(f"RTSP reader thread ended for camera {self.camera_id}")
def _initialize_capture(self) -> bool:
"""Initialize video capture with optimized settings for 1280x720@6fps."""
"""Initialize video capture with FFmpeg hardware acceleration (CUVID/NVDEC) for 1280x720@6fps."""
try:
# Release previous capture if exists
if self.cap:
self.cap.release()
time.sleep(0.5)
logger.info(f"Initializing capture for camera {self.camera_id}")
logger.info(f"Initializing capture for camera {self.camera_id} with FFmpeg hardware acceleration")
hw_accel_success = False
# Create capture with FFMPEG backend and TCP transport for reliability
# Use TCP instead of UDP to prevent packet loss
rtsp_url_tcp = self.rtsp_url.replace('rtsp://', 'rtsp://')
if '?' in rtsp_url_tcp:
rtsp_url_tcp += '&tcp'
# Method 1: Try OpenCV CUDA VideoReader (if built with CUVID support)
if not hw_accel_success:
try:
# Check if OpenCV was built with CUDA codec support
build_info = cv2.getBuildInformation()
if 'cudacodec' in build_info or 'CUVID' in build_info:
logger.info(f"Attempting OpenCV CUDA VideoReader for camera {self.camera_id}")
# Use OpenCV's CUDA backend
self.cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG, [
cv2.CAP_PROP_HW_ACCELERATION, cv2.VIDEO_ACCELERATION_ANY
])
if self.cap.isOpened():
hw_accel_success = True
logger.info(f"Camera {self.camera_id}: Using OpenCV CUDA hardware acceleration")
else:
rtsp_url_tcp += '?tcp'
logger.debug(f"Camera {self.camera_id}: OpenCV not built with CUDA codec support")
except Exception as e:
logger.debug(f"Camera {self.camera_id}: OpenCV CUDA not available: {e}")
# Alternative: Set environment variable for RTSP transport
# Method 2: Try FFmpeg with optimal hardware acceleration (CUVID/NVDEC)
if not hw_accel_success:
try:
from core.utils.ffmpeg_detector import get_optimal_rtsp_options
import os
# Get optimal FFmpeg options based on detected capabilities
optimal_options = get_optimal_rtsp_options(self.rtsp_url)
os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = optimal_options
logger.info(f"Attempting FFmpeg with detected hardware acceleration for camera {self.camera_id}")
logger.debug(f"Camera {self.camera_id}: Using FFmpeg options: {optimal_options}")
self.cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG)
if self.cap.isOpened():
hw_accel_success = True
# Try to get backend info to confirm hardware acceleration
backend = self.cap.getBackendName()
logger.info(f"Camera {self.camera_id}: Using FFmpeg hardware acceleration (backend: {backend})")
except Exception as e:
logger.debug(f"Camera {self.camera_id}: FFmpeg optimal hardware acceleration not available: {e}")
# Method 3: Try FFmpeg with NVIDIA NVDEC (better for RTX 3060)
if not hw_accel_success:
try:
import os
os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'hwaccel;cuda|hwaccel_device;0|rtsp_transport;tcp'
logger.info(f"Attempting FFmpeg with NVDEC hardware acceleration for camera {self.camera_id}")
self.cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG)
if self.cap.isOpened():
hw_accel_success = True
logger.info(f"Camera {self.camera_id}: Using FFmpeg NVDEC hardware acceleration")
except Exception as e:
logger.debug(f"Camera {self.camera_id}: FFmpeg NVDEC not available: {e}")
# Method 4: Try FFmpeg with VAAPI (Intel/AMD GPUs)
if not hw_accel_success:
try:
import os
os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'hwaccel;vaapi|hwaccel_device;/dev/dri/renderD128|video_codec;h264|rtsp_transport;tcp'
logger.info(f"Attempting FFmpeg with VAAPI for camera {self.camera_id}")
self.cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG)
if self.cap.isOpened():
hw_accel_success = True
logger.info(f"Camera {self.camera_id}: Using FFmpeg VAAPI hardware acceleration")
except Exception as e:
logger.debug(f"Camera {self.camera_id}: FFmpeg VAAPI not available: {e}")
# Fallback: Standard FFmpeg with software decoding
if not hw_accel_success:
logger.warning(f"Camera {self.camera_id}: Hardware acceleration not available, falling back to software decoding")
import os
os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'rtsp_transport;tcp'
self.cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG)
if not self.cap.isOpened():
logger.error(f"Failed to open stream for camera {self.camera_id}")
return False
# Set capture properties for 1280x720@6fps
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.expected_width)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.expected_height)
self.cap.set(cv2.CAP_PROP_FPS, self.expected_fps)
# Don't force resolution/fps - let the stream determine its natural specs
# The camera will provide whatever resolution/fps it supports
# Set moderate buffer to handle network jitter while avoiding excessive latency
# Buffer of 3 frames provides resilience without major delay
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 3)
# Set FFMPEG options for better H.264 handling
self.cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'H264'))
@ -337,15 +614,10 @@ class HTTPSnapshotReader:
time.sleep(min(2.0, interval_seconds))
continue
# Validate image dimensions
if frame.shape[1] != self.expected_width or frame.shape[0] != self.expected_height:
logger.info(f"Camera {self.camera_id}: Snapshot dimensions {frame.shape[1]}x{frame.shape[0]} "
f"(expected {self.expected_width}x{self.expected_height})")
# Resize if needed (maintaining aspect ratio for high quality)
if frame.shape[1] > 0 and frame.shape[0] > 0:
# Only resize if significantly different
if abs(frame.shape[1] - self.expected_width) > 100:
frame = self._resize_maintain_aspect(frame, self.expected_width, self.expected_height)
# Accept any valid image dimensions - don't force specific resolution
if frame.shape[1] <= 0 or frame.shape[0] <= 0:
logger.warning(f"Camera {self.camera_id}: Invalid frame dimensions {frame.shape[1]}x{frame.shape[0]}")
continue
# Reset retry counter on successful fetch
retries = 0

View file

@ -0,0 +1,214 @@
"""
FFmpeg hardware acceleration detection and configuration
"""
import subprocess
import logging
import re
from typing import Dict, List, Optional
logger = logging.getLogger("detector_worker")
class FFmpegCapabilities:
"""Detect and configure FFmpeg hardware acceleration capabilities."""
def __init__(self):
"""Initialize FFmpeg capabilities detector."""
self.hwaccels = []
self.codecs = {}
self.nvidia_support = False
self.vaapi_support = False
self.qsv_support = False
self._detect_capabilities()
def _detect_capabilities(self):
"""Detect available hardware acceleration methods."""
try:
# Get hardware accelerators
result = subprocess.run(
['ffmpeg', '-hide_banner', '-hwaccels'],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
self.hwaccels = [line.strip() for line in result.stdout.strip().split('\n')[1:] if line.strip()]
logger.info(f"Available FFmpeg hardware accelerators: {', '.join(self.hwaccels)}")
# Check for NVIDIA support
self.nvidia_support = any(hw in self.hwaccels for hw in ['cuda', 'cuvid', 'nvdec'])
self.vaapi_support = 'vaapi' in self.hwaccels
self.qsv_support = 'qsv' in self.hwaccels
# Get decoder information
self._detect_decoders()
# Log capabilities
if self.nvidia_support:
logger.info("NVIDIA hardware acceleration available (CUDA/CUVID/NVDEC)")
logger.info(f"Detected hardware codecs: {self.codecs}")
if self.vaapi_support:
logger.info("VAAPI hardware acceleration available")
if self.qsv_support:
logger.info("Intel QuickSync hardware acceleration available")
except Exception as e:
logger.warning(f"Failed to detect FFmpeg capabilities: {e}")
def _detect_decoders(self):
"""Detect available hardware decoders."""
try:
result = subprocess.run(
['ffmpeg', '-hide_banner', '-decoders'],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
# Parse decoder output to find hardware decoders
for line in result.stdout.split('\n'):
if 'cuvid' in line or 'nvdec' in line:
match = re.search(r'(\w+)\s+.*?(\w+(?:_cuvid|_nvdec))', line)
if match:
codec_type, decoder = match.groups()
if 'h264' in decoder:
self.codecs['h264_hw'] = decoder
elif 'hevc' in decoder or 'h265' in decoder:
self.codecs['h265_hw'] = decoder
elif 'vaapi' in line:
match = re.search(r'(\w+)\s+.*?(\w+_vaapi)', line)
if match:
codec_type, decoder = match.groups()
if 'h264' in decoder:
self.codecs['h264_vaapi'] = decoder
except Exception as e:
logger.debug(f"Failed to detect decoders: {e}")
def get_optimal_capture_options(self, codec: str = 'h264') -> Dict[str, str]:
"""
Get optimal FFmpeg capture options for the given codec.
Args:
codec: Video codec (h264, h265, etc.)
Returns:
Dictionary of FFmpeg options
"""
options = {
'rtsp_transport': 'tcp',
'buffer_size': '1024k',
'max_delay': '500000', # 500ms
'fflags': '+genpts',
'flags': '+low_delay',
'probesize': '32',
'analyzeduration': '0'
}
# Add hardware acceleration if available
if self.nvidia_support:
# Force enable CUDA hardware acceleration for H.264 if CUDA is available
if codec == 'h264':
options.update({
'hwaccel': 'cuda',
'hwaccel_device': '0'
})
logger.info("Using NVIDIA NVDEC hardware acceleration for H.264")
elif codec == 'h265':
options.update({
'hwaccel': 'cuda',
'hwaccel_device': '0',
'video_codec': 'hevc_cuvid',
'hwaccel_output_format': 'cuda'
})
logger.info("Using NVIDIA CUVID hardware acceleration for H.265")
elif self.vaapi_support:
if codec == 'h264':
options.update({
'hwaccel': 'vaapi',
'hwaccel_device': '/dev/dri/renderD128',
'video_codec': 'h264_vaapi'
})
logger.debug("Using VAAPI hardware acceleration")
return options
def format_opencv_options(self, options: Dict[str, str]) -> str:
"""
Format options for OpenCV FFmpeg backend.
Args:
options: Dictionary of FFmpeg options
Returns:
Formatted options string for OpenCV
"""
return '|'.join(f"{key};{value}" for key, value in options.items())
def get_hardware_encoder_options(self, codec: str = 'h264', quality: str = 'fast') -> Dict[str, str]:
"""
Get optimal hardware encoding options.
Args:
codec: Video codec for encoding
quality: Quality preset (fast, medium, slow)
Returns:
Dictionary of encoding options
"""
options = {}
if self.nvidia_support:
if codec == 'h264':
options.update({
'video_codec': 'h264_nvenc',
'preset': quality,
'tune': 'zerolatency',
'gpu': '0',
'rc': 'cbr_hq',
'surfaces': '64'
})
elif codec == 'h265':
options.update({
'video_codec': 'hevc_nvenc',
'preset': quality,
'tune': 'zerolatency',
'gpu': '0'
})
elif self.vaapi_support:
if codec == 'h264':
options.update({
'video_codec': 'h264_vaapi',
'vaapi_device': '/dev/dri/renderD128'
})
return options
# Global instance
_ffmpeg_caps = None
def get_ffmpeg_capabilities() -> FFmpegCapabilities:
"""Get or create the global FFmpeg capabilities instance."""
global _ffmpeg_caps
if _ffmpeg_caps is None:
_ffmpeg_caps = FFmpegCapabilities()
return _ffmpeg_caps
def get_optimal_rtsp_options(rtsp_url: str) -> str:
"""
Get optimal OpenCV FFmpeg options for RTSP streaming.
Args:
rtsp_url: RTSP stream URL
Returns:
Formatted options string for cv2.VideoCapture
"""
caps = get_ffmpeg_capabilities()
# Detect codec from URL or assume H.264
codec = 'h265' if any(x in rtsp_url.lower() for x in ['h265', 'hevc']) else 'h264'
options = caps.get_optimal_capture_options(codec)
return caps.format_opencv_options(options)

View file

@ -0,0 +1,173 @@
"""
Hardware-accelerated image encoding using NVIDIA NVENC or Intel QuickSync
"""
import cv2
import numpy as np
import logging
from typing import Optional, Tuple
import os
logger = logging.getLogger("detector_worker")
class HardwareEncoder:
"""Hardware-accelerated JPEG encoder using GPU."""
def __init__(self):
"""Initialize hardware encoder."""
self.nvenc_available = False
self.vaapi_available = False
self.turbojpeg_available = False
# Check for TurboJPEG (fastest CPU-based option)
try:
from turbojpeg import TurboJPEG
self.turbojpeg = TurboJPEG()
self.turbojpeg_available = True
logger.info("TurboJPEG accelerated encoding available")
except ImportError:
logger.debug("TurboJPEG not available")
# Check for NVIDIA NVENC support
try:
# Test if we can create an NVENC encoder
test_frame = np.zeros((720, 1280, 3), dtype=np.uint8)
fourcc = cv2.VideoWriter_fourcc(*'H264')
test_writer = cv2.VideoWriter(
"test.mp4",
fourcc,
30,
(1280, 720),
[cv2.CAP_PROP_HW_ACCELERATION, cv2.VIDEO_ACCELERATION_ANY]
)
if test_writer.isOpened():
self.nvenc_available = True
logger.info("NVENC hardware encoding available")
test_writer.release()
if os.path.exists("test.mp4"):
os.remove("test.mp4")
except Exception as e:
logger.debug(f"NVENC not available: {e}")
def encode_jpeg(self, frame: np.ndarray, quality: int = 85) -> Optional[bytes]:
"""
Encode frame to JPEG using the fastest available method.
Args:
frame: BGR image frame
quality: JPEG quality (1-100)
Returns:
Encoded JPEG bytes or None on failure
"""
try:
# Method 1: TurboJPEG (3-5x faster than cv2.imencode)
if self.turbojpeg_available:
# Convert BGR to RGB for TurboJPEG
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
encoded = self.turbojpeg.encode(rgb_frame, quality=quality)
return encoded
# Method 2: Hardware-accelerated encoding via GStreamer (if available)
if self.nvenc_available:
return self._encode_with_nvenc(frame, quality)
# Fallback: Standard OpenCV encoding
encode_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
success, encoded = cv2.imencode('.jpg', frame, encode_params)
if success:
return encoded.tobytes()
return None
except Exception as e:
logger.error(f"Failed to encode frame: {e}")
return None
def _encode_with_nvenc(self, frame: np.ndarray, quality: int) -> Optional[bytes]:
"""
Encode using NVIDIA NVENC hardware encoder.
This is complex to implement directly, so we'll use a GStreamer pipeline
if available.
"""
try:
# Create a GStreamer pipeline for hardware encoding
height, width = frame.shape[:2]
gst_pipeline = (
f"appsrc ! "
f"video/x-raw,format=BGR,width={width},height={height},framerate=30/1 ! "
f"videoconvert ! "
f"nvvideoconvert ! " # GPU color conversion
f"nvjpegenc quality={quality} ! " # Hardware JPEG encoder
f"appsink"
)
# This would require GStreamer Python bindings
# For now, fall back to TurboJPEG or standard encoding
logger.debug("NVENC JPEG encoding not fully implemented, using fallback")
encode_params = [cv2.IMWRITE_JPEG_QUALITY, quality]
success, encoded = cv2.imencode('.jpg', frame, encode_params)
if success:
return encoded.tobytes()
return None
except Exception as e:
logger.error(f"NVENC encoding failed: {e}")
return None
def encode_batch(self, frames: list, quality: int = 85) -> list:
"""
Batch encode multiple frames for better GPU utilization.
Args:
frames: List of BGR frames
quality: JPEG quality
Returns:
List of encoded JPEG bytes
"""
encoded_frames = []
if self.turbojpeg_available:
# TurboJPEG can handle batch encoding efficiently
for frame in frames:
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
encoded = self.turbojpeg.encode(rgb_frame, quality=quality)
encoded_frames.append(encoded)
else:
# Fallback to sequential encoding
for frame in frames:
encoded = self.encode_jpeg(frame, quality)
encoded_frames.append(encoded)
return encoded_frames
# Global encoder instance
_hardware_encoder = None
def get_hardware_encoder() -> HardwareEncoder:
"""Get or create the global hardware encoder instance."""
global _hardware_encoder
if _hardware_encoder is None:
_hardware_encoder = HardwareEncoder()
return _hardware_encoder
def encode_frame_hardware(frame: np.ndarray, quality: int = 85) -> Optional[bytes]:
"""
Convenience function to encode a frame using hardware acceleration.
Args:
frame: BGR image frame
quality: JPEG quality (1-100)
Returns:
Encoded JPEG bytes or None on failure
"""
encoder = get_hardware_encoder()
return encoder.encode_jpeg(frame, quality)

View file

@ -7,3 +7,4 @@ filterpy
psycopg2-binary
lap>=0.5.12
pynvml
PyTurboJPEG

View file

@ -6,3 +6,4 @@ redis
urllib3<2.0.0
numpy
requests
watchdog