fix: camera api endpoint
This commit is contained in:
parent
83aaf95f59
commit
519e073f7f
5 changed files with 69 additions and 68 deletions
56
app.py
56
app.py
|
@ -6,8 +6,9 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import cv2
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, WebSocket, HTTPException, Request
|
from fastapi import FastAPI, WebSocket, HTTPException
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
# Import new modular communication system
|
# Import new modular communication system
|
||||||
|
@ -27,8 +28,8 @@ logging.basicConfig(
|
||||||
logger = logging.getLogger("detector_worker")
|
logger = logging.getLogger("detector_worker")
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Store cached frames for REST API access (temporary storage)
|
# Frames are now stored in the shared cache buffer from core.streaming.buffers
|
||||||
latest_frames = {}
|
# latest_frames = {} # Deprecated - using shared_cache_buffer instead
|
||||||
|
|
||||||
# Lifespan event handler (modern FastAPI approach)
|
# Lifespan event handler (modern FastAPI approach)
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
@ -49,7 +50,7 @@ async def lifespan(app: FastAPI):
|
||||||
worker_state.set_subscriptions([])
|
worker_state.set_subscriptions([])
|
||||||
worker_state.session_ids.clear()
|
worker_state.session_ids.clear()
|
||||||
worker_state.progression_stages.clear()
|
worker_state.progression_stages.clear()
|
||||||
latest_frames.clear()
|
# latest_frames.clear() # No longer needed - frames are in shared_cache_buffer
|
||||||
logger.info("Detector Worker shutdown complete")
|
logger.info("Detector Worker shutdown complete")
|
||||||
|
|
||||||
# Create FastAPI application with detailed WebSocket logging
|
# Create FastAPI application with detailed WebSocket logging
|
||||||
|
@ -90,8 +91,8 @@ from core.streaming import initialize_stream_manager
|
||||||
initialize_stream_manager(max_streams=config.get('max_streams', 10))
|
initialize_stream_manager(max_streams=config.get('max_streams', 10))
|
||||||
logger.info(f"Initialized stream manager with max_streams={config.get('max_streams', 10)}")
|
logger.info(f"Initialized stream manager with max_streams={config.get('max_streams', 10)}")
|
||||||
|
|
||||||
# Store cached frames for REST API access (temporary storage)
|
# Frames are now stored in the shared cache buffer from core.streaming.buffers
|
||||||
latest_frames = {}
|
# latest_frames = {} # Deprecated - using shared_cache_buffer instead
|
||||||
|
|
||||||
logger.info("Starting detector worker application (refactored)")
|
logger.info("Starting detector worker application (refactored)")
|
||||||
logger.info(f"Configuration: Target FPS: {config.get('target_fps', 10)}, "
|
logger.info(f"Configuration: Target FPS: {config.get('target_fps', 10)}, "
|
||||||
|
@ -150,31 +151,36 @@ async def get_camera_image(camera_id: str):
|
||||||
detail=f"Camera {camera_id} not found or not active"
|
detail=f"Camera {camera_id} not found or not active"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if we have a cached frame for this camera
|
# Extract actual camera_id from subscription identifier (displayId;cameraId)
|
||||||
if camera_id not in latest_frames:
|
# Frames are stored using just the camera_id part
|
||||||
logger.warning(f"No cached frame available for camera '{camera_id}'")
|
actual_camera_id = camera_id.split(';')[-1] if ';' in camera_id else camera_id
|
||||||
|
|
||||||
|
# Get frame from the shared cache buffer
|
||||||
|
from core.streaming.buffers import shared_cache_buffer
|
||||||
|
|
||||||
|
# Debug: Log available cameras in buffer
|
||||||
|
available_cameras = shared_cache_buffer.frame_buffer.get_camera_list()
|
||||||
|
logger.debug(f"Available cameras in buffer: {available_cameras}")
|
||||||
|
logger.debug(f"Looking for camera: '{actual_camera_id}'")
|
||||||
|
|
||||||
|
frame = shared_cache_buffer.get_frame(actual_camera_id)
|
||||||
|
if frame is None:
|
||||||
|
logger.warning(f"No cached frame available for camera '{actual_camera_id}' (from subscription '{camera_id}')")
|
||||||
|
logger.warning(f"Available cameras in buffer: {available_cameras}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail=f"No frame available for camera {camera_id}"
|
detail=f"No frame available for camera {actual_camera_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
frame = latest_frames[camera_id]
|
logger.debug(f"Retrieved cached frame for camera '{actual_camera_id}' (from subscription '{camera_id}'), shape: {frame.shape}")
|
||||||
logger.debug(f"Retrieved cached frame for camera '{camera_id}', shape: {frame.shape}")
|
|
||||||
|
|
||||||
# TODO: This import will be replaced in Phase 3 (Streaming System)
|
# Encode frame as JPEG
|
||||||
# For now, we need to handle the case where OpenCV is not available
|
success, buffer_img = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||||
try:
|
if not success:
|
||||||
import cv2
|
raise HTTPException(status_code=500, detail="Failed to encode image as JPEG")
|
||||||
# Encode frame as JPEG
|
|
||||||
success, buffer_img = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to encode image as JPEG")
|
|
||||||
|
|
||||||
# Return image as binary response
|
# Return image as binary response
|
||||||
return Response(content=buffer_img.tobytes(), media_type="image/jpeg")
|
return Response(content=buffer_img.tobytes(), media_type="image/jpeg")
|
||||||
except ImportError:
|
|
||||||
logger.error("OpenCV not available for image encoding")
|
|
||||||
raise HTTPException(status_code=500, detail="Image processing not available")
|
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
@ -377,6 +377,8 @@ class WebSocketHandler:
|
||||||
camera_id = subscription_id.split(';')[-1]
|
camera_id = subscription_id.split(';')[-1]
|
||||||
model_id = payload['modelId']
|
model_id = payload['modelId']
|
||||||
|
|
||||||
|
logger.info(f"[SUBSCRIPTION_MAPPING] subscription_id='{subscription_id}' → camera_id='{camera_id}'")
|
||||||
|
|
||||||
# Get tracking integration for this model
|
# Get tracking integration for this model
|
||||||
tracking_integration = tracking_integrations.get(model_id)
|
tracking_integration = tracking_integrations.get(model_id)
|
||||||
|
|
||||||
|
|
|
@ -46,13 +46,7 @@ class FrameBuffer:
|
||||||
|
|
||||||
frame_data = self._frames[camera_id]
|
frame_data = self._frames[camera_id]
|
||||||
|
|
||||||
# Check if frame is too old
|
# Return frame regardless of age - frames persist until replaced
|
||||||
age = time.time() - frame_data['timestamp']
|
|
||||||
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]
|
|
||||||
return None
|
|
||||||
|
|
||||||
return frame_data['frame'].copy()
|
return frame_data['frame'].copy()
|
||||||
|
|
||||||
def get_frame_info(self, camera_id: str) -> Optional[Dict[str, Any]]:
|
def get_frame_info(self, camera_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
@ -64,10 +58,7 @@ class FrameBuffer:
|
||||||
frame_data = self._frames[camera_id]
|
frame_data = self._frames[camera_id]
|
||||||
age = time.time() - frame_data['timestamp']
|
age = time.time() - frame_data['timestamp']
|
||||||
|
|
||||||
if age > self.max_age_seconds:
|
# Return frame info regardless of age - frames persist until replaced
|
||||||
del self._frames[camera_id]
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'timestamp': frame_data['timestamp'],
|
'timestamp': frame_data['timestamp'],
|
||||||
'age': age,
|
'age': age,
|
||||||
|
@ -95,24 +86,10 @@ class FrameBuffer:
|
||||||
logger.debug(f"Cleared all frames ({count} cameras)")
|
logger.debug(f"Cleared all frames ({count} cameras)")
|
||||||
|
|
||||||
def get_camera_list(self) -> list:
|
def get_camera_list(self) -> list:
|
||||||
"""Get list of cameras with valid frames."""
|
"""Get list of cameras with frames - all frames persist until replaced."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
current_time = time.time()
|
# Return all cameras that have frames - no age-based filtering
|
||||||
valid_cameras = []
|
return list(self._frames.keys())
|
||||||
expired_cameras = []
|
|
||||||
|
|
||||||
for camera_id, frame_data in self._frames.items():
|
|
||||||
age = current_time - frame_data['timestamp']
|
|
||||||
if age <= self.max_age_seconds:
|
|
||||||
valid_cameras.append(camera_id)
|
|
||||||
else:
|
|
||||||
expired_cameras.append(camera_id)
|
|
||||||
|
|
||||||
# Clean up expired frames
|
|
||||||
for camera_id in expired_cameras:
|
|
||||||
del self._frames[camera_id]
|
|
||||||
|
|
||||||
return valid_cameras
|
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
"""Get buffer statistics."""
|
"""Get buffer statistics."""
|
||||||
|
@ -120,8 +97,8 @@ class FrameBuffer:
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
stats = {
|
stats = {
|
||||||
'total_cameras': len(self._frames),
|
'total_cameras': len(self._frames),
|
||||||
'valid_cameras': 0,
|
'recent_cameras': 0,
|
||||||
'expired_cameras': 0,
|
'stale_cameras': 0,
|
||||||
'total_memory_mb': 0,
|
'total_memory_mb': 0,
|
||||||
'cameras': {}
|
'cameras': {}
|
||||||
}
|
}
|
||||||
|
@ -130,16 +107,17 @@ class FrameBuffer:
|
||||||
age = current_time - frame_data['timestamp']
|
age = current_time - frame_data['timestamp']
|
||||||
size_mb = frame_data.get('size_mb', 0)
|
size_mb = frame_data.get('size_mb', 0)
|
||||||
|
|
||||||
|
# All frames are valid/available, but categorize by freshness for monitoring
|
||||||
if age <= self.max_age_seconds:
|
if age <= self.max_age_seconds:
|
||||||
stats['valid_cameras'] += 1
|
stats['recent_cameras'] += 1
|
||||||
else:
|
else:
|
||||||
stats['expired_cameras'] += 1
|
stats['stale_cameras'] += 1
|
||||||
|
|
||||||
stats['total_memory_mb'] += size_mb
|
stats['total_memory_mb'] += size_mb
|
||||||
|
|
||||||
stats['cameras'][camera_id] = {
|
stats['cameras'][camera_id] = {
|
||||||
'age': age,
|
'age': age,
|
||||||
'valid': age <= self.max_age_seconds,
|
'recent': age <= self.max_age_seconds, # Recent but all frames available
|
||||||
'shape': frame_data['shape'],
|
'shape': frame_data['shape'],
|
||||||
'dtype': frame_data['dtype'],
|
'dtype': frame_data['dtype'],
|
||||||
'size_mb': size_mb
|
'size_mb': size_mb
|
||||||
|
|
|
@ -130,6 +130,7 @@ class StreamManager:
|
||||||
try:
|
try:
|
||||||
if stream_config.rtsp_url:
|
if stream_config.rtsp_url:
|
||||||
# RTSP stream using FFmpeg subprocess with CUDA acceleration
|
# RTSP stream using FFmpeg subprocess with CUDA acceleration
|
||||||
|
logger.info(f"[STREAM_START] Starting FFmpeg RTSP stream for camera_id='{camera_id}' URL={stream_config.rtsp_url}")
|
||||||
reader = FFmpegRTSPReader(
|
reader = FFmpegRTSPReader(
|
||||||
camera_id=camera_id,
|
camera_id=camera_id,
|
||||||
rtsp_url=stream_config.rtsp_url,
|
rtsp_url=stream_config.rtsp_url,
|
||||||
|
@ -138,10 +139,11 @@ class StreamManager:
|
||||||
reader.set_frame_callback(self._frame_callback)
|
reader.set_frame_callback(self._frame_callback)
|
||||||
reader.start()
|
reader.start()
|
||||||
self._streams[camera_id] = reader
|
self._streams[camera_id] = reader
|
||||||
logger.info(f"Started FFmpeg RTSP stream for camera {camera_id}")
|
logger.info(f"[STREAM_START] ✅ Started FFmpeg RTSP stream for camera_id='{camera_id}'")
|
||||||
|
|
||||||
elif stream_config.snapshot_url:
|
elif stream_config.snapshot_url:
|
||||||
# HTTP snapshot stream
|
# HTTP snapshot stream
|
||||||
|
logger.info(f"[STREAM_START] Starting HTTP snapshot stream for camera_id='{camera_id}' URL={stream_config.snapshot_url}")
|
||||||
reader = HTTPSnapshotReader(
|
reader = HTTPSnapshotReader(
|
||||||
camera_id=camera_id,
|
camera_id=camera_id,
|
||||||
snapshot_url=stream_config.snapshot_url,
|
snapshot_url=stream_config.snapshot_url,
|
||||||
|
@ -151,7 +153,7 @@ class StreamManager:
|
||||||
reader.set_frame_callback(self._frame_callback)
|
reader.set_frame_callback(self._frame_callback)
|
||||||
reader.start()
|
reader.start()
|
||||||
self._streams[camera_id] = reader
|
self._streams[camera_id] = reader
|
||||||
logger.info(f"Started HTTP snapshot stream for camera {camera_id}")
|
logger.info(f"[STREAM_START] ✅ Started HTTP snapshot stream for camera_id='{camera_id}'")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"No valid URL provided for camera {camera_id}")
|
logger.error(f"No valid URL provided for camera {camera_id}")
|
||||||
|
@ -169,8 +171,9 @@ class StreamManager:
|
||||||
try:
|
try:
|
||||||
self._streams[camera_id].stop()
|
self._streams[camera_id].stop()
|
||||||
del self._streams[camera_id]
|
del self._streams[camera_id]
|
||||||
shared_cache_buffer.clear_camera(camera_id)
|
# DON'T clear frames - they should persist until replaced
|
||||||
logger.info(f"Stopped stream for camera {camera_id}")
|
# shared_cache_buffer.clear_camera(camera_id) # REMOVED - frames should persist
|
||||||
|
logger.info(f"Stopped stream for camera {camera_id} (frames preserved in buffer)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping stream for camera {camera_id}: {e}")
|
logger.error(f"Error stopping stream for camera {camera_id}: {e}")
|
||||||
|
|
||||||
|
@ -179,6 +182,11 @@ class StreamManager:
|
||||||
try:
|
try:
|
||||||
# Store frame in shared buffer
|
# Store frame in shared buffer
|
||||||
shared_cache_buffer.put_frame(camera_id, frame)
|
shared_cache_buffer.put_frame(camera_id, frame)
|
||||||
|
logger.info(f"[FRAME_CALLBACK] Stored frame for camera_id='{camera_id}' in shared_cache_buffer, shape={frame.shape}")
|
||||||
|
|
||||||
|
# Log current buffer state
|
||||||
|
available_cameras = shared_cache_buffer.frame_buffer.get_camera_list()
|
||||||
|
logger.info(f"[FRAME_CALLBACK] Buffer now contains {len(available_cameras)} cameras: {available_cameras}")
|
||||||
|
|
||||||
# Process tracking for subscriptions with tracking integration
|
# Process tracking for subscriptions with tracking integration
|
||||||
self._process_tracking_for_camera(camera_id, frame)
|
self._process_tracking_for_camera(camera_id, frame)
|
||||||
|
|
|
@ -101,14 +101,14 @@ class FFmpegRTSPReader:
|
||||||
# This ensures each file is complete when written
|
# This ensures each file is complete when written
|
||||||
camera_id_safe = self.camera_id.replace(' ', '_')
|
camera_id_safe = self.camera_id.replace(' ', '_')
|
||||||
self.frame_prefix = f"camera_{camera_id_safe}"
|
self.frame_prefix = f"camera_{camera_id_safe}"
|
||||||
# Using strftime pattern with microseconds for unique filenames
|
# Using strftime pattern with seconds for unique filenames (avoid %f which may not work)
|
||||||
self.frame_pattern = f"{self.frame_dir}/{self.frame_prefix}_%Y%m%d_%H%M%S_%f.ppm"
|
self.frame_pattern = f"{self.frame_dir}/{self.frame_prefix}_%Y%m%d_%H%M%S.ppm"
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
'ffmpeg',
|
'ffmpeg',
|
||||||
# DO NOT REMOVE
|
# DO NOT REMOVE
|
||||||
'-hwaccel', 'cuda',
|
# '-hwaccel', 'cuda',
|
||||||
'-hwaccel_device', '0',
|
# '-hwaccel_device', '0',
|
||||||
'-rtsp_transport', 'tcp',
|
'-rtsp_transport', 'tcp',
|
||||||
'-i', self.rtsp_url,
|
'-i', self.rtsp_url,
|
||||||
'-f', 'image2',
|
'-f', 'image2',
|
||||||
|
@ -201,14 +201,17 @@ class FFmpegRTSPReader:
|
||||||
# Sort by filename (which includes timestamp) and get the latest
|
# Sort by filename (which includes timestamp) and get the latest
|
||||||
frame_files.sort()
|
frame_files.sort()
|
||||||
latest_frame = frame_files[-1]
|
latest_frame = frame_files[-1]
|
||||||
|
logger.debug(f"Camera {self.camera_id}: Found {len(frame_files)} frames, processing latest: {latest_frame}")
|
||||||
|
|
||||||
# Read the latest frame (it's complete since FFmpeg wrote it atomically)
|
# Read the latest frame (it's complete since FFmpeg wrote it atomically)
|
||||||
frame = cv2.imread(latest_frame)
|
frame = cv2.imread(latest_frame)
|
||||||
|
|
||||||
if frame is not None and frame.shape == (self.height, self.width, 3):
|
if frame is not None:
|
||||||
# Call frame callback directly
|
logger.debug(f"Camera {self.camera_id}: Successfully read frame {frame.shape} from {latest_frame}")
|
||||||
|
# Accept any frame dimensions initially for debugging
|
||||||
if self.frame_callback:
|
if self.frame_callback:
|
||||||
self.frame_callback(self.camera_id, frame)
|
self.frame_callback(self.camera_id, frame)
|
||||||
|
logger.debug(f"Camera {self.camera_id}: Called frame callback")
|
||||||
|
|
||||||
frame_count += 1
|
frame_count += 1
|
||||||
|
|
||||||
|
@ -217,6 +220,8 @@ class FFmpegRTSPReader:
|
||||||
if current_time - last_log_time >= 30:
|
if current_time - last_log_time >= 30:
|
||||||
logger.info(f"Camera {self.camera_id}: {frame_count} frames processed")
|
logger.info(f"Camera {self.camera_id}: {frame_count} frames processed")
|
||||||
last_log_time = current_time
|
last_log_time = current_time
|
||||||
|
else:
|
||||||
|
logger.warning(f"Camera {self.camera_id}: Failed to read frame from {latest_frame}")
|
||||||
|
|
||||||
# Clean up old frame files to prevent disk filling
|
# Clean up old frame files to prevent disk filling
|
||||||
# Keep only the latest 5 frames
|
# Keep only the latest 5 frames
|
||||||
|
@ -226,6 +231,8 @@ class FFmpegRTSPReader:
|
||||||
os.remove(old_file)
|
os.remove(old_file)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
else:
|
||||||
|
logger.warning(f"Camera {self.camera_id}: No frame files found in {self.frame_dir} with pattern {self.frame_prefix}*.ppm")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Camera {self.camera_id}: Error reading frames: {e}")
|
logger.debug(f"Camera {self.camera_id}: Error reading frames: {e}")
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue