1313import numpy as np
1414from pydantic import BaseModel , Field , model_validator
1515
16- from ..base import CameraBackend , register_backend
16+ from ..base import CameraBackend , SupportLevel , register_backend
1717from ..factory import DetectedCamera
1818from .utils .opencv_discovery import (
1919 ModeRequest ,
3333
3434AspectPolicy = Literal ["strict" , "prefer" , "ignore" ]
3535FourCC = Literal ["MJPG" , "YUY2" , "NV12" , "H264" , "XRGB" , "BGR3" ] # expand as needed
36+ ResolutionPolicy = Literal ["warn" , "strict" , "accept" ]
3637
3738
3839class 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 ():
0 commit comments