Add unit tests for ChannelDeviationLUV background detector

This commit is contained in:
Julian Stirling 2025-11-25 17:01:45 +00:00
parent e5d2ebbb79
commit 2749a1818c
2 changed files with 148 additions and 6 deletions

View file

@ -207,11 +207,6 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
The image should be in LUV format, the output will be binary with the The image should be in LUV format, the output will be binary with the
same shape in the first two dimensions. same shape in the first two dimensions.
""" """
if not self.background_data:
raise MissingBackgroundDataError(
"Background is not set: you need to calibrate background detection."
)
# The ``[1:]`` selects only the U and V channels of the image. # The ``[1:]`` selects only the U and V channels of the image.
# Only U and V are used as brightness (L) often changes as # Only U and V are used as brightness (L) often changes as
# the height of the sample changes. # the height of the sample changes.
@ -236,6 +231,10 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
:returns: A value (between 0 and 100) is the percentage of the image that is :returns: A value (between 0 and 100) is the percentage of the image that is
sample. sample.
""" """
if not self.background_data:
raise MissingBackgroundDataError(
"Background is not set: you need to calibrate background detection."
)
image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV) image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV)
mask = self.background_mask(image_luv) mask = self.background_mask(image_luv)
@ -302,6 +301,10 @@ class ChannelDeviationLUV(BackgroundDetectAlgorithm):
:returns: A value (between 0 and 100) is the percentage of the image that is :returns: A value (between 0 and 100) is the percentage of the image that is
sample. sample.
""" """
if not self.background_data:
raise MissingBackgroundDataError(
"Background is not set: you need to calibrate background detection."
)
image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV) image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV)
stds = _chunked_stds(image_luv, 8, 8) stds = _chunked_stds(image_luv, 8, 8)
@ -335,7 +338,6 @@ class ChannelDeviationLUV(BackgroundDetectAlgorithm):
def set_background(self, image: np.ndarray) -> None: def set_background(self, image: np.ndarray) -> None:
"""Use the input image to update the background distributions.""" """Use the input image to update the background distributions."""
image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV) image_luv = cv2.cvtColor(image, cv2.COLOR_RGB2LUV)
mu = np.zeros(3) mu = np.zeros(3)
c_stds = _chunked_stds(image_luv, 8, 8) c_stds = _chunked_stds(image_luv, 8, 8)
channel_blank = np.all(c_stds == 0, axis=(0, 1)) channel_blank = np.all(c_stds == 0, axis=(0, 1))

View file

