Move hardware-specific-tests to tests dir

This commit is contained in:
Julian Stirling 2025-12-18 17:21:17 +00:00
parent 4e043f3df2
commit ef9a88104b
13 changed files with 7 additions and 221 deletions

View file

@ -0,0 +1,37 @@
"""Utilities to help with testing the camera."""
from contextlib import contextmanager
from typing import Optional
from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2
from ...shared_utils.lt_test_utils import LabThingsTestEnv
@contextmanager
def camera_test_env(settings_folder: Optional[str] = None):
"""Yield a test environment with a server that contains just the camera.
This is a context manager, not a pytest fixture, as it needs to be created
multiple times in some tests.
:param settings_folder: The settings folder for the camera, if none is supplied, new
temporary directory will be used as the settings folder.
"""
thing_conf = {"camera": StreamingPiCamera2}
with LabThingsTestEnv(things=thing_conf, settings_folder=settings_folder) as env:
yield env
@contextmanager
def camera_test_client(settings_folder: Optional[str] = None):
"""Yield a camera ThingClient on a camera server.
This is a context manager, not a pytest fixture, as it needs to be created
multiple times in some tests.
:param settings_folder: The settings folder for the camera, if none is supplied, new
temporary directory will be used as the settings folder.
"""
with camera_test_env(settings_folder=settings_folder) as env:
return env.get_thing_client("camera")

View file

@ -0,0 +1,21 @@
"""Shared fixtures for picamera tests."""
import pytest
import labthings_fastapi as lt
from .cam_test_utils import camera_test_client_and_server
@pytest.fixture
def picamera_test_env() -> lt.ThingClient:
"""Initialise a test environment with only a StreamingPiCamera2 Thing."""
with camera_test_client_and_server() as env:
yield env
@pytest.fixture
def picamera_client() -> lt.ThingClient:
"""Initialise a test picamera_client (in a LabThings test env)."""
with camera_test_client_and_server() as env:
return env.get_thing_client["camera"]

View file

@ -0,0 +1,31 @@
"""Test data collection from the Raspberry Picamera."""
import numpy as np
from PIL import Image
def test_jpeg_and_array(picamera_client):
"""Check that a jpeg grabbed from the stream is the same size as other captures.
Compare it to an array capture and a jpeg capture.
"""
# Grab a jpeg from the stream
blob = picamera_client.grab_jpeg()
mjpeg_frame = Image.open(blob.open())
# Verify throws an error if there are issues with the image
mjpeg_frame.verify()
assert mjpeg_frame.format == "JPEG"
# Capture a jpeg
blob = picamera_client.capture_jpeg(stream_name="main")
jpeg_capture = Image.open(blob.open())
jpeg_capture.verify()
assert jpeg_capture.format == "JPEG"
# Capture an array
arrlist = picamera_client.capture_array(stream_name="main")
array_main = np.array(arrlist)
# Verify image sizes are the same
assert mjpeg_frame.size == jpeg_capture.size
assert array_main.shape[1::-1] == jpeg_capture.size

View file

