Skip to content

Commit 3ea06cc

Browse files
committed
Aravis backend: resolution & settings namespace
Parse an 'aravis' options namespace from settings and add resolution handling/reporting. Introduces OPTIONS_KEY, requested vs actual resolution and actual_fps properties, and sets resolution only when explicitly requested; captures device-reported Width/Height/AcquisitionFrameRate. Adds static_capabilities entries and improves error messaging. Tests updated: FakeAravis gains GenICam-style get/set integer/float feature access, tests now use properties['aravis'], and new unit/integration tests cover device-default and requested resolution behavior.
1 parent 8787a53 commit 3ea06cc

2 files changed

Lines changed: 224 additions & 30 deletions

File tree

dlclivegui/cameras/backends/aravis_backend.py

Lines changed: 127 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
"""Aravis backend for GenICam cameras."""
22

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

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

810
import cv2
911
import numpy as np
1012

11-
from ..base import CameraBackend, register_backend
13+
from ..base import CameraBackend, SupportLevel, register_backend
1214

1315
LOG = logging.getLogger(__name__)
1416

@@ -28,23 +30,64 @@
2830
class AravisCameraBackend(CameraBackend):
2931
"""Capture frames from GenICam-compatible devices via Aravis."""
3032

33+
OPTIONS_KEY: ClassVar[str] = "aravis"
34+
3135
def __init__(self, settings):
3236
super().__init__(settings)
33-
props = settings.properties
34-
self._camera_id: str | None = props.get("camera_id")
35-
self._pixel_format: str = props.get("pixel_format", "Mono8")
36-
self._timeout: int = int(props.get("timeout", 2000000)) # microseconds
37-
self._n_buffers: int = int(props.get("n_buffers", 10))
37+
38+
props = settings.properties if isinstance(settings.properties, dict) else {}
39+
ns = props.get(self.OPTIONS_KEY, {})
40+
if not isinstance(ns, dict):
41+
ns = {}
42+
43+
self._camera_id: str | None = ns.get("camera_id") or props.get("camera_id")
44+
self._pixel_format: str = ns.get("pixel_format") or props.get("pixel_format", "Mono8")
45+
self._timeout: int = int(ns.get("timeout", props.get("timeout", 2_000_000)))
46+
self._n_buffers: int = int(ns.get("n_buffers", props.get("n_buffers", 10)))
47+
48+
# Resolution handling
49+
self._requested_resolution: tuple[int, int] | None = self._get_requested_resolution_or_none()
50+
self._actual_width: int | None = None
51+
self._actual_height: int | None = None
52+
self._actual_fps: float | None = None
3853

3954
self._camera = None
4055
self._stream = None
4156
self._device_label: str | None = None
4257

58+
@property
59+
def actual_resolution(self) -> tuple[int, int] | None:
60+
"""Return the actual resolution of the camera after opening."""
61+
if self._actual_width is not None and self._actual_height is not None:
62+
return (self._actual_width, self._actual_height)
63+
return None
64+
65+
@property
66+
def actual_fps(self) -> float | None:
67+
"""Return the actual frame rate of the camera after opening."""
68+
return self._actual_fps
69+
4370
@classmethod
4471
def is_available(cls) -> bool:
4572
"""Check if Aravis is available on this system."""
4673
return ARAVIS_AVAILABLE
4774

