Skip to content

Commit 8787a53

Browse files
committed
Add resolution policy and UI backend capabilities
Introduce backend capability metadata and resolution negotiation features for the OpenCV backend and GUI. Key changes: - cameras/base.py: Add SupportLevel enum and DEFAULT_CAPABILITIES; expose CameraBackend.static_capabilities() for UI use. - cameras/factory.py: Provide CameraFactory.backend_capabilities() to query backend feature support safely. - cameras/backends/opencv_backend.py: Add OpenCV options (resolution_policy, persist_last_applied_resolution), track requested resolution separately, enforce mismatch policy (warn/strict/accept), persist last-applied resolution, and declare static_capabilities for OpenCV. Improve resolution/fps configuration logic and fast-start handling. - cameras/backends/utils/opencv_discovery.py: Remove unused rebind helper and clarify apply_mode_with_verification docstring. - config.py: Add width/height fields to CameraSettings with defaults. - gui/camera_config_dialog.py: Add width/height controls, wire them into the form and preview logic, store fast_start under backend-specific properties, and enable/disable UI controls based on backend capabilities. Why: These changes let the UI accurately reflect and control backend capabilities, provide configurable resolution mismatch handling, and improve robustness when negotiating camera modes (including a fast-start path and optional persistence of the last-applied resolution).
1 parent 2cf3e8e commit 8787a53

6 files changed

Lines changed: 258 additions & 95 deletions

File tree

dlclivegui/cameras/backends/opencv_backend.py

Lines changed: 119 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import numpy as np
1414
from pydantic import BaseModel, Field, model_validator
1515

