feat: init

This commit is contained in:
2025-12-04 16:07:30 +08:00
commit 262583a57f
681 changed files with 117578 additions and 0 deletions

View File

View File

@@ -0,0 +1,80 @@
"""Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only
(subject to the limitations in the disclaimer below)
# provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#"""
# Third Party Imports
from navigate.model.devices.camera.synthetic import SyntheticCamera
def test_start_camera(dummy_model):
model = dummy_model
for microscope_name in model.configuration["configuration"]["microscopes"].keys():
camera = SyntheticCamera(microscope_name, None, model.configuration)
assert (
camera.camera_parameters["hardware"]["serial_number"]
== model.configuration["configuration"]["microscopes"][microscope_name][
"camera"
]["hardware"]["serial_number"]
), f"didn't load correct camera parameter for microscope {microscope_name}"
# non-exist microscope name
microscope_name = (
model.configuration["configuration"]["microscopes"].keys()[0] + "_random_error"
)
raised_error = False
try:
_ = SyntheticCamera(microscope_name, None, model.configuration)
except NameError:
raised_error = True
assert (
raised_error
), "should raise NameError when the microscope name doesn't exist!"
def test_camera_base_functions(dummy_model):
import random
model = dummy_model
microscope_name = model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
camera = SyntheticCamera(microscope_name, None, model.configuration)
funcs = ["set_readout_direction", "calculate_light_sheet_exposure_time"]
args = [[random.random()], [random.random(), random.random()]]
for f, a in zip(funcs, args):
if a is not None:
getattr(camera, f)(*a)
else:
getattr(camera, f)()

View File

@@ -0,0 +1,193 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Third Party Imports
import pytest
import numpy as np
from navigate.model.devices.camera.synthetic import (
SyntheticCamera,
SyntheticCameraController,
)
@pytest.fixture(scope="class")
def synthetic_camera(dummy_model):
dummy_model = dummy_model
scc = SyntheticCameraController()
microscope_name = dummy_model.configuration["experiment"]["MicroscopeState"][
"microscope_name"
]
synthetic_camera = SyntheticCamera(microscope_name, scc, dummy_model.configuration)
return synthetic_camera
class TestSyntheticCamera:
"""Unit Test for Camera Synthetic Class"""
@pytest.fixture(autouse=True)
def _prepare_camera(self, synthetic_camera):
self.synthetic_camera = synthetic_camera
def test_synthetic_camera_attributes(self):
desired_attributes = [
"x_pixels",
"y_pixels",
"is_acquiring",
"_mean_background_count",
"_noise_sigma",
"camera_controller",
"current_frame_idx",
"data_buffer",
"num_of_frame",
"pre_frame_idx",
]
for da in desired_attributes:
assert hasattr(self.synthetic_camera, da)
def test_synthetic_camera_wheel_attributes_type(self):
desired_attributes = {
"x_pixels": int,
"y_pixels": int,
"is_acquiring": bool,
"_mean_background_count": int,
"_noise_sigma": np.float64,
# 'current_frame_idx': None,
# 'data_buffer': None,
# 'num_of_frame': None,
# 'pre_frame_idx': None,
}
for key in desired_attributes:
attribute = getattr(self.synthetic_camera, key)
print(key, type(attribute), desired_attributes[key])
assert type(attribute) == desired_attributes[key]
def test_synthetic_camera_methods(self):
methods = [
"report_settings",
"close_camera",
"set_sensor_mode",
"set_exposure_time",
"set_line_interval",
"set_binning",
"initialize_image_series",
"close_image_series",
"generate_new_frame",
"get_new_frame",
"set_ROI",
]
for m in methods:
assert hasattr(self.synthetic_camera, m) and callable(
getattr(self.synthetic_camera, m)
)
def test_synthetic_camera_wheel_method_calls(self):
self.synthetic_camera.report_settings()
self.synthetic_camera.close_camera()
self.synthetic_camera.set_sensor_mode(mode="test")
self.synthetic_camera.set_exposure_time(exposure_time=0.2)
self.synthetic_camera.set_line_interval(line_interval_time=1)
self.synthetic_camera.set_binning(binning_string="2x2")
self.synthetic_camera.initialize_image_series()
self.synthetic_camera.close_image_series()
self.synthetic_camera.get_new_frame()
self.synthetic_camera.set_ROI()
def test_synthetic_camera_exposure(self):
exposure_time = 200
self.synthetic_camera.set_exposure_time(exposure_time=exposure_time / 1000)
assert (exposure_time / 1000) == self.synthetic_camera.camera_exposure_time
def test_synthetic_camera_binning(self):
x_pixels = self.synthetic_camera.x_pixels
self.synthetic_camera.set_binning(binning_string="2x2")
assert self.synthetic_camera.x_binning == 2
assert self.synthetic_camera.y_binning == 2
assert type(self.synthetic_camera.x_binning) == int
assert type(self.synthetic_camera.y_binning) == int
assert self.synthetic_camera.x_pixels == x_pixels / 2
def test_synthetic_camera_initialize_image_series(self):
self.synthetic_camera.initialize_image_series()
assert self.synthetic_camera.num_of_frame == 100
assert self.synthetic_camera.data_buffer is None
assert self.synthetic_camera.current_frame_idx == 0
assert self.synthetic_camera.pre_frame_idx == 0
assert self.synthetic_camera.is_acquiring is True
def test_synthetic_camera_close_image_series(self):
self.synthetic_camera.close_image_series()
assert self.synthetic_camera.pre_frame_idx == 0
assert self.synthetic_camera.current_frame_idx == 0
assert self.synthetic_camera.is_acquiring is False
def test_synthetic_camera_acquire_images(self):
import random
from navigate.model.concurrency.concurrency_tools import SharedNDArray
number_of_frames = 100
data_buffer = [
SharedNDArray(shape=(2048, 2048), dtype="uint16")
for i in range(number_of_frames)
]
self.synthetic_camera.initialize_image_series(data_buffer, number_of_frames)
assert self.synthetic_camera.is_acquiring is True, "should be acquring"
frame_idx = 0
for i in range(10):
frame_num = random.randint(1, 30)
for j in range(frame_num):
self.synthetic_camera.generate_new_frame()
frames = self.synthetic_camera.get_new_frame()
assert len(frames) == frame_num, "frame number isn't right!"
assert frames[0] == frame_idx, "frame idx isn't right!"
frame_idx = (frame_idx + frame_num) % number_of_frames
self.synthetic_camera.close_image_series()
assert (
self.synthetic_camera.is_acquiring is False
), "is_acquiring should be False"
def test_synthetic_camera_set_roi(self):
self.synthetic_camera.set_ROI()
assert self.synthetic_camera.x_pixels == 2048
assert self.synthetic_camera.y_pixels == 2048
self.synthetic_camera.set_ROI(roi_height=500, roi_width=700)
assert self.synthetic_camera.x_pixels == 700
assert self.synthetic_camera.y_pixels == 500

