11# tests/cameras/backends/conftest.py
22import importlib
33import os
4- from types import SimpleNamespace
54
5+ import numpy as np
66import pytest
77
88
@@ -17,8 +17,25 @@ def _has_module(name: str) -> bool:
1717 return False
1818
1919
20- ARAVIS_AVAILABLE = _has_module ("gi" ) # Aravis via GObject introspection
21- PYPYLON_AVAILABLE = _has_module ("pypylon" ) # Basler pypylon SDK
20+ def _has_aravis_gi () -> bool :
21+ """
22+ GI can exist without the Aravis typelib. Be representative:
23+ check that gi.repository.Aravis is importable and versionable.
24+ """
25+ try :
26+ import gi # type: ignore
27+
28+ gi .require_version ("Aravis" , "0.8" )
29+ from gi .repository import Aravis # noqa: F401
30+
31+ return True
32+ except Exception :
33+ return False
34+
35+
36+ ARAVIS_AVAILABLE = _has_aravis_gi ()
37+ PYPYLON_AVAILABLE = _has_module ("pypylon" )
38+ HARVESTERS_AVAILABLE = _has_module ("harvesters" )
2239
2340
2441# -----------------------------
@@ -109,23 +126,21 @@ def force_aravis_unavailable(monkeypatch):
109126def force_pypylon_unavailable (monkeypatch ):
110127 """
111128 Force Basler/pypylon to be unavailable for error-path testing.
129+ Basler backend availability is based on 'pylon is not None'.
112130 """
113131 try :
114132 import dlclivegui .cameras .backends .basler_backend as bas
115133 except Exception :
116- # If the module doesn't exist in your tree, ignore.
117134 yield
118135 return
119- monkeypatch . setattr ( bas , "PYPYLON_AVAILABLE" , False , raising = False )
136+
120137 monkeypatch .setattr (bas , "pylon" , None , raising = False )
121138 yield
122139
123140
124141# -----------------------------------------------------------------------------
125142# Fake Aravis SDK (module-like) + fixtures
126143# -----------------------------------------------------------------------------
127-
128-
129144class FakeAravis :
130145 """Minimal fake Aravis module used for SDK-less unit/contract tests."""
131146
@@ -495,61 +510,290 @@ def patch_basler_sdk(monkeypatch, fake_pylon_module):
495510
496511
497512# -----------------------------------------------------------------------------
498- # Fake GenTL / harvesters SDK (module-like ) + fixtures
513+ # Fake GenTL / harvesters SDK (open/read/close capable ) + fixtures
499514# -----------------------------------------------------------------------------
500515
501516
502- class FakeHarvesterTimeoutError (TimeoutError ):
517+ class FakeGenTLTimeoutException (TimeoutError ):
518+ """
519+ Representative timeout: Harvesters often surfaces GenTL TimeoutException semantics.
520+ """
521+
503522 pass
504523
505524
525+ class _DeviceInfoAdapter :
526+ """
527+ Make device_info_list entries behave whether they're dict-like or object-like.
528+ """
529+
530+ def __init__ (self , payload ):
531+ self ._payload = payload
532+
533+ def get (self , key , default = None ):
534+ if isinstance (self ._payload , dict ):
535+ return self ._payload .get (key , default )
536+ return getattr (self ._payload , key , default )
537+
538+ @property
539+ def serial_number (self ):
540+ return self .get ("serial_number" , "" )
541+
542+ @property
543+ def vendor (self ):
544+ return self .get ("vendor" , "" )
545+
546+ @property
547+ def model (self ):
548+ return self .get ("model" , "" )
549+
550+ @property
551+ def display_name (self ):
552+ return self .get ("display_name" , "" )
553+
554+
555+ class _FakeNode :
556+ """
557+ Minimal GenICam-style node with .value and optional constraints.
558+ Harvesters exposes nodes as objects; your backend uses:
559+ - node.value
560+ - node.min / node.max / node.inc (for Width/Height)
561+ - PixelFormat.symbolics (for allowed formats)
562+ """
563+
564+ def __init__ (self , value = None , * , min = None , max = None , inc = 1 , symbolics = None ):
565+ self .value = value
566+ self .min = min
567+ self .max = max
568+ self .inc = inc
569+ self .symbolics = symbolics or []
570+
571+
572+ class _FakeNodeMap :
573+ """Provides attribute access for nodes used by GenTLCameraBackend."""
574+
575+ def __init__ (self , * , width = 1920 , height = 1080 , fps = 30.0 , exposure = 10000.0 , gain = 0.0 , pixel_format = "Mono8" ):
576+ # Identification / label fields your _resolve_device_label() tries
577+ self .DeviceModelName = _FakeNode ("FakeGenTLModel" )
578+ self .DeviceSerialNumber = _FakeNode ("FAKE-GENTL-0" )
579+ self .DeviceDisplayName = _FakeNode ("FakeGenTLDisplay" )
580+
581+ # Format + acquisition nodes
582+ self .PixelFormat = _FakeNode (
583+ pixel_format ,
584+ symbolics = ["Mono8" , "Mono16" , "RGB8" , "BGR8" ],
585+ )
586+
587+ # Width/Height with constraints for increment alignment logic
588+ self .Width = _FakeNode (int (width ), min = 64 , max = 4096 , inc = 2 )
589+ self .Height = _FakeNode (int (height ), min = 64 , max = 4096 , inc = 2 )
590+
591+ # FPS related nodes (backend may set AcquisitionFrameRate)
592+ self .AcquisitionFrameRateEnable = _FakeNode (True )
593+ self .AcquisitionFrameRate = _FakeNode (float (fps ))
594+ # backend tries ResultingFrameRate for actual FPS; provide it
595+ self .ResultingFrameRate = _FakeNode (float (fps ))
596+
597+ # Exposure/Gain
598+ self .ExposureAuto = _FakeNode ("Off" )
599+ self .ExposureTime = _FakeNode (float (exposure ))
600+ self .GainAuto = _FakeNode ("Off" )
601+ self .Gain = _FakeNode (float (gain ))
602+
603+
604+ class _FakeRemoteDevice :
605+ def __init__ (self , node_map : _FakeNodeMap ):
606+ self .node_map = node_map
607+
608+
609+ class _FakeComponent :
610+ def __init__ (self , width : int , height : int , channels : int , dtype = np .uint8 ):
611+ self .width = int (width )
612+ self .height = int (height )
613+ self ._channels = int (channels )
614+ self ._dtype = dtype
615+
616+ # Create a deterministic image payload
617+ n = self .width * self .height * self ._channels
618+ if dtype == np .uint8 :
619+ arr = (np .arange (n ) % 255 ).astype (np .uint8 )
620+ else :
621+ # e.g., uint16
622+ arr = (np .arange (n ) % 65535 ).astype (np .uint16 )
623+
624+ # Harvesters often exposes component.data as a buffer-like object;
625+ # your backend does np.asarray(component.data) and may fall back to frombuffer(bytes(...)).
626+ # A numpy array works fine for both.
627+ self .data = arr
628+
629+
630+ class _FakePayload :
631+ def __init__ (self , component : _FakeComponent ):
632+ self .components = [component ]
633+
634+
635+ class _FakeFetchedBufferCtx :
636+ """
637+ Context manager returned by FakeImageAcquirer.fetch().
638+ Must provide .payload with components.
639+ """
640+
641+ def __init__ (self , payload : _FakePayload ):
642+ self .payload = payload
643+
644+ def __enter__ (self ):
645+ return self
646+
647+ def __exit__ (self , exc_type , exc , tb ):
648+ return False
649+
650+
651+ class FakeImageAcquirer :
652+ """
653+ Minimal Harvesters image acquirer:
654+ - remote_device.node_map
655+ - start()/stop()/destroy()
656+ - fetch(timeout=...) -> context manager
657+ - node_map shortcut (your backend uses self._acquirer.node_map in read())
658+ """
659+
660+ def __init__ (self , * , serial = "FAKE-GENTL-0" , width = 1920 , height = 1080 , pixel_format = "Mono8" ):
661+ self .serial = serial
662+ self ._started = False
663+ self ._destroyed = False
664+
665+ # Node map used by open() and read()
666+ self .remote_device = _FakeRemoteDevice (_FakeNodeMap (width = width , height = height , pixel_format = pixel_format ))
667+ self .node_map = self .remote_device .node_map
668+
669+ # Simple FIFO of frames (buffers)
670+ self ._queue : list [_FakePayload ] = []
671+ self ._populate_default_frames ()
672+
673+ def _populate_default_frames (self ):
674+ # Make one frame available by default
675+ pf = str (self .node_map .PixelFormat .value or "Mono8" )
676+ if pf in ("RGB8" , "BGR8" ):
677+ channels = 3
678+ dtype = np .uint8
679+ elif pf == "Mono16" :
680+ channels = 1
681+ dtype = np .uint16
682+ else :
683+ channels = 1
684+ dtype = np .uint8
685+
686+ comp = _FakeComponent (self .node_map .Width .value , self .node_map .Height .value , channels , dtype = dtype )
687+ self ._queue .append (_FakePayload (comp ))
688+
689+ def start (self ):
690+ self ._started = True
691+
692+ def stop (self ):
693+ self ._started = False
694+
695+ def destroy (self ):
696+ self ._destroyed = True
697+
698+ def fetch (self , timeout : float = 2.0 ):
699+ if not self ._started :
700+ raise FakeGenTLTimeoutException ("Acquirer not started" )
701+
702+ if not self ._queue :
703+ raise FakeGenTLTimeoutException (f"Timeout after { timeout } s" )
704+
705+ payload = self ._queue .pop (0 )
706+ return _FakeFetchedBufferCtx (payload )
707+
708+
506709class FakeHarvester :
507710 """
508- Minimal fake for 'from harvesters.core import Harvester' usage.
711+ Minimal fake for 'from harvesters.core import Harvester' supporting:
712+ - add_file/update/reset
713+ - device_info_list for enumeration
714+ - create()/create_image_acquirer() returning FakeImageAcquirer
509715
510- Enough for:
511- - is_available()
512- - get_device_count() flow (Harvester() -> add_file -> update -> device_info_list -> reset)
716+ This enables GenTLCameraBackend.open/read/close paths.
513717 """
514718
515719 def __init__ (self ):
516720 self .device_info_list = []
517721 self ._files = []
722+ self ._acquirers : list [FakeImageAcquirer ] = []
518723
519724 def add_file (self , file_path : str ):
520725 self ._files .append (str (file_path ))
521726
522727 def update (self ):
523- # Expose at least one device info entry
524- self .device_info_list = [SimpleNamespace (serial_number = "FAKE-GENTL-0" )]
728+ # Harvesters tutorial output shows dict-like device entries.
729+ self .device_info_list = [
730+ {
731+ "display_name" : "TLSimuMono (FAKE-GENTL-0)" ,
732+ "model" : "FakeGenTLModel" ,
733+ "vendor" : "FakeVendor" ,
734+ "serial_number" : "FAKE-GENTL-0" ,
735+ "id_" : "FakeDeviceId" ,
736+ "tl_type" : "Custom" ,
737+ "user_defined_name" : "Center" ,
738+ "version" : "1.0.0" ,
739+ }
740+ ]
525741
526742 def reset (self ):
743+ # "release" resources
527744 self .device_info_list = []
528745 self ._files = []
746+ self ._acquirers = []
747+
748+ def create (self , selector = None , index : int | None = None , * args , ** kwargs ):
749+ serial = None
529750
530- # Optional: creation methods referenced by GenTL backend (only needed if you test open())
531- def create (self , * args , ** kwargs ):
532- raise RuntimeError ("FakeHarvester.create() not implemented for open-path tests" )
751+ # Selector dict commonly used: {"serial_number": "..."} [1](https://github.com/genicam/harvesters/issues/454)
752+ if isinstance (selector , dict ):
753+ serial = selector .get ("serial_number" )
754+
755+ if serial is None and index is None :
756+ index = 0
757+
758+ if not self .device_info_list :
759+ self .update ()
760+
761+ if serial is None :
762+ if index is None :
763+ index = 0
764+ if index < 0 or index >= len (self .device_info_list ):
765+ raise RuntimeError ("Index out of range" )
766+ info = _DeviceInfoAdapter (self .device_info_list [index ])
767+ serial = info .serial_number or "FAKE-GENTL-0"
768+
769+ acq = FakeImageAcquirer (serial = serial )
770+ self ._acquirers .append (acq )
771+ return acq
533772
534773 def create_image_acquirer (self , * args , ** kwargs ):
535- raise RuntimeError ("FakeHarvester.create_image_acquirer() not implemented for open-path tests" )
774+ # Alias used by some Harvesters versions; just delegate to create()
775+ return self .create (* args , ** kwargs )
536776
537777
538778@pytest .fixture ()
539779def fake_harvester_class ():
540- """Provides FakeHarvester class (not an instance) for patching gentl backend."""
780+ """Provides FakeHarvester class for patching GenTL backend."""
541781 return FakeHarvester
542782
543783
544784@pytest .fixture ()
545785def patch_gentl_sdk (monkeypatch , fake_harvester_class ):
546- """
547- Patch GenTL backend to behave as if harvesters is installed, using FakeHarvester.
548- """
549786 import dlclivegui .cameras .backends .gentl_backend as gb
550787
551788 monkeypatch .setattr (gb , "Harvester" , fake_harvester_class , raising = False )
552- monkeypatch .setattr (gb , "HarvesterTimeoutError" , FakeHarvesterTimeoutError , raising = False )
789+ monkeypatch .setattr (gb , "HarvesterTimeoutError" , FakeGenTLTimeoutException , raising = False )
790+
791+ # Prevent CTI searching from blocking open/get_device_count
792+ monkeypatch .setattr (gb .GenTLCameraBackend , "_find_cti_file" , lambda self : "dummy.cti" , raising = False )
793+ monkeypatch .setattr (
794+ gb .GenTLCameraBackend , "_search_cti_file" , staticmethod (lambda patterns : "dummy.cti" ), raising = False
795+ )
796+
553797 return fake_harvester_class
554798
555799
0 commit comments