@ -0,0 +1,72 @@
"""Test data collection from the Raspberry Picamera."""
import tempfile
from copy import deepcopy
from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2
from .cam_test_utils import camera_test_client
def test_calibration(picamera_test_env):
"""Check that full auto calibrate completes and set the expected values."""
picamera_client, server = picamera_test_env.get_thing_client["camera"]
picamera_thing = picamera_test_env.get_thing_by_type(StreamingPiCamera2)
# Check the calibration_required property used by the calibration wizard
assert picamera_thing.calibration_required
# Save copy of default tuning file for end of test
original_default = deepcopy(picamera_thing.default_tuning)
# Tuning should start the same as the server is loading with no settings.
assert picamera_thing.default_tuning == picamera_thing.tuning
# The background detector isn't ready as there is no background image.
assert not picamera_thing.background_detector_status.ready
# Run full auto calibrate
picamera_client.full_auto_calibrate()
# After calibration it should report that calibration is no longer required
assert not picamera_thing.calibration_required
# The tuning files should be different
assert picamera_thing.default_tuning != picamera_thing.tuning
# The default should be unchanged
assert picamera_thing.default_tuning == original_default
assert picamera_thing.background_detector_status.ready
def test_tuning_is_persistent():
"""Check that tuning file adjustments are persistent."""
# First check full auto-calibrate is persistent if same save dir is used.
with tempfile.TemporaryDirectory() as tmpdir:
with camera_test_client(settings_folder=tmpdir) as client:
initial_tuning = deepcopy(client.tuning)
client.full_auto_calibrate()
calibrated_tuning = deepcopy(client.tuning)
# Full auto calibrate should have saved the tuning.
assert initial_tuning != calibrated_tuning
# Reload the camera and server from scratch
with camera_test_client(settings_folder=tmpdir) as client:
initial_tuning = deepcopy(client.tuning)
# On reload the initial tuning is as it was when calibrated last run
assert initial_tuning == calibrated_tuning
# Set the lens shading to be flat
client.flat_lens_shading()
flat_tuning = deepcopy(client.tuning)
assert flat_tuning != calibrated_tuning
# Reload to check the flat lst persists
with camera_test_client(settings_folder=tmpdir) as client:
initial_tuning = deepcopy(client.tuning)
# On reload the initial tuning the flat tuning
assert initial_tuning == flat_tuning
# Reset the lens shading to what is in the default tuning file
client.reset_lens_shading()
flat_tuning = deepcopy(client.tuning)
assert flat_tuning != calibrated_tuning
# Reload the one more time to check the reset lst persists
with camera_test_client(settings_folder=tmpdir) as client:
initial_tuning = deepcopy(client.tuning)
# On reload the initial tuning the flat tuning
assert initial_tuning == flat_tuning

View file

