diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index f723c669..9678b18c 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -10,10 +10,11 @@ from __future__ import annotations import io import logging +import re import time from threading import Thread from types import TracebackType -from typing import Literal, Optional, Self +from typing import Literal, Optional, Self, overload import numpy as np from PIL import Image, ImageFilter @@ -43,6 +44,68 @@ BG_COLOR = [220, 215, 217] # Random Number Generator RNG = np.random.default_rng() +DOWNSAMPLE = 2 +# Upsample for sprites and then downsample to create sharp edges for each sprite +# as these are small and calculated once there is almost no performance penalty +# for a nice gain in quality. +SPRITE_UPSAMPLE = 4 + +# A list of 6 digit hex colour codes separated by ;. Allow a trailing ; +# For example, OpenFlexure pink would be #C5247F; +COLOUR_LIST_REGEX = re.compile( + r"^\s*(#[0-9a-fA-F]{6})\s*(?:;\s*(#[0-9a-fA-F]{6})\s*)*;?\s*$" +) +# regex to separate R, G and B from a 6 digit hex code with preceding # +COLOUR_REGEX = re.compile(r"^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$") + + +@overload +def _downsample_shape(shape: tuple[int, int]) -> tuple[int, int]: ... + + +@overload +def _downsample_shape(shape: tuple[int, int, int]) -> tuple[int, int, int]: ... + + +def _downsample_shape( + shape: tuple[int, int] | tuple[int, int, int], +) -> tuple[int, int] | tuple[int, int, int]: + if len(shape) == 2: + return (shape[0] // DOWNSAMPLE, shape[1] // DOWNSAMPLE) + if len(shape) == 3: + return (shape[0] // DOWNSAMPLE, shape[1] // DOWNSAMPLE, shape[2]) + raise ValueError("Shape should be a 2 or 3 element tuple.") + + +def colour_str_to_colour(colour_str: str) -> tuple[int, int, int]: + """Convert a colour string into RGB colour values. + + :param colour_str: Should be a hex colour such as #33aa33 or a list of hex + colours separated by semicolons (with optional spaces). + :return: The colour as a tuple of 3 integers from 0 to 255 in value + :raises ValueError: If the hex string is not valid. This should never happen if the + user enters a bad colour string as the colour property setter checks the + whole string regex. + """ + if ";" in colour_str: + colours = colour_str.split(";") + if len(colours) > 1 and colours[-1].strip() == "": + colours.pop(-1) + single_colour_str = colours[RNG.integers(0, len(colours))] + else: + single_colour_str = colour_str + single_colour_str = single_colour_str.lower().strip() + colour_match = COLOUR_REGEX.match(single_colour_str) + if colour_match is None: + raise ValueError( + f"{colour_str} is not a valid colour. Please use HTML hex notation." + ) + + r = int("0x" + colour_match.group(1), 16) + g = int("0x" + colour_match.group(2), 16) + b = int("0x" + colour_match.group(3), 16) + return r, g, b + class SimulatedCamera(BaseCamera): """A Thing that simulates a camera for testing.""" @@ -55,62 +118,79 @@ class SimulatedCamera(BaseCamera): self, thing_server_interface: lt.ThingServerInterface, shape: tuple[int, int, int] = (616, 820, 3), - glyph_shape: tuple[int, int, int] = (121, 121, 3), - canvas_shape: tuple[int, int, int] = (3000, 4000, 3), - sample_limits: Optional[tuple[int, int]] = (1000, 1500), + canvas_shape: tuple[int, int, int] = (1500, 2000, 3), frame_interval: float = 0.1, ) -> None: """Initialise the simulated with settings for how images are generated. :param shape: The shape (size) of the generated image. - :param glyph_shape: The size randomly positioned glyphs. :param canvas_shape: The shape (size) of the canvas generated on initialisation that images are cropped from. If this is too large the it uses resources, but its size limits the range of motion of the simulation. - :param sample_limits: The shape of the sample. Outside this range, the - camera won't generate any blobs, preventing scanning from running - indefinitely and better demonstrating background detect. :param frame_interval: Nominally the time between frames on the MJPEG stream, however the rate may be slower due to calculation time for focus. """ super().__init__(thing_server_interface) self.shape = shape - self.glyph_shape = glyph_shape - self.canvas_shape = canvas_shape - self.sample_limits = ( - canvas_shape[:2] if sample_limits is None else sample_limits - ) + self.ds_shape = _downsample_shape(shape) + self.glyph_size = 105 // DOWNSAMPLE + self.canvas_shape = _downsample_shape(canvas_shape) + self.frame_interval = frame_interval self._capture_thread: Optional[Thread] = None self._capture_enabled = False - self.validate_inputs() self.generate_sprites() - self.generate_blobs() - self.generate_canvas() + + repeating: bool = lt.property(default=False) + + _blob_density: int = 400 + + @lt.property + def blob_density(self) -> int: + """The number of blobs per million pixels.""" + return self._blob_density + + @blob_density.setter + def _set_blob_density(self, value: int) -> None: + self._blob_density = value + if self._capture_enabled: + self.generate_canvas() + + _colour: str = "#b937b9" + + @lt.property + def colour(self) -> str: + """The colour of the blobs as a HTML hex string. + + The string can either be a single colour (e.g. "#c5247f") or a list of + colours separated by semicolons (e.g. "#c5247f; #b937b9"). Additional + spaces are allowed between colours. + """ + return self._colour + + @colour.setter + def _set_colour(self, colour_value: str) -> None: + if COLOUR_LIST_REGEX.match(colour_value) is None: + self.logger.warning(f"{colour_value} is not a valid colour string.") + return + + self._colour = colour_value + if self._capture_enabled: + self.generate_canvas() @lt.property def calibration_required(self) -> bool: """Whether the camera needs calibrating.""" return not self.background_detector_status.ready - def validate_inputs(self) -> None: - """Validate the inputs passed to the simulation, and raises an error if invalid. - - Currently only tests that the sample size is not greater than the canvas size in any dimension. - """ - # Iterate through elements in both tuples. As strict is False, will use the shorter of the two tuples - for a, b in zip(self.canvas_shape, self.sample_limits, strict=False): - if a < b: - raise ValueError( - "Canvas size must be bigger than or equal to canvas size" - ) - def generate_sprites(self) -> None: """Generate sprites to populate the image.""" sprite_sizes = [10, 21, 36, 40, 50] + sprite_sizes = [s * SPRITE_UPSAMPLE for s in sprite_sizes] self.sprites = [] - channel_block = np.zeros(self.glyph_shape[0:2]) + block_size = self.glyph_size * DOWNSAMPLE * SPRITE_UPSAMPLE + channel_block = np.zeros((block_size, block_size)) x = np.arange(channel_block.shape[0]) y = np.arange(channel_block.shape[1]) # 2D grid of radii @@ -121,22 +201,29 @@ class SimulatedCamera(BaseCamera): for sprite_size in sprite_sizes: # Mask of where this sprite is sprite_mask = r_coord < sprite_size - # Calculate a sharp edged circle with value varying from 0 in centre to 1 + # Calculate a sharp edged circle with value varying from 0 in centre to 255 # at the edge sprite_px = r_coord[sprite_mask] sprite_px -= np.min(sprite_px) sprite_px /= np.max(sprite_px) - # Create each channel. Note these will be subtracted from the white value. - sprite_r = channel_block.copy() - sprite_r[sprite_mask] = 70 * sprite_px - sprite_g = channel_block.copy() - sprite_g[sprite_mask] = 200 * sprite_px - sprite_b = channel_block.copy() - sprite_b[sprite_mask] = 70 * sprite_px - # Stack into a negative image of the sprite - sprite = np.stack([sprite_r, sprite_g, sprite_b], axis=2) - # Convert to uint8 and append to the list - self.sprites.append(sprite.astype(np.uint8)) + sprite = channel_block.copy() + sprite[sprite_mask] = 255 * sprite_px + + # Convert to uint8 + sprite = sprite.astype(np.uint8) + # Convert to PIL (and back) to resize then append to list of sprites + sprite_pil = Image.fromarray(sprite) + sprite_pil = sprite_pil.resize( + (self.glyph_size, self.glyph_size), Image.Resampling.BILINEAR + ) + # Convert back and ensure all edges are zero as these are repeated at sample + # edge + sprite = np.array(sprite_pil) + sprite[0, :] = 0 + sprite[-1, :] = 0 + sprite[:, 0] = 0 + sprite[:, -1] = 0 + self.sprites.append(sprite) def generate_blobs(self, n_blobs: int = 1000) -> None: """Generate coordinates of blobs and their sizes, centered around (0,0). @@ -148,10 +235,10 @@ class SimulatedCamera(BaseCamera): :param n_blobs: The number of blobs to generate. """ self.blobs = np.zeros((n_blobs, 3)) - w = np.max(self.glyph_shape) + w = self.glyph_size - self.blobs[:, 0] = RNG.uniform(w // 2, self.sample_limits[1] - w // 2, n_blobs) - self.blobs[:, 1] = RNG.uniform(w // 2, self.sample_limits[0] - w // 2, n_blobs) + self.blobs[:, 0] = RNG.uniform(w // 2, self.canvas_shape[1] - w // 2, n_blobs) + self.blobs[:, 1] = RNG.uniform(w // 2, self.canvas_shape[0] - w // 2, n_blobs) self.blobs[:, 2] = RNG.choice(len(self.sprites), n_blobs) def generate_canvas(self) -> None: @@ -160,22 +247,22 @@ class SimulatedCamera(BaseCamera): Canvas is int16 so that random noise can be added to simulation image before changing to unit8 to stop wrapping. """ + n_pixels = self.canvas_shape[0] * self.canvas_shape[1] * DOWNSAMPLE**2 + self.generate_blobs(int(self.blob_density * 1e-6 * n_pixels)) self.blank_canvas = np.ones(self.canvas_shape, dtype=np.int16) self.blank_canvas[:, :, 0] *= BG_COLOR[0] self.blank_canvas[:, :, 1] *= BG_COLOR[1] self.blank_canvas[:, :, 2] *= BG_COLOR[2] - self.canvas = self.blank_canvas.copy() + new_canvas = self.blank_canvas.copy() for blob_x, blob_y, sprite_index in self.blobs: self.draw_sprite_on_canvas( - self.sprites[int(sprite_index)], int(blob_y), int(blob_x) + new_canvas, self.sprites[int(sprite_index)], int(blob_y), int(blob_x) ) - - self.canvas[self.canvas < 0] = 0 - self.canvas[self.canvas > 255] = 255 + self.canvas = np.clip(new_canvas, 0, 255) def draw_sprite_on_canvas( - self, sprite: np.ndarray, centre_y: int, centre_x: int + self, canvas: np.ndarray, sprite: np.ndarray, centre_y: int, centre_x: int ) -> None: """Place one sprite on canvas at given centre coordinates. @@ -185,8 +272,15 @@ class SimulatedCamera(BaseCamera): :param centre_y: The y coordinate to place the centre of the sprite. :param centre_x: The x coordinate to place the centre of the sprite. """ - canvas_h, canvas_w, _ = self.canvas.shape - sprite_h, sprite_w, _ = sprite.shape + canvas_h, canvas_w, _ = canvas.shape + sprite_h, sprite_w = sprite.shape + + sprite_f = sprite.astype(float) / 255 + r, g, b = colour_str_to_colour(self.colour) + sprite_r = (255 - r) * sprite_f + sprite_g = (255 - g) * sprite_f + sprite_b = (255 - b) * sprite_f + sprite_rgb = np.stack([sprite_r, sprite_g, sprite_b], axis=2) # Canvas region containing the sprite top = max(centre_y - sprite_h // 2, 0) @@ -194,7 +288,7 @@ class SimulatedCamera(BaseCamera): bottom = min(centre_y + (sprite_h - sprite_h // 2), canvas_h) right = min(centre_x + (sprite_w - sprite_w // 2), canvas_w) - self.canvas[top:bottom, left:right] -= sprite + canvas[top:bottom, left:right] -= sprite_rgb.astype("int16") def generate_image(self, pos: tuple[int, int, int]) -> Image.Image: """Generate an image with blobs based on supplied coordinates. @@ -202,46 +296,55 @@ class SimulatedCamera(BaseCamera): :param pos: a 3-item tuple containing the x,y,z coordinates of the 'stage' """ canvas_width, canvas_height, _ = self.canvas_shape - image_width, image_height, _ = self.shape - # Scale position by RATIO to get position in base image. - im_pos = tuple(x * ratio for x, ratio in zip(pos, RATIO, strict=True)) - top_left = ( - int(im_pos[0]) - image_width // 2 + self.sample_limits[0] // 2, - int(im_pos[1]) - image_height // 2 + self.sample_limits[1] // 2, + image_width, image_height, _ = self.ds_shape + im_pos = ( + pos[0] * RATIO[0] / DOWNSAMPLE, + pos[1] * RATIO[1] / DOWNSAMPLE, + pos[2] * RATIO[2], ) - # Create index list with modulo rather than slicing to handle wrapping at the - # canvas edge. - x_indices = (np.arange(top_left[0], top_left[0] + image_width)) % canvas_width - y_indices = (np.arange(top_left[1], top_left[1] + image_height)) % canvas_height - z_indices = np.arange(self.shape[2]) + + top_left = ( + int(im_pos[0]) - image_width // 2 + self.canvas_shape[0] // 2, + int(im_pos[1]) - image_height // 2 + self.canvas_shape[1] // 2, + ) + + x_indices = np.arange(top_left[0], top_left[0] + image_width) + y_indices = np.arange(top_left[1], top_left[1] + image_height) + + if self.repeating: + # Create index list with modulo rather than slicing to handle wrapping at the + # canvas edge. + x_indices = x_indices % canvas_width + y_indices = y_indices % canvas_height + else: + # Rather than use a modulo for the index list, as above when wrapping, + # this uses np.clip to coerce all out of bound indices to repeat the + # first or last pixel in the canvas. This works because no sprite touches + # the very edge of the canvas (to prevent partial sprites). + x_indices = np.clip(x_indices, 0, canvas_width - 1) + y_indices = np.clip(y_indices, 0, canvas_height - 1) + + z_indices = np.arange(self.ds_shape[2]) canvas = self.canvas if self._show_sample else self.blank_canvas # Use npx to make each 1d index list 3D - focused_image = canvas[np.ix_(x_indices, y_indices, z_indices)] + focused_np_img = canvas[np.ix_(x_indices, y_indices, z_indices)] - image = fast_pil_blur(focused_image, sigma=np.abs(im_pos[2]) / 5) - - if image.shape != self.shape: - raise ValueError( - f"Image shape {image.shape} does not match intended shape {self.shape}" - ) + np_img = fast_pil_blur(focused_np_img, sigma=np.abs(im_pos[2]) / 5) # Add noise and convert to uint8 - image += RNG.normal(scale=self.noise_level, size=self.shape).astype("int16") - image[image < 0] = 0 - image[image > 255] = 255 - return Image.fromarray(image.astype("uint8")) + np_img += RNG.normal(scale=self.noise_level, size=self.ds_shape).astype("int16") + np.clip(np_img, 0, 255, out=np_img) + pl_img = Image.fromarray(np_img.astype("uint8")) + return pl_img.resize((self.shape[1], self.shape[0]), Image.Resampling.BILINEAR) def generate_frame(self) -> Image.Image: """Generate a frame with blobs based on the stage coordinates.""" - try: - pos = self._stage.instantaneous_position - except Exception as e: - LOGGER.debug(f"Failed to get stage position: {e}") - pos = {"x": 0, "y": 0, "z": 0} + pos = self._stage.instantaneous_position return self.generate_image((pos["y"], pos["x"], pos["z"])) def __enter__(self) -> Self: """Start the capture thread when the Thing context manager is opened.""" + self.generate_canvas() self.start_streaming() return self @@ -298,14 +401,11 @@ class SimulatedCamera(BaseCamera): if wait_time > 0: time.sleep(wait_time) last_frame_t = time.time() - try: - frame = self.generate_frame() - self.mjpeg_stream.add_frame(_frame2bytes(frame)) - ds_frame = frame.resize((320, 240), resample=Image.Resampling.NEAREST) - self.lores_mjpeg_stream.add_frame(_frame2bytes(ds_frame)) - except Exception as e: - LOGGER.exception(f"Failed to capture frame: {e}, retrying...") + frame = self.generate_frame() + self.mjpeg_stream.add_frame(_frame2bytes(frame)) + ds_frame = frame.resize((320, 240), resample=Image.Resampling.NEAREST) + self.lores_mjpeg_stream.add_frame(_frame2bytes(ds_frame)) @lt.action def discard_frames(self) -> None: @@ -401,7 +501,12 @@ class SimulatedCamera(BaseCamera): @lt.property def manual_camera_settings(self) -> list[PropertyControl]: """The camera settings to expose as property controls in the settings panel.""" - return [property_control_for(self, "noise_level", label="Noise Level")] + return [ + property_control_for(self, "repeating", label="Infinite Sample"), + property_control_for(self, "blob_density", label="Sample Density"), + property_control_for(self, "colour", label="Sample Colour"), + property_control_for(self, "noise_level", label="Noise Level"), + ] def _frame2bytes(frame: Image.Image) -> bytes: diff --git a/tests/unit_tests/test_camera.py b/tests/unit_tests/test_base_camera.py similarity index 80% rename from tests/unit_tests/test_camera.py rename to tests/unit_tests/test_base_camera.py index 6f49b6f3..aba5b4ea 100644 --- a/tests/unit_tests/test_camera.py +++ b/tests/unit_tests/test_base_camera.py @@ -1,11 +1,14 @@ -"""Use the Simulation camera to test base camera functionality.""" +"""Use the Simulated camera to test base camera functionality. + +For tests of functionality specific to the simulated camera see +test_simulated_camera.py and for testing the consistency of camera APIs see +test_cameras.py. +""" import numpy as np import pytest from PIL import Image -import labthings_fastapi as lt - from openflexure_microscope_server.things.camera.simulation import SimulatedCamera from openflexure_microscope_server.things.stage.dummy import DummyStage @@ -13,7 +16,7 @@ from ..shared_utils.lt_test_utils import LabThingsTestEnv @pytest.fixture -def test_env() -> lt.ThingClient: +def test_env() -> LabThingsTestEnv: """Yield a test environment with the Simulated Camera and Dummy Stage.""" thing_conf = {"camera": SimulatedCamera, "stage": DummyStage} with LabThingsTestEnv(things=thing_conf) as env: @@ -58,12 +61,3 @@ def test_handle_broken_frame(test_env): for _i in range(15): array = camera.grab_as_array() assert isinstance(array, np.ndarray) - - -def test_simulation_cam_calibration(test_env): - """Test that the simulated camera can be calibrated and reports calibration correctly.""" - camera = test_env.get_thing_by_type(SimulatedCamera) - assert camera.calibration_required - camera.full_auto_calibrate() - assert not camera.calibration_required - assert camera.background_detector_status.ready diff --git a/tests/unit_tests/test_simulated_camera.py b/tests/unit_tests/test_simulated_camera.py new file mode 100644 index 00000000..b9243f4a --- /dev/null +++ b/tests/unit_tests/test_simulated_camera.py @@ -0,0 +1,184 @@ +"""Test the functionality specific to the simulated camera.""" + +import logging +import time + +import numpy as np +import pytest +from hypothesis import given +from hypothesis import strategies as st + +import labthings_fastapi as lt + +from openflexure_microscope_server.things.camera import simulation +from openflexure_microscope_server.things.camera.simulation import SimulatedCamera +from openflexure_microscope_server.things.stage.dummy import DummyStage + +from ..shared_utils.lt_test_utils import LabThingsTestEnv + + +@pytest.fixture +def test_env() -> LabThingsTestEnv: + """Yield a test environment with the Simulated Camera and Dummy Stage.""" + thing_conf = {"camera": SimulatedCamera, "stage": DummyStage} + with LabThingsTestEnv(things=thing_conf) as env: + yield env + + +@pytest.fixture +def camera(test_env) -> lt.Thing: + """Return the SimulatedCamera Thing set up in the test environment.""" + return test_env.get_thing_by_type(SimulatedCamera) + + +@pytest.fixture +def stage(test_env) -> lt.Thing: + """Return the DummyStage Thing set up in the test environment.""" + return test_env.get_thing_by_type(DummyStage) + + +def test_downsample_shape_2d(): + """Test downsampling for 2D array.""" + shape_2d = (100, 80) + result_2d = simulation._downsample_shape(shape_2d) + assert len(result_2d) == 2 + assert result_2d == (100 // simulation.DOWNSAMPLE, 80 // simulation.DOWNSAMPLE) + + +def test_downsample_shape_3d(): + """Test downsampling for 3D array, should not affect 3rd axis or shape.""" + shape_3d = (120, 60, 3) + result_3d = simulation._downsample_shape(shape_3d) + assert len(result_3d) == 3 + assert result_3d == (120 // simulation.DOWNSAMPLE, 60 // simulation.DOWNSAMPLE, 3) + + +def test_downsample_shape_invalid_length(): + """Shapes that are not length 2 or 3 should raise ValueError.""" + with pytest.raises(ValueError, match="Shape should be a 2 or 3 element tuple."): + simulation._downsample_shape((1,)) + + with pytest.raises(ValueError, match="Shape should be a 2 or 3 element tuple."): + simulation._downsample_shape((1, 2, 3, 4)) + + +def all_colours_present( + col_str: str, colours: list[tuple[int, int, int]], tries: int = 100 +) -> bool: + """Check that for a given colour string that all listed colours are returned. + + A helper function for testing simulation.colour_str_to_colour. As the result is + randomised the test just tries multiple times. In theory could fail, so pick + tries high enough if there are lots of colours in col_str. + """ + found = [False for _c in colours] + for _i in range(tries): + col_tuple = simulation.colour_str_to_colour(col_str) + if col_tuple not in colours: + raise ValueError("Unexpected colour returned.") + found[colours.index(col_tuple)] = True + # Don't exit early or we don't confirm that extra colours are not returned. + return all(found) + + +def test_colour_str_to_colour(): + """Test colour_str_to_colour with some basic predefined test cases.""" + # A basic test to convert a single str to the expected colour + assert simulation.colour_str_to_colour("#123456") == (0x12, 0x34, 0x56) + # A basic test with a trailing semicolon and surrounding spacing + assert simulation.colour_str_to_colour(" #123456 ; ") == (0x12, 0x34, 0x56) + # A 2 colour test + assert all_colours_present( + "#123456; #654321", [(0x12, 0x34, 0x56), (0x65, 0x43, 0x21)] + ) + # A failure_test (to check the helper function works!) + with pytest.raises(ValueError, match="Unexpected colour returned."): + assert all_colours_present( + "#123456; #654321", [(0x12, 0x34, 0x56), (0x11, 0x11, 0x11)] + ) + # And some incorrect strings that should fire an error in colour_str_to_colour + bad_colours = ["foobar", "pink", "#123", "#123456, #654321"] + for colour_str in bad_colours: + with pytest.raises( + ValueError, match=r".*not a valid colour. Please use HTML hex notation." + ): + simulation.colour_str_to_colour(colour_str) + + +@given(st.from_regex(simulation.COLOUR_LIST_REGEX, fullmatch=True)) +def test_colour_list_regex(colour_str): + """Check that anything matching the regex doesn't error when generating colours. + + This will error if splitting colour_str into individual colours produces + an incorrect colour. Trying 100 times for each colour_str as the returned colour + is randomised. Hypothesis will try to create the strings that match the regex but + break the test. + """ + for _ in range(100): + simulation.colour_str_to_colour(colour_str) + + +def test_canvas_regeneration(camera, caplog): + """Check canvas is regenerated if blob density or colour are changed.""" + cached_canvas = camera.canvas + original_colour = camera.colour + + # First try a bad colour string + with caplog.at_level(logging.WARNING): + camera.colour = "foobar" + assert len(caplog.messages) == 1 + assert caplog.messages[0] == "foobar is not a valid colour string." + # Value and canvas unchanged + assert camera.colour == original_colour + assert camera.canvas is cached_canvas + + # Set a valid colour + camera.colour = "#123456" + assert camera.colour == "#123456" + # canvas updated + assert camera.canvas is not cached_canvas + + # Cache again + cached_canvas = camera.canvas + camera.blob_density = 321 + assert camera.blob_density == 321 + # Canvas updated again + assert camera.canvas is not cached_canvas + + +def test_infinite_sample(camera, stage): + """Check that setting camera.repeating makes the sample infinite.""" + # Turn off noise to make comparison easier + camera.noise_level = 0 + assert not camera.repeating + cached_canvas = camera.canvas + array_not_repeating = camera.capture_array() + camera.repeating = True + time.sleep(0.2) # Ensure frame regenerates + # Canvas shouldn't regenerate + assert camera.canvas is cached_canvas + array_repeating = camera.capture_array() + # Images are identical whether or not repeating + assert np.array_equal(array_not_repeating, array_repeating) + + # Move outside the non-repeating sample area + stage._hardware_position["x"] = 100_000_000 + + camera.repeating = False + time.sleep(0.2) # Ensure frame regenerates + # If not repeating the array is just background + assert np.all(camera.capture_array() == simulation.BG_COLOR) + + # Turn on repeating + camera.repeating = True + time.sleep(0.2) # Ensure frame regenerates + # Sample is now infinite, so not all background + assert not np.all(camera.capture_array() == simulation.BG_COLOR) + + +def test_simulation_cam_calibration(camera): + """Test that the simulated camera can be calibrated and reports calibration correctly.""" + assert camera.calibration_required + camera.full_auto_calibrate() + assert not camera.calibration_required + assert camera.background_detector_status.ready diff --git a/webapp/src/components/labThingsComponents/inputFromSchema.vue b/webapp/src/components/labThingsComponents/inputFromSchema.vue index ea5e6e4d..212e0665 100644 --- a/webapp/src/components/labThingsComponents/inputFromSchema.vue +++ b/webapp/src/components/labThingsComponents/inputFromSchema.vue @@ -68,6 +68,22 @@ +