"""Bounded vision inference queue — drop stale frames, skip, single worker.""" from __future__ import annotations import asyncio import logging import time from dataclasses import dataclass, field from typing import Awaitable, Callable import cv2 import numpy as np logger = logging.getLogger(__name__) FRAME_SKIP_MOD = max(1, int(__import__("os").getenv("CEPHEUS_FRAME_SKIP", "4") or "4")) INFER_WIDTH = max(160, int(__import__("os").getenv("CEPHEUS_INFER_WIDTH", "320") or "320")) LIVE_INFER_WIDTH = max(INFER_WIDTH, int(__import__("os").getenv("CEPHEUS_LIVE_INFER_WIDTH", "480") or "480")) PIPELINE_INFER_TIMEOUT = float(__import__("os").getenv("CEPHEUS_PIPELINE_TIMEOUT", "20") or "20") @dataclass class VisionPipelineStats: submitted: int = 0 processed: int = 0 dropped: int = 0 skipped: int = 0 last_inference_ms: float = 0.0 fps: float = 0.0 queue_depth: int = 0 _counter: int = 0 _last_process_ts: float = field(default_factory=time.time) def as_dict(self) -> dict: return { "submitted": self.submitted, "processed": self.processed, "dropped": self.dropped, "skipped": self.skipped, "last_inference_ms": round(self.last_inference_ms, 2), "fps": round(self.fps, 2), "queue_depth": self.queue_depth, "frame_skip_mod": FRAME_SKIP_MOD, "infer_width": INFER_WIDTH, "live_infer_width": LIVE_INFER_WIDTH, } def scale_match_bboxes(matches: list, from_frame, to_frame) -> list: """Map detection boxes from infer resolution back to full frame.""" if not matches or from_frame is None or to_frame is None: return matches fh, fw = from_frame.shape[:2] th, tw = to_frame.shape[:2] if fh == th and fw == tw: return matches sx, sy = tw / max(fw, 1), th / max(fh, 1) for m in matches: b = m.get("bbox") if b and len(b) >= 4: m["bbox"] = [b[0] * sx, b[1] * sy, b[2] * sx, b[3] * sy] return matches def resize_for_infer(frame: np.ndarray, width: int = INFER_WIDTH) -> np.ndarray: if frame is None or frame.size == 0: return frame h, w = frame.shape[:2] if w <= width: return frame nh = max(1, int(h * (width / w))) return cv2.resize(frame, (width, nh), interpolation=cv2.INTER_AREA) AsyncInferRunner = Callable[..., Awaitable] class VisionFramePipeline: """Frame skip + bounded async infer via shared face executor (no extra thread).""" def __init__(self, infer_fn, async_runner: AsyncInferRunner | None = None): self._infer_fn = infer_fn self._async_runner = async_runner self.stats = VisionPipelineStats() async def infer(self, cam_id: str, frame: np.ndarray) -> tuple[list, bool]: """Returns (matches, skipped). skipped=True means frame was not inferred.""" self.stats._counter += 1 if self.stats._counter % FRAME_SKIP_MOD != 0: self.stats.skipped += 1 return [], True if self._async_runner is None: logger.error("VisionFramePipeline missing async_runner") return [], True self.stats.submitted += 1 self.stats.queue_depth = 1 small = resize_for_infer(frame) t0 = time.perf_counter() try: matches = await self._async_runner( self._infer_fn, cam_id, small, timeout=PIPELINE_INFER_TIMEOUT, ) if not isinstance(matches, list): matches = [] except asyncio.TimeoutError: logger.error("Vision pipeline infer timed out after %.0fs", PIPELINE_INFER_TIMEOUT) matches = [] except Exception as exc: logger.warning("vision pipeline infer error: %s", exc) matches = [] elapsed_ms = (time.perf_counter() - t0) * 1000.0 self.stats.last_inference_ms = elapsed_ms self.stats.processed += 1 now = time.time() dt = now - self.stats._last_process_ts if dt > 0: self.stats.fps = 1.0 / dt self.stats._last_process_ts = now self.stats.queue_depth = 0 return matches, False