Feat/allied vision #279

Merged
holler merged 9 commits from feat/allied_vision into main 2026-07-28 10:59:04 +02:00
10 changed files with 901 additions and 2 deletions
@@ -38,3 +38,16 @@ eiger_1_5:
# softwareTrigger: True
# deviceTags:
# - bl_detectors
# 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:
# - bl_detectors
+13
View File
@@ -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 ########################
############################################################
@@ -0,0 +1,177 @@
# Allied Vision camera (native Aravis integration)
`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 **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 AlliedVisionAravisCamera
├── allied_vision_aravis_camera.py # ophyd device (PSIDeviceBase)
└── base_integration/
└── camera.py # low-level Aravis wrapper (class Camera)
```
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_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 `AlliedVisionAravisCamera`/`Camera` directly):
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
`pyueye` needs for the IDS cameras.
### Installing PyGObject + Aravis (Linux)
`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. **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).
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 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 Aravis loaded and can broadcast
discovery without error). `arv-tool-0.8 --list` (from `aravis-tools`) does the same check
from the shell.
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 Aravis device ID string — not an
IP address, and not the same format vmbpy would have used. For GigE Vision devices it's
typically `"<Vendor>-<Model>-<Serial>"` (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 PyGObject + Aravis are installed on a machine on the same network segment as the
camera, list connected cameras and their IDs with:
```bash
arv-tool-0.8 --list
```
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_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
`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 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 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() # 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
`dev.allied_vision_cam.start_live_mode()` produces a live preview.
## Notes / open items
- `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.
@@ -0,0 +1 @@
from .allied_vision_aravis_camera import AlliedVisionAravisCamera
@@ -0,0 +1,276 @@
"""Native (non-EPICS) Allied Vision camera class for cSAXS."""
from __future__ import annotations
import threading
import time
from collections import deque
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 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 Aravis, the open-source GenICam/GigE Vision
library, directly over GigE Vision - no vendor SDK or 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",
"get_live_fps",
]
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,
live_mode_poll_interval_s: float = 0.2,
**kwargs,
):
"""Initialize the Allied Vision Camera.
Args:
name (str): Name of the 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.
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)
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
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:
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 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}.")
@@ -0,0 +1,197 @@
"""
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 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 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
import atexit
import time
import numpy as np
from bec_lib.logger import bec_logger
logger = bec_logger.logger
try:
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 Aravis.
Args:
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):
self.Aravis = Aravis
self.GLib = GLib
self.camera_id = camera_id
self._cam = None
self._connected = False
self._pixel_format: str | None = None
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.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
try:
self._cam = self.Aravis.Camera.new(self.camera_id)
except self.GLib.Error:
self._cam = None
raise
self._configure_pixel_format()
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:
self._cam = None # drop the reference; Aravis closes the socket on finalization
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 _configure_pixel_format(self):
"""Set the camera to a supported monochrome pixel format."""
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._cam.get_exposure_time()
@exposure_time.setter
def exposure_time(self, value: float):
"""Set the exposure time of the camera, in microseconds."""
self._cam.set_exposure_time(value)
@property
def gain(self) -> float:
"""Get the gain of the camera."""
return self._cam.get_gain()
@gain.setter
def gain(self, value: float):
"""Set the gain of the camera."""
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 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._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)
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
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:
buffer = self._cam.acquisition(1_000_000) # timeout in microseconds
except self.GLib.Error as exc:
self._rate_limited_warning_log(
f"Timeout/error acquiring frame from camera {self.camera_id}: {exc}"
)
return None
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
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:
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="Allied Vision Technologies-50-0536885929")
camera.on_connect()
+80 -2
View File
@@ -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 `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 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
@@ -24,6 +25,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_aravis_camera import (
AlliedVisionAravisCamera,
)
from csaxs_bec.devices.ids_cameras.ids_camera import IDSCamera
from csaxs_bec.devices.omny.webcam_viewer import WebcamViewer
from csaxs_bec.devices.sim.sim_galil import SimGalilState
@@ -363,3 +367,77 @@ class SimIDSCamera(IDSCamera):
axis_offset_seed=sim_axis_offset_seed,
)
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 SimAlliedVisionAravisCamera(AlliedVisionAravisCamera):
"""AlliedVisionAravisCamera with the Aravis 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
)
+6
View File
@@ -22,6 +22,12 @@ dependencies = [
"rich",
"pyepics",
"pyueye", # for the IDS uEye camera
"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",
@@ -0,0 +1,138 @@
"""Unit tests for the AlliedVisionAravisCamera device."""
import threading
from unittest import mock
import numpy as np
import pytest
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 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()
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_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()
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