Merge branch 'sample-size-sim' into 'v3'

Simulate a rectangular sample

Closes #516

See merge request openflexure/openflexure-microscope-server!401
This commit is contained in:
Joe Knapper 2025-10-07 10:09:37 +00:00
commit dae5b816cc
3 changed files with 78 additions and 24 deletions

View file

@ -34,7 +34,7 @@ LOGGER = logging.getLogger(__name__)
# The ratio between "motor" steps and pixels # The ratio between "motor" steps and pixels
# higher related to a faster movement # higher related to a faster movement
RATIO = 0.2 RATIO = (2, 2, 0.2)
# Some colour variation, for bg detect. # Some colour variation, for bg detect.
BG_COLOR = [220, 215, 217] BG_COLOR = [220, 215, 217]
@ -53,8 +53,9 @@ class SimulatedCamera(BaseCamera):
def __init__( def __init__(
self, self,
shape: tuple[int, int, int] = (616, 820, 3), shape: tuple[int, int, int] = (616, 820, 3),
glyph_shape: tuple[int, int, int] = (91, 91, 3), glyph_shape: tuple[int, int, int] = (121, 121, 3),
canvas_shape: tuple[int, int, int] = (3000, 4000, 3), canvas_shape: tuple[int, int, int] = (3000, 4000, 3),
sample_limits: Optional[tuple[int, int]] = (1000, 1500),
frame_interval: float = 0.1, frame_interval: float = 0.1,
) -> None: ) -> None:
"""Initialise the simulated with settings for how images are generated. """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 :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, 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. 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, :param frame_interval: Nominally the time between frames on the MJPEG stream,
however the rate may be slower due to calculation time for focus. however the rate may be slower due to calculation time for focus.
""" """
@ -71,16 +75,32 @@ class SimulatedCamera(BaseCamera):
self.shape = shape self.shape = shape
self.glyph_shape = glyph_shape self.glyph_shape = glyph_shape
self.canvas_shape = canvas_shape self.canvas_shape = canvas_shape
self.sample_limits = (
canvas_shape[:2] if sample_limits is None else sample_limits
)
self.frame_interval = frame_interval self.frame_interval = frame_interval
self._capture_thread: Optional[Thread] = None self._capture_thread: Optional[Thread] = None
self._capture_enabled = False self._capture_enabled = False
self.validate_inputs()
self.generate_sprites() self.generate_sprites()
self.generate_blobs() self.generate_blobs()
self.generate_canvas() 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 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: def generate_sprites(self) -> None:
"""Generate sprites to populate the image.""" """Generate sprites to populate the image."""
sprite_sizes = [5, 7, 10, 21, 36, 40] sprite_sizes = [10, 21, 36, 40, 50]
self.sprites = [] self.sprites = []
channel_block = np.zeros(self.glyph_shape[0:2]) channel_block = np.zeros(self.glyph_shape[0:2])
@ -112,23 +132,23 @@ class SimulatedCamera(BaseCamera):
self.sprites.append(sprite.astype(np.uint8)) self.sprites.append(sprite.astype(np.uint8))
def generate_blobs(self, n_blobs: int = 1000) -> None: def generate_blobs(self, n_blobs: int = 1000) -> None:
"""Generate coordinates of blobs and their sizes. """Generate coordinates of blobs and their sizes, centered around (0,0).
A 1000x3 array is returned. Each row represents (x,y) coordinate Note that blob density is determined by sample size and n_blobs, and for larger
of the sprite and the index representing the size of the sprite. samples n_blobs will need increasing to keep a high level of sample coverage per
field of view.
Blobs are characterised by X, Y, sprite :param n_blobs: The number of blobs to generate.
We also generate a KD tree to rapidly find blobs in an image
""" """
self.blobs = np.zeros((n_blobs, 3)) self.blobs = np.zeros((n_blobs, 3))
w = np.max(self.glyph_shape) 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[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) self.blobs[:, 2] = RNG.choice(len(self.sprites), n_blobs)
def generate_canvas(self) -> None: 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 Canvas is int16 so that random noise can be added to simulation image before
changing to unit8 to stop wrapping. changing to unit8 to stop wrapping.
@ -138,23 +158,48 @@ class SimulatedCamera(BaseCamera):
self.blank_canvas[:, :, 1] *= BG_COLOR[1] self.blank_canvas[:, :, 1] *= BG_COLOR[1]
self.blank_canvas[:, :, 2] *= BG_COLOR[2] self.blank_canvas[:, :, 2] *= BG_COLOR[2]
self.canvas = self.blank_canvas.copy() self.canvas = self.blank_canvas.copy()
w, h, _ = self.glyph_shape
for x, y, sprite_size_index in self.blobs: for blob_x, blob_y, sprite_index in self.blobs:
self.canvas[ self.draw_sprite_on_canvas(
int(x) - w // 2 : int(x) - w // 2 + w, self.sprites[int(sprite_index)], int(blob_y), int(blob_x)
int(y) - h // 2 : int(y) - h // 2 + h, )
] -= self.sprites[int(sprite_size_index)]
self.canvas[self.canvas < 0] = 0 self.canvas[self.canvas < 0] = 0
self.canvas[self.canvas > 255] = 255 self.canvas[self.canvas > 255] = 255
def draw_sprite_on_canvas(
self, sprite: np.ndarray, centre_y: int, centre_x: int
) -> None:
"""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
# Canvas region containing the sprite
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)
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]) -> 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 canvas_width, canvas_height, _ = self.canvas_shape
image_width, image_height, _ = self.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 = ( top_left = (
int(pos[0]) - image_width // 2 - canvas_width // 2, int(pos[0]) - image_width // 2 + self.sample_limits[0] // 2,
int(pos[1]) - image_height // 2 - canvas_height // 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 # Create index list with modulo rather than slicing to handle wrapping at the
# canvas edge. # canvas edge.
@ -239,6 +284,9 @@ class SimulatedCamera(BaseCamera):
It will always issue a warning that the resolution is not respected. 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 If called while already streaming, the warning will be emitted and no other
action will be taken. action will be taken.
: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( LOGGER.warning(
f"Simulation camera doesn't respect {main_resolution=} or {buffer_count=} " f"Simulation camera doesn't respect {main_resolution=} or {buffer_count=} "
@ -293,6 +341,9 @@ class SimulatedCamera(BaseCamera):
This function will produce a nested list containing an uncompressed RGB image. 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 It's likely to be highly inefficient - raw and/or uncompressed captures using
binary image formats will be added in due course. binary image formats will be added in due course.
: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: if wait is not None:
LOGGER.warning("Simulation camera has no wait option. Use None.") LOGGER.warning("Simulation camera has no wait option. Use None.")
@ -307,6 +358,9 @@ class SimulatedCamera(BaseCamera):
"""Capture to a PIL image. This is not exposed as a ThingAction. """Capture to a PIL image. This is not exposed as a ThingAction.
It is used for capture to memory. It is used for capture to memory.
: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: if wait is not None:
LOGGER.warning("Simulation camera has no wait option. Use None.") LOGGER.warning("Simulation camera has no wait option. Use None.")

View file

@ -33,7 +33,7 @@ def thing_server():
server = lt.ThingServer(settings_folder=temp_folder.name) server = lt.ThingServer(settings_folder=temp_folder.name)
server.add_thing( server.add_thing(
SimulatedCamera( SimulatedCamera(
shape=(240, 320, 3), canvas_shape=(960, 1240, 3), frame_interval=0.01 shape=(240, 320, 3), canvas_shape=(1000, 1500, 3), frame_interval=0.01
), ),
"/camera/", "/camera/",
) )

View file

@ -60,7 +60,7 @@ def test_add_static_file(filename, allow_cache, mocker):
# the wrapped function should return the file response # the wrapped function should return the file response
response = wrapped() response = wrapped()
assert isinstance(response, FileResponse) 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 # 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 # allowed to be cached it should also have the no_cache data
for key, value in serve_static_files.NO_CACHE_HEADERS.items(): for key, value in serve_static_files.NO_CACHE_HEADERS.items():