Allow colour to be set for simulation sample.

This commit is contained in:
Julian Stirling 2025-12-28 20:19:01 +00:00
parent 7b9f4aa202
commit 11379d8fa7

View file

@ -10,6 +10,7 @@ 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
@ -49,6 +50,8 @@ DOWNSAMPLE = 2
# for a nice gain in quality. # for a nice gain in quality.
SPRITE_UPSAMPLE = 4 SPRITE_UPSAMPLE = 4
COLOUR_REGEX = re.compile(r"^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$")
@overload @overload
def _downsample_shape(shape: tuple[int, int]) -> tuple[int, int]: ... def _downsample_shape(shape: tuple[int, int]) -> tuple[int, int]: ...
@ -68,6 +71,26 @@ def _downsample_shape(
raise ValueError("A shape should be a 2 or 3 element tuple.") raise ValueError("A shape should be a 2 or 3 element tuple.")
def colour_str_to_colour(colour: str) -> tuple[int, int, int]:
"""Convert a colour string into RGB colour values.
:param colour: Should be a hex colour such as #33aa33
:return: The colour as a tuple of 3 integers from 0 to 255 in value
:raises ValueError: If the hex string is not valid.
"""
colour = colour.lower().strip()
colour_match = COLOUR_REGEX.match(colour)
if colour_match is None:
raise ValueError(
f"{colour} 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."""
@ -117,6 +140,23 @@ class SimulatedCamera(BaseCamera):
if self._capture_enabled: if self._capture_enabled:
self.generate_canvas() self.generate_canvas()
_colour: str = "#b937b9"
@lt.property
def colour(self) -> str:
"""The number of colour of the blobs."""
return self._colour
@colour.setter
def colour(self, value: str) -> None:
if COLOUR_REGEX.match(value) is None:
self.logger.warning(f"{value} is not a valid colour string.")
return
self._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."""
@ -140,20 +180,14 @@ 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()
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 # Convert to uint8
sprite = sprite.astype(np.uint8) sprite = sprite.astype(np.uint8)
# Convert to PIL (and back) to resize then append to list of sprites # Convert to PIL (and back) to resize then append to list of sprites
@ -213,7 +247,15 @@ class SimulatedCamera(BaseCamera):
: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, _ = self.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)
sprite_rgb = sprite_rgb.astype("uint8")
# 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)
@ -221,7 +263,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 self.canvas[top:bottom, left:right] -= sprite_rgb
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.
@ -449,6 +491,7 @@ class SimulatedCamera(BaseCamera):
return [ return [
property_control_for(self, "repeating", label="Infinite Sample"), property_control_for(self, "repeating", label="Infinite Sample"),
property_control_for(self, "blob_density", label="Sample Density"), 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"), property_control_for(self, "noise_level", label="Noise Level"),
] ]