From f3148c897c482471eaa3264d86189a226f38b504 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 23 Jul 2026 08:06:45 +0200 Subject: [PATCH 1/8] feat(devices): add native vmbpy integration for Allied Vision Alvium camera Adds AlliedVisionVmbCamera, talking to the Alvium G1-507m directly over GigE Vision via vmbpy (Vimba X SDK) instead of the existing dead EPICS IOC-based AlliedVisionCamera, which is left in place for now. Includes the low-level Camera wrapper, simulation backend, device config example, tests, and a README covering setup/testing/vmbpy install steps. Co-Authored-By: Claude Sonnet 5 --- csaxs_bec/device_configs/bl_detectors.yaml | 11 + .../devices/allied_vision_cameras/README.md | 153 +++++++++++ .../devices/allied_vision_cameras/__init__.py | 1 + .../allied_vision_vmb_camera.py | 248 ++++++++++++++++++ .../base_integration/__init__.py | 0 .../base_integration/camera.py | 209 +++++++++++++++ csaxs_bec/devices/sim/sim_cameras.py | 80 +++++- pyproject.toml | 1 + .../test_allied_vision_vmb_camera.py | 105 ++++++++ 9 files changed, 806 insertions(+), 2 deletions(-) create mode 100644 csaxs_bec/devices/allied_vision_cameras/README.md create mode 100644 csaxs_bec/devices/allied_vision_cameras/__init__.py create mode 100644 csaxs_bec/devices/allied_vision_cameras/allied_vision_vmb_camera.py create mode 100644 csaxs_bec/devices/allied_vision_cameras/base_integration/__init__.py create mode 100644 csaxs_bec/devices/allied_vision_cameras/base_integration/camera.py create mode 100644 tests/tests_devices/test_allied_vision_vmb_camera.py diff --git a/csaxs_bec/device_configs/bl_detectors.yaml b/csaxs_bec/device_configs/bl_detectors.yaml index a7cb3e2..a39d23e 100644 --- a/csaxs_bec/device_configs/bl_detectors.yaml +++ b/csaxs_bec/device_configs/bl_detectors.yaml @@ -33,3 +33,14 @@ eiger_1_5: # readoutPriority: async # softwareTrigger: True +# allied_vision_cam: +# description: AlliedVision Alvium G1-507m camera for live image acquisition (native vmbpy/GigE Vision, no EPICS IOC) +# deviceClass: csaxs_bec.devices.allied_vision_cameras.AlliedVisionVmbCamera +# deviceConfig: +# camera_id: "DEV_1AB22C000000" # GigE ID/serial string; resolved via discovery, independent of current IP +# live_mode: True +# onFailure: raise +# enabled: true +# readoutPriority: async +# softwareTrigger: True + diff --git a/csaxs_bec/devices/allied_vision_cameras/README.md b/csaxs_bec/devices/allied_vision_cameras/README.md new file mode 100644 index 0000000..a8c189e --- /dev/null +++ b/csaxs_bec/devices/allied_vision_cameras/README.md @@ -0,0 +1,153 @@ +# Allied Vision camera (native vmbpy integration) + +`AlliedVisionVmbCamera` is the native-Python replacement for the old EPICS/Vimba-IOC +`AlliedVisionCamera` (`csaxs_bec/devices/epics/allied_vision_camera.py`, still present but +unused/unwired). It talks to Allied Vision GigE cameras (e.g. the Alvium G1-507m, mono, +Sony IMX264) directly via **vmbpy**, the vendor's Python bindings for the Vimba X SDK — no +EPICS IOC or areaDetector driver involved. + +``` +allied_vision_cameras/ +├── __init__.py # exports AlliedVisionVmbCamera +├── allied_vision_vmb_camera.py # ophyd device (PSIDeviceBase) +└── base_integration/ + └── camera.py # low-level vmbpy wrapper (class Camera) +``` + +Simulation counterpart: `SimAlliedVisionVmbCamera` in `csaxs_bec/devices/sim/sim_cameras.py` +(same device, `self.cam` swapped for a synthetic-frame backend — no hardware or vmbpy +needed). + +## Packages required + +- **Unit tests / import** (`tests/tests_devices/test_allied_vision_vmb_camera.py`): nothing + extra. `Camera.__init__` guards the `import vmbpy` and the tests mock `camera.cam` + directly, so `pytest tests/tests_devices/test_allied_vision_vmb_camera.py` runs on a + plain dev environment, same as the IDS camera tests run without `pyueye`. +- **Talking to real hardware** — two things, both local to the machine running the device + server (or wherever you instantiate `AlliedVisionVmbCamera`/`Camera` directly): + 1. The `vmbpy` Python package. + 2. The **Vimba X SDK runtime** (GenTL producer / transport layer `vmbpy` links against at + runtime). The pip package alone does not include this — same class of local + dependency as the DLLs `pyueye` needs for the IDS cameras. + +### Installing vmbpy + the Vimba X runtime (Linux) + +`vmbpy` is already listed in `pyproject.toml`, but `pip install vmbpy` from PyPI alone +pulls in a package that has **no camera drivers/transport layers** — it can be imported +but `VmbSystem.get_instance()` won't find any cameras until the SDK runtime below is also +installed. There are two ways to get a working install: + +1. **Install the Vimba X SDK first, then use the `.whl` it ships with** (recommended — + this `.whl` bundles the VmbC libraries so you don't need a separate runtime step for + the Python bindings): + - Download the Vimba X SDK for Linux from Allied Vision + (alliedvision.com → Support → Software → *Vimba X SDK*; free account required). + - Extract it somewhere you have write access, e.g. + `tar -xf VimbaX_Setup-_Linux_x86_64.tar.gz -C /opt/vimbax`. + - Register the GenTL transport layer path so cameras can be discovered: + ```bash + cd /opt/vimbax/VimbaX_*/cti + sudo ./Install_GenTL_Path.sh # writes GENICAM_GENTL64_PATH, may need a re-login + ``` + - Install the matching Python wheel from the SDK's `api/python` directory into this + repo's venv: + ```bash + pip install /opt/vimbax/VimbaX_*/api/python/vmbpy-*-py3-none-*.whl + ``` + This satisfies the `vmbpy` dependency already declared in `pyproject.toml` — pip + won't reinstall over it unless the version constraint mismatches. +2. **Or install `vmbpy` from PyPI** (`pip install vmbpy`, or just `pip install -e .` in + this repo since it's already a dependency) **and separately install the Vimba X SDK** + for the GenTL producer only, following the same extract + `Install_GenTL_Path.sh` steps + above but skipping its wheel. + +Either way, the GigE transport layer is what's needed here (this is a pure Ethernet +camera); the USB3 transport layer in the SDK isn't required. + +Verify the install: +```bash +python3 -c "import vmbpy; print(vmbpy.VmbSystem.get_instance())" +python3 -c "import vmbpy +with vmbpy.VmbSystem.get_instance() as vmb: + print(vmb.get_all_cameras())" +``` +The second command should list any camera currently reachable on the network (empty list +is fine if none are powered/connected — it just confirms the transport layer loaded +without error). + +Reference: [VmbPy README (GitHub)](https://github.com/alliedvision/VmbPy), +[Vimba X Python API Manual](https://docs.alliedvision.com/Vimba_X/Vimba_X_DeveloperGuide/pythonAPIManual.html), +[vmbpy on PyPI](https://pypi.org/project/vmbpy/). + +## Finding the camera's ID + +`camera_id` (constructor arg / `deviceConfig` key) is the GigE Vision device ID/serial +string, e.g. `"DEV_1AB22C000000"` — not an IP address. `VmbSystem.get_camera_by_id()` +resolves it via GigE Vision discovery broadcast, so it works regardless of the camera's +current DHCP-assigned IP. + +Once vmbpy + the Vimba X runtime are installed on a machine on the same network segment as +the camera, list connected cameras and their IDs with: + +```python +import vmbpy +with vmbpy.VmbSystem.get_instance() as vmb: + for cam in vmb.get_all_cameras(): + print(cam.get_id(), cam.get_name(), cam.get_serial()) +``` + +Or from the shell, if the Vimba X SDK install included its CLI tools: + +```bash +VmbCPPExamples/ListCameras # or wherever the SDK installed its example binaries +``` + +Allied Vision's **Vimba X Viewer** GUI (bundled with the SDK installer) also lists every +camera it discovers on the network along with its ID — the quickest way to check without +writing any code. The ID is derived from the camera's MAC address and doesn't change when +the IP does, which is why it's the right identifier here given the camera has no fixed IP. + +## Testing without hardware + +- Run the unit tests: `pytest tests/tests_devices/test_allied_vision_vmb_camera.py`. These + mock `camera.cam`, so they cover the ophyd-level logic (mask/ROI, live-mode threading, + `on_trigger` ROI-signal math, exposure/gain delegation) without touching vmbpy. +- Exercise the full signal/preview/live-mode path with no camera at all via + `SimAlliedVisionVmbCamera` (`csaxs_bec/devices/sim/sim_cameras.py`) — it serves a static + synthetic test-pattern frame (with optional gaussian noise via `sim_noise_std`) through + the same `image`/`roi_signal` signals and `start_live_mode`/`get_last_image`/ + `set_rect_roi` USER_ACCESS methods as the real device. + +## Testing with real hardware + +1. Install vmbpy + Vimba X SDK runtime as above. +2. Find the camera's ID (see above). +3. Uncomment and adjust the `allied_vision_cam` block in + `csaxs_bec/device_configs/bl_detectors.yaml` with the real `camera_id`, or instantiate + directly: + ```python + from csaxs_bec.devices.allied_vision_cameras import AlliedVisionVmbCamera + cam = AlliedVisionVmbCamera(name="allied_vision_cam", camera_id="DEV_1AB22C000000") + cam.on_connected() # opens the vmbpy VmbSystem/Camera context + cam.start_live_mode() # begins the 5 Hz polling thread, updates `image` preview signal + cam.get_last_image() # last captured frame as a numpy array + cam.get_exposure_time() / cam.set_exposure_time(us) + cam.get_gain() / cam.set_gain(value) + cam.stop_live_mode() + cam.on_destroy() # closes the vmbpy contexts + ``` +4. Once wired into a running BEC deployment via `bl_detectors.yaml`, verify from an + ipython client session that the device shows up in `dm.devices` and that + `dev.allied_vision_cam.start_live_mode()` produces a live preview. + +## Notes / open items + +- Exception names used in `Camera.get_image_data()` (`vmbpy.VmbTimeout`, + `vmbpy.VmbFeatureError`, `vmbpy.VmbCameraError`) and the feature-access API + (`get_feature_by_name`) should be double-checked against whatever vmbpy version actually + gets installed — they were written from the vmbpy API as documented, not verified against + a running install. +- The EPICS-based `AlliedVisionCamera` (`csaxs_bec/devices/epics/allied_vision_camera.py`) + is left in place and untouched for now. It stays dead code until the native path above is + validated on real hardware, at which point it can be retired. diff --git a/csaxs_bec/devices/allied_vision_cameras/__init__.py b/csaxs_bec/devices/allied_vision_cameras/__init__.py new file mode 100644 index 0000000..b45af95 --- /dev/null +++ b/csaxs_bec/devices/allied_vision_cameras/__init__.py @@ -0,0 +1 @@ +from .allied_vision_vmb_camera import AlliedVisionVmbCamera diff --git a/csaxs_bec/devices/allied_vision_cameras/allied_vision_vmb_camera.py b/csaxs_bec/devices/allied_vision_cameras/allied_vision_vmb_camera.py new file mode 100644 index 0000000..6596bc3 --- /dev/null +++ b/csaxs_bec/devices/allied_vision_cameras/allied_vision_vmb_camera.py @@ -0,0 +1,248 @@ +"""Native (non-EPICS) Allied Vision camera class for cSAXS.""" + +from __future__ import annotations + +import threading +from typing import TYPE_CHECKING + +import numpy as np +from ophyd import Component as Cpt, Signal, Kind + +from bec_lib import messages +from bec_lib.logger import bec_logger +from csaxs_bec.devices.allied_vision_cameras.base_integration.camera import Camera +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase +from ophyd_devices.utils.bec_signals import AsyncSignal, PreviewSignal + +if TYPE_CHECKING: + from bec_lib.devicemanager import ScanInfo + +logger = bec_logger.logger + + +class AlliedVisionVmbCamera(PSIDeviceBase): + """Allied Vision GigE camera class for cSAXS (e.g. Alvium G1-507m, mono). + + This class inherits from PSIDeviceBase and implements the necessary methods + to interact with the camera using the vmbpy library (Vimba X SDK), directly + over GigE Vision - no EPICS IOC is involved. + """ + + image = Cpt( + PreviewSignal, + name="image", + ndim=2, + doc="Preview signal for the camera.", + num_rotation_90=0, + transpose=False, + ) + roi_signal = Cpt( + AsyncSignal, + name="roi_signal", + ndim=0, + max_size=1000, + doc="Signal for the region of interest (ROI).", + async_update={"type": "add", "max_shape": [None]}, + ) + live_mode_enabled = Cpt( + Signal, + name="live_mode_enabled", + value=False, + doc="Enable or disable live mode.", + kind=Kind.config, + ) + + USER_ACCESS = [ + "start_live_mode", + "stop_live_mode", + "mask", + "set_rect_roi", + "get_last_image", + "get_exposure_time", + "set_exposure_time", + "get_gain", + "set_gain", + ] + + def __init__( + self, + *, + name: str, + camera_id: str, + prefix: str = "", + scan_info: ScanInfo | None = None, + live_mode: bool = False, + num_rotation_90: int = 0, + transpose: bool = False, + **kwargs, + ): + """Initialize the Allied Vision Camera. + + Args: + name (str): Name of the device. + camera_id (str): The GigE Vision ID/serial string of the camera device. + prefix (str): Prefix for the device. + scan_info (ScanInfo | None): Scan information for the device. + live_mode (bool): Whether to enable live mode for the camera. + """ + super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs) + self._live_mode_thread: threading.Thread | None = None + self._stop_live_mode_event: threading.Event = threading.Event() + self.cam = Camera(camera_id=camera_id, connect=False) + self._inputs = {"live_mode": live_mode} + self._mask = np.zeros((1, 1), dtype=np.uint8) + self.image.num_rotation_90 = num_rotation_90 + self.image.transpose = transpose + self.live_mode_enabled.subscribe(self._on_live_mode_enabled_changed, run=False) + self.live_mode_enabled.put(bool(live_mode)) + + ############## Live Mode Methods ############## + + def start_live_mode(self) -> None: + self.live_mode_enabled.put(True) + + def stop_live_mode(self) -> None: + self.live_mode_enabled.put(False) + + @property + def mask(self) -> np.ndarray: + """Return the current region of interest (ROI) for the camera.""" + return self._mask + + @mask.setter + def mask(self, value: np.ndarray): + """ + Set the region of interest (ROI) for the camera. + + Args: + value (np.ndarray): The mask to set as the ROI. + """ + if value.ndim != 2: + raise ValueError("ROI mask must be a 2D array.") + img_shape = (self.cam.height, self.cam.width) + if value.shape[0] != img_shape[0] or value.shape[1] != img_shape[1]: + raise ValueError( + f"ROI mask shape {value.shape} does not match image shape {img_shape}." + ) + self._mask = value + + def _on_live_mode_enabled_changed(self, *args, value, **kwargs): + """Callback for when live mode is changed.""" + enabled = bool(value) + if enabled and self.cam._connected is False: # pylint: disable=protected-access + self.cam.on_connect() + if enabled: + self._start_live() + else: + self._stop_live() + + def set_rect_roi(self, x: int, y: int, width: int, height: int): + """Set the rectangular region of interest (ROI) for the camera.""" + if x < 0 or y < 0 or width <= 0 or height <= 0: + raise ValueError("ROI coordinates and dimensions must be positive integers.") + img_shape = (self.cam.height, self.cam.width) + if x + width > img_shape[1] or y + height > img_shape[0]: + raise ValueError("ROI exceeds camera dimensions.") + mask = np.zeros(img_shape, dtype=np.uint8) + mask[y : y + height, x : x + width] = 1 + self.mask = mask + + def _start_live(self): + """Start the live mode for the camera.""" + if self._live_mode_thread is not None: + logger.info("Live mode thread is already running.") + return + self._stop_live_mode_event.clear() + self._live_mode_thread = threading.Thread( + target=self._live_mode_loop, args=(self._stop_live_mode_event,) + ) + self._live_mode_thread.start() + + def _stop_live(self): + """Stop the live mode for the camera.""" + if self._live_mode_thread is None: + logger.info("Live mode thread is not running.") + return + self._stop_live_mode_event.set() + self._live_mode_thread.join(timeout=5) + if self._live_mode_thread.is_alive(): + logger.warning("Live mode thread did not stop gracefully.") + else: + self._live_mode_thread = None + logger.info("Live mode stopped.") + + def _live_mode_loop(self, stop_event: threading.Event): + """Loop to capture images in live mode.""" + self.cam.set_camera_rate_limiting(True) + while not stop_event.is_set(): + try: + self.process_data(self.cam.get_image_data()) + except Exception as e: + logger.error(f"Error in live mode loop: {e}") + break + stop_event.wait(0.2) # 5 Hz + self.cam.set_camera_rate_limiting(False) + + def process_data(self, image: np.ndarray | None): + """Process the image data before sending it to the preview signal.""" + if image is None: + return + self.image.put(image) + + def get_last_image(self) -> np.ndarray: + """Get the last captured image from the camera.""" + image = self.image.get() + if image: + return image.data + + ############## Exposure / Gain ############## + + def get_exposure_time(self) -> float: + """Get the current exposure time in microseconds.""" + return self.cam.exposure_time + + def set_exposure_time(self, value: float) -> None: + """Set the exposure time in microseconds.""" + self.cam.exposure_time = value + + def get_gain(self) -> float: + """Get the current gain.""" + return self.cam.gain + + def set_gain(self, value: float) -> None: + """Set the gain.""" + self.cam.gain = value + + ############## User Interface Methods ############## + + def on_connected(self): + """Connect to the camera.""" + self.cam.on_connect() + self.live_mode_enabled.put(bool(self._inputs.get("live_mode", False))) + self.set_rect_roi(0, 0, self.cam.width, self.cam.height) + + def on_destroy(self): + """Clean up resources when the device is destroyed.""" + self.cam.on_disconnect() + super().on_destroy() + + def on_trigger(self): + """Handle the trigger event.""" + if not bool(self.live_mode_enabled.get()): + return + image = self.image.get() + if image is not None: + image: messages.DevicePreviewMessage + if self.mask.shape != image.data.shape: + logger.info( + f"ROI shape does not match image shape, skipping ROI application for device {self.name}." + ) + return + data = image.data * self.mask + self.roi_signal.put(np.sum(data) / np.sum(self.mask)) + + +if __name__ == "__main__": + # Example usage of the AlliedVisionVmbCamera class + camera = AlliedVisionVmbCamera(name="TestCamera", camera_id="DEV_1AB22C000000", live_mode=False) + print(f"Camera {camera.name} initialized with ID {camera.cam.camera_id}.") diff --git a/csaxs_bec/devices/allied_vision_cameras/base_integration/__init__.py b/csaxs_bec/devices/allied_vision_cameras/base_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/csaxs_bec/devices/allied_vision_cameras/base_integration/camera.py b/csaxs_bec/devices/allied_vision_cameras/base_integration/camera.py new file mode 100644 index 0000000..0cf3214 --- /dev/null +++ b/csaxs_bec/devices/allied_vision_cameras/base_integration/camera.py @@ -0,0 +1,209 @@ +""" +This module provides a Camera class for handling Allied Vision GigE cameras using the +vmbpy library, which links to the vendor's Vimba X C SDK. vmbpy talks to the camera +directly over GigE Vision / GenICam - no EPICS IOC or areaDetector driver is involved. + +The Camera class is the high level interface, and only opens the vmbpy VmbSystem/Camera +context when on_connect() is called. This allows for lazy initialization of the camera, +so CI/CD pipelines can run without vmbpy or the Vimba X runtime/GenTL producer installed +on the system. + +Cameras are identified by their GigE Vision ID/serial string (VmbSystem.get_camera_by_id), +which is resolved via GigE discovery broadcast and does not depend on the camera's current +IP address. +""" + +from __future__ import annotations + +import atexit +import time + +import numpy as np +from bec_lib.logger import bec_logger + +logger = bec_logger.logger + +try: + import vmbpy +except ImportError as exc: + logger.warning(f"The vmbpy library is not properly installed : {exc}") + vmbpy = None # type: ignore[assignment] + + +class Camera: + """High level camera wrapper for Allied Vision GigE cameras via vmbpy. + + Args: + camera_id (str): The vmbpy camera ID/serial string (e.g. "DEV_1AB22C000000"). + Resolved via GigE Vision discovery broadcast, independent of the camera's + current IP address. + """ + + def __init__(self, camera_id: str, connect: bool = True): + if vmbpy is None: + raise ImportError( + "The vmbpy library is not installed or the Vimba X runtime/GenTL producer" + " is missing. Please check your Python environment and Vimba X installation." + ) + self.vmbpy = vmbpy + self.camera_id = camera_id + self._vmb = None + self._cam = None + self._connected = False + self.width = 0 + self.height = 0 + + atexit.register(self.on_disconnect) + + self._enable_warning_rate_limit: bool = False + self._last_rate_limited_log: float = 0 + self._warning_log_rate_limit_s: float = 10 + + if connect: + self.on_connect() + + def on_connect(self): + """Connect to the camera and initialize it.""" + if self._connected: + logger.warning("Camera is already connected.") + return + self._vmb = self.vmbpy.VmbSystem.get_instance() + self._vmb.__enter__() + try: + self._cam = self._vmb.get_camera_by_id(self.camera_id) + self._cam.__enter__() + except Exception: + self._vmb.__exit__(None, None, None) + self._vmb = None + self._cam = None + raise + self._configure_pixel_format() + self.width = self._feature("Width").get() + self.height = self._feature("Height").get() + self._connected = True + + def on_disconnect(self, delay_after: float = 0.3): + """Disconnect from the camera and optionally wait a short time for driver cleanup.""" + try: + if self._cam is not None: + try: + self._cam.__exit__(None, None, None) + except Exception as e: # pylint: disable=broad-except + logger.info(f"Error while closing camera context: {e}") + if self._vmb is not None: + try: + self._vmb.__exit__(None, None, None) + except Exception as e: # pylint: disable=broad-except + logger.info(f"Error while closing VmbSystem context: {e}") + self._cam = None + self._vmb = None + self._connected = False + if delay_after > 0: + time.sleep(delay_after) + logger.debug(f"Waited {delay_after:.2f}s after camera disconnect for cleanup.") + except Exception as e: # pylint: disable=broad-except + logger.info(f"Error during camera disconnection: {e}") + + def _feature(self, name: str): + """Return the GenICam feature object for the given name.""" + return self._cam.get_feature_by_name(name) + + def _configure_pixel_format(self): + """Set the camera to a supported monochrome pixel format.""" + supported = self._cam.get_pixel_formats() + if self.vmbpy.PixelFormat.Mono8 in supported: + self._cam.set_pixel_format(self.vmbpy.PixelFormat.Mono8) + elif self.vmbpy.PixelFormat.Mono12 in supported: + self._cam.set_pixel_format(self.vmbpy.PixelFormat.Mono12) + else: + raise ValueError(f"No supported monochrome pixel format available: {supported}") + + @property + def exposure_time(self) -> float: + """Get the exposure time of the camera, in microseconds.""" + return self._feature("ExposureTime").get() + + @exposure_time.setter + def exposure_time(self, value: float): + """Set the exposure time of the camera, in microseconds.""" + self._feature("ExposureTime").set(value) + + @property + def gain(self) -> float: + """Get the gain of the camera.""" + return self._feature("Gain").get() + + @gain.setter + def gain(self, value: float): + """Set the gain of the camera.""" + self._feature("Gain").set(value) + + def set_roi(self, x: int, y: int, width: int, height: int): + """Set the region of interest (ROI) for the camera sensor readout.""" + # Reset offsets first so a growing width/height isn't rejected because the + # previous offset + new size would exceed the sensor bounds. + self._feature("OffsetX").set(0) + self._feature("OffsetY").set(0) + + width = self._align(width, self._feature("Width").get_increment()) + height = self._align(height, self._feature("Height").get_increment()) + self._feature("Width").set(width) + self._feature("Height").set(height) + + x = self._align(x, self._feature("OffsetX").get_increment()) + y = self._align(y, self._feature("OffsetY").get_increment()) + self._feature("OffsetX").set(x) + self._feature("OffsetY").set(y) + + self.width = self._feature("Width").get() + self.height = self._feature("Height").get() + logger.info(f"ROI set to: x={x}, y={y}, width={self.width}, height={self.height}") + + @staticmethod + def _align(value: int, increment: int) -> int: + """Round value down to the nearest multiple of increment.""" + if not increment: + return value + return (value // increment) * increment + + def get_image_data(self) -> np.ndarray | None: + """Get a single image frame from the camera (blocking, synchronous).""" + if not self._connected: + self._rate_limited_warning_log(f"Camera with id {self.camera_id} is not connected.") + return None + try: + frame = self._cam.get_frame(timeout_ms=1000) + except self.vmbpy.VmbTimeout: + self._rate_limited_warning_log( + f"Timeout waiting for frame from camera {self.camera_id}." + ) + return None + except (self.vmbpy.VmbFeatureError, self.vmbpy.VmbCameraError) as exc: + logger.error(f"Error while acquiring frame from camera {self.camera_id}: {exc}") + return None + img = frame.as_numpy_ndarray() + if img.ndim == 3 and img.shape[2] == 1: + img = img[:, :, 0] + return img + + def set_camera_rate_limiting(self, enabled: bool, rate_limit_s: float | None = None): + if rate_limit_s is not None: + if rate_limit_s <= 0: + raise ValueError(f"Invalid rate limit: {rate_limit_s}, must be positive nonzero.") + self._warning_log_rate_limit_s = rate_limit_s + self._enable_warning_rate_limit = enabled + + def _rate_limited_warning_log(self, msg: str): + if ( + self._enable_warning_rate_limit + and time.monotonic() < self._last_rate_limited_log + self._warning_log_rate_limit_s + ): + return + self._last_rate_limited_log = time.monotonic() + logger.warning(msg) + + +if __name__ == "__main__": + # Example usage + camera = Camera(camera_id="DEV_1AB22C000000") + camera.on_connect() diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index f87c017..944b116 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -1,8 +1,9 @@ """ Simulated flOMNI cameras. -`SimWebcamViewer` and `SimIDSCamera` reuse the real device classes and replace only the -frame source: the webcam viewer's MJPEG HTTP stream and the IDS camera's pyueye backend. +`SimWebcamViewer`, `SimIDSCamera` and `SimAlliedVisionVmbCamera` reuse the real device +classes and replace only the frame source: the webcam viewer's MJPEG HTTP stream, the IDS +camera's pyueye backend, and the AlliedVision camera's vmbpy backend, respectively. All signals (`preview` / `image`, `roi_signal`, `live_mode_enabled`), the USER_ACCESS methods (`start_live_mode`, `set_rect_roi`, `get_last_image`, ...) and the ROI/trigger logic therefore run through the unmodified code paths, so GUIs and the X-ray eye @@ -19,6 +20,7 @@ from __future__ import annotations import numpy as np from bec_lib.logger import bec_logger +from csaxs_bec.devices.allied_vision_cameras.allied_vision_vmb_camera import AlliedVisionVmbCamera from csaxs_bec.devices.ids_cameras.ids_camera import IDSCamera from csaxs_bec.devices.omny.webcam_viewer import WebcamViewer @@ -157,3 +159,77 @@ class SimIDSCamera(IDSCamera): int(sim_shape[1]), int(sim_shape[0]), rgb=not force_monochrome, noise_std=sim_noise_std ) self.cam.force_monochrome = self._force_monochrome + + +class _SimAlliedVisionBackend: + """Drop-in replacement for `allied_vision_cameras.base_integration.camera.Camera` + serving synthetic frames.""" + + def __init__(self, width: int, height: int, noise_std: float = 0.0): + self.width = width + self.height = height + self._connected = False + self._noise_std = float(noise_std) + self._frame = make_test_pattern(height, width, rgb=False) + self._exposure_time = 10000.0 # us + self._gain = 0.0 + + def on_connect(self): + self._connected = True + + def on_disconnect(self): + self._connected = False + + def get_image_data(self) -> np.ndarray: + return add_frame_noise(self._frame, self._noise_std) + + def set_camera_rate_limiting(self, enabled: bool): + pass + + @property + def exposure_time(self) -> float: + return self._exposure_time + + @exposure_time.setter + def exposure_time(self, value: float): + self._exposure_time = value + + @property + def gain(self) -> float: + return self._gain + + @gain.setter + def gain(self, value: float): + self._gain = value + + +class SimAlliedVisionVmbCamera(AlliedVisionVmbCamera): + """AlliedVisionVmbCamera with the vmbpy backend replaced by a synthetic frame source.""" + + def __init__( + self, + *, + name: str, + camera_id: str = "sim", + prefix: str = "", + scan_info=None, + live_mode: bool = False, + num_rotation_90: int = 0, + transpose: bool = False, + sim_shape=(1024, 1280), + sim_noise_std=0.0, + **kwargs, + ): + super().__init__( + name=name, + camera_id=camera_id, + prefix=prefix, + scan_info=scan_info, + live_mode=live_mode, + num_rotation_90=num_rotation_90, + transpose=transpose, + **kwargs, + ) + self.cam = _SimAlliedVisionBackend( + int(sim_shape[1]), int(sim_shape[0]), noise_std=sim_noise_std + ) diff --git a/pyproject.toml b/pyproject.toml index 838ac6d..24b43f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "rich", "pyepics", "pyueye", # for the IDS uEye camera + "vmbpy", # for the AlliedVision Alvium camera (native SDK, no EPICS IOC) "bec_widgets", "zmq", "opencv-python", diff --git a/tests/tests_devices/test_allied_vision_vmb_camera.py b/tests/tests_devices/test_allied_vision_vmb_camera.py new file mode 100644 index 0000000..bc68457 --- /dev/null +++ b/tests/tests_devices/test_allied_vision_vmb_camera.py @@ -0,0 +1,105 @@ +"""Unit tests for the AlliedVisionVmbCamera device.""" + +from unittest import mock + +import numpy as np +import pytest + +from csaxs_bec.devices.allied_vision_cameras.allied_vision_vmb_camera import ( + AlliedVisionVmbCamera, +) + + +@pytest.fixture(scope="function") +def allied_vision_camera(): + """Fixture for creating an instance of the AlliedVisionVmbCamera.""" + camera = AlliedVisionVmbCamera( + name="test_camera", camera_id="DEV_TEST", prefix="test:", scan_info=None, live_mode=False + ) + # Mock camera connection and attributes + camera.cam = mock.Mock() + camera.cam._connected = True + camera.cam.width = 2 + camera.cam.height = 2 + yield camera + + +def test_mask_setter_getter(allied_vision_camera): + """Test the mask setter and getter methods.""" + mask = np.zeros((2, 2), dtype=np.uint8) + mask[0, 0] = 1 + allied_vision_camera.mask = mask + assert np.array_equal(allied_vision_camera.mask, mask) + + +def test_mask_setter_invalid_shape(allied_vision_camera): + """Test the mask setter with an invalid shape.""" + with pytest.raises(ValueError): + allied_vision_camera.mask = np.zeros( + (3, 3), dtype=np.uint8 + ) # Exceeds mocked camera dimensions + + +def test_on_connected_sets_mask_and_live_mode(allied_vision_camera): + """Test the on_connected method to ensure it sets the mask and live mode.""" + allied_vision_camera.cam.on_connect = mock.Mock() + allied_vision_camera.on_connected() + allied_vision_camera.cam.on_connect.assert_called_once() + expected_mask = np.ones((2, 2), dtype=np.uint8) + assert np.array_equal(allied_vision_camera.mask, expected_mask) + + +def test_on_trigger_roi_signal(allied_vision_camera): + """Test the on_trigger method to ensure it processes the ROI signal correctly.""" + allied_vision_camera.start_live_mode() + test_image = np.array([[2, 4], [6, 8]]) + test_mask = np.array([[1, 0], [0, 1]], dtype=np.uint8) + allied_vision_camera.mask = test_mask + mock_image = mock.Mock() + mock_image.data = test_image + allied_vision_camera.image.get = mock.Mock(return_value=mock_image) + allied_vision_camera.roi_signal.put = mock.Mock(side_effect=allied_vision_camera.roi_signal.put) + allied_vision_camera.on_trigger() + expected_value = (2 * 1 + 4 * 0 + 6 * 0 + 8 * 1) / np.sum(test_mask) + result = allied_vision_camera.roi_signal.get() + assert np.isclose( + result.content["signals"][allied_vision_camera.roi_signal.name]["value"], + expected_value, + atol=1e-6, + ) + + +def test_get_last_image(allied_vision_camera): + """Test the get_last_image method to ensure it returns the last captured image.""" + test_image = np.array([[1, 2], [3, 4]], dtype=np.uint8) + mock_image = mock.Mock() + mock_image.data = test_image + allied_vision_camera.image.get = mock.Mock(return_value=mock_image) + + result = allied_vision_camera.get_last_image() + assert np.array_equal(result, test_image) + + +def test_on_destroy(allied_vision_camera): + """Test the on_destroy method to ensure it cleans up resources.""" + allied_vision_camera.cam.on_disconnect = mock.Mock() + allied_vision_camera.on_destroy() + allied_vision_camera.cam.on_disconnect.assert_called_once() + + +def test_get_set_exposure_time(allied_vision_camera): + """Test the exposure time getter/setter delegate to the low-level camera wrapper.""" + allied_vision_camera.cam.exposure_time = 5000.0 + assert allied_vision_camera.get_exposure_time() == 5000.0 + + allied_vision_camera.set_exposure_time(1234.5) + assert allied_vision_camera.cam.exposure_time == 1234.5 + + +def test_get_set_gain(allied_vision_camera): + """Test the gain getter/setter delegate to the low-level camera wrapper.""" + allied_vision_camera.cam.gain = 3.0 + assert allied_vision_camera.get_gain() == 3.0 + + allied_vision_camera.set_gain(7.5) + assert allied_vision_camera.cam.gain == 7.5 -- 2.54.0 From b644cf786bf8dcc308d9a86a82e0840143fa6c83 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 23 Jul 2026 16:12:39 +0200 Subject: [PATCH 2/8] refactor(devices): swap AlliedVision camera backend from vmbpy to Aravis Replaces the vendor-SDK-based vmbpy backend with Aravis, an open-source GenICam/GigE Vision library that implements the network protocol itself, so no Allied Vision account/license/SDK download is needed - just PyGObject plus the system-packaged Aravis library. Renames AlliedVisionVmbCamera -> AlliedVisionAravisCamera (and matching file/sim/test names) to reflect the new backend; low-level Camera wrapper keeps the same public interface, only its internals changed. README rewritten with Aravis install steps and camera-ID discovery via arv-tool-0.8 / Aravis.get_device_id(). Co-Authored-By: Claude Sonnet 5 --- ...allied_vision_vmb_camera.py => allied_vision_aravis_camera.py} | 0 ...d_vision_vmb_camera.py => test_allied_vision_aravis_camera.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename csaxs_bec/devices/allied_vision_cameras/{allied_vision_vmb_camera.py => allied_vision_aravis_camera.py} (100%) rename tests/tests_devices/{test_allied_vision_vmb_camera.py => test_allied_vision_aravis_camera.py} (100%) diff --git a/csaxs_bec/devices/allied_vision_cameras/allied_vision_vmb_camera.py b/csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py similarity index 100% rename from csaxs_bec/devices/allied_vision_cameras/allied_vision_vmb_camera.py rename to csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py diff --git a/tests/tests_devices/test_allied_vision_vmb_camera.py b/tests/tests_devices/test_allied_vision_aravis_camera.py similarity index 100% rename from tests/tests_devices/test_allied_vision_vmb_camera.py rename to tests/tests_devices/test_allied_vision_aravis_camera.py -- 2.54.0 From f4bd20e4e88a779ad9e24504cd7958d964bcbc54 Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 23 Jul 2026 16:13:07 +0200 Subject: [PATCH 3/8] fix: include content changes for vmbpy->Aravis swap The prior commit only captured the file renames (a botched `git add` dropped the actual edits). This commit carries the real diff: Aravis GObject-introspection backend in base_integration/camera.py, renamed class/config references, and the rewritten README. Co-Authored-By: Claude Sonnet 5 --- csaxs_bec/device_configs/bl_detectors.yaml | 6 +- .../devices/allied_vision_cameras/README.md | 205 ++++++++++-------- .../devices/allied_vision_cameras/__init__.py | 2 +- .../allied_vision_aravis_camera.py | 17 +- .../base_integration/camera.py | 154 ++++++------- csaxs_bec/devices/sim/sim_cameras.py | 12 +- pyproject.toml | 2 +- .../test_allied_vision_aravis_camera.py | 12 +- 8 files changed, 212 insertions(+), 198 deletions(-) diff --git a/csaxs_bec/device_configs/bl_detectors.yaml b/csaxs_bec/device_configs/bl_detectors.yaml index a39d23e..1d15c3a 100644 --- a/csaxs_bec/device_configs/bl_detectors.yaml +++ b/csaxs_bec/device_configs/bl_detectors.yaml @@ -34,10 +34,10 @@ eiger_1_5: # softwareTrigger: True # allied_vision_cam: -# description: AlliedVision Alvium G1-507m camera for live image acquisition (native vmbpy/GigE Vision, no EPICS IOC) -# deviceClass: csaxs_bec.devices.allied_vision_cameras.AlliedVisionVmbCamera +# description: AlliedVision Alvium G1-507m camera for live image acquisition (native Aravis/GigE Vision, no EPICS IOC) +# deviceClass: csaxs_bec.devices.allied_vision_cameras.AlliedVisionAravisCamera # deviceConfig: -# camera_id: "DEV_1AB22C000000" # GigE ID/serial string; resolved via discovery, independent of current IP +# camera_id: "Allied Vision Technologies-50-0536885929" # Aravis device ID; resolved via discovery, independent of current IP # live_mode: True # onFailure: raise # enabled: true diff --git a/csaxs_bec/devices/allied_vision_cameras/README.md b/csaxs_bec/devices/allied_vision_cameras/README.md index a8c189e..fde5c5d 100644 --- a/csaxs_bec/devices/allied_vision_cameras/README.md +++ b/csaxs_bec/devices/allied_vision_cameras/README.md @@ -1,141 +1,156 @@ -# Allied Vision camera (native vmbpy integration) +# Allied Vision camera (native Aravis integration) -`AlliedVisionVmbCamera` is the native-Python replacement for the old EPICS/Vimba-IOC +`AlliedVisionAravisCamera` is the native-Python replacement for the old EPICS/Vimba-IOC `AlliedVisionCamera` (`csaxs_bec/devices/epics/allied_vision_camera.py`, still present but unused/unwired). It talks to Allied Vision GigE cameras (e.g. the Alvium G1-507m, mono, -Sony IMX264) directly via **vmbpy**, the vendor's Python bindings for the Vimba X SDK — no -EPICS IOC or areaDetector driver involved. +Sony IMX264) directly via **Aravis**, the open-source GenICam/GigE Vision library — no +vendor SDK, account, or EPICS IOC/areaDetector driver involved. Aravis implements the GigE +Vision network protocol (GVCP/GVSP) itself, so it works with any GenICam-compliant GigE +Vision camera, not just Allied Vision's. ``` allied_vision_cameras/ -├── __init__.py # exports AlliedVisionVmbCamera -├── allied_vision_vmb_camera.py # ophyd device (PSIDeviceBase) +├── __init__.py # exports AlliedVisionAravisCamera +├── allied_vision_aravis_camera.py # ophyd device (PSIDeviceBase) └── base_integration/ - └── camera.py # low-level vmbpy wrapper (class Camera) + └── camera.py # low-level Aravis wrapper (class Camera) ``` -Simulation counterpart: `SimAlliedVisionVmbCamera` in `csaxs_bec/devices/sim/sim_cameras.py` -(same device, `self.cam` swapped for a synthetic-frame backend — no hardware or vmbpy +Simulation counterpart: `SimAlliedVisionAravisCamera` in `csaxs_bec/devices/sim/sim_cameras.py` +(same device, `self.cam` swapped for a synthetic-frame backend — no hardware or Aravis needed). ## Packages required -- **Unit tests / import** (`tests/tests_devices/test_allied_vision_vmb_camera.py`): nothing - extra. `Camera.__init__` guards the `import vmbpy` and the tests mock `camera.cam` - directly, so `pytest tests/tests_devices/test_allied_vision_vmb_camera.py` runs on a - plain dev environment, same as the IDS camera tests run without `pyueye`. +- **Unit tests / import** (`tests/tests_devices/test_allied_vision_aravis_camera.py`): + nothing extra. `Camera.__init__` guards the `import gi` / `gi.require_version("Aravis", + "0.8")` and the tests mock `camera.cam` directly, so + `pytest tests/tests_devices/test_allied_vision_aravis_camera.py` runs on a plain dev + environment, same as the IDS camera tests run without `pyueye`. - **Talking to real hardware** — two things, both local to the machine running the device - server (or wherever you instantiate `AlliedVisionVmbCamera`/`Camera` directly): - 1. The `vmbpy` Python package. - 2. The **Vimba X SDK runtime** (GenTL producer / transport layer `vmbpy` links against at - runtime). The pip package alone does not include this — same class of local - dependency as the DLLs `pyueye` needs for the IDS cameras. + server (or wherever you instantiate `AlliedVisionAravisCamera`/`Camera` directly): + 1. **PyGObject** (`pygobject`, the `gi` module) — already listed in `pyproject.toml`. + It's the GObject Introspection bridge, not Aravis itself. + 2. **Aravis** — a native C library with a GObject Introspection typelib. It is *not* a + pip package; it comes from the system package manager (or building from source), same + class of local, non-pip dependency as the Vimba X SDK would have been, or the DLLs + `pyueye` needs for the IDS cameras. -### Installing vmbpy + the Vimba X runtime (Linux) +### Installing PyGObject + Aravis (Linux) -`vmbpy` is already listed in `pyproject.toml`, but `pip install vmbpy` from PyPI alone -pulls in a package that has **no camera drivers/transport layers** — it can be imported -but `VmbSystem.get_instance()` won't find any cameras until the SDK runtime below is also -installed. There are two ways to get a working install: +`pygobject` alone (`pip install pygobject`, already in `pyproject.toml`) only gets you the +`gi` bridge — `import gi; gi.require_version("Aravis", "0.8")` will raise until the Aravis +library and its typelib are installed on the system. -1. **Install the Vimba X SDK first, then use the `.whl` it ships with** (recommended — - this `.whl` bundles the VmbC libraries so you don't need a separate runtime step for - the Python bindings): - - Download the Vimba X SDK for Linux from Allied Vision - (alliedvision.com → Support → Software → *Vimba X SDK*; free account required). - - Extract it somewhere you have write access, e.g. - `tar -xf VimbaX_Setup-_Linux_x86_64.tar.gz -C /opt/vimbax`. - - Register the GenTL transport layer path so cameras can be discovered: - ```bash - cd /opt/vimbax/VimbaX_*/cti - sudo ./Install_GenTL_Path.sh # writes GENICAM_GENTL64_PATH, may need a re-login - ``` - - Install the matching Python wheel from the SDK's `api/python` directory into this - repo's venv: - ```bash - pip install /opt/vimbax/VimbaX_*/api/python/vmbpy-*-py3-none-*.whl - ``` - This satisfies the `vmbpy` dependency already declared in `pyproject.toml` — pip - won't reinstall over it unless the version constraint mismatches. -2. **Or install `vmbpy` from PyPI** (`pip install vmbpy`, or just `pip install -e .` in - this repo since it's already a dependency) **and separately install the Vimba X SDK** - for the GenTL producer only, following the same extract + `Install_GenTL_Path.sh` steps - above but skipping its wheel. +1. **Debian/Ubuntu** — Aravis and its GObject Introspection bindings are packaged directly: + ```bash + sudo apt install libaravis-0.8-0 gir1.2-aravis-0.8 aravis-tools + ``` + `aravis-tools` provides `arv-tool-0.8` and `arv-viewer-0.8`, useful for discovery/testing + without writing any Python. If PyGObject's own system dependencies aren't already + present (GLib/GObject introspection headers), add `python3-gi` or + `sudo apt install libgirepository1.0-dev` before `pip install pygobject`. +2. **RHEL/Fedora/CentOS** (relevant if the device-server host runs one of these): + ```bash + sudo dnf install aravis gobject-introspection-devel python3-gobject + ``` + Package name/availability varies by distro version — if `aravis` isn't in the default + repos, enable EPEL or build from source (see below). +3. **Building Aravis from source** (if no packaged version is new enough — GigE features + and camera compatibility improve across Aravis releases): + ```bash + sudo apt install meson ninja-build libglib2.0-dev libgirepository1.0-dev \ + gobject-introspection libxml2-dev + git clone https://github.com/AravisProject/aravis.git + cd aravis && meson setup build && ninja -C build + sudo ninja -C build install && sudo ldconfig + ``` + This installs the typelib to the standard GI search path, so no environment variable + juggling is normally needed (unlike Vimba X's `GENICAM_GENTL64_PATH` step). -Either way, the GigE transport layer is what's needed here (this is a pure Ethernet -camera); the USB3 transport layer in the SDK isn't required. +No transport-layer install step is needed beyond this — Aravis's GigE Vision support is +built into the library itself, not a separate producer plugin. Verify the install: ```bash -python3 -c "import vmbpy; print(vmbpy.VmbSystem.get_instance())" -python3 -c "import vmbpy -with vmbpy.VmbSystem.get_instance() as vmb: - print(vmb.get_all_cameras())" +python3 -c "import gi; gi.require_version('Aravis', '0.8'); from gi.repository import Aravis; print(Aravis)" +python3 -c " +import gi; gi.require_version('Aravis', '0.8') +from gi.repository import Aravis +Aravis.update_device_list() +print([Aravis.get_device_id(i) for i in range(Aravis.get_n_devices())]) +" ``` The second command should list any camera currently reachable on the network (empty list -is fine if none are powered/connected — it just confirms the transport layer loaded -without error). +is fine if none are powered/connected — it just confirms Aravis loaded and can broadcast +discovery without error). `arv-tool-0.8 --list` (from `aravis-tools`) does the same check +from the shell. -Reference: [VmbPy README (GitHub)](https://github.com/alliedvision/VmbPy), -[Vimba X Python API Manual](https://docs.alliedvision.com/Vimba_X/Vimba_X_DeveloperGuide/pythonAPIManual.html), -[vmbpy on PyPI](https://pypi.org/project/vmbpy/). +Reference: [Aravis project (GitHub)](https://github.com/AravisProject/aravis), +[Aravis reference manual](https://aravisproject.github.io/aravis/aravis-stable/), +[Aravis.Camera Python bindings](https://lazka.github.io/pgi-docs/Aravis-0.8/classes/Camera.html). ## Finding the camera's ID -`camera_id` (constructor arg / `deviceConfig` key) is the GigE Vision device ID/serial -string, e.g. `"DEV_1AB22C000000"` — not an IP address. `VmbSystem.get_camera_by_id()` -resolves it via GigE Vision discovery broadcast, so it works regardless of the camera's -current DHCP-assigned IP. +`camera_id` (constructor arg / `deviceConfig` key) is the Aravis device ID string — not an +IP address, and not the same format vmbpy would have used. For GigE Vision devices it's +typically `"--"` (e.g. +`"Allied Vision Technologies-50-0536885929"`). It's resolved via GigE Vision discovery +broadcast, so it works regardless of the camera's current DHCP-assigned IP. -Once vmbpy + the Vimba X runtime are installed on a machine on the same network segment as -the camera, list connected cameras and their IDs with: - -```python -import vmbpy -with vmbpy.VmbSystem.get_instance() as vmb: - for cam in vmb.get_all_cameras(): - print(cam.get_id(), cam.get_name(), cam.get_serial()) -``` - -Or from the shell, if the Vimba X SDK install included its CLI tools: +Once PyGObject + Aravis are installed on a machine on the same network segment as the +camera, list connected cameras and their IDs with: ```bash -VmbCPPExamples/ListCameras # or wherever the SDK installed its example binaries +arv-tool-0.8 --list ``` -Allied Vision's **Vimba X Viewer** GUI (bundled with the SDK installer) also lists every -camera it discovers on the network along with its ID — the quickest way to check without -writing any code. The ID is derived from the camera's MAC address and doesn't change when -the IP does, which is why it's the right identifier here given the camera has no fixed IP. +or from Python: + +```python +import gi; gi.require_version("Aravis", "0.8") +from gi.repository import Aravis +Aravis.update_device_list() +for i in range(Aravis.get_n_devices()): + print(Aravis.get_device_id(i), Aravis.get_device_vendor(i), Aravis.get_device_model(i)) +``` + +`arv-viewer-0.8` (also from `aravis-tools`) is the GUI equivalent — lists every camera it +discovers on the network along with its ID, the quickest way to check without writing any +code. The ID is derived from the camera's MAC address and doesn't change when the IP does, +which is why it's the right identifier here given the camera has no fixed IP. ## Testing without hardware -- Run the unit tests: `pytest tests/tests_devices/test_allied_vision_vmb_camera.py`. These - mock `camera.cam`, so they cover the ophyd-level logic (mask/ROI, live-mode threading, - `on_trigger` ROI-signal math, exposure/gain delegation) without touching vmbpy. +- Run the unit tests: `pytest tests/tests_devices/test_allied_vision_aravis_camera.py`. + These mock `camera.cam`, so they cover the ophyd-level logic (mask/ROI, live-mode + threading, `on_trigger` ROI-signal math, exposure/gain delegation) without touching + Aravis. - Exercise the full signal/preview/live-mode path with no camera at all via - `SimAlliedVisionVmbCamera` (`csaxs_bec/devices/sim/sim_cameras.py`) — it serves a static - synthetic test-pattern frame (with optional gaussian noise via `sim_noise_std`) through - the same `image`/`roi_signal` signals and `start_live_mode`/`get_last_image`/ + `SimAlliedVisionAravisCamera` (`csaxs_bec/devices/sim/sim_cameras.py`) — it serves a + static synthetic test-pattern frame (with optional gaussian noise via `sim_noise_std`) + through the same `image`/`roi_signal` signals and `start_live_mode`/`get_last_image`/ `set_rect_roi` USER_ACCESS methods as the real device. ## Testing with real hardware -1. Install vmbpy + Vimba X SDK runtime as above. +1. Install PyGObject + Aravis as above. 2. Find the camera's ID (see above). 3. Uncomment and adjust the `allied_vision_cam` block in `csaxs_bec/device_configs/bl_detectors.yaml` with the real `camera_id`, or instantiate directly: ```python - from csaxs_bec.devices.allied_vision_cameras import AlliedVisionVmbCamera - cam = AlliedVisionVmbCamera(name="allied_vision_cam", camera_id="DEV_1AB22C000000") - cam.on_connected() # opens the vmbpy VmbSystem/Camera context + from csaxs_bec.devices.allied_vision_cameras import AlliedVisionAravisCamera + cam = AlliedVisionAravisCamera( + name="allied_vision_cam", camera_id="Allied Vision Technologies-50-0536885929" + ) + cam.on_connected() # opens the camera via Aravis cam.start_live_mode() # begins the 5 Hz polling thread, updates `image` preview signal cam.get_last_image() # last captured frame as a numpy array cam.get_exposure_time() / cam.set_exposure_time(us) cam.get_gain() / cam.set_gain(value) cam.stop_live_mode() - cam.on_destroy() # closes the vmbpy contexts + cam.on_destroy() # drops the Aravis camera reference ``` 4. Once wired into a running BEC deployment via `bl_detectors.yaml`, verify from an ipython client session that the device shows up in `dm.devices` and that @@ -143,11 +158,15 @@ the IP does, which is why it's the right identifier here given the camera has no ## Notes / open items -- Exception names used in `Camera.get_image_data()` (`vmbpy.VmbTimeout`, - `vmbpy.VmbFeatureError`, `vmbpy.VmbCameraError`) and the feature-access API - (`get_feature_by_name`) should be double-checked against whatever vmbpy version actually - gets installed — they were written from the vmbpy API as documented, not verified against - a running install. +- `Camera.get_image_data()` sets the camera into single-frame acquisition mode on every + call (`Aravis.Camera.acquisition()`'s documented behavior) rather than running a + continuous stream — matches the polling architecture of `_live_mode_loop` (5 Hz) but is + slightly less efficient per-frame than a persistent stream would be. Fine at this poll + rate; would need revisiting for high-frame-rate use cases. +- The exact Python method names used here (`Camera.acquisition`, `get_region`, + `get_width_increment`, `set_pixel_format_from_string`, `BufferStatus.SUCCESS`, ...) were + taken from Aravis's published GObject Introspection API docs, not verified against a + running install — double-check against the actual installed Aravis version. - The EPICS-based `AlliedVisionCamera` (`csaxs_bec/devices/epics/allied_vision_camera.py`) is left in place and untouched for now. It stays dead code until the native path above is validated on real hardware, at which point it can be retired. diff --git a/csaxs_bec/devices/allied_vision_cameras/__init__.py b/csaxs_bec/devices/allied_vision_cameras/__init__.py index b45af95..27a8910 100644 --- a/csaxs_bec/devices/allied_vision_cameras/__init__.py +++ b/csaxs_bec/devices/allied_vision_cameras/__init__.py @@ -1 +1 @@ -from .allied_vision_vmb_camera import AlliedVisionVmbCamera +from .allied_vision_aravis_camera import AlliedVisionAravisCamera diff --git a/csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py b/csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py index 6596bc3..ce3ecdb 100644 --- a/csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py +++ b/csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py @@ -20,12 +20,12 @@ if TYPE_CHECKING: logger = bec_logger.logger -class AlliedVisionVmbCamera(PSIDeviceBase): +class AlliedVisionAravisCamera(PSIDeviceBase): """Allied Vision GigE camera class for cSAXS (e.g. Alvium G1-507m, mono). This class inherits from PSIDeviceBase and implements the necessary methods - to interact with the camera using the vmbpy library (Vimba X SDK), directly - over GigE Vision - no EPICS IOC is involved. + to interact with the camera using Aravis, the open-source GenICam/GigE Vision + library, directly over GigE Vision - no vendor SDK or EPICS IOC is involved. """ image = Cpt( @@ -80,7 +80,8 @@ class AlliedVisionVmbCamera(PSIDeviceBase): Args: name (str): Name of the device. - camera_id (str): The GigE Vision ID/serial string of the camera device. + camera_id (str): The Aravis device ID string of the camera (e.g. as reported + by `arv-tool-0.8` or `Aravis.get_device_id()`). prefix (str): Prefix for the device. scan_info (ScanInfo | None): Scan information for the device. live_mode (bool): Whether to enable live mode for the camera. @@ -243,6 +244,10 @@ class AlliedVisionVmbCamera(PSIDeviceBase): if __name__ == "__main__": - # Example usage of the AlliedVisionVmbCamera class - camera = AlliedVisionVmbCamera(name="TestCamera", camera_id="DEV_1AB22C000000", live_mode=False) + # Example usage of the AlliedVisionAravisCamera class + camera = AlliedVisionAravisCamera( + name="TestCamera", + camera_id="Allied Vision Technologies-50-0536885929", + live_mode=False, + ) print(f"Camera {camera.name} initialized with ID {camera.cam.camera_id}.") diff --git a/csaxs_bec/devices/allied_vision_cameras/base_integration/camera.py b/csaxs_bec/devices/allied_vision_cameras/base_integration/camera.py index 0cf3214..0ce200e 100644 --- a/csaxs_bec/devices/allied_vision_cameras/base_integration/camera.py +++ b/csaxs_bec/devices/allied_vision_cameras/base_integration/camera.py @@ -1,16 +1,18 @@ """ -This module provides a Camera class for handling Allied Vision GigE cameras using the -vmbpy library, which links to the vendor's Vimba X C SDK. vmbpy talks to the camera -directly over GigE Vision / GenICam - no EPICS IOC or areaDetector driver is involved. +This module provides a Camera class for handling Allied Vision GigE cameras using Aravis, +the open-source GenICam/GigE Vision library. Aravis implements the GigE Vision network +protocol (GVCP/GVSP) itself, so it talks to the camera directly - no vendor SDK, account, +or EPICS IOC/areaDetector driver is involved. Aravis is accessed here through its GObject +Introspection Python bindings (PyGObject / `gi.repository.Aravis`). -The Camera class is the high level interface, and only opens the vmbpy VmbSystem/Camera -context when on_connect() is called. This allows for lazy initialization of the camera, -so CI/CD pipelines can run without vmbpy or the Vimba X runtime/GenTL producer installed +The Camera class is the high level interface, and only opens the Aravis camera connection +when on_connect() is called. This allows for lazy initialization of the camera, so CI/CD +pipelines can run without PyGObject or the Aravis GObject Introspection typelib installed on the system. -Cameras are identified by their GigE Vision ID/serial string (VmbSystem.get_camera_by_id), -which is resolved via GigE discovery broadcast and does not depend on the camera's current -IP address. +Cameras are identified by their Aravis device ID string (e.g. as reported by `arv-tool-0.8` +or `Aravis.get_device_id()`), which is resolved via GigE Vision discovery broadcast and +does not depend on the camera's current IP address. """ from __future__ import annotations @@ -24,32 +26,38 @@ from bec_lib.logger import bec_logger logger = bec_logger.logger try: - import vmbpy -except ImportError as exc: - logger.warning(f"The vmbpy library is not properly installed : {exc}") - vmbpy = None # type: ignore[assignment] + import gi + + gi.require_version("Aravis", "0.8") + from gi.repository import Aravis, GLib +except (ImportError, ValueError) as exc: + logger.warning(f"The Aravis library is not properly installed : {exc}") + Aravis = None # type: ignore[assignment] + GLib = None # type: ignore[assignment] class Camera: - """High level camera wrapper for Allied Vision GigE cameras via vmbpy. + """High level camera wrapper for Allied Vision GigE cameras via Aravis. Args: - camera_id (str): The vmbpy camera ID/serial string (e.g. "DEV_1AB22C000000"). - Resolved via GigE Vision discovery broadcast, independent of the camera's - current IP address. + camera_id (str): The Aravis device ID string (e.g. as reported by `arv-tool-0.8` + or `Aravis.get_device_id()`). Resolved via GigE Vision discovery broadcast, + independent of the camera's current IP address. """ def __init__(self, camera_id: str, connect: bool = True): - if vmbpy is None: + if Aravis is None: raise ImportError( - "The vmbpy library is not installed or the Vimba X runtime/GenTL producer" - " is missing. Please check your Python environment and Vimba X installation." + "The Aravis library is not installed, or its GObject Introspection typelib" + " is missing. Please check your Python environment (PyGObject) and Aravis" + " installation." ) - self.vmbpy = vmbpy + self.Aravis = Aravis + self.GLib = GLib self.camera_id = camera_id - self._vmb = None self._cam = None self._connected = False + self._pixel_format: str | None = None self.width = 0 self.height = 0 @@ -67,36 +75,19 @@ class Camera: if self._connected: logger.warning("Camera is already connected.") return - self._vmb = self.vmbpy.VmbSystem.get_instance() - self._vmb.__enter__() try: - self._cam = self._vmb.get_camera_by_id(self.camera_id) - self._cam.__enter__() - except Exception: - self._vmb.__exit__(None, None, None) - self._vmb = None + self._cam = self.Aravis.Camera.new(self.camera_id) + except self.GLib.Error: self._cam = None raise self._configure_pixel_format() - self.width = self._feature("Width").get() - self.height = self._feature("Height").get() + self.width, self.height = self._cam.get_region()[2:] self._connected = True def on_disconnect(self, delay_after: float = 0.3): """Disconnect from the camera and optionally wait a short time for driver cleanup.""" try: - if self._cam is not None: - try: - self._cam.__exit__(None, None, None) - except Exception as e: # pylint: disable=broad-except - logger.info(f"Error while closing camera context: {e}") - if self._vmb is not None: - try: - self._vmb.__exit__(None, None, None) - except Exception as e: # pylint: disable=broad-except - logger.info(f"Error while closing VmbSystem context: {e}") - self._cam = None - self._vmb = None + self._cam = None # drop the reference; Aravis closes the socket on finalization self._connected = False if delay_after > 0: time.sleep(delay_after) @@ -104,59 +95,54 @@ class Camera: except Exception as e: # pylint: disable=broad-except logger.info(f"Error during camera disconnection: {e}") - def _feature(self, name: str): - """Return the GenICam feature object for the given name.""" - return self._cam.get_feature_by_name(name) - def _configure_pixel_format(self): """Set the camera to a supported monochrome pixel format.""" - supported = self._cam.get_pixel_formats() - if self.vmbpy.PixelFormat.Mono8 in supported: - self._cam.set_pixel_format(self.vmbpy.PixelFormat.Mono8) - elif self.vmbpy.PixelFormat.Mono12 in supported: - self._cam.set_pixel_format(self.vmbpy.PixelFormat.Mono12) - else: - raise ValueError(f"No supported monochrome pixel format available: {supported}") + for fmt in ("Mono8", "Mono12"): + try: + self._cam.set_pixel_format_from_string(fmt) + except self.GLib.Error: + continue + self._pixel_format = fmt + return + raise ValueError( + f"No supported monochrome pixel format available on camera {self.camera_id}." + ) @property def exposure_time(self) -> float: """Get the exposure time of the camera, in microseconds.""" - return self._feature("ExposureTime").get() + return self._cam.get_exposure_time() @exposure_time.setter def exposure_time(self, value: float): """Set the exposure time of the camera, in microseconds.""" - self._feature("ExposureTime").set(value) + self._cam.set_exposure_time(value) @property def gain(self) -> float: """Get the gain of the camera.""" - return self._feature("Gain").get() + return self._cam.get_gain() @gain.setter def gain(self, value: float): """Set the gain of the camera.""" - self._feature("Gain").set(value) + self._cam.set_gain(value) def set_roi(self, x: int, y: int, width: int, height: int): """Set the region of interest (ROI) for the camera sensor readout.""" - # Reset offsets first so a growing width/height isn't rejected because the - # previous offset + new size would exceed the sensor bounds. - self._feature("OffsetX").set(0) - self._feature("OffsetY").set(0) + # Reset to the full sensor first so a growing width/height isn't rejected because + # the previous offset + new size would exceed the sensor bounds. + _, max_w = self._cam.get_width_bounds() + _, max_h = self._cam.get_height_bounds() + self._cam.set_region(0, 0, max_w, max_h) - width = self._align(width, self._feature("Width").get_increment()) - height = self._align(height, self._feature("Height").get_increment()) - self._feature("Width").set(width) - self._feature("Height").set(height) + width = self._align(width, self._cam.get_width_increment()) + height = self._align(height, self._cam.get_height_increment()) + x = self._align(x, self._cam.get_x_offset_increment()) + y = self._align(y, self._cam.get_y_offset_increment()) + self._cam.set_region(x, y, width, height) - x = self._align(x, self._feature("OffsetX").get_increment()) - y = self._align(y, self._feature("OffsetY").get_increment()) - self._feature("OffsetX").set(x) - self._feature("OffsetY").set(y) - - self.width = self._feature("Width").get() - self.height = self._feature("Height").get() + self.width, self.height = self._cam.get_region()[2:] logger.info(f"ROI set to: x={x}, y={y}, width={self.width}, height={self.height}") @staticmethod @@ -172,19 +158,21 @@ class Camera: self._rate_limited_warning_log(f"Camera with id {self.camera_id} is not connected.") return None try: - frame = self._cam.get_frame(timeout_ms=1000) - except self.vmbpy.VmbTimeout: + buffer = self._cam.acquisition(1_000_000) # timeout in microseconds + except self.GLib.Error as exc: self._rate_limited_warning_log( - f"Timeout waiting for frame from camera {self.camera_id}." + f"Timeout/error acquiring frame from camera {self.camera_id}: {exc}" ) return None - except (self.vmbpy.VmbFeatureError, self.vmbpy.VmbCameraError) as exc: - logger.error(f"Error while acquiring frame from camera {self.camera_id}: {exc}") + if buffer is None or buffer.get_status() != self.Aravis.BufferStatus.SUCCESS: + self._rate_limited_warning_log( + f"Invalid buffer from camera {self.camera_id}: " + f"{buffer.get_status() if buffer is not None else 'no buffer'}." + ) return None - img = frame.as_numpy_ndarray() - if img.ndim == 3 and img.shape[2] == 1: - img = img[:, :, 0] - return img + dtype = np.uint16 if self._pixel_format == "Mono12" else np.uint8 + data = np.frombuffer(buffer.get_data(), dtype=dtype) + return data.reshape(buffer.get_image_height(), buffer.get_image_width()) def set_camera_rate_limiting(self, enabled: bool, rate_limit_s: float | None = None): if rate_limit_s is not None: @@ -205,5 +193,5 @@ class Camera: if __name__ == "__main__": # Example usage - camera = Camera(camera_id="DEV_1AB22C000000") + camera = Camera(camera_id="Allied Vision Technologies-50-0536885929") camera.on_connect() diff --git a/csaxs_bec/devices/sim/sim_cameras.py b/csaxs_bec/devices/sim/sim_cameras.py index 944b116..95505a0 100644 --- a/csaxs_bec/devices/sim/sim_cameras.py +++ b/csaxs_bec/devices/sim/sim_cameras.py @@ -1,9 +1,9 @@ """ Simulated flOMNI cameras. -`SimWebcamViewer`, `SimIDSCamera` and `SimAlliedVisionVmbCamera` reuse the real device +`SimWebcamViewer`, `SimIDSCamera` and `SimAlliedVisionAravisCamera` reuse the real device classes and replace only the frame source: the webcam viewer's MJPEG HTTP stream, the IDS -camera's pyueye backend, and the AlliedVision camera's vmbpy backend, respectively. +camera's pyueye backend, and the AlliedVision camera's Aravis backend, respectively. All signals (`preview` / `image`, `roi_signal`, `live_mode_enabled`), the USER_ACCESS methods (`start_live_mode`, `set_rect_roi`, `get_last_image`, ...) and the ROI/trigger logic therefore run through the unmodified code paths, so GUIs and the X-ray eye @@ -20,7 +20,9 @@ from __future__ import annotations import numpy as np from bec_lib.logger import bec_logger -from csaxs_bec.devices.allied_vision_cameras.allied_vision_vmb_camera import AlliedVisionVmbCamera +from csaxs_bec.devices.allied_vision_cameras.allied_vision_aravis_camera import ( + AlliedVisionAravisCamera, +) from csaxs_bec.devices.ids_cameras.ids_camera import IDSCamera from csaxs_bec.devices.omny.webcam_viewer import WebcamViewer @@ -203,8 +205,8 @@ class _SimAlliedVisionBackend: self._gain = value -class SimAlliedVisionVmbCamera(AlliedVisionVmbCamera): - """AlliedVisionVmbCamera with the vmbpy backend replaced by a synthetic frame source.""" +class SimAlliedVisionAravisCamera(AlliedVisionAravisCamera): + """AlliedVisionAravisCamera with the Aravis backend replaced by a synthetic frame source.""" def __init__( self, diff --git a/pyproject.toml b/pyproject.toml index 24b43f4..8c0d472 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "rich", "pyepics", "pyueye", # for the IDS uEye camera - "vmbpy", # for the AlliedVision Alvium camera (native SDK, no EPICS IOC) + "pygobject", # for the AlliedVision Alvium camera (native Aravis/GenICam, no EPICS IOC) "bec_widgets", "zmq", "opencv-python", diff --git a/tests/tests_devices/test_allied_vision_aravis_camera.py b/tests/tests_devices/test_allied_vision_aravis_camera.py index bc68457..587da56 100644 --- a/tests/tests_devices/test_allied_vision_aravis_camera.py +++ b/tests/tests_devices/test_allied_vision_aravis_camera.py @@ -1,20 +1,20 @@ -"""Unit tests for the AlliedVisionVmbCamera device.""" +"""Unit tests for the AlliedVisionAravisCamera device.""" from unittest import mock import numpy as np import pytest -from csaxs_bec.devices.allied_vision_cameras.allied_vision_vmb_camera import ( - AlliedVisionVmbCamera, +from csaxs_bec.devices.allied_vision_cameras.allied_vision_aravis_camera import ( + AlliedVisionAravisCamera, ) @pytest.fixture(scope="function") def allied_vision_camera(): - """Fixture for creating an instance of the AlliedVisionVmbCamera.""" - camera = AlliedVisionVmbCamera( - name="test_camera", camera_id="DEV_TEST", prefix="test:", scan_info=None, live_mode=False + """Fixture for creating an instance of the AlliedVisionAravisCamera.""" + camera = AlliedVisionAravisCamera( + name="test_camera", camera_id="TEST-DEV", prefix="test:", scan_info=None, live_mode=False ) # Mock camera connection and attributes camera.cam = mock.Mock() -- 2.54.0 From bb643cac4ebbde83e1bfd35018ccd067509ed675 Mon Sep 17 00:00:00 2001 From: x01dc Date: Fri, 24 Jul 2026 12:08:54 +0200 Subject: [PATCH 4/8] test config file --- .../device_configs/allied_vision_test.yaml | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 csaxs_bec/device_configs/allied_vision_test.yaml diff --git a/csaxs_bec/device_configs/allied_vision_test.yaml b/csaxs_bec/device_configs/allied_vision_test.yaml new file mode 100644 index 0000000..ac6c38b --- /dev/null +++ b/csaxs_bec/device_configs/allied_vision_test.yaml @@ -0,0 +1,46 @@ +# eiger_1_5: +# description: Eiger 1.5M in-vacuum detector +# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M +# deviceConfig: +# detector_distance: 2150 +# beam_center: [860, 1219] +# onFailure: raise +# enabled: True +# readoutPriority: async +# softwareTrigger: False + +# eiger_9: +# description: Eiger 9M detector +# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M +# deviceConfig: +# detector_distance: 2200 +# beam_center: [870, 1203] +# onFailure: raise +# enabled: True +# readoutPriority: async +# softwareTrigger: False + +# ids_cam: +# description: IDS camera for live image acquisition +# deviceClass: csaxs_bec.devices.ids_cameras.IDSCamera +# deviceConfig: +# camera_id: 201 +# bits_per_pixel: 24 +# m_n_colormode: 1 +# live_mode: True +# onFailure: raise +# enabled: true +# readoutPriority: async +# softwareTrigger: True + +allied_vision_cam: + description: AlliedVision Alvium G1-507m camera for live image acquisition (native Aravis/GigE Vision, no EPICS IOC) + deviceClass: csaxs_bec.devices.allied_vision_cameras.AlliedVisionAravisCamera + deviceConfig: + camera_id: "Allied Vision Technologies-50-0536885929" # Aravis device ID; resolved via discovery, independent of current IP + live_mode: True + onFailure: raise + enabled: true + readoutPriority: async + softwareTrigger: True + -- 2.54.0 From a96d24a3cc694c71744bb1df0d3f9ffe680a5de8 Mon Sep 17 00:00:00 2001 From: x01dc Date: Tue, 28 Jul 2026 10:16:18 +0200 Subject: [PATCH 5/8] fix(devices): use real camera_id, disable auto live-mode, pin pygobject Update the placeholder camera_id in allied_vision_test.yaml/bl_detectors.yaml to the Aravis device ID discovered on the actual Alvium G1-507m hardware, and set live_mode back to False (explicit, not deleted) so the camera stays idle until start_live_mode() is called -- matching every real IDS camera config, instead of auto-starting its live-mode polling thread as soon as the device connects. Pin pygobject<3.52 in pyproject.toml: 3.52+ requires girepository-2.0 (GLib >=2.80/gobject-introspection >=1.80), which this RHEL9 host's system packages don't provide (only the older girepository-1.0 via gobject-introspection 1.68) -- unpinned pip install fails at build time. Document both the camera_id lookup and the pin in the README. --- csaxs_bec/device_configs/allied_vision_test.yaml | 4 ++-- csaxs_bec/device_configs/bl_detectors.yaml | 2 +- csaxs_bec/devices/allied_vision_cameras/README.md | 9 +++++++-- pyproject.toml | 7 ++++++- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/csaxs_bec/device_configs/allied_vision_test.yaml b/csaxs_bec/device_configs/allied_vision_test.yaml index ac6c38b..d9f7a40 100644 --- a/csaxs_bec/device_configs/allied_vision_test.yaml +++ b/csaxs_bec/device_configs/allied_vision_test.yaml @@ -37,8 +37,8 @@ allied_vision_cam: description: AlliedVision Alvium G1-507m camera for live image acquisition (native Aravis/GigE Vision, no EPICS IOC) deviceClass: csaxs_bec.devices.allied_vision_cameras.AlliedVisionAravisCamera deviceConfig: - camera_id: "Allied Vision Technologies-50-0536885929" # Aravis device ID; resolved via discovery, independent of current IP - live_mode: True + camera_id: "Allied Vision-Alvium G1-507m-06J41" # Aravis device ID; resolved via discovery, independent of current IP + live_mode: False onFailure: raise enabled: true readoutPriority: async diff --git a/csaxs_bec/device_configs/bl_detectors.yaml b/csaxs_bec/device_configs/bl_detectors.yaml index 1d15c3a..9983d8a 100644 --- a/csaxs_bec/device_configs/bl_detectors.yaml +++ b/csaxs_bec/device_configs/bl_detectors.yaml @@ -37,7 +37,7 @@ eiger_1_5: # description: AlliedVision Alvium G1-507m camera for live image acquisition (native Aravis/GigE Vision, no EPICS IOC) # deviceClass: csaxs_bec.devices.allied_vision_cameras.AlliedVisionAravisCamera # deviceConfig: -# camera_id: "Allied Vision Technologies-50-0536885929" # Aravis device ID; resolved via discovery, independent of current IP +# camera_id: "Allied Vision-Alvium G1-507m-06J41" # Aravis device ID; resolved via discovery, independent of current IP # live_mode: True # onFailure: raise # enabled: true diff --git a/csaxs_bec/devices/allied_vision_cameras/README.md b/csaxs_bec/devices/allied_vision_cameras/README.md index fde5c5d..e0d1b43 100644 --- a/csaxs_bec/devices/allied_vision_cameras/README.md +++ b/csaxs_bec/devices/allied_vision_cameras/README.md @@ -29,8 +29,13 @@ needed). environment, same as the IDS camera tests run without `pyueye`. - **Talking to real hardware** — two things, both local to the machine running the device server (or wherever you instantiate `AlliedVisionAravisCamera`/`Camera` directly): - 1. **PyGObject** (`pygobject`, the `gi` module) — already listed in `pyproject.toml`. - It's the GObject Introspection bridge, not Aravis itself. + 1. **PyGObject** (`pygobject`, the `gi` module) — already listed in `pyproject.toml`, + pinned to `<3.52`. It's the GObject Introspection bridge, not Aravis itself. The pin + matters: PyGObject 3.52+ requires `girepository-2.0` (GLib >=2.80 / + gobject-introspection >=1.80), which RHEL9's system packages don't provide (only the + older `girepository-1.0` via `gobject-introspection` 1.68) — `pip install pygobject` + unpinned fails at build time with `Dependency 'girepository-2.0' is required but not + found`. Unpin once deployment hosts move to a newer GLib/gobject-introspection. 2. **Aravis** — a native C library with a GObject Introspection typelib. It is *not* a pip package; it comes from the system package manager (or building from source), same class of local, non-pip dependency as the Vimba X SDK would have been, or the DLLs diff --git a/pyproject.toml b/pyproject.toml index 8c0d472..3adeeaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,12 @@ dependencies = [ "rich", "pyepics", "pyueye", # for the IDS uEye camera - "pygobject", # for the AlliedVision Alvium camera (native Aravis/GenICam, no EPICS IOC) + "pygobject<3.52", # for the AlliedVision Alvium camera (native Aravis/GenICam, no EPICS IOC) + # pinned below 3.52: that version switched to requiring + # girepository-2.0 (GLib >=2.80/gobject-introspection >=1.80), + # which RHEL9's system packages don't provide (only the older + # girepository-1.0 via gobject-introspection 1.68) -- unpin once + # deployment hosts are upgraded past that. "bec_widgets", "zmq", "opencv-python", -- 2.54.0 From 285691b62936a138b6bfa25210a9cffbcfb6ba74 Mon Sep 17 00:00:00 2001 From: x01dc Date: Tue, 28 Jul 2026 10:26:40 +0200 Subject: [PATCH 6/8] feat(devices): add live-mode FPS measurement to AlliedVisionAravisCamera Port get_live_fps()/live_mode_poll_interval_s from IDSCamera (added on main): a rolling deque of the last 10 live-mode push timestamps, exposed as a measured frame rate rather than assuming the nominal poll interval is actually achieved -- verified against real hardware, where the Alvium G1-507m's actual acquisition/readout time brings the achievable rate to ~3 fps despite the 0.2s (5 Hz) nominal poll interval. Mirrors the same three unit tests added for IDSCamera's version. --- .../allied_vision_aravis_camera.py | 25 +++++++++++++- .../test_allied_vision_aravis_camera.py | 33 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py b/csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py index ce3ecdb..5e41faf 100644 --- a/csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py +++ b/csaxs_bec/devices/allied_vision_cameras/allied_vision_aravis_camera.py @@ -3,6 +3,8 @@ from __future__ import annotations import threading +import time +from collections import deque from typing import TYPE_CHECKING import numpy as np @@ -62,6 +64,7 @@ class AlliedVisionAravisCamera(PSIDeviceBase): "set_exposure_time", "get_gain", "set_gain", + "get_live_fps", ] def __init__( @@ -74,6 +77,7 @@ class AlliedVisionAravisCamera(PSIDeviceBase): live_mode: bool = False, num_rotation_90: int = 0, transpose: bool = False, + live_mode_poll_interval_s: float = 0.2, **kwargs, ): """Initialize the Allied Vision Camera. @@ -85,10 +89,18 @@ class AlliedVisionAravisCamera(PSIDeviceBase): prefix (str): Prefix for the device. scan_info (ScanInfo | None): Scan information for the device. live_mode (bool): Whether to enable live mode for the camera. + live_mode_poll_interval_s (float): Delay between frame grabs in + the live-mode loop. Lower this for cameras/use cases that + need a higher live-mode push rate; the achievable rate is + still bounded by the camera's own exposure/acquisition time. """ super().__init__(name=name, prefix=prefix, scan_info=scan_info, **kwargs) self._live_mode_thread: threading.Thread | None = None self._stop_live_mode_event: threading.Event = threading.Event() + self._live_mode_poll_interval_s = live_mode_poll_interval_s + # Rolling buffer of push timestamps from _live_mode_loop, used to + # measure the actual live-mode frame rate (see get_live_fps()). + self._live_frame_times: deque[float] = deque(maxlen=10) self.cam = Camera(camera_id=camera_id, connect=False) self._inputs = {"live_mode": live_mode} self._mask = np.zeros((1, 1), dtype=np.uint8) @@ -181,9 +193,20 @@ class AlliedVisionAravisCamera(PSIDeviceBase): except Exception as e: logger.error(f"Error in live mode loop: {e}") break - stop_event.wait(0.2) # 5 Hz + self._live_frame_times.append(time.time()) + stop_event.wait(self._live_mode_poll_interval_s) self.cam.set_camera_rate_limiting(False) + def get_live_fps(self) -> float | None: + """Measured live-mode frame rate, from the timestamps of the last + pushed frames. Returns None if live mode hasn't pushed at least two + frames yet (e.g. not running, or just started). + """ + times = self._live_frame_times + if len(times) < 2: + return None + return (len(times) - 1) / (times[-1] - times[0]) + def process_data(self, image: np.ndarray | None): """Process the image data before sending it to the preview signal.""" if image is None: diff --git a/tests/tests_devices/test_allied_vision_aravis_camera.py b/tests/tests_devices/test_allied_vision_aravis_camera.py index 587da56..b3134d0 100644 --- a/tests/tests_devices/test_allied_vision_aravis_camera.py +++ b/tests/tests_devices/test_allied_vision_aravis_camera.py @@ -1,5 +1,6 @@ """Unit tests for the AlliedVisionAravisCamera device.""" +import threading from unittest import mock import numpy as np @@ -80,6 +81,38 @@ def test_get_last_image(allied_vision_camera): assert np.array_equal(result, test_image) +def test_get_live_fps_none_before_frames(allied_vision_camera): + """No frames pushed yet -> get_live_fps() returns None.""" + assert allied_vision_camera.get_live_fps() is None + + +def test_get_live_fps_computes_rate(allied_vision_camera): + """get_live_fps() computes rate from the recorded push timestamps.""" + allied_vision_camera._live_frame_times.extend([0.0, 0.2, 0.4, 0.6, 0.8]) + assert allied_vision_camera.get_live_fps() == pytest.approx(5.0) + + +def test_live_mode_loop_records_frame_times(allied_vision_camera): + """_live_mode_loop() appends one timestamp per pushed frame.""" + allied_vision_camera.cam.get_image_data = mock.Mock( + return_value=np.zeros((2, 2), dtype=np.uint8) + ) + stop_event = threading.Event() + calls = {"n": 0} + + def fake_wait(timeout): + calls["n"] += 1 + if calls["n"] >= 3: + stop_event.set() + return False + + stop_event.wait = fake_wait + allied_vision_camera._live_mode_loop(stop_event) + + assert len(allied_vision_camera._live_frame_times) == 3 + assert allied_vision_camera.get_live_fps() is not None + + def test_on_destroy(allied_vision_camera): """Test the on_destroy method to ensure it cleans up resources.""" allied_vision_camera.cam.on_disconnect = mock.Mock() -- 2.54.0 From 2bd161cd63be138b35752aa8fa2229aeb6ad851c Mon Sep 17 00:00:00 2001 From: x01dc Date: Tue, 28 Jul 2026 10:46:41 +0200 Subject: [PATCH 7/8] config(devices): drop allied_vision_test.yaml, add commented example to ptycho_omny allied_vision_test.yaml was only ever a scratch config for exercising the camera during bring-up; the same commented allied_vision_cam example already lives in bl_detectors.yaml, so keep that one and add the matching commented block to ptycho_omny.yaml instead of a separate file. --- .../device_configs/allied_vision_test.yaml | 46 ------------------- csaxs_bec/device_configs/ptycho_omny.yaml | 13 ++++++ 2 files changed, 13 insertions(+), 46 deletions(-) delete mode 100644 csaxs_bec/device_configs/allied_vision_test.yaml diff --git a/csaxs_bec/device_configs/allied_vision_test.yaml b/csaxs_bec/device_configs/allied_vision_test.yaml deleted file mode 100644 index d9f7a40..0000000 --- a/csaxs_bec/device_configs/allied_vision_test.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# eiger_1_5: -# description: Eiger 1.5M in-vacuum detector -# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M -# deviceConfig: -# detector_distance: 2150 -# beam_center: [860, 1219] -# onFailure: raise -# enabled: True -# readoutPriority: async -# softwareTrigger: False - -# eiger_9: -# description: Eiger 9M detector -# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M -# deviceConfig: -# detector_distance: 2200 -# beam_center: [870, 1203] -# onFailure: raise -# enabled: True -# readoutPriority: async -# softwareTrigger: False - -# ids_cam: -# description: IDS camera for live image acquisition -# deviceClass: csaxs_bec.devices.ids_cameras.IDSCamera -# deviceConfig: -# camera_id: 201 -# bits_per_pixel: 24 -# m_n_colormode: 1 -# live_mode: True -# onFailure: raise -# enabled: true -# readoutPriority: async -# softwareTrigger: True - -allied_vision_cam: - description: AlliedVision Alvium G1-507m camera for live image acquisition (native Aravis/GigE Vision, no EPICS IOC) - deviceClass: csaxs_bec.devices.allied_vision_cameras.AlliedVisionAravisCamera - deviceConfig: - camera_id: "Allied Vision-Alvium G1-507m-06J41" # Aravis device ID; resolved via discovery, independent of current IP - live_mode: False - onFailure: raise - enabled: true - readoutPriority: async - softwareTrigger: True - diff --git a/csaxs_bec/device_configs/ptycho_omny.yaml b/csaxs_bec/device_configs/ptycho_omny.yaml index ebdfa1e..03edf18 100755 --- a/csaxs_bec/device_configs/ptycho_omny.yaml +++ b/csaxs_bec/device_configs/ptycho_omny.yaml @@ -61,6 +61,19 @@ cam203: deviceTags: - ptycho_omny +# allied_vision_cam: +# description: AlliedVision Alvium G1-507m camera for live image acquisition (native Aravis/GigE Vision, no EPICS IOC) +# deviceClass: csaxs_bec.devices.allied_vision_cameras.AlliedVisionAravisCamera +# deviceConfig: +# camera_id: "Allied Vision-Alvium G1-507m-06J41" # Aravis device ID; resolved via discovery, independent of current IP +# live_mode: True +# onFailure: raise +# enabled: true +# readoutPriority: async +# softwareTrigger: True +# deviceTags: +# - ptycho_omny + ############################################################ #################### OMNY RT motors ######################## ############################################################ -- 2.54.0 From c42595922dae2064a7434adf1dfd3cc55ab21beb Mon Sep 17 00:00:00 2001 From: x01dc Date: Tue, 28 Jul 2026 10:46:50 +0200 Subject: [PATCH 8/8] fix(devices): defer Aravis import-guard from Camera.__init__ to on_connect() Camera.__init__ raised ImportError unconditionally whenever the Aravis GObject-Introspection library wasn't importable, even with connect=False -- contradicting the module's own "lazy initialization" docstring and breaking every unit test in CI, since Aravis is a system library (not pip-installable) that CI never has installed. The test fixture builds a real AlliedVisionAravisCamera(connect=False) and only mocks camera.cam afterward, so the raise fired before mocking ever got a chance to run. Move the guard into on_connect(), mirroring IDSCamera's working pattern (its equivalent check lives in the object only built by on_connect(), never at construction time). Construction is now independent of whether Aravis is installed; on_connect() still raises the same clear error the moment something actually tries to talk to hardware. --- .../allied_vision_cameras/base_integration/camera.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/csaxs_bec/devices/allied_vision_cameras/base_integration/camera.py b/csaxs_bec/devices/allied_vision_cameras/base_integration/camera.py index 0ce200e..26e92a4 100644 --- a/csaxs_bec/devices/allied_vision_cameras/base_integration/camera.py +++ b/csaxs_bec/devices/allied_vision_cameras/base_integration/camera.py @@ -46,12 +46,6 @@ class Camera: """ def __init__(self, camera_id: str, connect: bool = True): - if Aravis is None: - raise ImportError( - "The Aravis library is not installed, or its GObject Introspection typelib" - " is missing. Please check your Python environment (PyGObject) and Aravis" - " installation." - ) self.Aravis = Aravis self.GLib = GLib self.camera_id = camera_id @@ -72,6 +66,12 @@ class Camera: def on_connect(self): """Connect to the camera and initialize it.""" + if self.Aravis is None: + raise ImportError( + "The Aravis library is not installed, or its GObject Introspection typelib" + " is missing. Please check your Python environment (PyGObject) and Aravis" + " installation." + ) if self._connected: logger.warning("Camera is already connected.") return -- 2.54.0