Start fixing tests. This involves further blocking portal changes.

This will need PR 225 of labthings-fastapi to be merged to run.
This commit is contained in:
Julian Stirling 2025-12-14 19:21:06 +00:00
parent 9ef417971f
commit cfbb7cf7f9
5 changed files with 33 additions and 35 deletions

View file

@ -319,7 +319,6 @@ class BaseCamera(lt.Thing):
@lt.action
def grab_jpeg(
self,
portal: lt.deps.BlockingPortal,
stream_name: Literal["main", "lores"] = "main",
) -> JPEGBlob:
"""Acquire one image from the preview stream and return as blob of JPEG data.
@ -337,13 +336,12 @@ class BaseCamera(lt.Thing):
stream = (
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
)
frame = portal.call(stream.grab_frame)
frame = self._thing_server_interface.call_async_task(stream.grab_frame)
return JPEGBlob.from_bytes(frame)
@lt.action
def grab_as_array(
self,
portal: lt.deps.BlockingPortal,
stream_name: Literal["main", "lores"] = "main",
) -> ArrayModel:
"""Acquire one image from the preview stream and return as an array.
@ -361,7 +359,7 @@ class BaseCamera(lt.Thing):
tries = 0
while tries < 3:
try:
frame = portal.call(stream.grab_frame)
frame = self._thing_server_interface.call_async_task(stream.grab_frame)
return np.asarray(Image.open(io.BytesIO(frame)))
except OSError:
tries += 1
@ -370,14 +368,13 @@ class BaseCamera(lt.Thing):
@lt.action
def grab_jpeg_size(
self,
portal: lt.deps.BlockingPortal,
stream_name: Literal["main", "lores"] = "main",
) -> int:
"""Acquire one image from the preview stream and return its size."""
stream = (
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
)
return portal.call(stream.next_frame_size)
return self._thing_server_interface.call_async_task(stream.next_frame_size)
def capture_image(
self,
@ -705,13 +702,13 @@ class BaseCamera(lt.Thing):
)
@lt.action
def image_is_sample(self, portal: lt.deps.BlockingPortal) -> tuple[bool, str]:
def image_is_sample(self) -> tuple[bool, str]:
"""Label the current image as either background or sample."""
current_image = self.grab_as_array(portal, stream_name="lores")
current_image = self.grab_as_array(stream_name="lores")
return self.active_detector.image_is_sample(current_image)
@lt.action
def set_background(self, portal: lt.deps.BlockingPortal) -> None:
def set_background(self) -> None:
"""Grab an image, and use its statistics to set the background.
This should be run when the microscope is looking at an empty region,
@ -720,7 +717,7 @@ class BaseCamera(lt.Thing):
future images to the distribution, to determine if each pixel is
foreground or background.
"""
background = self.grab_as_array(portal, stream_name="lores")
background = self.grab_as_array(stream_name="lores")
self.active_detector.set_background(background)
# Manually save settings as the setter is not called.
self.save_settings()

View file

@ -71,10 +71,8 @@ class PicameraStreamOutput(Output):
def __init__(self, stream: lt.outputs.MJPEGStream) -> None:
"""Create an output that puts frames in an MJPEGStream.
We need to pass the stream object, and also the blocking portal, because
new frame notifications happen in the anyio event loop and frames are
sent from a thread. The blocking portal enables thread-to-async
communication.
We need to pass the stream object, because new frame notifications happen in
the anyio event loop and frames are sent from a thread.
"""
Output.__init__(self)
self.stream = stream
@ -744,7 +742,7 @@ class StreamingPiCamera2(BaseCamera):
self._initialise_picamera()
@lt.action
def full_auto_calibrate(self, portal: lt.deps.BlockingPortal) -> None:
def full_auto_calibrate(self) -> None:
"""Perform a full auto-calibration.
This function will call the other calibration actions in sequence:
@ -763,7 +761,7 @@ class StreamingPiCamera2(BaseCamera):
for _i in range(3):
try:
time.sleep(self._sensor_info.long_pause)
self.set_background(portal)
self.set_background()
# Return if background is set
return
except ChannelBlankError:

View file

@ -350,7 +350,7 @@ class SimulatedCamera(BaseCamera):
return self.generate_frame()
@lt.action
def full_auto_calibrate(self, portal: lt.deps.BlockingPortal) -> None:
def full_auto_calibrate(self) -> None:
"""Perform a full auto-calibration.
For the simulation microscope the process is:
@ -361,7 +361,7 @@ class SimulatedCamera(BaseCamera):
"""
self.remove_sample()
time.sleep(0.2)
self.set_background(portal)
self.set_background()
time.sleep(0.2)
self.load_sample()

View file

@ -6,6 +6,8 @@ This doesn't check the behaviour of the JPEG shaprness monitor.
import numpy as np
import pytest
from labthings_fastapi.testing import create_thing_without_server
from openflexure_microscope_server.things.autofocus import (
AutofocusThing,
NoFocusFoundError,
@ -77,7 +79,7 @@ def test_looping_autofocus(start_z, max_loc, centre, attempts_expected, passes,
sharpness_monitor.move_data.side_effect = return_sharpness
autofocus_thing = AutofocusThing()
autofocus_thing = create_thing_without_server(AutofocusThing)
if passes:
autofocus_thing.looping_autofocus(
stage=stage,

View file

@ -1,7 +1,6 @@
"""Use the Simulation camera to test base camera functionality."""
import tempfile
from contextlib import contextmanager
import numpy as np
import pytest
@ -10,29 +9,30 @@ from PIL import Image
import labthings_fastapi as lt
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
@contextmanager
def camera_server(camera: SimulatedCamera) -> lt.ThingClient:
@pytest.fixture
def camera_server() -> lt.ThingClient:
"""Add the camera to a ThingServer and start a TestClient application.
The test client application is needed for the camera to have a blocking portal.
The test client will be needed for the camera to run async frame generation code.
"""
with tempfile.TemporaryDirectory() as tmpdir:
server = lt.ThingServer(settings_folder=tmpdir)
server.add_thing(camera, "camera")
with TestClient(server.app):
yield server
conf = {
"camera": "openflexure_microscope_server.things.camera.simulation:SimulatedCamera",
"stage": "openflexure_microscope_server.things.stage.dummy:DummyStage",
}
server = lt.ThingServer(things=conf, settings_folder=tmpdir)
yield server
def test_handle_broken_frame():
def test_handle_broken_frame(camera_server):
"""Monkey patch the the mjpeg steam so 1 in 5 frames are broken, then test operation.
This simulates the very occasional broken frames that can occur when grabbing
directly from the MJPEG stream.
"""
camera = SimulatedCamera()
camera = camera_server.things["camera"]
# Money patch the mjpeg_stream grab_frame to break 1 in 5 frames.
frame_number = 0
@ -50,7 +50,8 @@ def test_handle_broken_frame():
return frame
camera.mjpeg_stream.grab_frame = flaky_grabber
with camera_server(camera):
with TestClient(camera_server.app):
# Check that this does cause broken frames.
# The noqa is because we don't know exactly when the error is thrown so we
# can't have a single simple statement in the pytest raises.
@ -68,10 +69,10 @@ def test_handle_broken_frame():
assert isinstance(array, np.ndarray)
def test_simulation_cam_calibration():
def test_simulation_cam_calibration(camera_server):
"""Test that the simulated camera can be calibrated and reports calibration correctly."""
camera = SimulatedCamera()
with camera_server(camera):
camera = camera_server.things["camera"]
with TestClient(camera_server.app):
assert camera.calibration_required
camera.full_auto_calibrate()
assert not camera.calibration_required