From 012af9dd1cd216b9216c8bf42985150365effada Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 31 Dec 2025 00:22:01 +0100 Subject: [PATCH 1/4] Add test case that reproduces bug --- tests/integration/test__cli.py | 28 ++++++++++++++++++++++++++-- tests/tools.py | 6 +++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/tests/integration/test__cli.py b/tests/integration/test__cli.py index b375656..6f3c902 100644 --- a/tests/integration/test__cli.py +++ b/tests/integration/test__cli.py @@ -4,6 +4,7 @@ import subprocess import sys import tempfile +import types import unittest import numpy as np @@ -12,7 +13,10 @@ import giatools.image import giatools.typing as _T -from ..tools import minimum_python_version +from ..tools import ( + minimum_python_version, + random_io_test, +) def _threshold(image1: np.ndarray, image2: _T.Optional[np.ndarray]) -> np.ndarray: @@ -45,7 +49,7 @@ def _threshold(image1: np.ndarray, image2: _T.Optional[np.ndarray]) -> np.ndarra proc['output'] = _threshold(proc['input1'].data, None) -class ToolBaseplate(unittest.TestCase): +class ToolBaseplate__cli(unittest.TestCase): def setUp(self): super().setUp() @@ -159,3 +163,23 @@ def test__params(self): '--output', output_filepath, ) self.assertEqual(result.stdout.strip('\n'), str(params)) + + +class ToolBaseplate(unittest.TestCase): + + @random_io_test(shape=(10, 10), dtype=bool, ext='tiff') + def test__preserve__bool(self, filepath, data): + output_filepath = str(pathlib.Path(filepath).parent / 'output.tiff') + tool = giatools.cli.ToolBaseplate(params_required=False) + tool.add_input_image('input') + tool.add_output_image('output') + tool.args = types.SimpleNamespace( + params=None, + verbose=False, + input_filepaths={'input': filepath}, + input_images={'input': giatools.image.Image(data, axes='YX')}, + output_filepaths={'output': output_filepath}, + raw_args=types.SimpleNamespace(), + ) + for section in tool.run('YX', output_dtype_hint='preserve'): + section['output'] = section['input'].data diff --git a/tests/tools.py b/tests/tools.py index 52044dd..79c1d04 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -61,8 +61,12 @@ def wrapper(self): # Create random image data np.random.seed(0) data = np.random.rand(*shape) - if not np.issubdtype(dtype, np.floating): + if np.issubdtype(dtype, np.integer): data = (data * np.iinfo(dtype).max).astype(dtype) + elif np.issubdtype(dtype, bool): + data = (data > 0.5).round().astype(bool) + else: + assert False, f'Unsupported dtype {dtype}' # Supply a temporary file to write the image to filepath = os.path.join(temp_path, f'test.{ext}') From 7ca60ebac9e794b8591987da5e30d83791ea2e7e Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 31 Dec 2025 00:43:02 +0100 Subject: [PATCH 2/4] Fix bug --- giatools/image.py | 15 +++++++++++++-- tests/integration/test__cli.py | 2 +- tests/tools.py | 4 +++- tests/unit/test__image.py | 24 ++++++++++++++++++++---- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/giatools/image.py b/giatools/image.py index 97caf5b..86947bb 100644 --- a/giatools/image.py +++ b/giatools/image.py @@ -480,8 +480,19 @@ def clip_to_dtype(self, dtype: _np.dtype, force_copy: bool = False) -> _T.Self: Raises: TypeError: If `dtype` is `bool`. """ - if dtype in (bool, _np.bool_): - raise TypeError('Clipping to boolean dtype is not supported.') + if _np.issubdtype(dtype, bool): + if _np.issubdtype(self.data.dtype, bool): + if force_copy: + return Image( + data=self.data.copy(), + axes=self.axes, + original_axes=self.original_axes, + metadata=self.metadata, + ) + else: + return self + else: + raise TypeError('Clipping to boolean dtype is not supported.') # Determine the actual range of the source image min_src_value, max_src_value = _get_min_max_values(self.data) diff --git a/tests/integration/test__cli.py b/tests/integration/test__cli.py index 6f3c902..0867ee9 100644 --- a/tests/integration/test__cli.py +++ b/tests/integration/test__cli.py @@ -177,7 +177,7 @@ def test__preserve__bool(self, filepath, data): params=None, verbose=False, input_filepaths={'input': filepath}, - input_images={'input': giatools.image.Image(data, axes='YX')}, + input_images={'input': giatools.image.Image(data, axes='YX', original_axes='YX')}, output_filepaths={'output': output_filepath}, raw_args=types.SimpleNamespace(), ) diff --git a/tests/tools.py b/tests/tools.py index 79c1d04..c3f0e38 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -61,7 +61,9 @@ def wrapper(self): # Create random image data np.random.seed(0) data = np.random.rand(*shape) - if np.issubdtype(dtype, np.integer): + if np.issubdtype(dtype, np.floating): + pass + elif np.issubdtype(dtype, np.integer): data = (data * np.iinfo(dtype).max).astype(dtype) elif np.issubdtype(dtype, bool): data = (data > 0.5).round().astype(bool) diff --git a/tests/unit/test__image.py b/tests/unit/test__image.py index 8976f53..2fa97c0 100644 --- a/tests/unit/test__image.py +++ b/tests/unit/test__image.py @@ -195,24 +195,40 @@ def test__spurious_axis(self): class Image__clip_to_dtype(ImageTestCase): + def _issubdtype(self, a, b): + ret = self._issubdtype__results.get((a, b), None) + assert ret is not None, f'Unexpected args to issubdtype: {a}, {b}' + return ret + def test__bool(self): + self._issubdtype__results = { + (bool, bool): True, + (self.img1.data.dtype, bool): False, + } + self._np.issubdtype.side_effect = self._issubdtype with self.assertRaises(TypeError): self.img1.clip_to_dtype(bool) def test__to_superset_int(self): self._get_min_max_values.return_value = (-15, +15) - self._np.issubdtype.return_value = True # target dtype is an integer type + self._issubdtype__results = { + (self._np.uint8, bool): False, + (self._np.uint8, self._np.integer): True, # target dtype is an integer type + } self._np.iinfo.return_value.min = -15 self._np.iinfo.return_value.max = +15 - img_clipped = self.img1.clip_to_dtype('mocked-int-type') + img_clipped = self.img1.clip_to_dtype(self._np.uint8) self.assertIs(img_clipped, self.img1) self.img1.data.copy.assert_not_called() def test__to_superset_float(self): self._get_min_max_values.return_value = (-15, +15) - self._np.issubdtype.return_value = False # target dtype is a float type + self._issubdtype__results = { + (self._np.uint8, bool): False, + (self._np.uint8, self._np.integer): False, # target dtype is a float type + } self._np.finfo.return_value.min.item.return_value = -15. self._np.finfo.return_value.max.item.return_value = +15. - img_clipped = self.img1.clip_to_dtype('mocked-float-type') + img_clipped = self.img1.clip_to_dtype(self._np.float64) self.assertIs(img_clipped, self.img1) self.img1.data.copy.assert_not_called() From 764d623bd14dfb891e7ae0e180b915aab7df5a8e Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 31 Dec 2025 00:43:16 +0100 Subject: [PATCH 3/4] Increment version to 0.7.3 --- giatools/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/giatools/version.py b/giatools/version.py index 0f78390..2f5df0c 100644 --- a/giatools/version.py +++ b/giatools/version.py @@ -1,5 +1,5 @@ VERSION_MAJOR = 0 VERSION_MINOR = 7 -VERSION_PATCH = 2 +VERSION_PATCH = 3 __version__ = '%d.%d.%d' % (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) From cd2001fdeedda2559efa2e2491a7c6aaeb7af54d Mon Sep 17 00:00:00 2001 From: Leonid Kostrykin Date: Wed, 31 Dec 2025 00:46:52 +0100 Subject: [PATCH 4/4] Fix tests for Python <3.11 --- tests/integration/test__cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/test__cli.py b/tests/integration/test__cli.py index 0867ee9..a90d0b1 100644 --- a/tests/integration/test__cli.py +++ b/tests/integration/test__cli.py @@ -167,6 +167,7 @@ def test__params(self): class ToolBaseplate(unittest.TestCase): + @minimum_python_version(3, 11) @random_io_test(shape=(10, 10), dtype=bool, ext='tiff') def test__preserve__bool(self, filepath, data): output_filepath = str(pathlib.Path(filepath).parent / 'output.tiff')