Spaces:
Sleeping
Sleeping
| """Singleton pipeline manager β owns the running Pipeline instance. | |
| The FastAPI app imports this module and calls start/stop. | |
| WebSocket handlers consume frames from the async queue. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import time | |
| from core.pipeline import Pipeline, PipelineConfig | |
| from api.schemas import CountingLineStatus, MetricsMessage, PipelineStatus | |
| # Max frames queued for WebSocket consumers; older frames are dropped when full | |
| _VIDEO_QUEUE_MAXSIZE = 4 | |
| _METRICS_QUEUE_MAXSIZE = 64 | |
| class PipelineManager: | |
| """Manages the lifecycle of a single active pipeline. | |
| One pipeline runs at a time. Multiple WebSocket clients can subscribe | |
| to video and metrics queues simultaneously. | |
| """ | |
| def __init__(self) -> None: | |
| self._pipeline: Pipeline | None = None | |
| self._task: asyncio.Task | None = None | |
| self._config: PipelineConfig | None = None | |
| self._start_time: float = 0.0 | |
| self._frame_index: int = 0 | |
| # Latest frame/metrics cache β used by HTTP polling endpoints | |
| self._latest_frame: bytes | None = None | |
| self._latest_metrics: MetricsMessage | None = None | |
| # Subscriber queues β one per connected WebSocket client | |
| self._video_queues: list[asyncio.Queue[bytes]] = [] | |
| self._metrics_queues: list[asyncio.Queue[MetricsMessage]] = [] | |
| self._lock = asyncio.Lock() | |
| # ββ Public interface ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def running(self) -> bool: | |
| return self._task is not None and not self._task.done() | |
| def status(self) -> PipelineStatus: | |
| uptime = time.time() - self._start_time if self.running else 0.0 | |
| fps = self._frame_index / uptime if uptime > 0 else 0.0 | |
| lines = [ | |
| CountingLineStatus(name=s.name, x1=s.p1[0], y1=s.p1[1], x2=s.p2[0], y2=s.p2[1]) | |
| for s in (self._config.counting_lines if self._config else []) | |
| ] | |
| return PipelineStatus( | |
| running=self.running, | |
| source=self._config.source if self._config else None, | |
| frame_index=self._frame_index, | |
| uptime_seconds=round(uptime, 1), | |
| fps=round(fps, 1), | |
| demo_mode=self._config.demo_mode if self._config else False, | |
| counting_lines=lines, | |
| ) | |
| async def start(self, config: PipelineConfig) -> None: | |
| async with self._lock: | |
| if self.running: | |
| await self._stop_pipeline() | |
| self._config = config | |
| self._pipeline = Pipeline(config) | |
| self._frame_index = 0 | |
| self._start_time = time.time() | |
| self._task = asyncio.create_task(self._run_loop(), name="pipeline") | |
| async def stop(self) -> None: | |
| async with self._lock: | |
| await self._stop_pipeline() | |
| # ββ Queue subscription ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def latest_frame(self) -> bytes | None: | |
| return self._latest_frame | |
| def latest_metrics(self) -> MetricsMessage | None: | |
| return self._latest_metrics | |
| def subscribe_video(self) -> asyncio.Queue[bytes]: | |
| q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=_VIDEO_QUEUE_MAXSIZE) | |
| self._video_queues.append(q) | |
| return q | |
| def unsubscribe_video(self, q: asyncio.Queue[bytes]) -> None: | |
| try: | |
| self._video_queues.remove(q) | |
| except ValueError: | |
| pass | |
| def subscribe_metrics(self) -> asyncio.Queue[MetricsMessage]: | |
| q: asyncio.Queue[MetricsMessage] = asyncio.Queue(maxsize=_METRICS_QUEUE_MAXSIZE) | |
| self._metrics_queues.append(q) | |
| return q | |
| def unsubscribe_metrics(self, q: asyncio.Queue[MetricsMessage]) -> None: | |
| try: | |
| self._metrics_queues.remove(q) | |
| except ValueError: | |
| pass | |
| # ββ Internal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def _stop_pipeline(self) -> None: | |
| if self._pipeline: | |
| self._pipeline.stop() | |
| if self._task and not self._task.done(): | |
| self._task.cancel() | |
| try: | |
| await self._task | |
| except asyncio.CancelledError: | |
| pass | |
| self._task = None | |
| self._pipeline = None | |
| async def _run_loop(self) -> None: | |
| """Async loop: pull frames from pipeline, fan-out to subscribers.""" | |
| from api.database import ring_push, upsert_hourly | |
| try: | |
| async for jpeg_bytes, raw_metrics in self._pipeline.run_async(): | |
| self._frame_index += 1 | |
| metrics = MetricsMessage.from_frame_metrics(raw_metrics) | |
| # Cache latest for HTTP polling | |
| self._latest_frame = jpeg_bytes | |
| self._latest_metrics = metrics | |
| # Persist | |
| ring_push(metrics) | |
| # Upsert hourly aggregate every 25 frames (~1s) to avoid DB thrash | |
| if self._frame_index % 25 == 0: | |
| asyncio.create_task(upsert_hourly(metrics)) | |
| # Fan-out video frame | |
| for q in list(self._video_queues): | |
| if q.full(): | |
| try: | |
| q.get_nowait() # drop oldest frame | |
| except asyncio.QueueEmpty: | |
| pass | |
| try: | |
| q.put_nowait(jpeg_bytes) | |
| except asyncio.QueueFull: | |
| pass | |
| # Fan-out metrics | |
| for q in list(self._metrics_queues): | |
| try: | |
| q.put_nowait(metrics) | |
| except asyncio.QueueFull: | |
| pass # slow consumer β drop frame | |
| except asyncio.CancelledError: | |
| pass | |
| except Exception as exc: | |
| print(f"[PipelineManager] Pipeline error: {exc}") | |
| raise | |
| # Module-level singleton β imported by routes | |
| manager = PipelineManager() | |