Merge branch 'colour-simualation-image' into 'v3'

Update simulation canvas to have colour so it works with background detect.

See merge request openflexure/openflexure-microscope-server!328
This commit is contained in:
Julian Stirling 2025-07-24 15:56:49 +00:00
commit f12ee9bd69

View file

@ -28,6 +28,12 @@ from ..stage import BaseStage
# higher related to a faster movement
RATIO = 0.2
# Some colour variation, for bg detect.
BG_COLOR = [220, 215, 217]
# Random Number Generator
RNG = np.random.default_rng()
class SimulatedCamera(BaseCamera):
"""A Thing that simulates a camera for testing."""
@ -38,7 +44,7 @@ class SimulatedCamera(BaseCamera):
def __init__(
self,
shape: tuple[int, int, int] = (600, 800, 3),
glyph_shape: tuple[int, int, int] = (51, 51, 3),
glyph_shape: tuple[int, int, int] = (91, 91, 3),
canvas_shape: tuple[int, int, int] = (3000, 4000, 3),
frame_interval: float = 0.1,
):
@ -64,39 +70,71 @@ class SimulatedCamera(BaseCamera):
def generate_sprites(self):
"""Generate sprites to populate the image."""
sprite_sizes = [5, 7, 10, 21, 36, 40]
self.sprites = []
black = np.zeros(self.glyph_shape, dtype=np.uint8)
x = np.arange(black.shape[0])
y = np.arange(black.shape[1])
rr = np.sqrt((x[:, None] - np.mean(x)) ** 2 + (y[None, :] - np.mean(y)) ** 2)
for i in [5, 7, 9, 11, 13, 15]:
sprite = black.copy()
sprite[rr < i] = 255
self.sprites.append(sprite)
channel_block = np.zeros(self.glyph_shape[0:2])
x = np.arange(channel_block.shape[0])
y = np.arange(channel_block.shape[1])
# 2D grid of radii
r_coord = np.sqrt(
(x[:, None] - np.mean(x)) ** 2 + (y[None, :] - np.mean(y)) ** 2
)
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
# 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)
# Convert to uint8 and append to the list
self.sprites.append(sprite.astype(np.uint8))
def generate_blobs(self, n_blobs: int = 1000):
"""Generate coordinates of blobs.
"""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
"""
self.blobs = np.zeros((n_blobs, 3))
rng = np.random.default_rng()
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[:, 2] = rng.choice(len(self.sprites), n_blobs)
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[:, 2] = RNG.choice(len(self.sprites), n_blobs)
def generate_canvas(self):
"""Generate a blank canvas."""
self.canvas = np.zeros(self.canvas_shape, dtype=np.uint8)
self.canvas[...] = 255
"""Generate a canvas.
Canvas is int16 so that random noise can be added to simulation image before
changing to unit8 to stop wrapping.
"""
self.canvas = np.ones(self.canvas_shape, dtype=np.int16)
self.canvas[:, :, 0] *= BG_COLOR[0]
self.canvas[:, :, 1] *= BG_COLOR[1]
self.canvas[:, :, 2] *= BG_COLOR[2]
w, h, _ = self.glyph_shape
for x, y, sprite in self.blobs:
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)]
] -= self.sprites[int(sprite_size_index)]
self.canvas[self.canvas < 0] = 0
self.canvas[self.canvas > 255] = 255
def generate_image(self, pos: tuple[int, int, int]):
"""Generate an image with blobs based on supplied coordinates."""
@ -120,7 +158,12 @@ class SimulatedCamera(BaseCamera):
raise ValueError(
f"Image shape {image.shape} does not match intended shape {self.shape}"
)
return image
# Add noise and convert to uint8
image += RNG.normal(scale=2, size=self.shape).astype("int16")
image[image < 0] = 0
image[image > 255] = 255
return image.astype("uint8")
def attach_to_server(
self, server: lt.ThingServer, path: str, setting_storage_path: str