From fa249a0f24e4f6ebea9ac019adb86362040b91bb Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 26 Dec 2025 12:15:22 +0000 Subject: [PATCH 01/16] Calculate simulation images downsampled for speed --- .../things/camera/simulation.py | 54 ++++++++++++++----- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index f723c669..f169558f 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -13,7 +13,7 @@ import logging 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 +43,26 @@ BG_COLOR = [220, 215, 217] # Random Number Generator RNG = np.random.default_rng() +DOWNSAMPLE = 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("A shape should be a 2 or 3 element tuple.") + class SimulatedCamera(BaseCamera): """A Thing that simulates a camera for testing.""" @@ -75,11 +95,11 @@ class SimulatedCamera(BaseCamera): """ 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_shape = _downsample_shape(glyph_shape) + self.canvas_shape = _downsample_shape(canvas_shape) + sample_limits = canvas_shape[:2] if sample_limits is None else sample_limits + self.sample_limits = _downsample_shape(sample_limits) self.frame_interval = frame_interval self._capture_thread: Optional[Thread] = None self._capture_enabled = False @@ -108,6 +128,7 @@ class SimulatedCamera(BaseCamera): def generate_sprites(self) -> None: """Generate sprites to populate the image.""" sprite_sizes = [10, 21, 36, 40, 50] + sprite_sizes = [s / DOWNSAMPLE for s in sprite_sizes] self.sprites = [] channel_block = np.zeros(self.glyph_shape[0:2]) @@ -202,9 +223,13 @@ 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)) + image_width, image_height, _ = self.ds_shape + im_pos = ( + pos[0] * RATIO[0] / DOWNSAMPLE, + pos[1] * RATIO[1] / DOWNSAMPLE, + pos[2] * RATIO[2], + ) + 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, @@ -213,23 +238,24 @@ class SimulatedCamera(BaseCamera): # 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]) + 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)] image = fast_pil_blur(focused_image, sigma=np.abs(im_pos[2]) / 5) - if image.shape != self.shape: + if image.shape != self.ds_shape: raise ValueError( - f"Image shape {image.shape} does not match intended shape {self.shape}" + f"Image shape {image.shape} does not match intended shape {self.ds_shape}" ) # Add noise and convert to uint8 - image += RNG.normal(scale=self.noise_level, size=self.shape).astype("int16") + image += RNG.normal(scale=self.noise_level, size=self.ds_shape).astype("int16") image[image < 0] = 0 image[image > 255] = 255 - return Image.fromarray(image.astype("uint8")) + image = Image.fromarray(image.astype("uint8")) + return image.resize((self.shape[1], self.shape[0]), Image.BILINEAR) def generate_frame(self) -> Image.Image: """Generate a frame with blobs based on the stage coordinates.""" From 99f7802195b7245187a3c784e4c7ca8f9226b118 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 26 Dec 2025 13:14:57 +0000 Subject: [PATCH 02/16] Create sprites upsampled, before downsampling so they hhave a smoother edge interpolation --- .../things/camera/simulation.py | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index f169558f..2ff6eccf 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -44,6 +44,10 @@ BG_COLOR = [220, 215, 217] 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 almos no performance penalty +# for a nice gain in quality. +SPRITE_UPSAMPLE = 4 @overload @@ -75,7 +79,7 @@ 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), + glyph_size: int = 121, canvas_shape: tuple[int, int, int] = (3000, 4000, 3), sample_limits: Optional[tuple[int, int]] = (1000, 1500), frame_interval: float = 0.1, @@ -83,7 +87,7 @@ class SimulatedCamera(BaseCamera): """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 glyph_size: 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. @@ -96,7 +100,7 @@ class SimulatedCamera(BaseCamera): super().__init__(thing_server_interface) self.shape = shape self.ds_shape = _downsample_shape(shape) - self.glyph_shape = _downsample_shape(glyph_shape) + self.glyph_size = glyph_size // DOWNSAMPLE self.canvas_shape = _downsample_shape(canvas_shape) sample_limits = canvas_shape[:2] if sample_limits is None else sample_limits self.sample_limits = _downsample_shape(sample_limits) @@ -128,10 +132,11 @@ class SimulatedCamera(BaseCamera): def generate_sprites(self) -> None: """Generate sprites to populate the image.""" sprite_sizes = [10, 21, 36, 40, 50] - sprite_sizes = [s / DOWNSAMPLE for s in sprite_sizes] + 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 @@ -156,8 +161,14 @@ class SimulatedCamera(BaseCamera): 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)) + # 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.BILINEAR + ) + self.sprites.append(np.array(sprite_pil)) def generate_blobs(self, n_blobs: int = 1000) -> None: """Generate coordinates of blobs and their sizes, centered around (0,0). @@ -169,7 +180,7 @@ 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) From 159e2d14ab0e99abc8dcb06216a47b1b8ebab0b2 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 26 Dec 2025 21:57:25 +0000 Subject: [PATCH 03/16] Unify canvas and sample shape for simulation, set flag for if repeating. --- .../things/camera/simulation.py | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 2ff6eccf..ea17463a 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -79,37 +79,38 @@ class SimulatedCamera(BaseCamera): self, thing_server_interface: lt.ThingServerInterface, shape: tuple[int, int, int] = (616, 820, 3), - glyph_size: int = 121, - 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), + repeating: bool = True, + blob_density: int = 300, 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_size: 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 + :param repeating: If set True, outside the canvas, the camera won't generate any blobs, preventing scanning from running - indefinitely and better demonstrating background detect. + indefinitely allowing testing of demonstrating background detect. If False + the canvas will repeat. + :param blob_density: The number of blobs per million pixels. :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.ds_shape = _downsample_shape(shape) - self.glyph_size = glyph_size // DOWNSAMPLE + self.glyph_size = 101 // DOWNSAMPLE self.canvas_shape = _downsample_shape(canvas_shape) - sample_limits = canvas_shape[:2] if sample_limits is None else sample_limits - self.sample_limits = _downsample_shape(sample_limits) + self.repeating = repeating 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_blobs( + int(blob_density * 1e-6 * canvas_shape[0] * canvas_shape[1]) + ) self.generate_canvas() @lt.property @@ -117,18 +118,6 @@ class SimulatedCamera(BaseCamera): """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] @@ -182,8 +171,8 @@ class SimulatedCamera(BaseCamera): self.blobs = np.zeros((n_blobs, 3)) 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: @@ -242,13 +231,25 @@ class SimulatedCamera(BaseCamera): ) 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, + int(im_pos[0]) - image_width // 2 + self.canvas_shape[0] // 2, + int(im_pos[1]) - image_height // 2 + self.canvas_shape[1] // 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 + + 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: + # No sprites are placed right at the edge of the image so that the whole + # sprite is on the canvas. For a non-repeating image just clip to the + # first or last pixel. + 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 From 7b9f4aa202383d1082d2a56bb95536bd6ec8d6b1 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 26 Dec 2025 22:44:03 +0000 Subject: [PATCH 04/16] Expose sample density and sample repeat for simulation camera to the UI in camera settings rather than as init options --- .../things/camera/simulation.py | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index ea17463a..dc96b991 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -80,8 +80,6 @@ class SimulatedCamera(BaseCamera): thing_server_interface: lt.ThingServerInterface, shape: tuple[int, int, int] = (616, 820, 3), canvas_shape: tuple[int, int, int] = (1500, 2000, 3), - repeating: bool = True, - blob_density: int = 300, frame_interval: float = 0.1, ) -> None: """Initialise the simulated with settings for how images are generated. @@ -90,11 +88,6 @@ class SimulatedCamera(BaseCamera): :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 repeating: If set True, outside the canvas, the - camera won't generate any blobs, preventing scanning from running - indefinitely allowing testing of demonstrating background detect. If False - the canvas will repeat. - :param blob_density: The number of blobs per million pixels. :param frame_interval: Nominally the time between frames on the MJPEG stream, however the rate may be slower due to calculation time for focus. """ @@ -103,15 +96,26 @@ class SimulatedCamera(BaseCamera): self.ds_shape = _downsample_shape(shape) self.glyph_size = 101 // DOWNSAMPLE self.canvas_shape = _downsample_shape(canvas_shape) - self.repeating = repeating + self.frame_interval = frame_interval self._capture_thread: Optional[Thread] = None self._capture_enabled = False self.generate_sprites() - self.generate_blobs( - int(blob_density * 1e-6 * canvas_shape[0] * canvas_shape[1]) - ) - 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 blob_density(self, value: int) -> None: + self._blob_density = value + if self._capture_enabled: + self.generate_canvas() @lt.property def calibration_required(self) -> bool: @@ -181,6 +185,8 @@ 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] @@ -280,6 +286,7 @@ class SimulatedCamera(BaseCamera): def __enter__(self) -> Self: """Start the capture thread when the Thing context manager is opened.""" + self.generate_canvas() self.start_streaming() return self @@ -439,7 +446,11 @@ 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, "noise_level", label="Noise Level"), + ] def _frame2bytes(frame: Image.Image) -> bytes: From 11379d8fa74641e91355b403efc847d96d587720 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 28 Dec 2025 20:19:01 +0000 Subject: [PATCH 05/16] Allow colour to be set for simulation sample. --- .../things/camera/simulation.py | 67 +++++++++++++++---- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index dc96b991..9bfd0e5f 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -10,6 +10,7 @@ from __future__ import annotations import io import logging +import re import time from threading import Thread from types import TracebackType @@ -49,6 +50,8 @@ DOWNSAMPLE = 2 # for a nice gain in quality. SPRITE_UPSAMPLE = 4 +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]: ... @@ -68,6 +71,26 @@ def _downsample_shape( raise ValueError("A shape should be a 2 or 3 element tuple.") +def colour_str_to_colour(colour: str) -> tuple[int, int, int]: + """Convert a colour string into RGB colour values. + + :param colour: Should be a hex colour such as #33aa33 + :return: The colour as a tuple of 3 integers from 0 to 255 in value + :raises ValueError: If the hex string is not valid. + """ + colour = colour.lower().strip() + colour_match = COLOUR_REGEX.match(colour) + if colour_match is None: + raise ValueError( + f"{colour} 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.""" @@ -117,6 +140,23 @@ class SimulatedCamera(BaseCamera): if self._capture_enabled: self.generate_canvas() + _colour: str = "#b937b9" + + @lt.property + def colour(self) -> str: + """The number of colour of the blobs.""" + return self._colour + + @colour.setter + def colour(self, value: str) -> None: + if COLOUR_REGEX.match(value) is None: + self.logger.warning(f"{value} is not a valid colour string.") + return + + self._colour = value + if self._capture_enabled: + self.generate_canvas() + @lt.property def calibration_required(self) -> bool: """Whether the camera needs calibrating.""" @@ -140,20 +180,14 @@ 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) + 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 @@ -213,7 +247,15 @@ class SimulatedCamera(BaseCamera): :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 + 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) + sprite_rgb = sprite_rgb.astype("uint8") # Canvas region containing the sprite top = max(centre_y - sprite_h // 2, 0) @@ -221,7 +263,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 + self.canvas[top:bottom, left:right] -= sprite_rgb def generate_image(self, pos: tuple[int, int, int]) -> Image.Image: """Generate an image with blobs based on supplied coordinates. @@ -449,6 +491,7 @@ class SimulatedCamera(BaseCamera): 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"), ] From 980af66548c578888687006feaf87aedf77093ab Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 28 Dec 2025 21:32:27 +0000 Subject: [PATCH 06/16] Let lists of colours be set for simulation sample. --- .../things/camera/simulation.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 9bfd0e5f..9f43a940 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -50,6 +50,9 @@ DOWNSAMPLE = 2 # for a nice gain in quality. SPRITE_UPSAMPLE = 4 +COLOUR_LIST_REGEX = re.compile( + r"\s*^(#[0-9a-fA-F]{6})\s*(?:;\s*(#[0-9a-fA-F]{6})\s*)*$" +) COLOUR_REGEX = re.compile(r"^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$") @@ -71,18 +74,22 @@ def _downsample_shape( raise ValueError("A shape should be a 2 or 3 element tuple.") -def colour_str_to_colour(colour: str) -> tuple[int, int, int]: +def colour_str_to_colour(colour_str: str) -> tuple[int, int, int]: """Convert a colour string into RGB colour values. - :param colour: Should be a hex colour such as #33aa33 + :param colour_str: Should be a hex colour such as #33aa33 or a list separated by + semicolons. :return: The colour as a tuple of 3 integers from 0 to 255 in value :raises ValueError: If the hex string is not valid. """ - colour = colour.lower().strip() - colour_match = COLOUR_REGEX.match(colour) + if ";" in colour_str: + colours = colour_str.split(";") + colour_str = colours[RNG.integers(0, len(colours))] + colour_str = colour_str.lower().strip() + colour_match = COLOUR_REGEX.match(colour_str) if colour_match is None: raise ValueError( - f"{colour} is not a valid colour. Please use HTML hex notation." + f"{colour_str} is not a valid colour. Please use HTML hex notation." ) r = int("0x" + colour_match.group(1), 16) @@ -149,7 +156,7 @@ class SimulatedCamera(BaseCamera): @colour.setter def colour(self, value: str) -> None: - if COLOUR_REGEX.match(value) is None: + if COLOUR_LIST_REGEX.match(value) is None: self.logger.warning(f"{value} is not a valid colour string.") return From fc3ca8cbe7d97a960fab7f93c0babe557be6be60 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 28 Dec 2025 21:40:26 +0000 Subject: [PATCH 07/16] Slightly increase glyph shape so that blob cannot touch edge --- .../things/camera/simulation.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 9f43a940..c5ebea90 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -124,7 +124,7 @@ class SimulatedCamera(BaseCamera): super().__init__(thing_server_interface) self.shape = shape self.ds_shape = _downsample_shape(shape) - self.glyph_size = 101 // DOWNSAMPLE + self.glyph_size = 105 // DOWNSAMPLE self.canvas_shape = _downsample_shape(canvas_shape) self.frame_interval = frame_interval @@ -202,7 +202,14 @@ class SimulatedCamera(BaseCamera): sprite_pil = sprite_pil.resize( (self.glyph_size, self.glyph_size), Image.BILINEAR ) - self.sprites.append(np.array(sprite_pil)) + # Concert 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). From 69cfb54e4622e3c57e9cc19b6dadf7f9f956a7af Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sat, 10 Jan 2026 19:11:54 +0000 Subject: [PATCH 08/16] String PropertyControls --- .../labThingsComponents/inputFromSchema.vue | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) 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 @@ +