From afcb02bf7e2a3f6874cd266dda55a1a19ae25d6d Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Wed, 24 Sep 2025 18:09:08 +0100 Subject: [PATCH 01/12] Increase density of blobs, limit range --- .../things/camera/simulation.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 37df5d1c..f140a250 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -34,7 +34,7 @@ LOGGER = logging.getLogger(__name__) # The ratio between "motor" steps and pixels # higher related to a faster movement -RATIO = 0.2 +RATIO = (2, 2, 0.2) # Some colour variation, for bg detect. BG_COLOR = [220, 215, 217] @@ -53,8 +53,9 @@ class SimulatedCamera(BaseCamera): def __init__( self, shape: tuple[int, int, int] = (616, 820, 3), - glyph_shape: tuple[int, int, int] = (91, 91, 3), + glyph_shape: tuple[int, int, int] = (151, 151, 3), canvas_shape: tuple[int, int, int] = (3000, 4000, 3), + sample_limits: tuple[int, int, int] = (2000, 3000, 3), frame_interval: float = 0.1, ) -> None: """Initialise the simulated with settings for how images are generated. @@ -64,6 +65,9 @@ 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 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. """ @@ -71,6 +75,7 @@ class SimulatedCamera(BaseCamera): self.shape = shape self.glyph_shape = glyph_shape self.canvas_shape = canvas_shape + self.sample_limits = sample_limits self.frame_interval = frame_interval self._capture_thread: Optional[Thread] = None self._capture_enabled = False @@ -80,7 +85,7 @@ class SimulatedCamera(BaseCamera): def generate_sprites(self) -> None: """Generate sprites to populate the image.""" - sprite_sizes = [5, 7, 10, 21, 36, 40] + sprite_sizes = [10, 21, 36, 40, 50, 70] self.sprites = [] channel_block = np.zeros(self.glyph_shape[0:2]) @@ -123,8 +128,8 @@ class SimulatedCamera(BaseCamera): self.blobs = np.zeros((n_blobs, 3)) w = np.max(self.glyph_shape) - self.blobs[:, 0] = RNG.uniform(w / 2, self.canvas_shape[0] - w / 2, n_blobs) - self.blobs[:, 1] = RNG.uniform(w / 2, self.canvas_shape[1] - w / 2, n_blobs) + self.blobs[:, 0] = RNG.uniform(w / 2, self.sample_limits[0] - w / 2, n_blobs) + self.blobs[:, 1] = RNG.uniform(w / 2, self.sample_limits[1] - w / 2, n_blobs) self.blobs[:, 2] = RNG.choice(len(self.sprites), n_blobs) def generate_canvas(self) -> None: @@ -151,7 +156,7 @@ class SimulatedCamera(BaseCamera): """Generate an image with blobs based on supplied coordinates.""" canvas_width, canvas_height, _ = self.canvas_shape image_width, image_height, _ = self.shape - pos = tuple(x * RATIO for x in pos) + pos = tuple(x * s for x, s in zip(pos, RATIO, strict=True)) top_left = ( int(pos[0]) - image_width // 2 - canvas_width // 2, int(pos[1]) - image_height // 2 - canvas_height // 2, From 49070d4163e97fe29bf573a23f39ac448bceb101 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Wed, 24 Sep 2025 19:31:11 +0100 Subject: [PATCH 02/12] Generated limited size sample of larger blobs, move faster in xy --- .../things/camera/simulation.py | 67 ++++++++++++++----- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index f140a250..a687d87b 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -55,7 +55,7 @@ class SimulatedCamera(BaseCamera): shape: tuple[int, int, int] = (616, 820, 3), glyph_shape: tuple[int, int, int] = (151, 151, 3), canvas_shape: tuple[int, int, int] = (3000, 4000, 3), - sample_limits: tuple[int, int, int] = (2000, 3000, 3), + sample_limits: tuple[int, int, int] = (1000, 1500, 3), frame_interval: float = 0.1, ) -> None: """Initialise the simulated with settings for how images are generated. @@ -117,23 +117,23 @@ class SimulatedCamera(BaseCamera): self.sprites.append(sprite.astype(np.uint8)) def generate_blobs(self, n_blobs: int = 1000) -> None: - """Generate coordinates of blobs and their sizes. - - A 1000x3 array is returned. Each row represents (x,y) coordinate - of the sprite and the index representing the size of the sprite. - - Blobs are characterised by X, Y, sprite - We also generate a KD tree to rapidly find blobs in an image - """ + """Generate coordinates of blobs and their sizes, centered around (0,0).""" self.blobs = np.zeros((n_blobs, 3)) w = np.max(self.glyph_shape) - self.blobs[:, 0] = RNG.uniform(w / 2, self.sample_limits[0] - w / 2, n_blobs) - self.blobs[:, 1] = RNG.uniform(w / 2, self.sample_limits[1] - w / 2, n_blobs) + sample_centre_x, sample_centre_y = self.sample_limits[1], self.sample_limits[0] + + # Coordinates relative to center (0,0) + self.blobs[:, 0] = RNG.uniform( + -sample_centre_x + w / 2, sample_centre_x - w / 2, n_blobs + ) + self.blobs[:, 1] = RNG.uniform( + -sample_centre_y + w / 2, sample_centre_y - w / 2, n_blobs + ) self.blobs[:, 2] = RNG.choice(len(self.sprites), n_blobs) def generate_canvas(self) -> None: - """Generate a canvas. + """Generate a canvas with generated blobs centered at the middle. Canvas is int16 so that random noise can be added to simulation image before changing to unit8 to stop wrapping. @@ -143,15 +143,46 @@ class SimulatedCamera(BaseCamera): self.blank_canvas[:, :, 1] *= BG_COLOR[1] self.blank_canvas[:, :, 2] *= BG_COLOR[2] self.canvas = self.blank_canvas.copy() - w, h, _ = self.glyph_shape - for x, y, sprite_size_index in self.blobs: - self.canvas[ - int(x) - w // 2 : int(x) - w // 2 + w, - int(y) - h // 2 : int(y) - h // 2 + h, - ] -= self.sprites[int(sprite_size_index)] + + canvas_h, canvas_w, _ = self.canvas.shape + canvas_centre_x, canvas_centre_y = canvas_w // 2, canvas_h // 2 + + for blob_x, blob_y, sprite_index in self.blobs: + canvas_blob_x = int(round(blob_x + canvas_centre_x)) + canvas_blob_y = int(round(blob_y + canvas_centre_y)) + self.draw_sprite_on_canvas( + self.sprites[int(sprite_index)], canvas_blob_y, canvas_blob_x + ) + self.canvas[self.canvas < 0] = 0 self.canvas[self.canvas > 255] = 255 + def draw_sprite_on_canvas( + self, sprite: np.ndarray, center_y: int, center_x: int + ) -> None: + """Place one sprite on canvas at given centre coordinates, clipping automatically.""" + canvas_h, canvas_w, _ = self.canvas.shape + sprite_h, sprite_w, _ = sprite.shape + + # Compute top-left corner + top = max(center_y - sprite_h // 2, 0) + left = max(center_x - sprite_w // 2, 0) + + # Compute bottom-right corner + bottom = min(center_y + (sprite_h - sprite_h // 2), canvas_h) + right = min(center_x + (sprite_w - sprite_w // 2), canvas_w) + + # Compute sprite slice to match clipped canvas region + sprite_top = 0 if center_y - sprite_h // 2 >= 0 else sprite_h // 2 - center_y + sprite_left = 0 if center_x - sprite_w // 2 >= 0 else sprite_w // 2 - center_x + sprite_bottom = sprite_top + (bottom - top) + sprite_right = sprite_left + (right - left) + + # Subtract sprite from canvas + self.canvas[top:bottom, left:right] -= sprite[ + sprite_top:sprite_bottom, sprite_left:sprite_right + ] + def generate_image(self, pos: tuple[int, int, int]) -> np.ndarray: """Generate an image with blobs based on supplied coordinates.""" canvas_width, canvas_height, _ = self.canvas_shape From 0e5517d45c8011b0d720826d7e59d5699a6bc5ce Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 25 Sep 2025 12:04:39 +0100 Subject: [PATCH 03/12] Fix another hardcoded linux test --- tests/test_serve_static_files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_serve_static_files.py b/tests/test_serve_static_files.py index 9755cc1c..8d381456 100644 --- a/tests/test_serve_static_files.py +++ b/tests/test_serve_static_files.py @@ -60,7 +60,7 @@ def test_add_static_file(filename, allow_cache, mocker): # the wrapped function should return the file response response = wrapped() assert isinstance(response, FileResponse) - assert response.path == "bar/" + filename + assert response.path == os.path.join("bar", filename) # The file response headers always have some standard data, and if the file is # allowed to be cached it should also have the no_cache data for key, value in serve_static_files.NO_CACHE_HEADERS.items(): From 89214ea2ec9232fbf25bd117b67259d1602b4ebd Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 25 Sep 2025 12:05:49 +0100 Subject: [PATCH 04/12] Add sphinx docstrings, add sprites in a way to test sizes match --- .../things/camera/simulation.py | 60 ++++++++++++++----- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index a687d87b..7edd1483 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -117,7 +117,10 @@ class SimulatedCamera(BaseCamera): self.sprites.append(sprite.astype(np.uint8)) def generate_blobs(self, n_blobs: int = 1000) -> None: - """Generate coordinates of blobs and their sizes, centered around (0,0).""" + """Generate coordinates of blobs and their sizes, centered around (0,0). + + :param n_blobs: The number of blobs to generate. + """ self.blobs = np.zeros((n_blobs, 3)) w = np.max(self.glyph_shape) @@ -160,31 +163,49 @@ class SimulatedCamera(BaseCamera): def draw_sprite_on_canvas( self, sprite: np.ndarray, center_y: int, center_x: int ) -> None: - """Place one sprite on canvas at given centre coordinates, clipping automatically.""" + """Place one sprite on canvas at given centre coordinates. + + Note that self.canvas is modified in place. + + :param sprite: The sprite array to place on the canvas. + :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 - # Compute top-left corner + # Canvas region containing the sprite top = max(center_y - sprite_h // 2, 0) left = max(center_x - sprite_w // 2, 0) - - # Compute bottom-right corner bottom = min(center_y + (sprite_h - sprite_h // 2), canvas_h) right = min(center_x + (sprite_w - sprite_w // 2), canvas_w) - # Compute sprite slice to match clipped canvas region - sprite_top = 0 if center_y - sprite_h // 2 >= 0 else sprite_h // 2 - center_y - sprite_left = 0 if center_x - sprite_w // 2 >= 0 else sprite_w // 2 - center_x - sprite_bottom = sprite_top + (bottom - top) - sprite_right = sprite_left + (right - left) + # Size of the sprite + sprite_top = 0 + sprite_left = 0 + sprite_bottom = self.glyph_shape[0] + sprite_right = self.glyph_shape[1] - # Subtract sprite from canvas - self.canvas[top:bottom, left:right] -= sprite[ - sprite_top:sprite_bottom, sprite_left:sprite_right - ] + # Extract regions + canvas_region = self.canvas[top:bottom, left:right] + sprite_region = sprite[sprite_top:sprite_bottom, sprite_left:sprite_right] + + # Ensure shapes are the same size before subtraction - can't have one bigger than the other + # In the case of a mismatch, use the smaller region + if canvas_region.shape != sprite_region.shape: + min_h = min(canvas_region.shape[0], sprite_region.shape[0]) + min_w = min(canvas_region.shape[1], sprite_region.shape[1]) + self.canvas[top : top + min_h, left : left + min_w] -= sprite[ + :min_h, :min_w + ] + else: + self.canvas[top:bottom, left:right] -= sprite_region def generate_image(self, pos: tuple[int, int, int]) -> np.ndarray: - """Generate an image with blobs based on supplied coordinates.""" + """Generate an image with blobs based on supplied coordinates. + + :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 pos = tuple(x * s for x, s in zip(pos, RATIO, strict=True)) @@ -275,6 +296,9 @@ class SimulatedCamera(BaseCamera): It will always issue a warning that the resolution is not respected. If called while already streaming, the warning will be emitted and no other action will be taken. + + :param main_resolution: ignored, provided for compatibility with other server modes + :param buffer_count: ignored, provided for compatibility with other server modes """ LOGGER.warning( f"Simulation camera doesn't respect {main_resolution=} or {buffer_count=} " @@ -329,6 +353,9 @@ class SimulatedCamera(BaseCamera): This function will produce a nested list containing an uncompressed RGB image. It's likely to be highly inefficient - raw and/or uncompressed captures using binary image formats will be added in due course. + + :param stream_name: ignored, provided for compatibility with other server modes + :param wait: ignored, provided for compatibility with other server modes """ if wait is not None: LOGGER.warning("Simulation camera has no wait option. Use None.") @@ -343,6 +370,9 @@ class SimulatedCamera(BaseCamera): """Capture to a PIL image. This is not exposed as a ThingAction. It is used for capture to memory. + + :param stream_name: ignored, provided for compatibility with other server modes + :param wait: ignored, provided for compatibility with other server modes """ if wait is not None: LOGGER.warning("Simulation camera has no wait option. Use None.") From d3aeb75ed9755674c2712a8e52065d90ab9fd427 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 25 Sep 2025 12:52:17 +0100 Subject: [PATCH 05/12] Fixed centre typo --- .../things/camera/simulation.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 7edd1483..a239c6ff 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -126,7 +126,7 @@ class SimulatedCamera(BaseCamera): w = np.max(self.glyph_shape) sample_centre_x, sample_centre_y = self.sample_limits[1], self.sample_limits[0] - # Coordinates relative to center (0,0) + # Coordinates relative to centre (0,0) self.blobs[:, 0] = RNG.uniform( -sample_centre_x + w / 2, sample_centre_x - w / 2, n_blobs ) @@ -161,7 +161,7 @@ class SimulatedCamera(BaseCamera): self.canvas[self.canvas > 255] = 255 def draw_sprite_on_canvas( - self, sprite: np.ndarray, center_y: int, center_x: int + self, sprite: np.ndarray, centre_y: int, centre_x: int ) -> None: """Place one sprite on canvas at given centre coordinates. @@ -175,10 +175,10 @@ class SimulatedCamera(BaseCamera): sprite_h, sprite_w, _ = sprite.shape # Canvas region containing the sprite - top = max(center_y - sprite_h // 2, 0) - left = max(center_x - sprite_w // 2, 0) - bottom = min(center_y + (sprite_h - sprite_h // 2), canvas_h) - right = min(center_x + (sprite_w - sprite_w // 2), canvas_w) + top = max(centre_y - sprite_h // 2, 0) + left = max(centre_x - sprite_w // 2, 0) + bottom = min(centre_y + (sprite_h - sprite_h // 2), canvas_h) + right = min(centre_x + (sprite_w - sprite_w // 2), canvas_w) # Size of the sprite sprite_top = 0 From 16bc7aeede66512f426a93c79c510d3b892dd9ee Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 25 Sep 2025 13:33:16 +0000 Subject: [PATCH 06/12] Apply suggestions from code review of branch sample-size-sim Co-authored-by: Julian Stirling --- .../things/camera/simulation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index a239c6ff..a78215fe 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -55,7 +55,7 @@ class SimulatedCamera(BaseCamera): shape: tuple[int, int, int] = (616, 820, 3), glyph_shape: tuple[int, int, int] = (151, 151, 3), canvas_shape: tuple[int, int, int] = (3000, 4000, 3), - sample_limits: tuple[int, int, int] = (1000, 1500, 3), + sample_limits: Optional[tuple[int, int]] = (1000, 1500), frame_interval: float = 0.1, ) -> None: """Initialise the simulated with settings for how images are generated. @@ -297,8 +297,8 @@ class SimulatedCamera(BaseCamera): If called while already streaming, the warning will be emitted and no other action will be taken. - :param main_resolution: ignored, provided for compatibility with other server modes - :param buffer_count: ignored, provided for compatibility with other server modes + :param main_resolution: Currently ignored, this argument exists to ensure consistent API across camera Things. + :param buffer_count: Currently ignored, this argument exists to ensure consistent API across camera Things. """ LOGGER.warning( f"Simulation camera doesn't respect {main_resolution=} or {buffer_count=} " From 39b085a0c296473f7ed22da4ebcb238bf0b9e613 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 25 Sep 2025 13:35:55 +0000 Subject: [PATCH 07/12] Apply suggestions from code review of branch sample-size-sim Co-authored-by: Julian Stirling --- src/openflexure_microscope_server/things/camera/simulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index a78215fe..7a1900c7 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -75,7 +75,7 @@ class SimulatedCamera(BaseCamera): self.shape = shape self.glyph_shape = glyph_shape self.canvas_shape = canvas_shape - self.sample_limits = sample_limits + self.sample_limits = canvas_shape[:2] if sample_limits is None else sample_limits self.frame_interval = frame_interval self._capture_thread: Optional[Thread] = None self._capture_enabled = False From bfe0064ce8e73717cd9958b633a2f12d9c6e15bf Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 25 Sep 2025 16:14:13 +0100 Subject: [PATCH 08/12] Update dummy test and fix sample coords --- .../things/camera/simulation.py | 52 ++++++------------- tests/test_dummy_server.py | 2 +- 2 files changed, 17 insertions(+), 37 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index a78215fe..c8368ee1 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -53,7 +53,7 @@ class SimulatedCamera(BaseCamera): def __init__( self, shape: tuple[int, int, int] = (616, 820, 3), - glyph_shape: tuple[int, int, int] = (151, 151, 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), frame_interval: float = 0.1, @@ -85,7 +85,7 @@ class SimulatedCamera(BaseCamera): def generate_sprites(self) -> None: """Generate sprites to populate the image.""" - sprite_sizes = [10, 21, 36, 40, 50, 70] + sprite_sizes = [10, 21, 36, 40, 50] self.sprites = [] channel_block = np.zeros(self.glyph_shape[0:2]) @@ -122,17 +122,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) - sample_centre_x, sample_centre_y = self.sample_limits[1], self.sample_limits[0] - # Coordinates relative to centre (0,0) - self.blobs[:, 0] = RNG.uniform( - -sample_centre_x + w / 2, sample_centre_x - w / 2, n_blobs - ) - self.blobs[:, 1] = RNG.uniform( - -sample_centre_y + w / 2, sample_centre_y - w / 2, n_blobs - ) + 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[:, 2] = RNG.choice(len(self.sprites), n_blobs) def generate_canvas(self) -> None: @@ -147,14 +140,9 @@ class SimulatedCamera(BaseCamera): self.blank_canvas[:, :, 2] *= BG_COLOR[2] self.canvas = self.blank_canvas.copy() - canvas_h, canvas_w, _ = self.canvas.shape - canvas_centre_x, canvas_centre_y = canvas_w // 2, canvas_h // 2 - for blob_x, blob_y, sprite_index in self.blobs: - canvas_blob_x = int(round(blob_x + canvas_centre_x)) - canvas_blob_y = int(round(blob_y + canvas_centre_y)) self.draw_sprite_on_canvas( - self.sprites[int(sprite_index)], canvas_blob_y, canvas_blob_x + self.sprites[int(sprite_index)], int(blob_y), int(blob_x) ) self.canvas[self.canvas < 0] = 0 @@ -180,26 +168,18 @@ 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) - # Size of the sprite - sprite_top = 0 - sprite_left = 0 - sprite_bottom = self.glyph_shape[0] - sprite_right = self.glyph_shape[1] - - # Extract regions canvas_region = self.canvas[top:bottom, left:right] - sprite_region = sprite[sprite_top:sprite_bottom, sprite_left:sprite_right] # Ensure shapes are the same size before subtraction - can't have one bigger than the other - # In the case of a mismatch, use the smaller region - if canvas_region.shape != sprite_region.shape: - min_h = min(canvas_region.shape[0], sprite_region.shape[0]) - min_w = min(canvas_region.shape[1], sprite_region.shape[1]) + # In the case of a mismatch, use the smaller region. + if canvas_region.shape != sprite.shape: + min_h = min(canvas_region.shape[0], sprite.shape[0]) + min_w = min(canvas_region.shape[1], sprite.shape[1]) self.canvas[top : top + min_h, left : left + min_w] -= sprite[ :min_h, :min_w ] else: - self.canvas[top:bottom, left:right] -= sprite_region + self.canvas[top:bottom, left:right] -= sprite def generate_image(self, pos: tuple[int, int, int]) -> np.ndarray: """Generate an image with blobs based on supplied coordinates. @@ -210,8 +190,8 @@ class SimulatedCamera(BaseCamera): image_width, image_height, _ = self.shape pos = tuple(x * s for x, s in zip(pos, RATIO, strict=True)) top_left = ( - int(pos[0]) - image_width // 2 - canvas_width // 2, - int(pos[1]) - image_height // 2 - canvas_height // 2, + int(pos[0]) - image_width // 2 + self.sample_limits[0] // 2, + int(pos[1]) - image_height // 2 + self.sample_limits[1] // 2, ) # Create index list with modulo rather than slicing to handle wrapping at the # canvas edge. @@ -354,8 +334,8 @@ class SimulatedCamera(BaseCamera): It's likely to be highly inefficient - raw and/or uncompressed captures using binary image formats will be added in due course. - :param stream_name: ignored, provided for compatibility with other server modes - :param wait: ignored, provided for compatibility with other server modes + :param main_resolution: Currently ignored, this argument exists to ensure consistent API across camera Things. + :param buffer_count: Currently ignored, this argument exists to ensure consistent API across camera Things. """ if wait is not None: LOGGER.warning("Simulation camera has no wait option. Use None.") @@ -371,8 +351,8 @@ class SimulatedCamera(BaseCamera): It is used for capture to memory. - :param stream_name: ignored, provided for compatibility with other server modes - :param wait: ignored, provided for compatibility with other server modes + :param main_resolution: Currently ignored, this argument exists to ensure consistent API across camera Things. + :param buffer_count: Currently ignored, this argument exists to ensure consistent API across camera Things. """ if wait is not None: LOGGER.warning("Simulation camera has no wait option. Use None.") diff --git a/tests/test_dummy_server.py b/tests/test_dummy_server.py index 3f911992..c25378b8 100644 --- a/tests/test_dummy_server.py +++ b/tests/test_dummy_server.py @@ -33,7 +33,7 @@ def thing_server(): server = lt.ThingServer(settings_folder=temp_folder.name) server.add_thing( SimulatedCamera( - shape=(240, 320, 3), canvas_shape=(960, 1240, 3), frame_interval=0.01 + shape=(240, 320, 3), canvas_shape=(1920, 2480, 3), frame_interval=0.01 ), "/camera/", ) From 0a687e1678a7034af4b5f3d87710a6fbd4926c96 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 25 Sep 2025 18:51:33 +0100 Subject: [PATCH 09/12] Ruff --- src/openflexure_microscope_server/things/camera/simulation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 7cbd345b..6dcb5cf6 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -75,7 +75,9 @@ class SimulatedCamera(BaseCamera): 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.sample_limits = ( + canvas_shape[:2] if sample_limits is None else sample_limits + ) self.frame_interval = frame_interval self._capture_thread: Optional[Thread] = None self._capture_enabled = False From 3812e9053a343d1ad9ef4ed1e4c5d6e4377a571e Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Fri, 26 Sep 2025 17:16:55 +0100 Subject: [PATCH 10/12] Docstring fix --- .../things/camera/simulation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 6dcb5cf6..032ee31c 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -336,8 +336,8 @@ class SimulatedCamera(BaseCamera): It's likely to be highly inefficient - raw and/or uncompressed captures using binary image formats will be added in due course. - :param main_resolution: Currently ignored, this argument exists to ensure consistent API across camera Things. - :param buffer_count: Currently ignored, this argument exists to ensure consistent API across camera Things. + :param stream_name: Currently ignored, this argument exists to ensure consistent API across camera Things. + :param wait: Currently ignored, this argument exists to ensure consistent API across camera Things. """ if wait is not None: LOGGER.warning("Simulation camera has no wait option. Use None.") @@ -353,8 +353,8 @@ class SimulatedCamera(BaseCamera): It is used for capture to memory. - :param main_resolution: Currently ignored, this argument exists to ensure consistent API across camera Things. - :param buffer_count: Currently ignored, this argument exists to ensure consistent API across camera Things. + :param stream_name: Currently ignored, this argument exists to ensure consistent API across camera Things. + :param wait: Currently ignored, this argument exists to ensure consistent API across camera Things. """ if wait is not None: LOGGER.warning("Simulation camera has no wait option. Use None.") From 2fc92fc1ffc7a58ea59423e06dec0bce1c3516d0 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Thu, 2 Oct 2025 13:56:08 +0100 Subject: [PATCH 11/12] Test sample and canvas size --- .../things/camera/simulation.py | 32 ++++++++++++------- tests/test_dummy_server.py | 2 +- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 032ee31c..e4df0e9a 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -81,10 +81,25 @@ class SimulatedCamera(BaseCamera): 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() + 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 _i, (a, b) in enumerate( + 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] @@ -121,6 +136,10 @@ class SimulatedCamera(BaseCamera): def generate_blobs(self, n_blobs: int = 1000) -> None: """Generate coordinates of blobs and their sizes, centered around (0,0). + Note that blob density is determined by sample size and n_blobs, and for larger + samples n_blobs will need increasing to keep a high level of sample coverage per + field of view. + :param n_blobs: The number of blobs to generate. """ self.blobs = np.zeros((n_blobs, 3)) @@ -170,18 +189,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) - canvas_region = self.canvas[top:bottom, left:right] - - # Ensure shapes are the same size before subtraction - can't have one bigger than the other - # In the case of a mismatch, use the smaller region. - if canvas_region.shape != sprite.shape: - min_h = min(canvas_region.shape[0], sprite.shape[0]) - min_w = min(canvas_region.shape[1], sprite.shape[1]) - self.canvas[top : top + min_h, left : left + min_w] -= sprite[ - :min_h, :min_w - ] - else: - self.canvas[top:bottom, left:right] -= sprite + self.canvas[top:bottom, left:right] -= sprite def generate_image(self, pos: tuple[int, int, int]) -> np.ndarray: """Generate an image with blobs based on supplied coordinates. diff --git a/tests/test_dummy_server.py b/tests/test_dummy_server.py index c25378b8..3a871f36 100644 --- a/tests/test_dummy_server.py +++ b/tests/test_dummy_server.py @@ -33,7 +33,7 @@ def thing_server(): server = lt.ThingServer(settings_folder=temp_folder.name) server.add_thing( SimulatedCamera( - shape=(240, 320, 3), canvas_shape=(1920, 2480, 3), frame_interval=0.01 + shape=(240, 320, 3), canvas_shape=(1000, 1500, 3), frame_interval=0.01 ), "/camera/", ) From a34a9f99c5948d1c1555566a531f59d9f7be8193 Mon Sep 17 00:00:00 2001 From: Joe Knapper Date: Mon, 6 Oct 2025 10:16:22 +0000 Subject: [PATCH 12/12] Apply suggestions from code review of branch sample-size-sim Co-authored-by: Julian Stirling --- src/openflexure_microscope_server/things/camera/simulation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index e4df0e9a..7c32aa08 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -92,9 +92,7 @@ class SimulatedCamera(BaseCamera): 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 _i, (a, b) in enumerate( - zip(self.canvas_shape, self.sample_limits, strict=False) - ): + 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"