11"""Basler camera backend implemented with :mod:`pypylon`."""
22
3+ # dlclivegui/cameras/backends/basler_backend.py
34from __future__ import annotations
45
56import logging
67import time
8+ from typing import ClassVar
79
810import numpy as np
911
10- from ..base import CameraBackend , register_backend
12+ from ..base import CameraBackend , SupportLevel , register_backend
1113
1214LOG = logging .getLogger (__name__ )
1315
2123class BaslerCameraBackend (CameraBackend ):
2224 """Capture frames from Basler cameras using the Pylon SDK."""
2325
26+ OPTIONS_KEY : ClassVar [str ] = "basler"
27+
2428 def __init__ (self , settings ):
2529 super ().__init__ (settings )
30+
31+ props = settings .properties if isinstance (settings .properties , dict ) else {}
32+ ns = props .get (self .OPTIONS_KEY , {})
33+ if not isinstance (ns , dict ):
34+ ns = {}
35+
2636 self ._camera : pylon .InstantCamera | None = None
2737 self ._converter : pylon .ImageFormatConverter | None = None
28- # Parse resolution with defaults (720x540)
29- self ._resolution : tuple [int , int ] = self ._parse_resolution (settings .properties .get ("resolution" ))
38+
39+ # Resolution request (None = device default)
40+ self ._requested_resolution : tuple [int , int ] | None = self ._get_requested_resolution_or_none ()
41+
42+ # Actuals for GUI
43+ self ._actual_width : int | None = None
44+ self ._actual_height : int | None = None
45+ self ._actual_fps : float | None = None
46+
47+ @property
48+ def actual_resolution (self ) -> tuple [int , int ] | None :
49+ if self ._actual_width and self ._actual_height :
50+ return (self ._actual_width , self ._actual_height )
51+ return None
52+
53+ @property
54+ def actual_fps (self ) -> float | None :
55+ return self ._actual_fps
3056
3157 @classmethod
3258 def is_available (cls ) -> bool :
3359 return pylon is not None
3460
61+ @classmethod
62+ def static_capabilities (cls ) -> dict [str , SupportLevel ]:
63+ caps = super ().static_capabilities ()
64+ caps .update (
65+ {
66+ "set_resolution" : SupportLevel .SUPPORTED ,
67+ "set_fps" : SupportLevel .SUPPORTED ,
68+ "set_exposure" : SupportLevel .SUPPORTED ,
69+ "set_gain" : SupportLevel .SUPPORTED ,
70+ "device_discovery" : SupportLevel .BEST_EFFORT ,
71+ "stable_identity" : SupportLevel .SUPPORTED ,
72+ }
73+ )
74+ return caps
75+
3576 def open (self ) -> None :
36- if pylon is None : # pragma: no cover - optional dependency
77+ if pylon is None :
3778 raise RuntimeError ("pypylon is required for the Basler backend but is not installed" )
79+
3880 devices = self ._enumerate_devices ()
3981 if not devices :
4082 raise RuntimeError ("No Basler cameras detected" )
83+
4184 device = self ._select_device (devices )
4285 self ._camera = pylon .InstantCamera (pylon .TlFactory .GetInstance ().CreateDevice (device ))
4386 self ._camera .Open ()
4487
45- # Configure exposure
88+ # Exposure
4689 exposure = self ._settings_value ("exposure" , self .settings .properties )
4790 if exposure is not None :
4891 try :
4992 self ._camera .ExposureTime .SetValue (float (exposure ))
50- actual = self ._camera .ExposureTime .GetValue ()
51- if abs (actual - float (exposure )) > 1.0 : # Allow 1μs tolerance
52- LOG .warning (f"Exposure mismatch: requested { exposure } μs, got { actual } μs" )
53- else :
54- LOG .info (f"Exposure set to { actual } μs" )
55- except Exception as e :
56- LOG .warning (f"Failed to set exposure to { exposure } μs: { e } " )
57-
58- # Configure gain
93+ except Exception :
94+ pass
95+
96+ # Gain
5997 gain = self ._settings_value ("gain" , self .settings .properties )
6098 if gain is not None :
6199 try :
62100 self ._camera .Gain .SetValue (float (gain ))
63- actual = self ._camera .Gain .GetValue ()
64- if abs (actual - float (gain )) > 0.1 : # Allow 0.1 tolerance
65- LOG .warning (f"Gain mismatch: requested { gain } , got { actual } " )
66- else :
67- LOG .info (f"Gain set to { actual } " )
68- except Exception as e :
69- LOG .warning (f"Failed to set gain to { gain } : { e } " )
70-
71- # Configure resolution
72- requested_width , requested_height = self ._resolution
73- try :
74- self ._camera .Width .SetValue (requested_width )
75- self ._camera .Height .SetValue (requested_height )
76- actual_width = self ._camera .Width .GetValue ()
77- actual_height = self ._camera .Height .GetValue ()
78- if actual_width != requested_width or actual_height != requested_height :
79- LOG .warning (
80- f"Resolution mismatch: requested { requested_width } x{ requested_height } , "
81- f"got { actual_width } x{ actual_height } "
82- )
83- else :
84- LOG .info (f"Resolution set to { actual_width } x{ actual_height } " )
85- except Exception as e :
86- LOG .warning (f"Failed to set resolution to { requested_width } x{ requested_height } : { e } " )
101+ except Exception :
102+ pass
103+
104+ # Resolution (device default if None)
105+ self ._configure_resolution ()
87106
88- # Configure frame rate
107+ # Frame rate
89108 fps = self ._settings_value ("fps" , self .settings .properties , fallback = self .settings .fps )
90109 if fps is not None :
91110 try :
92111 self ._camera .AcquisitionFrameRateEnable .SetValue (True )
93112 self ._camera .AcquisitionFrameRate .SetValue (float (fps ))
94- actual_fps = self ._camera .AcquisitionFrameRate .GetValue ()
95- if abs (actual_fps - float (fps )) > 0.1 :
96- LOG .warning (f"FPS mismatch: requested { fps :.2f} , got { actual_fps :.2f} " )
97- else :
98- LOG .info (f"Frame rate set to { actual_fps :.2f} FPS" )
99- except Exception as e :
100- LOG .warning (f"Failed to set frame rate to { fps } : { e } " )
113+ self ._actual_fps = float (self ._camera .AcquisitionFrameRate .GetValue ())
114+ except Exception :
115+ self ._actual_fps = None
116+
117+ # Capture actual resolution even when using defaults
118+ try :
119+ self ._actual_width = int (self ._camera .Width .GetValue ())
120+ self ._actual_height = int (self ._camera .Height .GetValue ())
121+ except Exception :
122+ pass
101123
102124 self ._camera .StartGrabbing (pylon .GrabStrategy_LatestImageOnly )
125+
103126 self ._converter = pylon .ImageFormatConverter ()
104127 self ._converter .OutputPixelFormat = pylon .PixelType_BGR8packed
105128 self ._converter .OutputBitAlignment = pylon .OutputBitAlignment_MsbAligned
106129
107- # Read back final settings
108- try :
109- self .settings .width = int (self ._camera .Width .GetValue ())
110- self .settings .height = int (self ._camera .Height .GetValue ())
111- except Exception :
112- pass
113- try :
114- self .settings .fps = float (self ._camera .ResultingFrameRateAbs .GetValue ())
115- LOG .info (f"Camera configured with resulting FPS: { self .settings .fps :.2f} " )
116- except Exception :
117- pass
118-
119130 def read (self ) -> tuple [np .ndarray , float ]:
120131 if self ._camera is None or self ._converter is None :
121132 raise RuntimeError ("Basler camera not opened" )
@@ -129,6 +140,12 @@ def read(self) -> tuple[np.ndarray, float]:
129140 image = self ._converter .Convert (grab_result )
130141 frame = image .GetArray ()
131142 grab_result .Release ()
143+
144+ if self ._actual_width is None or self ._actual_height is None :
145+ h , w = frame .shape [:2 ]
146+ self ._actual_width = int (w )
147+ self ._actual_height = int (h )
148+
132149 rotate = self ._settings_value ("rotate" , self .settings .properties )
133150 if rotate :
134151 frame = self ._rotate (frame , float (rotate ))
@@ -176,25 +193,61 @@ def _rotate(self, frame: np.ndarray, angle: float) -> np.ndarray:
176193 raise RuntimeError ("Rotation requested for Basler camera but imutils is not installed" ) from exc
177194 return rotate_bound (frame , angle )
178195
179- def _parse_resolution (self , resolution ) -> tuple [int , int ]:
180- """Parse resolution setting.
196+ def _get_requested_resolution_or_none (self ) -> tuple [int , int ] | None :
197+ """
198+ Return (w, h) if user explicitly requested a resolution.
199+ Return None to keep device defaults.
200+ """
201+ props = self .settings .properties if isinstance (self .settings .properties , dict ) else {}
181202
182- Args:
183- resolution: Can be a tuple/list [width, height], or None
203+ legacy = props .get ("resolution" )
204+ if isinstance (legacy , (list , tuple )) and len (legacy ) == 2 :
205+ try :
206+ w , h = int (legacy [0 ]), int (legacy [1 ])
207+ if w > 0 and h > 0 :
208+ return (w , h )
209+ except Exception :
210+ pass
184211
185- Returns:
186- Tuple of (width, height), defaults to (720, 540)
212+ try :
213+ w = int (getattr (self .settings , "width" , 0 ) or 0 )
214+ h = int (getattr (self .settings , "height" , 0 ) or 0 )
215+ if w > 0 and h > 0 :
216+ return (w , h )
217+ except Exception :
218+ pass
219+
220+ return None
221+
222+ def _configure_resolution (self ) -> None :
223+ """
224+ Apply width/height only if explicitly requested.
225+ If None, keep device defaults.
187226 """
188- if resolution is None :
189- return ( 720 , 540 ) # Default resolution
227+ if self . _camera is None :
228+ return
190229
191- if isinstance (resolution , (list , tuple )) and len (resolution ) == 2 :
192- try :
193- return (int (resolution [0 ]), int (resolution [1 ]))
194- except (ValueError , TypeError ):
195- return (720 , 540 )
230+ req = self ._requested_resolution
231+ if req is None :
232+ LOG .info ("Resolution: using device default." )
233+ return
234+
235+ req_w , req_h = req
236+ try :
237+ self ._camera .Width .SetValue (int (req_w ))
238+ self ._camera .Height .SetValue (int (req_h ))
196239
197- return (720 , 540 )
240+ aw = int (self ._camera .Width .GetValue ())
241+ ah = int (self ._camera .Height .GetValue ())
242+ self ._actual_width = aw
243+ self ._actual_height = ah
244+
245+ if (aw , ah ) != (req_w , req_h ):
246+ LOG .warning (f"Resolution mismatch: requested { req_w } x{ req_h } , got { aw } x{ ah } " )
247+ else :
248+ LOG .info (f"Resolution set to { aw } x{ ah } " )
249+ except Exception as exc :
250+ LOG .warning (f"Failed to set resolution to { req_w } x{ req_h } : { exc } " )
198251
199252 @staticmethod
200253 def _settings_value (key : str , source : dict , fallback : float | None = None ):
0 commit comments