View File

@@ -0,0 +1,618 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# Standard Library Imports
import pytest
from typing import Tuple
from unittest.mock import patch, MagicMock
import logging
import io
# Third Party Imports
import numpy as np
from numpy.testing import assert_array_equal
# Local Imports
from navigate.model.utils.exceptions import UserVisibleException
try:
import gxipy # noqa: F401
except:
@pytest.fixture(autouse=True)
def mock_daheng_module():
fake_gx = MagicMock()
with patch.dict("sys.modules", {"gxipy": fake_gx}):
yield fake_gx
@pytest.fixture
def mock_daheng_sdk():
"""Patch Daheng SDK (gxipy) and return mocked device + subsystems."""
with patch(
"navigate.model.devices.camera.daheng.gx.DeviceManager"
) as mock_device_manager:
device = _create_mock_device(mock_device_manager)
feature_control = _create_mock_feature_control()
data_stream, raw_image = _create_mock_image_pipeline()
_attach_mock_interfaces_to_device(device, feature_control, data_stream)
yield {
"device": device,
"feature_control": feature_control,
"data_stream": data_stream,
"raw_image": raw_image,
}
def _attach_mock_interfaces_to_device(device, feature_control, data_stream):
"""
Attach core SDK interfaces to a mock Daheng device.
In the gxipy SDK, once a device is opened, it provides two key subsystems:
- feature_control (via get_remote_device_feature_control()):
This is the interface for configuring camera hardware settings such as
exposure time, gain, trigger mode, binning, resolution, and ROI.
The SDK exposes these through feature "objects" with .get()/.set() methods.
- data_stream (accessed as a property):
This handles actual image acquisition. It provides methods to start/stop
streaming and to retrieve frames via .snap_image().
This function sets up mock versions of those subsystems to a MagicMock-based
device object, enabling testable interaction without requiring physical hardware.
Parameters
----------
device : MagicMock
The mocked gxipy.Device object to configure.
feature_control : MagicMock
Mocked feature control interface to simulate hardware parameters.
data_stream : MagicMock
Mocked data stream interface to simulate image capture.
"""
device.get_remote_device_feature_control.return_value = feature_control
device.data_stream = data_stream
def _create_mock_device(mock_device_manager) -> MagicMock:
"""
Create a fake Daheng device and configure DeviceManager return values.
This sets up:
- update_device_list() -> None
- get_device_list() -> list with one fake serial number
- open_device_by_index(i) -> the mock device
Returns
-------
MagicMock
A fake Daheng device object.
"""
mock_device = MagicMock(name="FakeDevice")
mock_device_manager.return_value.update_device_list.return_value = None
mock_device_manager.return_value.get_device_list.return_value = [{"sn": "1234"}]
mock_device_manager.return_value.open_device_by_index.return_value = mock_device
return mock_device
def _create_mock_feature_control() -> MagicMock:
"""
Create a fake FeatureControl interface that simulates Daheng camera settings.
Simulates:
- get_string_feature("DeviceSerialNumber").get() -> "1234"
- get_int_feature(...).get() -> 2048
- get_enum_feature(...).set(...) -> None
Returns
-------
MagicMock
A mock feature_control object.
"""
mock_feature_control = MagicMock(name="FakeFeatureControl")
mock_feature_control.get_string_feature.return_value.get.return_value = "1234"
mock_feature_control.get_int_feature.side_effect = lambda name: MagicMock(
get=MagicMock(return_value=2048)
)
mock_feature_control.get_enum_feature.return_value.set.return_value = None
return mock_feature_control
def _create_mock_image_pipeline() -> Tuple[MagicMock, MagicMock]:
"""
Create a mocked data stream and raw image pipeline.
Simulates:
- data_stream.snap_image() -> mock_raw_image
- raw_image.get_numpy_array() -> np.zeros((2048, 2048), dtype=np.uint16)
Returns
-------
Tuple[MagicMock, MagicMock]
(mock_data_stream, mock_raw_image)
"""
stream = MagicMock(name="FakeDataStream")
image = MagicMock(name="FakeRawImage")
stream.snap_image.return_value = image
image.get_numpy_array.return_value = np.zeros((2048, 2048), dtype=np.uint16)
return stream, image
@pytest.fixture
def camera(mock_daheng_sdk):
"""
Return a DahengCamera instance connected via mocked SDK.
The mock_daheng_sdk fixture is required to patch the SDK and simulate hardware.
It's not used directly in this function, but must be active when connect() is called.
"""
# Use the patched classmethod to simulate SDK connection.
# This is where mock_daheng_sdk enters.
from navigate.model.devices.camera.daheng import DahengCamera
# Minimal config object matching Navigate's expected schema
config = {
"configuration": {
"microscopes": {
"test_scope": {
"camera": {
"hardware": {
"serial_number": "1234",
}
}
}
}
}
}
camera = DahengCamera(
microscope_name="test_scope",
device_connection=mock_daheng_sdk["device"],
configuration=config,
)
# Initialize and return the test camera instance
return camera
@patch("navigate.model.devices.camera.daheng.gx.DeviceManager")
def test_connect_without_serial(mock_dm):
"""
Test that DahengCamera.connect() connects to the first camera if no serial number is provided.
This uses patching to replace the actual DeviceManager with a mock,
simulating a single connected camera with serial '1234'.
"""
mock_device = MagicMock()
# Simulate SDK returning one device with serial '1234'
mock_dm.return_value.get_device_list.return_value = [{"sn": "1234"}]
# Simulate opening that device returns our mock_device
mock_dm.return_value.open_device_by_index.return_value = mock_device
# Call connect without specifying serial number
from navigate.model.devices.camera.daheng import DahengCamera
device = DahengCamera.connect()
# Verify that we get the mocked device object
assert device == mock_device
@patch("navigate.model.devices.camera.daheng.gx.DeviceManager")
def test_connect_invalid_serial_raises(mock_dm):
"""
Test that DahengCamera.connect() raises a UserVisibleException if the
specified serial number does not match any connected camera.
This verifies the fallback else-block logic in the for-loop of connect().
"""
# Simulate one connected device with serial '1234'
mock_dm.return_value.get_device_list.return_value = [{"sn": "1234"}]
from navigate.model.devices.camera.daheng import DahengCamera
# Attempt to connect with a non-existent serial number
with pytest.raises(
UserVisibleException, match="Daheng camera with serial INVALID_SN not found."
):
DahengCamera.connect(serial_number="INVALID_SN")
def test_str(camera):
"""
Test the string representation of the DahengCamera object.
Ensures that the __str__ method includes the camera model name,
serial number, and connection status in the returned string.
"""
result = str(camera)
assert "MER2_1220_32U3C Camera" in result
assert "Serial: 1234" in result
assert "Connected" in result
def test_camera_connected(camera):
"""
Test that the camera object reports a connected state after setup.
This relies on the 'camera' fixture, which internally calls DahengCamera.connect()
and initializes the SDK state. Verifies that is_connected is True and the
device serial number is correctly cached.
"""
assert camera.is_connected
assert camera.device_serial_number == "1234"
def test_disconnect_clears_state(camera):
"""
Test that disconnect() resets internal state and marks camera as disconnected.
"""
camera.disconnect()
assert camera.device is None
assert camera.feature_control is None
assert camera.is_connected is False
assert camera.serial_number == "UNKNOWN"
def test_set_exposure_time(camera):
"""
Test that set_exposure_time() updates internal state and calls correct SDK feature.
"""
camera.set_exposure_time(0.1)
# Internal caching of exposure time (in seconds)
assert camera._exposure_time == 0.1
# Verifies that the SDK was told to get the 'ExposureTime' feature
camera.feature_control.get_float_feature.assert_called_with("ExposureTime")
def test_set_gain(camera):
"""
Ensure set_gain() calls the Gain feature with the expected float value.
"""
camera.set_gain(5.0)
camera.feature_control.get_float_feature.assert_called_with("Gain")
camera.feature_control.get_float_feature.return_value.set.assert_called_with(5.0)
def test_set_binning(camera):
"""
Test that set_binning() parses input string, updates binning values,
and accesses correct SDK features.
"""
result = camera.set_binning("2x2")
assert result is True
assert camera.x_binning == 2
assert camera.y_binning == 2
# Check that the SDK was asked for the correct feature names at least once
camera.feature_control.get_int_feature.assert_any_call("BinningHorizontal")
camera.feature_control.get_int_feature.assert_any_call("BinningVertical")
def test_set_invalid_ROI(camera):
"""
Test that set_ROI() returns False and logs a warning when given invalid dimensions.
"""
# Set invalid ROI parameters
roi_width = 9000
roi_height = 2048
center_x = 1000
center_y = 1000
logger = logging.getLogger("model")
logger.propagate = False # prevent sending logs to root CLI handler
stream = io.StringIO()
handler = logging.StreamHandler(stream)
logger.addHandler(handler)
camera.stop_acquisition()
handler.flush()
result = camera.set_ROI(
roi_width=roi_width, roi_height=roi_height, center_x=center_x, center_y=center_y
)
assert result is False
assert f"Invalid ROI dimensions: {roi_width}x{roi_height}" in stream.getvalue()
logger.removeHandler(handler)
def test_snap_image_returns_numpy_array(camera):
"""
Test that snap_image() calls the SDK and returns a NumPy array.
The camera fixture uses the mock_daheng_sdk fixture to simulate:
- A data stream whose snap_image() returns a fake image object
- An image object whose get_numpy_array() returns np.ndarray representing a fake image (zeros)
"""
result = camera.snap_image()
expected = np.zeros((2048, 2048), dtype=np.uint16)
assert_array_equal(result, expected)
camera.data_stream.snap_image.assert_called_once()
def test_snap_software_triggered_invalid_config(camera):
"""
Test that snap_software_triggered() raises if trigger config is invalid.
This mocks the 'TriggerMode' and 'TriggerSource' enum features to return
incorrect values ('OFF' and 'HARDWARE'), and verifies that the method
raises a UserVisibleException with a helpful message.
"""
# Override trigger mode/source with bad values
mock_enum_feature = MagicMock()
mock_enum_feature.get_current_entry.return_value.get_symbolic.side_effect = [
"OFF",
"HARDWARE",
]
camera.feature_control.get_enum_feature.return_value = mock_enum_feature
with pytest.raises(
UserVisibleException, match="TriggerMode='ON' and TriggerSource='SOFTWARE'"
):
camera.snap_software_triggered()
def test_send_software_trigger(camera):
"""
Test that send_software_trigger() calls the correct Daheng SDK command.
Verifies that the camera issues a 'TriggerSoftware' command via the
command feature interface and that send_command() is called exactly once.
"""
camera.send_software_trigger()
camera.feature_control.get_command_feature.assert_called_with("TriggerSoftware")
camera.feature_control.get_command_feature.return_value.send_command.assert_called_once()
def test_set_trigger_mode(camera):
"""
Test that set_trigger_mode() calls the correct enum feature and sets it to 'ON'.
"""
camera.set_trigger_mode("ON")
camera.feature_control.get_enum_feature.assert_called_with("TriggerMode")
camera.feature_control.get_enum_feature.return_value.set.assert_called_with("ON")
def test_set_trigger_source(camera):
"""
Test that set_trigger_source() selects the correct SDK enum feature and sets it to 'LINE1'.
'LINE1' refers to a physical input pin used for hardware triggering,
typically driven by a DAQ, microcontroller, or timing controller.
"""
camera.set_trigger_source("LINE1")
camera.feature_control.get_enum_feature.assert_called_with("TriggerSource")
camera.feature_control.get_enum_feature.return_value.set.assert_called_with("LINE1")
def test_initialize_and_start_acquisition(camera):
"""
Test that initialize_image_series and start_acquisition correctly
update internal state and interact with the SDK.
"""
# Create a fake image buffer with shape matching camera resolution
fake_buffer = [MagicMock(name=f"Frame{i}") for i in range(5)]
number_of_frames = 5
# Initialize acquisition
camera.initialize_image_series(
data_buffer=fake_buffer, number_of_frames=number_of_frames
)
# Assert acquisition is marked as started
assert camera.is_acquiring is True
assert camera._number_of_frames == number_of_frames
assert camera._frames_received == 0
assert camera._data_buffer == fake_buffer
# Start the acquisition and verify SDK interaction
camera.data_stream.start_stream.assert_called_once()
camera.feature_control.get_command_feature.assert_called_with("AcquisitionStart")
camera.feature_control.get_command_feature.return_value.send_command.assert_called_once()
def test_initialize_start_and_receive_image(camera):
"""
Test full acquisition flow:
- initialize_image_series()
- start_acquisition()
- get_new_frame() to simulate image reception
Verifies that the SDK methods are called, internal state is updated,
and image data is written to the circular buffer.
"""
fake_buffer = [MagicMock(name=f"Frame{i}") for i in range(3)]
number_of_frames = 3
camera.initialize_image_series(
data_buffer=fake_buffer, number_of_frames=number_of_frames
)
# Simulate receiving frames
for i in range(3):
frame_indices = camera.get_new_frame()
assert frame_indices == [i]
fake_buffer[i].__setitem__.assert_called() # Simulates [:, :] = image_data
# Circular buffer check
wraparound = camera.get_new_frame()
assert wraparound == [0]
def test_stop_acquisition(camera):
"""
Test that stop_acquisition() stops both the command and data stream,
clears acquisition state, and accesses the correct command feature.
"""
# Pretend acquisition is running
camera.is_acquiring = True
# Run method
camera.stop_acquisition()
# Ensure the correct SDK command was accessed and triggered
camera.feature_control.get_command_feature.assert_called_with("AcquisitionStop")
camera.feature_control.get_command_feature.return_value.send_command.assert_called_once()
# Ensure the data stream was stopped
camera.data_stream.stop_stream.assert_called_once()
# Verify internal state was updated
assert camera.is_acquiring is False
def test_stop_acquisition_when_disconnected(camera):
"""
Test that stop_acquisition() logs a warning and does not raise
when called on a disconnected camera.
"""
camera.is_connected = False
logger = logging.getLogger("model")
logger.propagate = False # prevent sending logs to root CLI handler
stream = io.StringIO()
handler = logging.StreamHandler(stream)
logger.addHandler(handler)
camera.stop_acquisition()
handler.flush()
assert "not connected" in stream.getvalue()
logger.removeHandler(handler)
def test_set_sensor_mode_logs(camera):
"""
Test that set_sensor_mode() logs a warning for unsupported modes.
If an invalid mode is specified, the camera will be set to Normal
mode (using global shutter).
"""
camera.set_sensor_mode("InvalidModeName")
camera.device.SensorShutterMode.set.assert_called_with(0)
assert camera._scan_mode == 0
def test_snap_software_triggered_success(camera):
"""
Test that snap_software_triggered() works when trigger config is correct.
Mocks TriggerMode='ON' and TriggerSource='SOFTWARE', verifies that the
method sends a software trigger, captures an image, and returns the result.
Uses side_effect to return two enum values from a shared enum feature mock.
"""
# Patch enum feature to simulate correct trigger mode and source
mock_enum_feature = MagicMock()
# First call to get_symbolic() returns 'ON', second returns 'SOFTWARE'
mock_enum_feature.get_current_entry.return_value.get_symbolic.side_effect = [
"ON",
"SOFTWARE",
]
camera.feature_control.get_enum_feature.return_value = mock_enum_feature
# Snap image - behind the scenes, this calls data_stream.snap_image() which
# is mocked during setup to return a fake image whose get_numpy_array() method
# returns np.ndarray representing a fake image.
result = camera.snap_software_triggered()
expected = np.zeros((2048, 2048), dtype=np.uint16)
assert_array_equal(result, expected)
# Ensure the correct trigger command was issued via SDK
camera.feature_control.get_command_feature.return_value.send_command.assert_called_with()
def test_get_new_frame(camera):
"""
Test that get_new_frame() returns correct buffer index in sequence,
and wraps around when the number of received frames exceeds the buffer length.
This simulates a circular buffer behavior across multiple frames.
"""
number_of_images = 3
buffer = [MagicMock() for _ in range(number_of_images)]
# Initialize image acquisition
camera.initialize_image_series(
data_buffer=buffer, number_of_frames=number_of_images
)
camera._frames_received = 0
# First full loop through buffer
for i in range(number_of_images):
result = camera.get_new_frame()
assert result == [i]
# Wraparound: next result should start from 0 again
result = camera.get_new_frame()
assert result == [0]
result = camera.get_new_frame()
assert result == [1]
def test_close_image_series(camera):
"""
Test that close_image_series() stops acquisition and clears buffer state.
This ensures the SDK stream is stopped and internal flags like
is_acquiring and _data_buffer are reset properly.
The data_stream is mocked in the camera fixture (via mock_daheng_sdk).
"""
camera.is_acquiring = True
camera._data_buffer = [MagicMock(), MagicMock()] # Simulate buffered frames
camera.close_image_series()
# Acquisition state should be cleared
assert camera.is_acquiring is False
assert camera._data_buffer == None
# SDK stream should be stopped
camera.data_stream.stop_stream.assert_called_once()