75+
@classmethod
76+
def static_capabilities(cls) -> dict[str, SupportLevel]:
77+
"""Return a dict describing supported features for UI purposes."""
78+
caps = super().static_capabilities()
79+
caps.update(
80+
{
81+
"set_resolution": SupportLevel.SUPPORTED,
82+
"set_fps": SupportLevel.SUPPORTED,
83+
"set_exposure": SupportLevel.SUPPORTED,
84+
"set_gain": SupportLevel.SUPPORTED,
85+
"device_discovery": SupportLevel.SUPPORTED,
86+
"stable_identity": SupportLevel.SUPPORTED,
87+
}
88+
)
89+
return caps
90+
4891
@classmethod
4992
def get_device_count(cls) -> int:
5093
"""Get the actual number of Aravis devices detected.
@@ -61,54 +104,54 @@ def get_device_count(cls) -> int:
61104
return -1
62105

63106
def open(self) -> None:
64-
"""Open the Aravis camera device."""
65-
if not ARAVIS_AVAILABLE: # pragma: no cover - optional dependency
66-
raise RuntimeError(
67-
"The 'aravis' library is required for the Aravis backend. "
68-
"Install it via your system package manager (e.g., 'sudo apt install gir1.2-aravis-0.8' on Ubuntu)."
69-
)
70-
71-
# Update device list
107+
if not ARAVIS_AVAILABLE:
108+
raise RuntimeError("Aravis library not available")
109+
72110
Aravis.update_device_list()
73111
n_devices = Aravis.get_n_devices()
74-
75112
if n_devices == 0:
76113
raise RuntimeError("No Aravis cameras detected")
77114

78-
# Open camera by ID or index
79115
if self._camera_id:
80116
self._camera = Aravis.Camera.new(self._camera_id)
81-
if self._camera is None:
82-
raise RuntimeError(f"Failed to open camera with ID '{self._camera_id}'")
83117
else:
84118
index = int(self.settings.index or 0)
85119
if index < 0 or index >= n_devices:
86120
raise RuntimeError(f"Camera index {index} out of range for {n_devices} Aravis device(s)")
87121
camera_id = Aravis.get_device_id(index)
88122
self._camera = Aravis.Camera.new(camera_id)
89-
if self._camera is None:
90-
raise RuntimeError(f"Failed to open camera at index {index}")
91123

92-
# Get device information for label
124+
if self._camera is None:
125+
raise RuntimeError("Failed to open Aravis camera")
126+
93127
self._device_label = self._resolve_device_label()
94128

95-
# Configure camera
96129
self._configure_pixel_format()
130+
self._configure_resolution()
97131
self._configure_exposure()
98132
self._configure_gain()
99133
self._configure_frame_rate()
100134

101-
# Create stream
135+
# Capture actual resolution even when using defaults
136+
try:
137+
self._actual_width = int(self._camera.get_integer("Width"))
138+
self._actual_height = int(self._camera.get_integer("Height"))
139+
except Exception:
140+
pass
141+
142+
try:
143+
self._actual_fps = float(self._camera.get_float("AcquisitionFrameRate"))
144+
except Exception:
145+
self._actual_fps = None
146+
102147
self._stream = self._camera.create_stream(None, None)
103148
if self._stream is None:
104149
raise RuntimeError("Failed to create Aravis stream")
105150

106-
# Push buffers to stream
107151
payload_size = self._camera.get_payload()
108152
for _ in range(self._n_buffers):
109153
self._stream.push_buffer(Aravis.Buffer.new_allocate(payload_size))
110154

111-
# Start acquisition
112155
self._camera.start_acquisition()
113156

114157
def read(self) -> tuple[np.ndarray, float]:
@@ -136,6 +179,10 @@ def read(self) -> tuple[np.ndarray, float]:
136179
height = buffer.get_image_height()
137180
pixel_format = buffer.get_image_pixel_format()
138181

182+
if self._actual_width is None or self._actual_height is None:
183+
self._actual_width = int(width)
184+
self._actual_height = int(height)
185+
139186
# Convert to numpy array
140187
if pixel_format == Aravis.PIXEL_FORMAT_MONO_8:
141188
frame = np.frombuffer(data, dtype=np.uint8).reshape((height, width))
@@ -214,6 +261,61 @@ def device_name(self) -> str:
214261
# ------------------------------------------------------------------
215262
# Configuration helpers
216263
# ------------------------------------------------------------------
264+
def _get_requested_resolution_or_none(self) -> tuple[int, int] | None:
265+
"""
266+
Return (w, h) if user explicitly requested a resolution.
267+
Return None to keep device defaults.
268+
"""
269+
props = self.settings.properties if isinstance(self.settings.properties, dict) else {}
270+
271+
legacy = props.get("resolution")
272+
if isinstance(legacy, (list, tuple)) and len(legacy) == 2:
273+
try:
274+
w, h = int(legacy[0]), int(legacy[1])
275+
if w > 0 and h > 0:
276+
return (w, h)
277+
except Exception:
278+
pass
279+
280+
try:
281+
w = int(getattr(self.settings, "width", 0) or 0)
282+
h = int(getattr(self.settings, "height", 0) or 0)
283+
if w > 0 and h > 0:
284+
return (w, h)
285+
except Exception:
286+
pass
287+
288+
return None
289+
290+
def _configure_resolution(self) -> None:
291+
"""
292+
Apply width/height only if explicitly requested.
293+
If None, keep device defaults.
294+
"""
295+
if self._camera is None:
296+
return
297+
298+
req = self._requested_resolution
299+
if req is None:
300+
LOG.info("Resolution: using device default.")
301+
return
302+
303+
req_w, req_h = req
304+
try:
305+
self._camera.set_integer("Width", int(req_w))
306+
self._camera.set_integer("Height", int(req_h))
307+
308+
aw = int(self._camera.get_integer("Width"))
309+
ah = int(self._camera.get_integer("Height"))
310+
self._actual_width = aw
311+
self._actual_height = ah
312+
313+
if (aw, ah) != (req_w, req_h):
314+
LOG.warning(f"Resolution mismatch: requested {req_w}x{req_h}, got {aw}x{ah}")
315+
else:
316+
LOG.info(f"Resolution set to {aw}x{ah}")
317+
except Exception as exc:
318+
LOG.warning(f"Failed to set resolution to {req_w}x{req_h}: {exc}")
217319

218320
def _configure_pixel_format(self) -> None:
219321
"""Configure the camera pixel format."""

tests/cameras/backends/test_aravis_backend.py

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,32 @@ def __init__(self, device_id="dev0"):
6565
self.payload = 100
6666
self.stream = None # should be a FakeStream
6767

68+
# Device default "features"
69+
self._features_int = {
70+
"Width": 1920,
71+
"Height": 1080,
72+
}
73+
self._features_float = {
74+
"AcquisitionFrameRate": 30.0,
75+
}
76+
6877
@classmethod
6978
def new(cls, device_id):
7079
return cls(device_id)
7180

81+
# GenICam feature-style access used by backend
82+
def set_integer(self, name: str, value: int):
83+
self._features_int[name] = int(value)
84+
85+
def get_integer(self, name: str) -> int:
86+
return int(self._features_int[name])
87+
88+
def set_float(self, name: str, value: float):
89+
self._features_float[name] = float(value)
90+
91+
def get_float(self, name: str) -> float:
92+
return float(self._features_float[name])
93+
7294
# Pixel format
7395
def set_pixel_format(self, fmt):
7496
self.pixel_format = fmt
@@ -96,9 +118,10 @@ def set_gain(self, v):
96118
def get_gain(self):
97119
return self._gain
98120

99-
# FPS
121+
# FPS (legacy methods still used by your backend)
100122
def set_frame_rate(self, v):
101123
self._fps = v
124+
self._features_float["AcquisitionFrameRate"] = float(v)
102125

103126
def get_frame_rate(self):
104127
return self._fps
@@ -118,7 +141,6 @@ def get_payload(self):
118141
return self.payload
119142

120143
def create_stream(self, *_):
121-
# In tests we often set self.stream in advance
122144
return self.stream
123145

124146
def start_acquisition(self):
@@ -179,14 +201,26 @@ def push_buffer(self, buf):
179201
class Settings:
180202
"""Mimic the settings object used by CameraBackend."""
181203

182-
def __init__(self, properties=None, index=0, exposure=0, gain=0.0, fps=None, name="Test"):
204+
def __init__(
205+
self,
206+
properties=None,
207+
index=0,
208+
exposure=0,
209+
gain=0.0,
210+
fps=None,
211+
width=0,
212+
height=0,
213+
name="Test",
214+
):
183215
self.properties = properties or {}
184216
self.index = index
185217
self.exposure = exposure
186218
self.gain = gain
187219
self.fps = fps
220+
self.width = width
221+
self.height = height
188222
self.name = name
189-
self.backend = "aravis" # for completeness
223+
self.backend = "aravis"
190224

191225

192226
def make_backend(settings, buffers):
@@ -404,7 +438,7 @@ def new_camera(device_id):
404438

405439
# Use a pixel_format and runtime settings to test configuration calls
406440
settings = Settings(
407-
properties={"pixel_format": "Mono8", "n_buffers": 4}, # speed up test
441+
properties={"aravis": {"pixel_format": "Mono8", "n_buffers": 4}}, # speed up test
408442
index=0,
409443
fps=15.0,
410444
exposure=1200.0,
@@ -431,6 +465,64 @@ def new_camera(device_id):
431465
be.close()
432466

433467

468+
@pytest.mark.unit
469+
@pytest.mark.integration
470+
def test_open_device_default_resolution_sets_actual_resolution(monkeypatch):
471+
import dlclivegui.cameras.backends.aravis_backend as ar
472+
473+
monkeypatch.setattr(ar, "ARAVIS_AVAILABLE", True, raising=False)
474+
monkeypatch.setattr(ar, "Aravis", FakeAravis, raising=False)
475+
476+
cam = FakeAravis.Camera("dev0")
477+
cam.set_integer("Width", 1600)
478+
cam.set_integer("Height", 900)
479+
stream = FakeStream([])
480+
cam.stream = stream
481+
482+
monkeypatch.setattr(FakeAravis.Camera, "new", staticmethod(lambda device_id: cam))
483+
484+
settings = Settings(
485+
properties={"aravis": {"pixel_format": "Mono8", "n_buffers": 1}},
486+
index=0,
487+
width=0,
488+
height=0,
489+
)
490+
be = AravisCameraBackend(settings)
491+
be.open()
492+
493+
assert be.actual_resolution == (1600, 900)
494+
be.close()
495+
496+
497+
@pytest.mark.unit
498+
@pytest.mark.integration
499+
def test_open_requested_resolution_applies_and_reports_actual(monkeypatch):
500+
import dlclivegui.cameras.backends.aravis_backend as ar
501+
502+
monkeypatch.setattr(ar, "ARAVIS_AVAILABLE", True, raising=False)
503+
monkeypatch.setattr(ar, "Aravis", FakeAravis, raising=False)
504+
505+
cam = FakeAravis.Camera("dev0")
506+
stream = FakeStream([])
507+
cam.stream = stream
508+
509+
monkeypatch.setattr(FakeAravis.Camera, "new", staticmethod(lambda device_id: cam))
510+
511+
settings = Settings(
512+
properties={"aravis": {"pixel_format": "Mono8", "n_buffers": 1}},
513+
index=0,
514+
width=640,
515+
height=480,
516+
)
517+
be = AravisCameraBackend(settings)
518+
be.open()
519+
520+
assert cam.get_integer("Width") == 640
521+
assert cam.get_integer("Height") == 480
522+
assert be.actual_resolution == (640, 480)
523+
be.close()
524+
525+
434526
@pytest.mark.unit
435527
@pytest.mark.integration
436528
def test_close_flushes_stream_and_clears_state(monkeypatch):

0 commit comments

Comments
 (0)