From b8bdb430b779f4741eb1d6ccf4cb09bd81db6d67 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 26 Oct 2025 14:59:13 +0000 Subject: [PATCH 1/3] Do blur in PIL --- .../things/camera/simulation.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 7c32aa08..6276e173 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -15,8 +15,7 @@ import time import cv2 import numpy as np -from PIL import Image -from scipy.ndimage import gaussian_filter +from PIL import Image, ImageFilter import labthings_fastapi as lt @@ -209,11 +208,9 @@ class SimulatedCamera(BaseCamera): 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 = gaussian_filter( - focused_image, - sigma=np.abs(pos[2]) / 5, - axes=(0, 1), - ) + + image = fast_pil_blur(focused_image, sigma=np.abs(pos[2]) / 5) + if image.shape != self.shape: raise ValueError( f"Image shape {image.shape} does not match intended shape {self.shape}" @@ -393,3 +390,15 @@ class SimulatedCamera(BaseCamera): 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")] + + +def fast_pil_blur(array: np.ndarray, sigma: float) -> np.ndarray: + """Apply Gaussian blur using PIL (faster than scipy).""" + if sigma < 0.5: + return array # no visible blur needed + + img_pil = Image.fromarray(array.astype(np.uint8)) + img_pil = img_pil.filter(ImageFilter.GaussianBlur(radius=sigma)) + + # Convert back to NumPy array + return np.array(img_pil, dtype=array.dtype) From c8a028e27c8e16c8f250643c473d28f9265b591d Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 26 Oct 2025 14:59:33 +0000 Subject: [PATCH 2/3] And file coms in PIL --- .../things/camera/simulation.py | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 6276e173..8d027734 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -12,8 +12,8 @@ from typing import Literal, Optional, Mapping from types import TracebackType from threading import Thread import time +import io -import cv2 import numpy as np from PIL import Image, ImageFilter @@ -188,7 +188,7 @@ class SimulatedCamera(BaseCamera): self.canvas[top:bottom, left:right] -= sprite - def generate_image(self, pos: tuple[int, int, int]) -> np.ndarray: + def generate_image(self, pos: tuple[int, int, int]) -> Image: """Generate an image with blobs based on supplied coordinates. :param pos: a 3-item tuple containing the x,y,z coordinates of the 'stage' @@ -220,7 +220,7 @@ class SimulatedCamera(BaseCamera): image += RNG.normal(scale=self.noise_level, size=self.shape).astype("int16") image[image < 0] = 0 image[image > 255] = 255 - return image.astype("uint8") + return Image.fromarray(image.astype("uint8")) def attach_to_server( self, server: lt.ThingServer, path: str, setting_storage_path: str @@ -243,7 +243,7 @@ class SimulatedCamera(BaseCamera): self._stage = self._server.things["/stage/"] return self._stage.instantaneous_position - def generate_frame(self) -> np.ndarray: + def generate_frame(self) -> Image: """Generate a frame with blobs based on the stage coordinates.""" try: pos = self.get_stage_position() @@ -309,14 +309,10 @@ class SimulatedCamera(BaseCamera): time.sleep(self.frame_interval) try: frame = self.generate_frame() - jpeg = cv2.imencode(".jpg", frame)[1].tobytes() - self.mjpeg_stream.add_frame(jpeg, portal) - # Downsample for lores - ds_frame = cv2.resize( - frame, (320, 240), interpolation=cv2.INTER_NEAREST - ) - jpeg_lores = cv2.imencode(".jpg", ds_frame)[1].tobytes() - self.lores_mjpeg_stream.add_frame(jpeg_lores, portal) + self.mjpeg_stream.add_frame(_frame2bytes(frame), portal) + ds_frame = frame.resize((320, 240), resample=Image.NEAREST) + self.lores_mjpeg_stream.add_frame(_frame2bytes(ds_frame), portal) + except Exception as e: LOGGER.exception(f"Failed to capture frame: {e}, retrying...") @@ -345,7 +341,7 @@ class SimulatedCamera(BaseCamera): if wait is not None: LOGGER.warning("Simulation camera has no wait option. Use None.") LOGGER.warning(f"Simulation camera camera doesn't respect {stream_name=}") - return self.generate_frame() + return np.array(self.generate_frame()) def capture_image( self, @@ -362,7 +358,7 @@ class SimulatedCamera(BaseCamera): if wait is not None: LOGGER.warning("Simulation camera has no wait option. Use None.") LOGGER.warning(f"Simulation camera camera doesn't respect {stream_name=}") - return Image.fromarray(self.generate_frame()) + return self.generate_frame() @lt.thing_action def remove_sample(self) -> None: @@ -392,6 +388,14 @@ class SimulatedCamera(BaseCamera): return [property_control_for(self, "noise_level", label="Noise Level")] +def _frame2bytes(frame: Image) -> bytes: + """Convert frame to bytes.""" + with io.BytesIO() as buf: + # Save in low quality for speed. + frame.save(buf, format="JPEG", quality=85) + return buf.getvalue() + + def fast_pil_blur(array: np.ndarray, sigma: float) -> np.ndarray: """Apply Gaussian blur using PIL (faster than scipy).""" if sigma < 0.5: From d27948a155c64d85ad9e4c98e550cd3aebc9cdf3 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 26 Oct 2025 15:22:34 +0000 Subject: [PATCH 3/3] Reduce time jitter in simulation. --- .../things/camera/simulation.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index 8d027734..8fd4848e 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -305,8 +305,12 @@ class SimulatedCamera(BaseCamera): def _capture_frames(self) -> None: portal = lt.get_blocking_portal(self) + last_frame_t = time.time() while self._capture_enabled: - time.sleep(self.frame_interval) + wait_time = last_frame_t - time.time() - self.frame_interval + 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), portal)