Skip to content

Commit ab3d764

Browse files
committed
Enhance FakePylon and add Basler backend tests
Extend the test conftest FakePylon to better emulate pypylon: add FakePylonTimeoutException, richer _Feature (symbolics, min/max/inc, read/write checks, call tracking), _EnumEntry, expanded _DeviceInfo, GrabResult.release tracking, and a more complete InstantCamera (timeouts, software trigger, trigger/line features, buffer and grab controls, test knobs). Reset the fake factory and provide default fake devices and a basler_settings_factory fixture. Patch the basler SDK fixture to use FakePylon. Add new test suite tests/cameras/backends/test_basler_backend.py covering lifecycle (open/read/close, fast-start, idempotent close), discovery/rebind, resolution/exposure/gain/fps handling, and comprehensive trigger behavior (follower/master/software/external) to validate backend logic.
1 parent d329c66 commit ab3d764

2 files changed

Lines changed: 672 additions & 19 deletions

File tree

tests/cameras/backends/conftest.py

Lines changed: 209 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -381,32 +381,115 @@ def _make(buffers):
381381
# -----------------------------------------------------------------------------
382382

383383

384+
class FakePylonTimeoutException(RuntimeError):
385+
pass
386+
387+
384388
class FakePylon:
385-
"""Minimal fake for 'from pypylon import pylon' usage in basler_backend."""
389+
"""Fake for 'from pypylon import pylon' used by BaslerCameraBackend."""
386390

387-
# Constants used by Basler backend
388391
GrabStrategy_LatestImageOnly = 1
389392
TimeoutHandling_ThrowException = 1
390-
PixelType_BGR8packed = 0x02180014 # arbitrary token
393+
PixelType_BGR8packed = 0x02180014
391394
OutputBitAlignment_MsbAligned = 1
392395

396+
class _EnumEntry:
397+
def __init__(self, symbolic: str):
398+
self._symbolic = symbolic
399+
400+
def GetSymbolic(self):
401+
return self._symbolic
402+
393403
class _Feature:
394-
def __init__(self, value=0):
404+
def __init__(
405+
self,
406+
value=0,
407+
*,
408+
symbolics: list[str] | None = None,
409+
minimum=None,
410+
maximum=None,
411+
increment=1,
412+
writable=True,
413+
readable=True,
414+
):
395415
self._value = value
416+
self._symbolics = list(symbolics or [])
417+
self._min = minimum
418+
self._max = maximum
419+
self._inc = increment
420+
self._writable = writable
421+
self._readable = readable
422+
self.set_calls: list[object] = []
396423

397424
def SetValue(self, v):
425+
if not self._writable:
426+
raise RuntimeError("feature is not writable")
427+
if self._symbolics and v not in self._symbolics:
428+
raise RuntimeError(f"unsupported symbolic {v!r}; available={self._symbolics}")
398429
self._value = v
430+
self.set_calls.append(v)
399431

400432
def GetValue(self):
433+
if not self._readable:
434+
raise RuntimeError("feature is not readable")
401435
return self._value
402436

437+
def GetSymbolics(self):
438+
return list(self._symbolics)
439+
440+
def GetEntries(self):
441+
return [FakePylon._EnumEntry(s) for s in self._symbolics]
442+
443+
def IsWritable(self):
444+
return bool(self._writable)
445+
446+
def IsReadable(self):
447+
return bool(self._readable)
448+
449+
def GetMin(self):
450+
if self._min is None:
451+
raise RuntimeError("no min")
452+
return self._min
453+
454+
def GetMax(self):
455+
if self._max is None:
456+
raise RuntimeError("no max")
457+
return self._max
458+
459+
def GetInc(self):
460+
return self._inc
461+
403462
class _DeviceInfo:
404-
def __init__(self, serial: str):
463+
def __init__(
464+
self,
465+
serial: str,
466+
*,
467+
vendor: str = "Basler",
468+
model: str = "FakeBasler",
469+
friendly: str | None = None,
470+
full_name: str | None = None,
471+
):
405472
self._serial = serial
473+
self._vendor = vendor
474+
self._model = model
475+
self._friendly = friendly or f"{vendor} {model} ({serial})"
476+
self._full_name = full_name or f"FakeFullName-{serial}"
406477

