Merge branch 'fast-sim' into 'v3'
Simulation Camera Improvements Closes #638 See merge request openflexure/openflexure-microscope-server!458
This commit is contained in:
commit
9df3fd6360
4 changed files with 406 additions and 104 deletions
|
|
@ -10,10 +10,11 @@ from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from typing import Literal, Optional, Self
|
from typing import Literal, Optional, Self, overload
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image, ImageFilter
|
from PIL import Image, ImageFilter
|
||||||
|
|
@ -43,6 +44,68 @@ BG_COLOR = [220, 215, 217]
|
||||||
# Random Number Generator
|
# Random Number Generator
|
||||||
RNG = np.random.default_rng()
|
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):
|
class SimulatedCamera(BaseCamera):
|
||||||
"""A Thing that simulates a camera for testing."""
|
"""A Thing that simulates a camera for testing."""
|
||||||
|
|
@ -55,62 +118,79 @@ class SimulatedCamera(BaseCamera):
|
||||||
self,
|
self,
|
||||||
thing_server_interface: lt.ThingServerInterface,
|
thing_server_interface: lt.ThingServerInterface,
|
||||||
shape: tuple[int, int, int] = (616, 820, 3),
|
shape: tuple[int, int, int] = (616, 820, 3),
|
||||||
glyph_shape: tuple[int, int, int] = (121, 121, 3),
|
canvas_shape: tuple[int, int, int] = (1500, 2000, 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.
|
||||||
|
|
||||||
:param shape: The shape (size) of the generated image.
|
: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
|
: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.
|
||||||
"""
|
"""
|
||||||
super().__init__(thing_server_interface)
|
super().__init__(thing_server_interface)
|
||||||
self.shape = shape
|
self.shape = shape
|
||||||
self.glyph_shape = glyph_shape
|
self.ds_shape = _downsample_shape(shape)
|
||||||
self.canvas_shape = canvas_shape
|
self.glyph_size = 105 // DOWNSAMPLE
|
||||||
self.sample_limits = (
|
self.canvas_shape = _downsample_shape(canvas_shape)
|
||||||
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_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
|
@lt.property
|
||||||
def calibration_required(self) -> bool:
|
def calibration_required(self) -> bool:
|
||||||
"""Whether the camera needs calibrating."""
|
"""Whether the camera needs calibrating."""
|
||||||
return not self.background_detector_status.ready
|
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:
|
def generate_sprites(self) -> None:
|
||||||
"""Generate sprites to populate the image."""
|
"""Generate sprites to populate the image."""
|
||||||
sprite_sizes = [10, 21, 36, 40, 50]
|
sprite_sizes = [10, 21, 36, 40, 50]
|
||||||
|
sprite_sizes = [s * SPRITE_UPSAMPLE for s in sprite_sizes]
|
||||||
self.sprites = []
|
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])
|
x = np.arange(channel_block.shape[0])
|
||||||
y = np.arange(channel_block.shape[1])
|
y = np.arange(channel_block.shape[1])
|
||||||
# 2D grid of radii
|
# 2D grid of radii
|
||||||
|
|
@ -121,22 +201,29 @@ class SimulatedCamera(BaseCamera):
|
||||||
for sprite_size in sprite_sizes:
|
for sprite_size in sprite_sizes:
|
||||||
# Mask of where this sprite is
|
# Mask of where this sprite is
|
||||||
sprite_mask = r_coord < sprite_size
|
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
|
# at the edge
|
||||||
sprite_px = r_coord[sprite_mask]
|
sprite_px = r_coord[sprite_mask]
|
||||||
sprite_px -= np.min(sprite_px)
|
sprite_px -= np.min(sprite_px)
|
||||||
sprite_px /= np.max(sprite_px)
|
sprite_px /= np.max(sprite_px)
|
||||||
# Create each channel. Note these will be subtracted from the white value.
|
sprite = channel_block.copy()
|
||||||
sprite_r = channel_block.copy()
|
sprite[sprite_mask] = 255 * sprite_px
|
||||||
sprite_r[sprite_mask] = 70 * sprite_px
|
|
||||||
sprite_g = channel_block.copy()
|
# Convert to uint8
|
||||||
sprite_g[sprite_mask] = 200 * sprite_px
|
sprite = sprite.astype(np.uint8)
|
||||||
sprite_b = channel_block.copy()
|
# Convert to PIL (and back) to resize then append to list of sprites
|
||||||
sprite_b[sprite_mask] = 70 * sprite_px
|
sprite_pil = Image.fromarray(sprite)
|
||||||
# Stack into a negative image of the sprite
|
sprite_pil = sprite_pil.resize(
|
||||||
sprite = np.stack([sprite_r, sprite_g, sprite_b], axis=2)
|
(self.glyph_size, self.glyph_size), Image.Resampling.BILINEAR
|
||||||
# Convert to uint8 and append to the list
|
)
|
||||||
self.sprites.append(sprite.astype(np.uint8))
|
# 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:
|
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).
|
||||||
|
|
@ -148,10 +235,10 @@ class SimulatedCamera(BaseCamera):
|
||||||
:param n_blobs: The number of blobs to generate.
|
:param n_blobs: The number of blobs to generate.
|
||||||
"""
|
"""
|
||||||
self.blobs = np.zeros((n_blobs, 3))
|
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[:, 0] = RNG.uniform(w // 2, self.canvas_shape[1] - w // 2, n_blobs)
|
||||||
self.blobs[:, 1] = RNG.uniform(w // 2, self.sample_limits[0] - 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)
|
self.blobs[:, 2] = RNG.choice(len(self.sprites), n_blobs)
|
||||||
|
|
||||||
def generate_canvas(self) -> None:
|
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
|
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.
|
||||||
"""
|
"""
|
||||||
|
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 = np.ones(self.canvas_shape, dtype=np.int16)
|
||||||
self.blank_canvas[:, :, 0] *= BG_COLOR[0]
|
self.blank_canvas[:, :, 0] *= BG_COLOR[0]
|
||||||
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()
|
new_canvas = self.blank_canvas.copy()
|
||||||
|
|
||||||
for blob_x, blob_y, sprite_index in self.blobs:
|
for blob_x, blob_y, sprite_index in self.blobs:
|
||||||
self.draw_sprite_on_canvas(
|
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 = np.clip(new_canvas, 0, 255)
|
||||||
self.canvas[self.canvas < 0] = 0
|
|
||||||
self.canvas[self.canvas > 255] = 255
|
|
||||||
|
|
||||||
def draw_sprite_on_canvas(
|
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:
|
) -> None:
|
||||||
"""Place one sprite on canvas at given centre coordinates.
|
"""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_y: The y coordinate to place the centre of the sprite.
|
||||||
:param centre_x: The x 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
|
canvas_h, canvas_w, _ = 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)
|
||||||
|
|
||||||
# Canvas region containing the sprite
|
# Canvas region containing the sprite
|
||||||
top = max(centre_y - sprite_h // 2, 0)
|
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)
|
bottom = min(centre_y + (sprite_h - sprite_h // 2), canvas_h)
|
||||||
right = min(centre_x + (sprite_w - sprite_w // 2), canvas_w)
|
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:
|
def generate_image(self, pos: tuple[int, int, int]) -> Image.Image:
|
||||||
"""Generate an image with blobs based on supplied coordinates.
|
"""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'
|
: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.ds_shape
|
||||||
# Scale position by RATIO to get position in base image.
|
im_pos = (
|
||||||
im_pos = tuple(x * ratio for x, ratio in zip(pos, RATIO, strict=True))
|
pos[0] * RATIO[0] / DOWNSAMPLE,
|
||||||
top_left = (
|
pos[1] * RATIO[1] / DOWNSAMPLE,
|
||||||
int(im_pos[0]) - image_width // 2 + self.sample_limits[0] // 2,
|
pos[2] * RATIO[2],
|
||||||
int(im_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.
|
top_left = (
|
||||||
x_indices = (np.arange(top_left[0], top_left[0] + image_width)) % canvas_width
|
int(im_pos[0]) - image_width // 2 + self.canvas_shape[0] // 2,
|
||||||
y_indices = (np.arange(top_left[1], top_left[1] + image_height)) % canvas_height
|
int(im_pos[1]) - image_height // 2 + self.canvas_shape[1] // 2,
|
||||||
z_indices = np.arange(self.shape[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
|
canvas = self.canvas if self._show_sample else self.blank_canvas
|
||||||
# Use npx to make each 1d index list 3D
|
# 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)
|
np_img = fast_pil_blur(focused_np_img, 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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add noise and convert to uint8
|
# Add noise and convert to uint8
|
||||||
image += RNG.normal(scale=self.noise_level, size=self.shape).astype("int16")
|
np_img += RNG.normal(scale=self.noise_level, size=self.ds_shape).astype("int16")
|
||||||
image[image < 0] = 0
|
np.clip(np_img, 0, 255, out=np_img)
|
||||||
image[image > 255] = 255
|
pl_img = Image.fromarray(np_img.astype("uint8"))
|
||||||
return Image.fromarray(image.astype("uint8"))
|
return pl_img.resize((self.shape[1], self.shape[0]), Image.Resampling.BILINEAR)
|
||||||
|
|
||||||
def generate_frame(self) -> Image.Image:
|
def generate_frame(self) -> Image.Image:
|
||||||
"""Generate a frame with blobs based on the stage coordinates."""
|
"""Generate a frame with blobs based on the stage coordinates."""
|
||||||
try:
|
pos = self._stage.instantaneous_position
|
||||||
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}
|
|
||||||
return self.generate_image((pos["y"], pos["x"], pos["z"]))
|
return self.generate_image((pos["y"], pos["x"], pos["z"]))
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
def __enter__(self) -> Self:
|
||||||
"""Start the capture thread when the Thing context manager is opened."""
|
"""Start the capture thread when the Thing context manager is opened."""
|
||||||
|
self.generate_canvas()
|
||||||
self.start_streaming()
|
self.start_streaming()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -298,14 +401,11 @@ class SimulatedCamera(BaseCamera):
|
||||||
if wait_time > 0:
|
if wait_time > 0:
|
||||||
time.sleep(wait_time)
|
time.sleep(wait_time)
|
||||||
last_frame_t = time.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:
|
frame = self.generate_frame()
|
||||||
LOGGER.exception(f"Failed to capture frame: {e}, retrying...")
|
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
|
@lt.action
|
||||||
def discard_frames(self) -> None:
|
def discard_frames(self) -> None:
|
||||||
|
|
@ -401,7 +501,12 @@ class SimulatedCamera(BaseCamera):
|
||||||
@lt.property
|
@lt.property
|
||||||
def manual_camera_settings(self) -> list[PropertyControl]:
|
def manual_camera_settings(self) -> list[PropertyControl]:
|
||||||
"""The camera settings to expose as property controls in the settings panel."""
|
"""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:
|
def _frame2bytes(frame: Image.Image) -> bytes:
|
||||||
|
|
|
||||||
|
|
@ -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 numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
import labthings_fastapi as lt
|
|
||||||
|
|
||||||
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
|
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
|
||||||
from openflexure_microscope_server.things.stage.dummy import DummyStage
|
from openflexure_microscope_server.things.stage.dummy import DummyStage
|
||||||
|
|
||||||
|
|
@ -13,7 +16,7 @@ from ..shared_utils.lt_test_utils import LabThingsTestEnv
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_env() -> lt.ThingClient:
|
def test_env() -> LabThingsTestEnv:
|
||||||
"""Yield a test environment with the Simulated Camera and Dummy Stage."""
|
"""Yield a test environment with the Simulated Camera and Dummy Stage."""
|
||||||
thing_conf = {"camera": SimulatedCamera, "stage": DummyStage}
|
thing_conf = {"camera": SimulatedCamera, "stage": DummyStage}
|
||||||
with LabThingsTestEnv(things=thing_conf) as env:
|
with LabThingsTestEnv(things=thing_conf) as env:
|
||||||
|
|
@ -58,12 +61,3 @@ def test_handle_broken_frame(test_env):
|
||||||
for _i in range(15):
|
for _i in range(15):
|
||||||
array = camera.grab_as_array()
|
array = camera.grab_as_array()
|
||||||
assert isinstance(array, np.ndarray)
|
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
|
|
||||||
184
tests/unit_tests/test_simulated_camera.py
Normal file
184
tests/unit_tests/test_simulated_camera.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -68,6 +68,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<label v-if="dataType == 'string'" class="uk-form-label"
|
||||||
|
>{{ label }}
|
||||||
|
<div class="input-and-buttons-container">
|
||||||
|
<input
|
||||||
|
v-model="internalValue"
|
||||||
|
class="uk-form-small numeric-setting-line-input"
|
||||||
|
:class="{ edited: isEdited, flash: animateUpdate }"
|
||||||
|
type="text"
|
||||||
|
@focusin="focusIn"
|
||||||
|
@focusout="focusOut"
|
||||||
|
@keydown="keyDown"
|
||||||
|
@animationend="animationEnd"
|
||||||
|
/>
|
||||||
|
<sync-property-button @click="requestUpdate" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
<label v-if="dataType == 'other'" class="uk-form-label"
|
<label v-if="dataType == 'other'" class="uk-form-label"
|
||||||
>{{ label }}
|
>{{ label }}
|
||||||
<div class="input-and-buttons-container">
|
<div class="input-and-buttons-container">
|
||||||
|
|
@ -157,7 +173,7 @@ export default {
|
||||||
if (num_types.includes(prop.type)) {
|
if (num_types.includes(prop.type)) {
|
||||||
return "number";
|
return "number";
|
||||||
}
|
}
|
||||||
if (prop.type == "array") {
|
if (prop.type === "array") {
|
||||||
if (num_types.includes(prop.items.type)) {
|
if (num_types.includes(prop.items.type)) {
|
||||||
return "number_array";
|
return "number_array";
|
||||||
}
|
}
|
||||||
|
|
@ -167,10 +183,13 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (prop.type == "boolean") {
|
if (prop.type === "boolean") {
|
||||||
return "boolean";
|
return "boolean";
|
||||||
}
|
}
|
||||||
if (prop.type == "object") {
|
if (prop.type === "string") {
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
if (prop.type === "object") {
|
||||||
let numeric = true;
|
let numeric = true;
|
||||||
for (let key in prop.properties) {
|
for (let key in prop.properties) {
|
||||||
if (!num_types.includes(prop.properties[key].type)) {
|
if (!num_types.includes(prop.properties[key].type)) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue