Skip to content

Commit e421029

Browse files
committed
WIP Basler backend: resolution, FPS and capabilities
Refactor Basler camera backend to improve resolution/fps handling and provide capability metadata. Adds OPTIONS_KEY, explicit requested vs actual resolution/fps fields and accessors, and a static_capabilities() method. Resolution is now applied only if explicitly requested (legacy resolution or settings.width/height), otherwise the device default is preserved. Simplifies exposure/gain/fps error handling, captures actual camera width/height/fps on open/read for GUI use, and consolidates resolution configuration into _configure_resolution(). Miscellaneous logging and minor API cleanup to better surface device-reported settings.
1 parent 71ef8d5 commit e421029

1 file changed

Lines changed: 125 additions & 72 deletions

File tree

dlclivegui/cameras/backends/basler_backend.py

Lines changed: 125 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"""Basler camera backend implemented with :mod:`pypylon`."""
22

3+
# dlclivegui/cameras/backends/basler_backend.py
34
from __future__ import annotations
45

56
import logging
67
import time
8+
from typing import ClassVar
79

810
import numpy as np
911

10-
from ..base import CameraBackend, register_backend
12+
from ..base import CameraBackend, SupportLevel, register_backend
1113

1214
LOG = logging.getLogger(__name__)
1315

@@ -21,101 +23,110 @@
2123
class 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

Comments
 (0)