View File

@@ -0,0 +1,379 @@
# Copyright (c) 2021-2025 The University of Texas Southwestern Medical Center.
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted for academic and research use only (subject to the
# limitations in the disclaimer below) provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from this
# software without specific prior written permission.
# NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
# THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
# BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
# IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Third Party Imports
import pytest
@pytest.mark.hardware
@pytest.fixture(scope="module")
def prepare_cameras(dummy_model):
from navigate.model.devices.APIs.hamamatsu.HamamatsuAPI import DCAM, camReg
from navigate.model.devices.camera.hamamatsu import HamamatsuOrca
def start_camera(idx=0):
# open camera
for i in range(10):
assert camReg.numCameras == idx
try:
camera = DCAM(idx)
if camera.get_camera_handler() != 0:
break
camera.dev_close()
except Exception:
continue
camera = None
return camera
model = dummy_model
temp = {}
for microscope_name in model.configuration["configuration"]["microscopes"].keys():
serial_number = model.configuration["configuration"]["microscopes"][
microscope_name
]["camera"]["hardware"]["serial_number"]
temp[str(serial_number)] = microscope_name
camera_connections = {}
camera = start_camera()
for i in range(camReg.maxCameras):
if i > 0:
camera = start_camera(i)
if str(camera._serial_number) in temp:
microscope_name = temp[str(camera._serial_number)]
camera = HamamatsuOrca(microscope_name, camera, model.configuration)
camera_connections[microscope_name] = camera
yield camera_connections
# close all the cameras
for k in camera_connections:
camera_connections[k].camera_controller.dev_close()
@pytest.mark.hardware
class TestHamamatsuOrca:
"""Unit Test for HamamamatsuOrca Class"""
model = None
@pytest.fixture(autouse=True)
def _prepare_test(self, dummy_model, prepare_cameras):
self.num_of_tests = 10
self.model = dummy_model
self.cameras = prepare_cameras
self.microscope_name = self.model.configuration["experiment"][
"MicroscopeState"
]["microscope_name"]
self.camera = self.cameras[self.microscope_name]
def is_in_range(self, value, target, precision=100):
target_min = target - target / precision
target_max = target + target / precision
return value > target_min and value < target_max
def test_hamamatsu_camera_attributes(self):
from navigate.model.devices.camera.hamamatsu import HamamatsuOrca
attributes = dir(HamamatsuOrca)
desired_attributes = [
"serial_number",
"report_settings",
"close_camera",
"set_sensor_mode",
"set_readout_direction",
"calculate_light_sheet_exposure_time",
"calculate_readout_time",
"set_exposure_time",
"set_line_interval",
"set_binning",
"set_ROI",
"initialize_image_series",
"close_image_series",
"get_new_frame",
]
for da in desired_attributes:
assert da in attributes
def test_init_camera(self):
for microscope_name in self.model.configuration["configuration"][
"microscopes"
].keys():
camera = self.cameras[microscope_name]
assert camera is not None, f"Should start the camera {microscope_name}"
camera_controller = camera.camera_controller
camera_configs = self.model.configuration["configuration"]["microscopes"][
microscope_name
]["camera"]
# serial number
assert str(camera_controller._serial_number) == str(
camera_configs["hardware"]["serial_number"]
), f"the camera serial number isn't right for {microscope_name}!"
assert str(camera.serial_number) == str(
camera_configs["hardware"]["serial_number"]
), f"the camera serial number isn't right for {microscope_name}!"
# verify camera is initialized with the attributes from configuration.yaml
parameters = [
"defect_correct_mode",
"readout_speed",
"trigger_active",
"trigger_mode",
"trigger_polarity",
"trigger_source",
]
for parameter in parameters:
value = camera_controller.get_property_value(parameter)
assert value == camera_configs[parameter]
# sensor mode
sensor_mode = camera_controller.get_property_value("sensor_mode")
expected_value = 1 if camera_configs["sensor_mode"] == "Normal" else 12
assert sensor_mode == expected_value, "Sensor mode isn't right!"
# exposure time
exposure_time = camera_controller.get_property_value("exposure_time")
assert self.is_in_range(
exposure_time, camera_configs["exposure_time"] / 1000, 10
), "Exposure time isn't right!"
# binning
binning = camera_controller.get_property_value("binning")
assert int(binning) == int(
camera_configs["binning"][0]
), "Binning isn't right!"
# image width and height
width = camera_controller.get_property_value("image_width")
assert width == camera_configs["x_pixels"], "image width isn't right"
height = camera_controller.get_property_value("image_height")
assert height == camera_configs["y_pixels"], "image height isn't right"
def test_set_sensor_mode(self):
modes = {"Normal": 1, "Light-Sheet": 12, "RandomMode": None}
for mode in modes:
pre_value = self.camera.camera_controller.get_property_value("sensor_mode")
self.camera.set_sensor_mode(mode)
value = self.camera.camera_controller.get_property_value("sensor_mode")
if modes[mode] is not None:
assert value == modes[mode], f"sensor mode {mode} isn't right!"
else:
assert value == pre_value, "sensor mode shouldn't be set!"
def test_set_readout_direction(self):
readout_directions = {"Top-to-Bottom": 1, "Bottom-to-Top": 2}
for direction in readout_directions:
self.camera.set_readout_direction(direction)
value = self.camera.camera_controller.get_property_value(
"readout_direction"
)
assert (
value == readout_directions[direction]
), f"readout direction setting isn't right for {direction}"
# def test_calculate_readout_time(self):
# pass
def test_set_exposure_time(self):
import random
modes_dict = {
"Normal": 10000,
"Light-Sheet": 20,
}
for mode in modes_dict:
self.camera.set_sensor_mode(mode)
for i in range(self.num_of_tests):
exposure_time = random.randint(1, modes_dict[mode])
self.camera.set_exposure_time(exposure_time / 1000)
value = self.camera.camera_controller.get_property_value(
"exposure_time"
)
assert self.is_in_range(
value, exposure_time / 1000, 10
), f"exposure time({exposure_time}) isn't right!"
self.camera.set_sensor_mode("Normal")
def test_set_line_interval(self):
import random
self.camera.set_sensor_mode("Light-Sheet")
for i in range(self.num_of_tests):
line_interval = random.random() / 10.0
r = self.camera.set_line_interval(line_interval)
if r is True:
value = self.camera.camera_controller.get_property_value(
"internal_line_interval"
)
assert self.is_in_range(
value, line_interval
), f"line interval {line_interval} isn't right! {value}"
self.camera.set_sensor_mode("Normal")
def test_set_binning(self):
import random
binning_dict = {
"1x1": 1,
"2x2": 2,
"4x4": 4,
# '8x8': 8,
# '16x16': 16,
# '1x2': 102,
# '2x4': 204
}
for binning_string in binning_dict:
self.camera.set_binning(binning_string)
value = self.camera.camera_controller.get_property_value("binning")
assert (
int(value) == binning_dict[binning_string]
), f"binning {binning_string} isn't right!"
for i in range(self.num_of_tests):
x = random.randint(1, 20)
y = random.randint(1, 20)
binning_string = f"{x}x{y}"
assert self.camera.set_binning(binning_string) == (
binning_string in binning_dict
)
def test_set_ROI(self):
import random
self.camera.set_binning("1x1")
width = self.camera.camera_parameters["x_pixels"]
height = self.camera.camera_parameters["x_pixels"]
w = self.camera.camera_controller.get_property_value("image_width")
h = self.camera.camera_controller.get_property_value("image_height")
assert width == w, f"maximum width should be the same {width} - {w}"
assert height == h, f"maximum height should be the same {height} -{h}"
for i in range(self.num_of_tests):
pre_x, pre_y = self.camera.x_pixels, self.camera.y_pixels
x = random.randint(1, self.camera.camera_parameters["x_pixels"])
y = random.randint(1, self.camera.camera_parameters["y_pixels"])
r = self.camera.set_ROI(y, x)
if x % 2 == 1 or y % 2 == 1:
assert r is False
assert self.camera.x_pixels == pre_x, "width shouldn't be chaged!"
assert self.camera.y_pixels == pre_y, "height shouldn't be changed!"
else:
top = (height - y) / 2
bottom = top + y - 1
if top % 2 == 1 or bottom % 2 == 0:
assert r is False
else:
assert r is True, (
f"try to set{x}x{y}, but get "
f"{self.camera.x_pixels}x{self.camera.y_pixels}"
)
assert (
self.camera.x_pixels == x
), f"trying to set {x}x{y}. width should be changed to {x}"
assert self.camera.y_pixels == y, f"height should be chagned to {y}"
self.camera.set_ROI(512, 512)
assert self.camera.x_pixels == 512
assert self.camera.y_pixels == 512
self.camera.set_ROI(
self.camera.camera_parameters["x_pixels"],
self.camera.camera_parameters["y_pixels"],
)
assert self.camera.x_pixels == self.camera.camera_parameters["x_pixels"]
assert self.camera.y_pixels == self.camera.camera_parameters["y_pixels"]
self.camera.set_ROI(
self.camera.camera_parameters["x_pixels"] + 100,
self.camera.camera_parameters["y_pixels"] + 100,
)
assert self.camera.x_pixels == self.camera.camera_parameters["x_pixels"]
assert self.camera.y_pixels == self.camera.camera_parameters["y_pixels"]
def test_acquire_image(self):
import random
import time
from navigate.model.concurrency.concurrency_tools import SharedNDArray
# set software trigger
self.camera.camera_controller.set_property_value("trigger_source", 3)
assert self.camera.is_acquiring is False
number_of_frames = 100
data_buffer = [
SharedNDArray(shape=(2048, 2048), dtype="uint16")
for i in range(number_of_frames)
]
# initialize without release/close the camera
self.camera.initialize_image_series(data_buffer, number_of_frames)
assert self.camera.is_acquiring is True
self.camera.initialize_image_series(data_buffer, number_of_frames)
assert self.camera.is_acquiring is True
exposure_time = self.camera.camera_controller.get_property_value(
"exposure_time"
)
readout_time = self.camera.camera_controller.get_property_value("readout_time")
for i in range(self.num_of_tests):
triggers = random.randint(1, 100)
for j in range(triggers):
self.camera.camera_controller.fire_software_trigger()
time.sleep(exposure_time + readout_time)
time.sleep(0.01)
frames = self.camera.get_new_frame()
assert len(frames) == triggers
self.camera.close_image_series()
assert self.camera.is_acquiring is False
for i in range(self.num_of_tests):
self.camera.initialize_image_series(data_buffer, number_of_frames)
assert self.camera.is_acquiring is True
self.camera.close_image_series()
assert self.camera.is_acquiring is False
# close a closed camera
self.camera.close_image_series()
self.camera.close_image_series()
assert self.camera.is_acquiring is False