@ -15,6 +15,9 @@ from openflexure_microscope_server.background_detect import (
ChannelDistributions, ChannelDistributions,
ColourChannelDetectSettings, ColourChannelDetectSettings,
ColourChannelDetectLUV, ColourChannelDetectLUV,
_chunked_stds,
ChannelDeviationLUV,
ChannelBlankError,
) )
RNG = np.random.default_rng() RNG = np.random.default_rng()
@ -181,3 +184,140 @@ def test_colour_channel_luv_load_bad_data():
cc_luv.settings = WrongModel() cc_luv.settings = WrongModel()
with pytest.raises(TypeError): with pytest.raises(TypeError):
cc_luv.background_data = WrongModel() cc_luv.background_data = WrongModel()
def create_patchwork_image(magnitude=3, blank_channels=None):
"""Create a patchwork image, with known stds per chunk.
:return: The image, and the (8,8,3) array of stds
"""
if blank_channels is None:
blank_channels = []
n_rows = 8
n_cols = 8
chunk_h = 10
chunk_w = 10
# Precompute chunk stds and construct the final image
expected_stds = np.zeros((n_rows, n_cols, 3), dtype=float)
img = np.zeros((n_rows * chunk_h, n_cols * chunk_w, 3), dtype=np.uint8)
for i in range(n_rows):
for j in range(n_cols):
for channel in range(3):
if channel in blank_channels:
chunk = np.zeros((chunk_h, chunk_w), dtype=np.uint8)
else:
chunk = 9.8 * np.ones((chunk_h, chunk_w))
chunk += RNG.normal(scale=magnitude, size=(chunk_h, chunk_w))
chunk = chunk.astype(np.uint8)
# Store its std
expected_stds[i, j, channel] = np.std(chunk)
# Insert chunk into the final large image
y0, y1 = i * chunk_h, (i + 1) * chunk_h
x0, x1 = j * chunk_w, (j + 1) * chunk_w
img[y0:y1, x0:x1, channel] = chunk
return img, expected_stds
def test_chunked_stds_with_precomputed_chunk_stds():
"""Test _chunked_stds returns the answer calculated when making a patchwork image."""
img, expected_stds = create_patchwork_image()
# Run the function under test
result = _chunked_stds(img, n_rows=8, n_cols=8)
# Compare
np.testing.assert_allclose(result, expected_stds, rtol=1e-6, atol=1e-12)
def test_channel_deviation_luv_set_background(mocker):
"""Test set_background takes the median of each channel, and errors for blank channels."""
cd_luv = ChannelDeviationLUV()
# Patch RGB to LUV so or we don't know what the STDs should be
mocker.patch("cv2.cvtColor", side_effect=lambda img, _method: img)
for magnitude in [0.2, 1, 10]:
# Create an image and set it as background
img, expected_stds = create_patchwork_image(magnitude=magnitude)
cd_luv.set_background(img)
# Do a somewhat verbose checking for clarity
for channel in range(3):
# Saved std
channel_std = cd_luv.background_data.standard_deviations[channel]
# Expected median
channel_median = np.median(expected_stds[:, :, channel])
# If the median is above the minimum allowed then it should be returned
if channel_median > cd_luv.min_stds[channel]:
assert np.isclose(channel_std, channel_median, rtol=1e-6, atol=1e-12)
else:
# If not the minimum is returned.
assert channel_std == cd_luv.min_stds[channel]
# Also check that if channels are blank then an error is thrown
img, _expected_stds = create_patchwork_image(magnitude=1, blank_channels=[1, 2])
with pytest.raises(ChannelBlankError):
cd_luv.set_background(img)
def test_channel_deviation_luv_image_is_sample(background_image, mocker):
"""Check image_is_sample reports the result from get_sample_coverage."""
cd_luv = ChannelDeviationLUV()
# No background data so it is not ready and will error if image_is_sample is called.
assert not cd_luv.status.ready
with pytest.raises(MissingBackgroundDataError):
cd_luv.image_is_sample(background_image)
cd_luv.settings.min_sample_coverage = 20
cd_luv.get_sample_coverage = mocker.Mock(return_value=10)
is_sample, message = cd_luv.image_is_sample(background_image)
assert not is_sample
assert message == r"only 10.0% sample"
# Reduce the min coverage
cd_luv.settings.min_sample_coverage = 9
is_sample, message = cd_luv.image_is_sample(background_image)
assert is_sample
assert message == r"10.0% sample"
def test_channel_deviation_luv_get_sample_coverage(background_image, mocker):
"""Check _get_sample_coverage returns the values expected."""
cd_luv = ChannelDeviationLUV()
# Create fake chunked STD data where each channel os the numbers 0 -> 31.5 in 0.5
# steps
grid = np.arange(0, 32, 0.5).reshape(8, 8)
fake_stds = np.stack([grid, grid, grid], axis=-1)
mocker.patch(
"openflexure_microscope_server.background_detect._chunked_stds",
return_value=fake_stds,
)
# Create fake background
cd_luv.background_data = ChannelDistributions(
means=[0, 0, 0], standard_deviations=[1.1, 1.1, 1.1]
)
# Get sample coverage with channel tolerance of 7. Checking each channel for the
# numbers below 7.7. There are 16 out of 64. So 75% should be sample
cd_luv.settings.channel_tolerance = 7
assert cd_luv.get_sample_coverage(background_image) == 75
# This is unchanged if two channels have larger background values.
cd_luv.background_data = ChannelDistributions(
means=[0, 0, 0], standard_deviations=[1.6, 1.6, 1.1]
)
assert cd_luv.get_sample_coverage(background_image) == 75
# This is but increases if any channels has a lower background value.
cd_luv.background_data = ChannelDistributions(
means=[0, 0, 0], standard_deviations=[1.6, 0.6, 1.1]
)
assert cd_luv.get_sample_coverage(background_image) == 85.9375
# Returns to 75% if that channel is empty
fake_stds[:, :, 1] = 0
assert cd_luv.get_sample_coverage(background_image) == 75