407478
def GetSerialNumber(self):
408479
return self._serial
409480

481+
def GetVendorName(self):
482+
return self._vendor
483+
484+
def GetModelName(self):
485+
return self._model
486+
487+
def GetFriendlyName(self):
488+
return self._friendly
489+
490+
def GetFullName(self):
491+
return self._full_name
492+
410493
class _Device:
411494
def __init__(self, info):
412495
self.info = info
@@ -433,49 +516,120 @@ class _GrabResult:
433516
def __init__(self, ok=True, array=None):
434517
self._ok = ok
435518
self._array = array
519+
self.released = False
436520

437521
def GrabSucceeded(self):
438522
return bool(self._ok)
439523

440524
def Release(self):
441-
return None
525+
self.released = True
442526

443527
class InstantCamera:
444528
def __init__(self, device):
445529
self._device = device
446530
self._open = False
447531
self._grabbing = False
448532

449-
# Feature nodes the backend uses
533+
self.retrieve_calls: list[int] = []
534+
self.start_calls = 0
535+
self.stop_calls = 0
536+
self.close_calls = 0
537+
self.software_trigger_calls = 0
538+
self._software_trigger_pending = 0
539+
540+
# General camera controls.
541+
self.ExposureAuto = FakePylon._Feature("Off", symbolics=["Off", "Once", "Continuous"])
450542
self.ExposureTime = FakePylon._Feature(1000.0)
543+
self.GainAuto = FakePylon._Feature("Off", symbolics=["Off", "Once", "Continuous"])
451544
self.Gain = FakePylon._Feature(0.0)
452-
self.Width = FakePylon._Feature(1920)
453-
self.Height = FakePylon._Feature(1080)
545+
546+
self.Width = FakePylon._Feature(1920, minimum=64, maximum=4096, increment=2)
547+
self.Height = FakePylon._Feature(1080, minimum=64, maximum=4096, increment=2)
454548

455549
self.AcquisitionFrameRateEnable = FakePylon._Feature(False)
456550
self.AcquisitionFrameRate = FakePylon._Feature(30.0)
457551

552+
self.MaxNumBuffer = FakePylon._Feature(10)
553+
554+
# Basler/pypylon trigger features.
555+
self.AcquisitionMode = FakePylon._Feature("Continuous", symbolics=["Continuous", "SingleFrame"])
556+
self.TriggerSelector = FakePylon._Feature("FrameStart", symbolics=["FrameStart"])
557+
self.TriggerMode = FakePylon._Feature("Off", symbolics=["Off", "On"])
558+
self.TriggerSource = FakePylon._Feature(
559+
"Software",
560+
symbolics=[
561+
"Software",
562+
"Line1",
563+
"Line2",
564+
"Line3",
565+
"PeriodicSignal1",
566+
"Action1",
567+
],
568+
)
569+
self.TriggerActivation = FakePylon._Feature(
570+
"RisingEdge",
571+
symbolics=["RisingEdge", "FallingEdge", "AnyEdge", "LevelHigh", "LevelLow"],
572+
)
573+
self.TriggerDelay = FakePylon._Feature(0.0)
574+
575+
# Generic output line features.
576+
self.LineSelector = FakePylon._Feature("Line1", symbolics=["Line1", "Line2", "Line3"])
577+
self.LineMode = FakePylon._Feature("Input", symbolics=["Input", "Output"])
578+
self.LineSource = FakePylon._Feature(
579+
"Off",
580+
symbolics=["Off", "ExposureActive", "AcquisitionActive"],
581+
)
582+
self.LineInverter = FakePylon._Feature(False)
583+
584+
# Test knobs.
585+
self.allow_hardware_trigger_frame = False
586+
self.force_failed_grab = False
587+
458588
def Open(self):
459589
self._open = True
460590

461591
def Close(self):
592+
self.close_calls += 1
462593
self._open = False
463594

