refractor capture to a module
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""Capture configuration, health state, and reconnect policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Sequence, Tuple, Union
|
||||
|
||||
MAX_SIMULTANEOUS_CAPTURES = 10
|
||||
MAX_QUEUE_SIZE = 10
|
||||
DEFAULT_QUEUE_SIZE = 1
|
||||
|
||||
|
||||
class CaptureMode(str, Enum):
|
||||
"""Frame delivery mode for a live capture worker."""
|
||||
|
||||
LATEST = "latest"
|
||||
QUEUE = "queue"
|
||||
|
||||
|
||||
class OverflowPolicy(str, Enum):
|
||||
"""Behavior when a bounded queue is full."""
|
||||
|
||||
DROP_OLDEST = "drop_oldest"
|
||||
DROP_NEWEST = "drop_newest"
|
||||
BLOCK = "block"
|
||||
|
||||
|
||||
class CaptureHealth(str, Enum):
|
||||
"""Lifecycle / health state for a capture source."""
|
||||
|
||||
CLOSED = "closed"
|
||||
CONNECTING = "connecting"
|
||||
CONNECTED = "connected"
|
||||
RECONNECTING = "reconnecting"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
SourceType = Union[int, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconnectPolicy:
|
||||
"""Auto-reconnect settings for live cameras.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
enabled:
|
||||
When False, failed reads do not attempt reopen.
|
||||
max_attempts:
|
||||
Maximum reopen attempts per disconnect episode. ``None`` means unlimited.
|
||||
initial_backoff_s:
|
||||
First wait before a reconnect attempt.
|
||||
max_backoff_s:
|
||||
Upper bound for exponential backoff.
|
||||
fail_threshold:
|
||||
Consecutive failed reads that trigger reconnect.
|
||||
stale_timeout_s:
|
||||
If no frame arrives for this many seconds while connected, treat as stale
|
||||
and reconnect. ``None`` disables stale detection.
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
max_attempts: Optional[int] = None
|
||||
initial_backoff_s: float = 0.25
|
||||
max_backoff_s: float = 5.0
|
||||
fail_threshold: int = 5
|
||||
stale_timeout_s: Optional[float] = 2.0
|
||||
|
||||
def next_backoff(self, attempt: int) -> float:
|
||||
"""Exponential backoff capped at ``max_backoff_s`` (attempt is 0-based)."""
|
||||
delay = self.initial_backoff_s * (2 ** max(0, attempt))
|
||||
return min(delay, self.max_backoff_s)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptureConfig:
|
||||
"""Configuration for opening and running a capture source."""
|
||||
|
||||
source: SourceType
|
||||
backend: str = "auto"
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
fps: Optional[float] = None
|
||||
fourcc: Optional[str] = "MJPG"
|
||||
mode: CaptureMode = CaptureMode.LATEST
|
||||
queue_size: int = DEFAULT_QUEUE_SIZE
|
||||
overflow: OverflowPolicy = OverflowPolicy.DROP_OLDEST
|
||||
reconnect: ReconnectPolicy = field(default_factory=ReconnectPolicy)
|
||||
start_worker: bool = True
|
||||
verbose: bool = False
|
||||
# Optional discovery filters (used by list/open helpers, not open itself).
|
||||
name_include: Optional[Sequence[str]] = None
|
||||
name_exclude: Optional[Sequence[str]] = None
|
||||
vid_pid_allowlist: Optional[Sequence[Tuple[int, int]]] = None
|
||||
min_width: Optional[int] = None
|
||||
min_height: Optional[int] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if isinstance(self.mode, str):
|
||||
self.mode = CaptureMode(self.mode)
|
||||
if isinstance(self.overflow, str):
|
||||
self.overflow = OverflowPolicy(self.overflow)
|
||||
if not isinstance(self.reconnect, ReconnectPolicy):
|
||||
raise TypeError("reconnect must be a ReconnectPolicy")
|
||||
size = int(self.queue_size)
|
||||
if size < 1 or size > MAX_QUEUE_SIZE:
|
||||
raise ValueError(f"queue_size must be in 1..{MAX_QUEUE_SIZE}, got {size}")
|
||||
self.queue_size = size
|
||||
if self.mode == CaptureMode.LATEST:
|
||||
# Latest mode keeps only one published frame regardless of queue_size.
|
||||
self.queue_size = 1
|
||||
|
||||
@classmethod
|
||||
def from_camera_dict(
|
||||
cls,
|
||||
source: SourceType,
|
||||
camera: dict[str, Any],
|
||||
*,
|
||||
mode: CaptureMode | str = CaptureMode.LATEST,
|
||||
reconnect: Optional[ReconnectPolicy] = None,
|
||||
verbose: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> "CaptureConfig":
|
||||
"""Build config from a sensor JSON ``camera`` section."""
|
||||
res = camera.get("resolution_raw") or [None, None]
|
||||
width = int(res[0]) if res and res[0] else None
|
||||
height = int(res[1]) if res and res[1] else None
|
||||
fps = camera.get("fps")
|
||||
return cls(
|
||||
source=source,
|
||||
width=width,
|
||||
height=height,
|
||||
fps=float(fps) if fps is not None else None,
|
||||
mode=mode,
|
||||
reconnect=reconnect or ReconnectPolicy(),
|
||||
verbose=verbose,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptureStatus:
|
||||
"""Snapshot of capture health for diagnostics / multi-camera UIs."""
|
||||
|
||||
health: CaptureHealth = CaptureHealth.CLOSED
|
||||
source: Optional[SourceType] = None
|
||||
last_error: Optional[str] = None
|
||||
last_frame_time: Optional[float] = None
|
||||
last_seq: int = 0
|
||||
dropped_frames: int = 0
|
||||
reconnect_attempts: int = 0
|
||||
consecutive_failures: int = 0
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
fps: float = 0.0
|
||||
backend_name: str = ""
|
||||
fourcc: str = ""
|
||||
Reference in New Issue
Block a user