feat: initial BantayCam scaffold
- FastAPI backend with async SQLAlchemy - Camera RTSP management (add, start, stop) - Vehicle detection (YOLO + fast-alpr) - Type: car, motorcycle, truck, jeepney - Color detection (HSV) - License plate OCR - Motorcycle person count - Face detection + InsightFace ArcFace embedding - pgvector identity grouping (auto-cluster same face) - Vehicle + person movement trail APIs - Docker Compose with pgvector/pg16 - Models: Camera, VehicleIdentity, VehicleEvent, PersonIdentity, FaceEvent
This commit is contained in:
153
backend/app/services/stream_manager.py
Normal file
153
backend/app/services/stream_manager.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
StreamManager — Manages RTSP stream connections and dispatches frames to workers.
|
||||
Each camera runs in its own asyncio task with a frame queue.
|
||||
"""
|
||||
import asyncio
|
||||
import cv2
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StreamWorker:
|
||||
"""
|
||||
Handles one RTSP camera stream.
|
||||
Reads frames and puts them into a queue for the AI pipeline to consume.
|
||||
"""
|
||||
|
||||
def __init__(self, camera_id: UUID, camera_name: str, rtsp_url: str):
|
||||
self.camera_id = camera_id
|
||||
self.camera_name = camera_name
|
||||
self.rtsp_url = rtsp_url
|
||||
self.frame_queue: asyncio.Queue = asyncio.Queue(maxsize=10)
|
||||
self.is_running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self.frame_count = 0
|
||||
self.last_frame_at: Optional[datetime] = None
|
||||
self.error: Optional[str] = None
|
||||
|
||||
async def start(self):
|
||||
self.is_running = True
|
||||
self._task = asyncio.create_task(self._read_loop())
|
||||
logger.info(f"[{self.camera_name}] Stream started")
|
||||
|
||||
async def stop(self):
|
||||
self.is_running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info(f"[{self.camera_name}] Stream stopped")
|
||||
|
||||
async def _read_loop(self):
|
||||
"""Read frames from RTSP in a thread pool (OpenCV is blocking)."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
cap = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: cv2.VideoCapture(self.rtsp_url)
|
||||
)
|
||||
|
||||
if not cap.isOpened():
|
||||
self.error = "Cannot open RTSP stream"
|
||||
logger.error(f"[{self.camera_name}] {self.error}")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
self.error = None
|
||||
frame_idx = 0
|
||||
|
||||
while self.is_running:
|
||||
ret, frame = await loop.run_in_executor(None, cap.read)
|
||||
|
||||
if not ret:
|
||||
logger.warning(f"[{self.camera_name}] Frame read failed, reconnecting...")
|
||||
break
|
||||
|
||||
frame_idx += 1
|
||||
self.frame_count += 1
|
||||
self.last_frame_at = datetime.utcnow()
|
||||
|
||||
# Skip frames for performance
|
||||
if frame_idx % settings.PROCESS_EVERY_N_FRAMES != 0:
|
||||
continue
|
||||
|
||||
# Non-blocking put — drop frame if queue is full
|
||||
try:
|
||||
self.frame_queue.put_nowait({
|
||||
"frame": frame,
|
||||
"frame_idx": frame_idx,
|
||||
"captured_at": self.last_frame_at,
|
||||
})
|
||||
except asyncio.QueueFull:
|
||||
pass # AI pipeline is slow — drop frame
|
||||
|
||||
cap.release()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.error = str(e)
|
||||
logger.exception(f"[{self.camera_name}] Stream error: {e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
class StreamManager:
|
||||
"""
|
||||
Singleton that manages all active camera streams.
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
def __init__(self):
|
||||
self._workers: Dict[str, StreamWorker] = {}
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> "StreamManager":
|
||||
if cls._instance is None:
|
||||
cls._instance = StreamManager()
|
||||
return cls._instance
|
||||
|
||||
async def start_stream(self, camera_id: UUID, camera_name: str, rtsp_url: str):
|
||||
key = str(camera_id)
|
||||
if key in self._workers:
|
||||
await self.stop_stream(camera_id)
|
||||
|
||||
worker = StreamWorker(camera_id, camera_name, rtsp_url)
|
||||
self._workers[key] = worker
|
||||
await worker.start()
|
||||
return worker
|
||||
|
||||
async def stop_stream(self, camera_id: UUID):
|
||||
key = str(camera_id)
|
||||
if key in self._workers:
|
||||
await self._workers[key].stop()
|
||||
del self._workers[key]
|
||||
|
||||
def get_worker(self, camera_id: UUID) -> Optional[StreamWorker]:
|
||||
return self._workers.get(str(camera_id))
|
||||
|
||||
def get_all_status(self) -> list:
|
||||
return [
|
||||
{
|
||||
"camera_id": str(k),
|
||||
"is_running": w.is_running,
|
||||
"frame_count": w.frame_count,
|
||||
"last_frame_at": w.last_frame_at,
|
||||
"error": w.error,
|
||||
}
|
||||
for k, w in self._workers.items()
|
||||
]
|
||||
|
||||
async def stop_all(self):
|
||||
for worker in list(self._workers.values()):
|
||||
await worker.stop()
|
||||
self._workers.clear()
|
||||
Reference in New Issue
Block a user