464595
def IsOpen(self):
465596
return bool(self._open)
466597

467598
def StartGrabbing(self, *_args, **_kwargs):
599+
self.start_calls += 1
468600
self._grabbing = True
469601

470602
def StopGrabbing(self):
603+
self.stop_calls += 1
471604
self._grabbing = False
472605

473606
def IsGrabbing(self):
474607
return bool(self._grabbing)
475608

476-
def RetrieveResult(self, *_args, **_kwargs):
477-
# Always succeed with a small dummy image (BGR)
478-
import numpy as np
609+
def ExecuteSoftwareTrigger(self):
610+
self.software_trigger_calls += 1
611+
self._software_trigger_pending += 1
612+
613+
def RetrieveResult(self, timeout_ms, *_args, **_kwargs):
614+
self.retrieve_calls.append(int(timeout_ms))
615+
616+
if not self._grabbing:
617+
raise FakePylonTimeoutException("Grab timed out: acquisition not started")
618+
619+
if self.force_failed_grab:
620+
return FakePylon._GrabResult(ok=False, array=None)
621+
622+
trigger_on = self.TriggerMode.GetValue() == "On"
623+
source = self.TriggerSource.GetValue()
624+
625+
if trigger_on:
626+
if source == "Software":
627+
if self._software_trigger_pending <= 0:
628+
raise FakePylonTimeoutException("Grab timed out: waiting for software trigger")
629+
self._software_trigger_pending -= 1
630+
else:
631+
if not self.allow_hardware_trigger_frame:
632+
raise FakePylonTimeoutException("Grab timed out: waiting for hardware trigger")
479633

480634
frame = np.zeros((10, 10, 3), dtype=np.uint8)
481635
return FakePylon._GrabResult(ok=True, array=frame)
@@ -498,25 +652,61 @@ def Convert(self, grab_result):
498652

499653
@pytest.fixture()
500654
def fake_pylon_module():
501-
"""
502-
Returns the FakePylon 'module' and resets singleton devices for isolation.
503-
"""
504-
# reset singleton factory so devices list resets per test
655+
"""Returns fake pylon module and resets fake device inventory."""
505656
FakePylon.TlFactory._instance = None
657+
factory = FakePylon.TlFactory.GetInstance()
658+
factory._devices = [
659+
FakePylon._DeviceInfo("FAKE-BASLER-0"),
660+
FakePylon._DeviceInfo("FAKE-BASLER-1"),
661+
]
506662
return FakePylon
507663

508664

509665
@pytest.fixture()
510666
def patch_basler_sdk(monkeypatch, fake_pylon_module):
511-
"""
512-
Patch Basler backend to behave as if pypylon is installed, using FakePylon.
513-
"""
667+
"""Patch Basler backend to use FakePylon."""
514668
import dlclivegui.cameras.backends.basler_backend as bb
515669

516670
monkeypatch.setattr(bb, "pylon", fake_pylon_module, raising=False)
517671
return fake_pylon_module
518672

519673

674+
@pytest.fixture()
675+
def basler_settings_factory():
676+
from dlclivegui.config import CameraSettings
677+
678+
def _make(
679+
*,
680+
index=0,
681+
name="BaslerTestCam",
682+
width=0,
683+
height=0,
684+
fps=0.0,
685+
exposure=0,
686+
gain=0.0,
687+
enabled=True,
688+
properties=None,
689+
):
690+
props = properties if isinstance(properties, dict) else {}
691+
props.setdefault("basler", {})
692+
props["basler"] = dict(props["basler"])
693+
694+
return CameraSettings(
695+
name=name,
696+
index=index,
697+
backend="basler",
698+
width=width,
699+
height=height,
700+
fps=fps,
701+
exposure=exposure,
702+
gain=gain,
703+
enabled=enabled,
704+
properties=props,
705+
)
706+
707+
return _make
708+
709+
520710
# -----------------------------------------------------------------------------
521711
# Fake GenTL / harvesters SDK (SDK-free) + fixtures for strict lifecycle tests
522712
# -----------------------------------------------------------------------------

0 commit comments

Comments
 (0)