16-
from ..base import CameraBackend, register_backend
16+
from ..base import CameraBackend, SupportLevel, register_backend
1717
from ..factory import DetectedCamera
1818
from .utils.opencv_discovery import (
1919
ModeRequest,
@@ -33,6 +33,7 @@
3333

3434
AspectPolicy = Literal["strict", "prefer", "ignore"]
3535
FourCC = Literal["MJPG", "YUY2", "NV12", "H264", "XRGB", "BGR3"] # expand as needed
36+
ResolutionPolicy = Literal["warn", "strict", "accept"]
3637

3738

3839
class OpenCVOptions(BaseModel):
@@ -48,6 +49,8 @@ class OpenCVOptions(BaseModel):
4849
alt_index_probe: bool = False
4950

5051
# --- format negotiation policy ---
52+
resolution_policy: ResolutionPolicy = "warn"
53+
persist_last_applied_resolution: bool = False
5154
enforce_aspect: AspectPolicy = "strict"
5255
aspect_tol: float = Field(default=0.01, ge=0.0, le=0.2) # 1% default
5356
area_tol: float = Field(default=0.05, ge=0.0, le=1.0) # 5% default
@@ -98,7 +101,10 @@ class OpenCVCameraBackend(CameraBackend):
98101
def __init__(self, settings):
99102
super().__init__(settings)
100103
self._capture: cv2.VideoCapture | None = None
101-
self._resolution: tuple[int, int] = self._parse_resolution(settings.properties.get("resolution"))
104+
105+
# do not overwrite based on actual resolution
106+
self._requested_resolution: tuple[int, int] = self._get_requested_resolution()
107+
102108
opt = self.parse_options(settings)
103109
self._fast_start: bool = opt.fast_start
104110
self._alt_index_probe: bool = opt.alt_index_probe
@@ -118,6 +124,21 @@ def parse_options(cls, settings: CameraSettings) -> OpenCVOptions:
118124
def options_schema(cls) -> dict:
119125
return OpenCVOptions.model_json_schema()
120126

127+
@classmethod
128+
def static_capabilities(cls) -> dict[str, SupportLevel]:
129+
caps = super().static_capabilities()
130+
caps.update(
131+
{
132+
"set_resolution": SupportLevel.SUPPORTED,
133+
"set_fps": SupportLevel.BEST_EFFORT,
134+
"set_exposure": SupportLevel.BEST_EFFORT,
135+
"set_gain": SupportLevel.BEST_EFFORT,
136+
"device_discovery": SupportLevel.SUPPORTED,
137+
"stable_identity": SupportLevel.SUPPORTED,
138+
}
139+
)
140+
return caps
141+
121142
# ----------------------------
122143
# Public API
123144
# ----------------------------
@@ -238,17 +259,61 @@ def _release_capture(self) -> None:
238259
self._capture = None
239260
time.sleep(0.02 if platform.system() == "Windows" else 0.0)
240261

241-
def _parse_resolution(self, resolution) -> tuple[int, int]:
242-
if resolution is None:
243-
return (720, 540)
244-
if isinstance(resolution, (list, tuple)) and len(resolution) == 2:
262+
def _get_requested_resolution(self) -> tuple[int, int]:
263+
"""Return (w, h) requested by settings with precedence."""
264+
# 1) legacy / explicit property
265+
props = self.settings.properties or {}
266+
res = props.get("resolution", None)
267+
if isinstance(res, (list, tuple)) and len(res) == 2:
245268
try:
246-
return (int(resolution[0]), int(resolution[1]))
247-
except (ValueError, TypeError):
248-
logger.debug(f"Invalid resolution values: {resolution}, defaulting to 720x540")
249-
return (720, 540)
269+
w, h = int(res[0]), int(res[1])
270+
if w > 0 and h > 0:
271+
return (w, h)
272+
except Exception:
273+
pass
274+
275+
# 2) canonical GUI fields
276+
try:
277+
w, h = int(getattr(self.settings, "width", 0)), int(getattr(self.settings, "height", 0))
278+
if w > 0 and h > 0:
279+
return (w, h)
280+
except Exception:
281+
pass
282+
283+
# 3) default
250284
return (720, 540)
251285

286+
def _apply_resolution_policy(
287+
self,
288+
*,
289+
requested: tuple[int, int],
290+
actual: tuple[int, int] | None,
291+
policy: ResolutionPolicy,
292+
) -> None:
293+
"""Enforce mismatch policy (warn/strict/accept)."""
294+
if not actual:
295+
if policy == "strict":
296+
logger.warning("Cannot verify resolution; proceeding in strict mode")
297+
return
298+
299+
req_w, req_h = requested
300+
act_w, act_h = actual
301+
302+
if req_w <= 0 or req_h <= 0:
303+
return # no request
304+
305+
if (act_w, act_h) == (req_w, req_h):
306+
return
307+
308+
msg = f"Resolution mismatch: requested {req_w}x{req_h}, got {act_w}x{act_h}"
309+
310+
if policy == "strict":
311+
raise RuntimeError(msg)
312+
elif policy == "warn":
313+
logger.warning(msg)
314+
else: # "accept"
315+
logger.info(msg)
316+
252317
def _preferred_backend_flag(self, backend: str | None) -> int:
253318
"""Resolve preferred backend by platform."""
254319
if backend: # user override
@@ -296,42 +361,66 @@ def _configure_capture(self) -> None:
296361
if not self._capture:
297362
return
298363

299-
# --- FOURCC (Windows benefits from setting this first) ---
364+
opt = self.parse_options(self.settings)
365+
366+
# --- FOURCC ---
300367
self._codec_str = self._read_codec_string()
301368
logger.info(f"Camera using codec: {self._codec_str}")
302369

303-
# --- Resolution ---
304-
req_w, req_h = self._resolution
305-
enforce_aspect = self.parse_options(self.settings).enforce_aspect
370+
# --- Resolution (explicit request) ---
371+
req_w, req_h = self._requested_resolution
372+
enforce_aspect = opt.enforce_aspect
306373

307374
if not self._fast_start:
375+
# verified, robust path
308376
result = apply_mode_with_verification(
309377
self._capture,
310378
ModeRequest(
311-
width=req_w, height=req_h, fps=float(self.settings.fps or 0.0), enforce_aspect=enforce_aspect
379+
width=req_w,
380+
height=req_h,
381+
fps=float(self.settings.fps or 0.0),
382+
enforce_aspect=enforce_aspect,
383+
aspect_tol=float(opt.aspect_tol),
384+
area_tol=float(opt.area_tol),
312385
),
313386
)
314387
self._actual_width, self._actual_height, self._actual_fps = result.width, result.height, result.fps
315388
else:
389+
# fast-start: best-effort set (no heavy negotiation)
390+
if req_w > 0 and req_h > 0:
391+
try:
392+
self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(req_w))
393+
self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(req_h))
394+
except Exception as exc:
395+
logger.debug(f"Fast-start resolution set failed: {exc}")
396+
316397
self._actual_width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
317398
self._actual_height = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
318-
# if self._actual_width and self._actual_height:
319-
# self.settings.properties["resolution"] = (self._actual_width, self._actual_height)
320-
321-
# Handle mismatch quickly with a few known-good UVC fallbacks (Windows only)
322-
if platform.system() == "Windows" and self._actual_width and self._actual_height:
323-
if (self._actual_width, self._actual_height) != (req_w, req_h) and not self._fast_start:
324-
logger.warning(
325-
f"Resolution mismatch: requested {req_w}x{req_h}, got {self._actual_width}x{self._actual_height}"
326-
)
327-
self._resolution = (self._actual_width or req_w, self._actual_height or req_h)
328-
else:
329-
# Non-Windows: accept actual as-is
330-
self._resolution = (self._actual_width or req_w, self._actual_height or req_h)
331399

