Skip to content

Commit f94484f

Browse files
authored
Upgrade Aravis backend features and testing (#42)
* Enhance Aravis backend device discovery & rebind Add device enumeration and rebinding utilities to the Aravis backend: implement quick_ping, discover_devices, rebind_settings, _arv_snapshot_devices and _safe_str to allow probing and best-effort rebinds without opening cameras. Update open() to record and refresh device identity (device_id, physical id, vendor/model/serial, label) into CameraSettings properties and compute a higher-quality label from the opened camera. Change namespace key usage from camera_id to device_id and import CameraSettings and DetectedCamera to support the new APIs. * Add fake camera SDKs and contract tests Introduce comprehensive test scaffolding for camera backends: add fake SDK implementations and patching fixtures in tests/cameras/backends/conftest.py (FakeAravis, FakePylon, FakeHarvester and helpers to monkeypatch aravis/pypylon/harvesters). Add backend-agnostic contract tests in tests/cameras/backends/test_generic_contracts.py to validate backend capability shapes, availability reporting, safe discovery, create/close semantics, optional accelerator warnings, and a GUI identity helper. These changes enable running backend contract tests in CI without real SDKs and help ensure consistent backend behavior. * Enhance fake GenTL/Aravis test fixtures Make test backend mocks more robust for SDK-less unit tests: add numpy import and a stricter Aravis availability check (require gi and Aravis typelib), expose HARVESTERS_AVAILABLE, and refine force_pypylon_unavailable to set pylon=None. Replace the simple FakeHarvester with a richer fake GenTL implementation (device info adapter, node/node_map, image acquirer, payload/components, timeout exception) that supports create()/create_image_acquirer(), start/stop/fetch and realistic buffer payloads. Patch GenTLCameraBackend to avoid CTI file searching during tests. Also remove pytest.mark.integration markers from many aravis tests so they run as unit tests.
1 parent 05c66cb commit f94484f

File tree

4 files changed

+1199
-22
lines changed

4 files changed

+1199
-22
lines changed

dlclivegui/cameras/backends/aravis_backend.py

Lines changed: 282 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
import cv2
1111
import numpy as np
1212

13+
from ...config import CameraSettings
1314
from ..base import CameraBackend, SupportLevel, register_backend
15+
from ..factory import DetectedCamera
1416

1517
LOG = logging.getLogger(__name__)
1618

@@ -40,7 +42,7 @@ def __init__(self, settings):
4042
if not isinstance(ns, dict):
4143
ns = {}
4244

43-
self._camera_id: str | None = ns.get("camera_id") or props.get("camera_id")
45+
self._camera_id: str | None = ns.get("device_id") or props.get("device_id")
4446
self._pixel_format: str = ns.get("pixel_format") or props.get("pixel_format", "Mono8")
4547
self._timeout: int = int(ns.get("timeout", props.get("timeout", 2_000_000)))
4648
self._n_buffers: int = int(ns.get("n_buffers", props.get("n_buffers", 10)))
@@ -103,6 +105,153 @@ def get_device_count(cls) -> int:
103105
except Exception:
104106
return -1
105107

108+
@classmethod
109+
def quick_ping(cls, index: int, *_args, **_kwargs) -> bool:
110+
"""
111+
Cheap presence test for CameraFactory probing.
112+
Uses update_device_list() then bounds-check.
113+
"""
114+
if not ARAVIS_AVAILABLE:
115+
return False
116+
try:
117+
Aravis.update_device_list()
118+
n = int(Aravis.get_n_devices() or 0)
119+
return 0 <= int(index) < n
120+
except Exception:
121+
return False
122+
123+
@classmethod
124+
def discover_devices(cls, max_devices: int = 10, should_cancel=None, progress_cb=None):
125+
if not ARAVIS_AVAILABLE:
126+
return []
127+
128+
# Refresh list once; indices may change after update_device_list()
129+
Aravis.update_device_list()
130+
131+
snap = cls._arv_snapshot_devices(limit=max_devices)
132+
133+
cams: list[DetectedCamera] = []
134+
for d in snap:
135+
if should_cancel and should_cancel():
136+
break
137+
if progress_cb:
138+
progress_cb(f"Found {d['label']}")
139+
140+
path = d.get("physical_id") or d.get("address")
141+
142+
cams.append(
143+
DetectedCamera(
144+
index=int(d["index"]),
145+
label=str(d["label"]),
146+
device_id=d.get("device_id"),
147+
path=path,
148+
)
149+
)
150+
return cams
151+
152+
@classmethod
153+
def rebind_settings(cls, settings: CameraSettings) -> CameraSettings:
154+
"""
155+
Best-effort quick rebind using only Aravis enumeration APIs (no camera open).
156+
Indices may change after Aravis.update_device_list().
157+
"""
158+
if not ARAVIS_AVAILABLE:
159+
return settings
160+
161+
props = settings.properties if isinstance(settings.properties, dict) else {}
162+
ns = props.get(cls.OPTIONS_KEY, {}) if isinstance(props.get(cls.OPTIONS_KEY), dict) else {}
163+
164+
# Stored identifiers (some may be missing)
165+
stored_device_id = cls._safe_str(
166+
ns.get("device_id") or props.get("device_id") or ns.get("camera_id") or props.get("camera_id")
167+
)
168+
stored_physical = cls._safe_str(
169+
ns.get("device_physical_id") or ns.get("device_path") or props.get("device_path")
170+
)
171+
stored_vendor = cls._safe_str(ns.get("device_vendor"))
172+
stored_model = cls._safe_str(ns.get("device_model"))
173+
stored_serial = cls._safe_str(ns.get("device_serial_nbr") or ns.get("device_serial"))
174+
stored_name = cls._safe_str(ns.get("device_name"))
175+
176+
# Nothing to rebind with
177+
if not any(
178+
[stored_device_id, stored_physical, (stored_vendor and stored_model and stored_serial), stored_name]
179+
):
180+
return settings
181+
182+
try:
183+
Aravis.update_device_list() # must be called before get_device_*
184+
snap = cls._arv_snapshot_devices(limit=None)
185+
186+
# 1) device_id exact match (fast)
187+
chosen = None
188+
if stored_device_id:
189+
for d in snap:
190+
if d.get("device_id") == stored_device_id:
191+
chosen = d
192+
break
193+
194+
# 2) physical_id exact match
195+
if chosen is None and stored_physical:
196+
for d in snap:
197+
if d.get("physical_id") == stored_physical or d.get("address") == stored_physical:
198+
chosen = d
199+
break
200+
201+
# 3) vendor/model/serial exact triple match
202+
if chosen is None and stored_vendor and stored_model and stored_serial:
203+
for d in snap:
204+
if (d.get("vendor"), d.get("model"), d.get("serial")) == (
205+
stored_vendor,
206+
stored_model,
207+
stored_serial,
208+
):
209+
chosen = d
210+
break
211+
212+
# 4) name substring match against computed label
213+
if chosen is None and stored_name:
214+
needle = stored_name.lower()
215+
for d in snap:
216+
label = (d.get("label") or "").lower()
217+
if needle and needle in label:
218+
chosen = d
219+
break
220+
221+
# 5) fallback to current index if still plausible
222+
if chosen is None:
223+
idx = int(getattr(settings, "index", 0) or 0)
224+
if 0 <= idx < len(snap):
225+
chosen = snap[idx]
226+
else:
227+
return settings
228+
229+
# Apply new index
230+
settings.index = int(chosen["index"])
231+
232+
# Refresh namespace fields (keeps GUI stable identity fresh)
233+
if isinstance(settings.properties, dict):
234+
out = settings.properties.setdefault(cls.OPTIONS_KEY, {})
235+
if isinstance(out, dict):
236+
out["device_id"] = chosen.get("device_id")
237+
out["device_physical_id"] = chosen.get("physical_id")
238+
out["device_vendor"] = chosen.get("vendor")
239+
out["device_model"] = chosen.get("model")
240+
out["device_serial_nbr"] = chosen.get("serial")
241+
out["device_protocol"] = chosen.get("protocol")
242+
out["device_address"] = chosen.get("address")
243+
out["device_name"] = chosen.get("label") # computed label (no open)
244+
245+
# also keep 'device_path' aligned with physical id for GUI fallback
246+
if chosen.get("physical_id"):
247+
out["device_path"] = chosen.get("physical_id")
248+
249+
return settings
250+
251+
except Exception:
252+
# Never hard-fail creation just because rebinding couldn't happen
253+
return settings
254+
106255
def open(self) -> None:
107256
if not ARAVIS_AVAILABLE:
108257
raise RuntimeError("Aravis library not available")
@@ -120,11 +269,68 @@ def open(self) -> None:
120269
raise RuntimeError(f"Camera index {index} out of range for {n_devices} Aravis device(s)")
121270
camera_id = Aravis.get_device_id(index)
122271
self._camera = Aravis.Camera.new(camera_id)
272+
self._camera_id = self._safe_str(camera_id)
123273

124274
if self._camera is None:
125275
raise RuntimeError("Failed to open Aravis camera")
126276

277+
# --- Refresh identity and align index (best-effort, no heavy open needed) ---
278+
try:
279+
snap = self._arv_snapshot_devices(limit=None)
280+
281+
opened_id = self._camera_id
282+
if opened_id is None:
283+
# Opened by index
284+
try:
285+
opened_id = self._safe_str(Aravis.get_device_id(int(self.settings.index)))
286+
except Exception:
287+
opened_id = None
288+
289+
chosen = None
290+
if opened_id:
291+
for d in snap:
292+
if d.get("device_id") == opened_id:
293+
chosen = d
294+
break
295+
296+
# If we found it, align settings.index and refresh identity cache
297+
if chosen:
298+
self.settings.index = int(chosen["index"])
299+
if isinstance(self.settings.properties, dict):
300+
ns = self.settings.properties.setdefault(self.OPTIONS_KEY, {})
301+
if isinstance(ns, dict):
302+
ns["device_id"] = chosen.get("device_id")
303+
ns["device_physical_id"] = chosen.get("physical_id")
304+
ns["device_vendor"] = chosen.get("vendor")
305+
ns["device_model"] = chosen.get("model")
306+
ns["device_serial_nbr"] = chosen.get("serial")
307+
ns["device_protocol"] = chosen.get("protocol")
308+
ns["device_address"] = chosen.get("address")
309+
ns["device_path"] = chosen.get("physical_id") or chosen.get("address")
310+
else:
311+
if isinstance(self.settings.properties, dict):
312+
ns = self.settings.properties.setdefault(self.OPTIONS_KEY, {})
313+
if isinstance(ns, dict):
314+
ns["device_id"] = opened_id
315+
except Exception:
316+
pass
317+
318+
# Compute higher-quality label from the opened camera object
127319
self._device_label = self._resolve_device_label()
320+
# Always populate minimal identity into backend namespace for GUI
321+
if isinstance(self.settings.properties, dict):
322+
ns = self.settings.properties.setdefault(self.OPTIONS_KEY, {})
323+
if isinstance(ns, dict):
324+
# Always write a device_id after a successful open
325+
try:
326+
if self._camera_id:
327+
ns["device_id"] = self._camera_id
328+
else:
329+
ns["device_id"] = self._safe_str(Aravis.get_device_id(int(self.settings.index)))
330+
except Exception:
331+
pass
332+
if self._device_label:
333+
ns["device_name"] = self._device_label
128334

129335
self._configure_pixel_format()
130336
self._configure_resolution()
@@ -261,6 +467,81 @@ def device_name(self) -> str:
261467
# ------------------------------------------------------------------
262468
# Configuration helpers
263469
# ------------------------------------------------------------------
470+
@staticmethod
471+
def _safe_str(x) -> str | None:
472+
try:
473+
if x is None:
474+
return None
475+
s = str(x).strip()
476+
return s if s else None
477+
except Exception:
478+
return None
479+
480+
@classmethod
481+
def _arv_snapshot_devices(cls, limit: int | None = None) -> list[dict]:
482+
"""
483+
Fast snapshot of the current Aravis device list without opening cameras.
484+
Requires Aravis.update_device_list() before calling.
485+
"""
486+
n = int(Aravis.get_n_devices() or 0) # valid until next update_device_list()
487+
if limit is not None:
488+
n = min(n, int(limit))
489+
490+
devices: list[dict] = []
491+
for i in range(n):
492+
try:
493+
dev_id = cls._safe_str(Aravis.get_device_id(i))
494+
except Exception:
495+
dev_id = None
496+
497+
try:
498+
physical = cls._safe_str(Aravis.get_device_physical_id(i))
499+
except Exception:
500+
physical = None
501+
try:
502+
vendor = cls._safe_str(Aravis.get_device_vendor(i))
503+
except Exception:
504+
vendor = None
505+
try:
506+
model = cls._safe_str(Aravis.get_device_model(i))
507+
except Exception:
508+
model = None
509+
try:
510+
serial = cls._safe_str(Aravis.get_device_serial_nbr(i))
511+
except Exception:
512+
serial = None
513+
try:
514+
protocol = cls._safe_str(Aravis.get_device_protocol(i))
515+
except Exception:
516+
protocol = None
517+
try:
518+
address = cls._safe_str(Aravis.get_device_address(i))
519+
except Exception:
520+
address = None
521+
522+
# Construct a stable-ish human label without opening the camera
523+
label_parts = [p for p in (vendor, model) if p]
524+
label = " ".join(label_parts) if label_parts else None
525+
if serial:
526+
label = f"{label} ({serial})" if label else f"({serial})"
527+
if not label:
528+
label = dev_id or f"Aravis #{i}"
529+
530+
devices.append(
531+
{
532+
"index": int(i),
533+
"device_id": dev_id,
534+
"physical_id": physical,
535+
"vendor": vendor,
536+
"model": model,
537+
"serial": serial,
538+
"protocol": protocol,
539+
"address": address,
540+
"label": label,
541+
}
542+
)
543+
return devices
544+
264545
def _get_requested_resolution_or_none(self) -> tuple[int, int] | None:
265546
"""
266547
Return (w, h) if user explicitly requested a resolution.

0 commit comments

Comments
 (0)