@ -0,0 +1,176 @@
"""Check exposure times do not drift.
This can get very tedious. Recommend running pytest with -s option
to monitor progress.
"""
import json
import logging
import os
import tempfile
import time
from typing import Any
from .cam_test_utils import camera_test_client
logging.basicConfig(level=logging.DEBUG)
# The tolerance used to be a setting of the camera, and any changes within tolerance
# were ignored. Now this isn't needed we always set a value 1 larger than the last
# accepted value as the PiCamera always rounds down.
# The tolerance is needed in this test as when setting an arbitrary value it is rounded
# down to a hardware compatible one.
EXPOSURE_TOL = 30
def _test_exposure_time_drift(desired_time: int) -> None:
"""Capture 10 full res images and check that the exposure time remains constant.
This confirms that automatic exposure time adjustment is fully turned off during
capture.
"""
with camera_test_client() as client:
client.exposure_time = desired_time
print(f"Setting desired time of {desired_time}")
time.sleep(0.5)
pre_capture_et = client.exposure_time
print(f"Pre-capture the time is set to {pre_capture_et}")
# Check exp is set correctly within known tolerance
assert abs(pre_capture_et - desired_time) < EXPOSURE_TOL
for i in range(10):
client.capture_jpeg(stream_name="full")
if i == 0:
# Exposure can update on first capture, due to frame rate restrictions
first_et = client.exposure_time
assert abs(first_et - desired_time) < EXPOSURE_TOL
else:
frame_et = client.exposure_time
print(f"Frame {i} captured with exposure time {frame_et}")
# Check no further drift in value
assert first_et == frame_et
# Set the exposure time to the value it already is. To check it doesn't shift
print(f"Setting exposure time to {frame_et} to check it doesn't change")
client.exposure_time = frame_et
time.sleep(0.5)
# Check before and after capture
assert client.exposure_time == frame_et
client.capture_jpeg(stream_name="full")
assert client.exposure_time == frame_et
print("Exposure time didn't change!!")
print(f"End of test for exposure target {desired_time}")
def test_exposure_time_drift():
"""Performs the exposure time test for a range of exposure time values."""
for desired_time in [100, 1000, 10000]:
_test_exposure_time_drift(desired_time)
def test_exposure_time_on_start_and_stop_stream():
"""Start and stop stream in the same way a scan does and check exposure doesn't drift.
Take images using capture_to_memory() just as a scan would.
"""
desired_time = 1000
with camera_test_client() as client:
# Set a desired exposure time
client.exposure_time = desired_time
print(f"Setting desired time of {desired_time}")
time.sleep(0.5)
# Take a couple of images to make sure that the exposure is adjusted to
# a hardware compatible value.
for _i in range(2):
client.capture_jpeg(stream_name="full")
# Save this time.
set_time = client.exposure_time
assert abs(set_time - desired_time) < EXPOSURE_TOL
# Mimic doing 10 smart scans. Change to full res, take some images. Return
# to standard preview resolution.
for i in range(10):
print(f"Starting simulation scan {i}")
# This will need updating if we start supporting other Picamera models
# It is currently used here to mimic the behaviour in in scanning.
client.start_streaming(main_resolution=(3280, 2464))
time.sleep(0.5)
for _j in range(5):
client.capture_to_memory(buffer_max=1)
# Reset to main resolution
time.sleep(0.5)
client.start_streaming()
# Check after all of this the exposure time is the same.
assert client.exposure_time == set_time
def _load_camera_and_return_exposure(tmpdir: str) -> int:
"""Load a camera (using any settings in tempdir) take images and check exposure."""
with camera_test_client(settings_folder=tmpdir) as client:
# Set a desired exposure time
time.sleep(0.5)
# Take a couple of images to make sure that the exposure is adjusted to
# a hardware compatible value.
for _i in range(2):
client.capture_jpeg(stream_name="full")
# Save this time.
return client.exposure_time
def _load_setting(setting_file: str) -> dict[str, Any]:
"""Load settings json from disk to dictionary."""
with open(setting_file, "r", encoding="utf-8") as file_obj:
return json.load(file_obj)
def _save_setting(settings: dict[str, Any], setting_file: str):
"""Save settings dictionary to disk."""
with open(setting_file, "w", encoding="utf-8") as file_obj:
json.dump(settings, file_obj)
def test_exposure_time_saves_and_loads():
"""Check that exposure time saves to disk and loads correctly."""
with tempfile.TemporaryDirectory() as tmpdir:
setting_file = os.path.join(tmpdir, "camera", "settings.json")
# Create a server, take some images, and get the exposure time
initial_exposure = _load_camera_and_return_exposure(tmpdir)
settings = _load_setting(setting_file)
assert settings["exposure_time"] == initial_exposure
# Adjust exposure to 1000 and save
settings["exposure_time"] = 1000
_save_setting(settings, setting_file)
# Create a server, take some images, and get the exposure time
recorded_exposure = _load_camera_and_return_exposure(tmpdir)
settings = _load_setting(setting_file)
assert settings["exposure_time"] == recorded_exposure
# Check it was set correctly within tolerance
assert abs(recorded_exposure - 1000) < EXPOSURE_TOL
# Load a second time without changing the file. Exposure should not change
recorded_exposure_second_load = _load_camera_and_return_exposure(tmpdir)
settings = _load_setting(setting_file)
assert settings["exposure_time"] == recorded_exposure_second_load
# Check it was set to exactly the value previously saved to disk
assert recorded_exposure == recorded_exposure_second_load
# Repeat with 2000
settings["exposure_time"] = 2000
_save_setting(settings, setting_file)
# Create a server, take some images, and get the exposure time
recorded_exposure = _load_camera_and_return_exposure(tmpdir)
settings = _load_setting(setting_file)
assert settings["exposure_time"] == recorded_exposure
# Check it was set correctly within tolerance
assert abs(recorded_exposure - 2000) < EXPOSURE_TOL
# Load a second time without changing the file. Exposure should not change
recorded_exposure_second_load = _load_camera_and_return_exposure(tmpdir)
settings = _load_setting(setting_file)
assert settings["exposure_time"] == recorded_exposure_second_load
# Check it was set to exactly the value previously saved to disk
assert recorded_exposure == recorded_exposure_second_load

View file