332-
logger.info(f"Camera configured with resolution: {self._resolution[0]}x{self._resolution[1]}")
400+
actual_res = None
401+
if (self._actual_width or 0) > 0 and (self._actual_height or 0) > 0:
402+
actual_res = (int(self._actual_width), int(self._actual_height))
403+
404+
logger.info(
405+
"Resolution requested=%sx%s, actual=%s",
406+
req_w,
407+
req_h,
408+
f"{actual_res[0]}x{actual_res[1]}" if actual_res else "unknown",
409+
)
333410

334-
# --- FPS ---
411+
# enforce mismatch policy (warn/strict/accept)
412+
self._apply_resolution_policy(
413+
requested=(req_w, req_h),
414+
actual=actual_res,
415+
policy=opt.resolution_policy,
416+
)
417+
418+
# optional persistence of "what worked"
419+
if opt.persist_last_applied_resolution and actual_res:
420+
ns = self.settings.properties.setdefault(self.OPTIONS_KEY, {})
421+
ns["last_applied_resolution"] = [actual_res[0], actual_res[1]]
422+
423+
# --- FPS (keep your current logic) ---
335424
requested_fps = float(self.settings.fps or 0.0)
336425
if not self._fast_start and requested_fps > 0.0:
337426
current_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0)
@@ -342,20 +431,10 @@ def _configure_capture(self) -> None:
342431
else:
343432
self._actual_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0)
344433

345-
# Log any mismatch
346434
if self._actual_fps and requested_fps and abs(self._actual_fps - requested_fps) > 0.1:
347435
logger.warning(f"FPS mismatch: requested {requested_fps:.2f}, got {self._actual_fps:.2f}")
348436

349-
# Always reconcile the settings with what we measured/obtained
350-
# if self._actual_fps:
351-
# self.settings.fps = float(self._actual_fps)
352437
logger.info(f"Camera configured with FPS: {self._actual_fps:.2f}")
353-
logger.debug(
354-
"CAP_PROP_FPS requested=%s set_ok=%s get=%s",
355-
self.settings.fps,
356-
self._capture.set(cv2.CAP_PROP_FPS, float(self.settings.fps)),
357-
self._capture.get(cv2.CAP_PROP_FPS),
358-
)
359438

360439
# --- Extra properties (safe whitelist) ---
361440
for prop, value in self.settings.properties.items():

dlclivegui/cameras/backends/utils/opencv_discovery.py

Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import TYPE_CHECKING, Any
99

1010
if TYPE_CHECKING:
11-
from ....config import CameraSettings
11+
pass
1212

1313
import cv2
1414

@@ -86,40 +86,6 @@ def _try_import_enumerator():
8686
return None
8787

8888