@ -0,0 +1,21 @@
"""Test changing and setting camerat modes on the Raspberry Picamera."""
import logging
import numpy as np
from .cam_test_utils import camera_test_client
logging.basicConfig(level=logging.DEBUG)
def test_sensor_mode():
"""Test capturing raw arrays in two different sensor modes."""
with camera_test_client() as client:
for size in [(3280, 2464), (1640, 1232)]:
client.sensor_mode = {"output_size": size, "bit_depth": 10}
arr = np.array(client.capture_array(stream_name="raw"))
# Check that the array dimensions match the requested image size.
# Note: Numpy array shape is (y,x), but the sensor is set with (x,y)
# hence the need to compare index 0 with index 1.
assert arr.shape[0] == size[1]

View file

@ -0,0 +1,100 @@
"""Tests that check that a tuning file can be reloaded."""
import pytest
from picamera2 import Picamera2
from openflexure_microscope_server.things.camera import (
picamera_recalibrate_utils as recalibrate_utils,
)
from openflexure_microscope_server.things.camera import (
picamera_tuning_file_utils as tf_utils,
)
MODEL = Picamera2.global_camera_info()[0]["Model"]
def generate_bad_tuning():
"""Return a tuning file with an invalid version number to force an error when loaded."""
default_tuning = tf_utils.load_default_tuning("imx219")
bad_tuning = default_tuning.copy()
bad_tuning["version"] = 999
return bad_tuning
def _test_bad_tuning_after_good_tuning(configure: bool = False):
"""Test loading good, bad, then another good tuning files in sequence.
Load the default tuning file into the camera, re-load with a broken tuning file,
check it errors. Finally check the default tuning file will load again afterwards.
:param configure: Boolean, set true to configure the camera on initial loading
This test checks that:
recalibrate_utils.recreate_camera_manager() is working as expected. As the default
PiCamera2 behaviour does not expect the tuning file to be reloaded.
"""
bad_tuning = generate_bad_tuning()
default_tuning = tf_utils.load_default_tuning("imx219")
print("opening camera with default tuning")
with Picamera2(tuning=default_tuning) as cam:
if configure:
cam.configure(cam.create_preview_configuration())
del cam
recalibrate_utils.recreate_camera_manager()
print(f"Opening camera with bad tuning - ['version'] = {bad_tuning['version']}")
with pytest.raises(IndexError):
# The bad version should cause a problem
cam = Picamera2(tuning=bad_tuning)
recalibrate_utils.recreate_camera_manager()
with Picamera2(tuning=default_tuning) as cam:
# Reload the camera with working tuning, or it will stop responding
# and fail future tests
pass
del cam
# Note: The tests below are marked with
# @pytest.mark.filterwarnings("ignore: Exception ignored")
# as the picamera will throw an exception ignored warning when deleting
# the picamera object. This is expected
# The 2 pairs of repeated identical tests exist because pytest only
# starts once, and so by running the test more than one time checks
# that the camera can be initialised multiple times without restarting
# python.
@pytest.mark.filterwarnings("ignore: Exception ignored")
def test_bad_tuning_after_good_tuning_noconfigure():
"""First run setting good, then bad, then good tuning.
create_preview_configuration() is NOT run on initial setup.
"""
_test_bad_tuning_after_good_tuning(configure=False)
@pytest.mark.filterwarnings("ignore: Exception ignored")
def test_bad_tuning_after_good_tuning_configure():
"""Second run setting good, then bad, then good tuning.
create_preview_configuration() is run on initial setup this time.
"""
_test_bad_tuning_after_good_tuning(configure=True)
@pytest.mark.filterwarnings("ignore: Exception ignored")
def test_bad_tuning_after_good_tuning_noconfigure2():
"""3rd run setting good, then bad, then good tuning.
create_preview_configuration() is NOT run on initial setup.
"""
_test_bad_tuning_after_good_tuning(configure=False)
@pytest.mark.filterwarnings("ignore: Exception ignored")
def test_bad_tuning_after_good_tuning_configure2():
"""Final run setting good, then bad, then good tuning.
create_preview_configuration() is run on initial setup again.
"""
_test_bad_tuning_after_good_tuning(configure=True)