89-
def _try_rebind_opencv(self, cam: CameraSettings) -> bool:
90-
if (cam.backend or "").lower() != "opencv":
91-
return False
92-
93-
opt = (cam.properties or {}).get("opencv", {})
94-
device_id = opt.get("device_id")
95-
vid = opt.get("device_vid")
96-
pid = opt.get("device_pid")
97-
name = opt.get("device_name")
98-
99-
if not (device_id or (vid and pid) or name):
100-
return False
101-
102-
import cv2
103-
104-
from dlclivegui.cameras.backends.utils.opencv_discovery import list_cameras, select_camera
105-
106-
cams = list_cameras(cv2.CAP_ANY)
107-
chosen = select_camera(
108-
cams,
109-
prefer_stable_id=device_id,
110-
prefer_vid_pid=(int(vid), int(pid)) if vid and pid else None,
111-
prefer_name_substr=name,
112-
fallback_index=int(cam.index),
113-
)
114-
if not chosen:
115-
return False
116-
117-
cam.index = int(chosen.index)
118-
opt["device_id"] = chosen.stable_id
119-
cam.properties["opencv"] = opt
120-
return True
121-
122-
12389
def list_cameras(
12490
api_preference: int | None = None,
12591
enumerator: Callable[..., Sequence[Any]] | None = None,
@@ -309,10 +275,12 @@ def apply_mode_with_verification(
309275
warmup_grabs: int = 3,
310276
) -> ModeResult:
311277
"""
312-
Attempt to configure the camera as close as possible to request.
278+
Attempt to set width/height (and fps if provided) and read back actual values.
313279
314-
Returns ModeResult(accepted=True) if we achieved a “close enough” match based on policy.
280+
`accepted` only reflects internal constraints used during probing (e.g. strict aspect),
281+
not whether the backend should accept the result. The backend enforces its own policy.
315282
"""
283+
316284
req_w, req_h = int(request.width), int(request.height)
317285
req_fps = float(request.fps or 0.0)
318286
req_aspect = _aspect(req_w, req_h)

dlclivegui/cameras/base.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import logging
55
from abc import ABC, abstractmethod
6+
from enum import Enum
67
from typing import TYPE_CHECKING, Any, ClassVar
78

89
import numpy as np
@@ -54,6 +55,24 @@ def reset_backends():
5455
_BACKEND_REGISTRY.clear()
5556

5657

58+
class SupportLevel(str, Enum):
59+
"""Allows definition of backend capabilities for UI"""
60+
61+
UNSUPPORTED = "unsupported"
62+
BEST_EFFORT = "best_effort"
63+
SUPPORTED = "supported"
64+
65+
66+
DEFAULT_CAPABILITIES: dict[str, SupportLevel] = {
67+
"set_resolution": SupportLevel.UNSUPPORTED,
68+
"set_fps": SupportLevel.UNSUPPORTED,
69+
"set_exposure": SupportLevel.UNSUPPORTED,
70+
"set_gain": SupportLevel.UNSUPPORTED,
71+
"device_discovery": SupportLevel.UNSUPPORTED,
72+
"stable_identity": SupportLevel.UNSUPPORTED,
73+
}
74+
75+
5776
class CameraBackend(ABC):
5877
"""Abstract base class for camera backends."""
5978

@@ -73,6 +92,11 @@ def is_available(cls) -> bool:
7392
"""Return whether the backend can be used on this system."""
7493
return True
7594

95+
@classmethod
96+
def static_capabilities(cls) -> dict[str, SupportLevel]:
97+
"""Return a dict describing supported features for UI purposes."""
98+
return DEFAULT_CAPABILITIES
99+
76100
@classmethod
77101
def options_key(cls) -> str:
78102
"""Return the key used to store this backend's options in CameraSettings."""

dlclivegui/cameras/factory.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from os import environ
1313

1414
from ..config import CameraSettings
15-
from .base import _BACKEND_REGISTRY, CameraBackend
15+
from .base import _BACKEND_REGISTRY, DEFAULT_CAPABILITIES, CameraBackend, SupportLevel
1616

1717
logger = logging.getLogger(__name__)
1818
_BACKEND_IMPORT_ERRORS: dict[str, str] = {}
@@ -150,6 +150,23 @@ def available_backends() -> dict[str, bool]:
150150
availability[name] = backend_cls.is_available()
151151
return availability
152152

153+
@staticmethod
154+
def backend_capabilities(backend: str) -> dict[str, SupportLevel]:
155+
"""
156+
Return the backend’s static capabilities (safe to call even if backend unavailable).
157+
"""
158+
_ensure_backends_loaded()
159+
key = (backend or "opencv").lower()
160+
try:
161+
backend_cls = CameraFactory._resolve_backend(key)
162+
except Exception:
163+
return dict(DEFAULT_CAPABILITIES)
164+
165+
try:
166+
return backend_cls.static_capabilities()
167+
except Exception:
168+
return dict(DEFAULT_CAPABILITIES)
169+
153170
@staticmethod
154171
def detect_cameras(
155172
backend: str,

dlclivegui/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ class CameraSettings(BaseModel):
1717
index: int = 0
1818
fps: float = 25.0
1919
backend: str = "opencv"
20+
width: int = 720
21+
height: int = 540
2022
exposure: int = 500 # 0=auto else µs
2123
gain: float = 10.0 # 0.0=auto else value
2224
crop_x0: int = 0

0 commit comments

Comments
 (0)