diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index f24b184ec8..48ebd1788d 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -414,7 +414,7 @@ def __init__( minv: minimum value of output data. maxv: maximum value of output data. factor: factor scale by ``v = v * (1 + factor)``. In order to use - this parameter, please set `minv` and `maxv` into None. + this parameter, please set both `minv` and `maxv` into None. channel_wise: if True, scale on each channel separately. Please ensure that the first dimension represents the channel of the image if True. dtype: output data type, if None, same as input image. defaults to float32. @@ -433,7 +433,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: ValueError: When ``self.minv=None`` or ``self.maxv=None`` and ``self.factor=None``. Incompatible values. """ - if self.minv is not None and self.maxv is not None: + if self.minv is not None or self.maxv is not None: if self.channel_wise: out = [rescale_array(d, self.minv, self.maxv, dtype=self.dtype) for d in img] ret = torch.stack(out) if isinstance(img, torch.Tensor) else np.stack(out) # type: ignore @@ -722,6 +722,9 @@ class ScaleIntensityRange(Transform): Apply specific intensity scaling to the whole numpy array. Scaling from [a_min, a_max] to [b_min, b_max] with clip option. + When `b_min` or `b_max` are `None`, `scacled_array * (b_max - b_min) + b_min` will be skipped. + If `clip=True`, when `b_min`/`b_max` is None, the clipping is not performed on the corresponding edge. + Args: a_min: intensity original range min. a_max: intensity original range max. @@ -734,7 +737,13 @@ class ScaleIntensityRange(Transform): backend = [TransformBackends.TORCH, TransformBackends.NUMPY] def __init__( - self, a_min: float, a_max: float, b_min: float, b_max: float, clip: bool = False, dtype: DtypeLike = np.float32 + self, + a_min: float, + a_max: float, + b_min: Optional[float] = None, + b_max: Optional[float] = None, + clip: bool = False, + dtype: DtypeLike = np.float32, ) -> None: self.a_min = a_min self.a_max = a_max @@ -750,10 +759,13 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: dtype = self.dtype or img.dtype if self.a_max - self.a_min == 0.0: warn("Divide by zero (a_min == a_max)", Warning) + if self.b_min is None: + return img - self.a_min return img - self.a_min + self.b_min img = (img - self.a_min) / (self.a_max - self.a_min) - img = img * (self.b_max - self.b_min) + self.b_min + if (self.b_min is not None) and (self.b_max is not None): + img = img * (self.b_max - self.b_min) + self.b_min if self.clip: img = clip(img, self.b_min, self.b_max) ret, *_ = convert_data_type(img, dtype=dtype) @@ -844,11 +856,12 @@ class ScaleIntensityRangePercentiles(Transform): """ Apply range scaling to a numpy array based on the intensity distribution of the input. - By default this transform will scale from [lower_intensity_percentile, upper_intensity_percentile] to [b_min, b_max], where - {lower,upper}_intensity_percentile are the intensity values at the corresponding percentiles of ``img``. + By default this transform will scale from [lower_intensity_percentile, upper_intensity_percentile] to + `[b_min, b_max]`, where {lower,upper}_intensity_percentile are the intensity values at the corresponding + percentiles of ``img``. - The ``relative`` parameter can also be set to scale from [lower_intensity_percentile, upper_intensity_percentile] to the - lower and upper percentiles of the output range [b_min, b_max] + The ``relative`` parameter can also be set to scale from [lower_intensity_percentile, upper_intensity_percentile] + to the lower and upper percentiles of the output range [b_min, b_max]. For example: @@ -885,6 +898,9 @@ class ScaleIntensityRangePercentiles(Transform): [20., 60., 100., 140., 180.], [20., 60., 100., 140., 180.]]] + See Also: + + - :py:class:`monai.transforms.ScaleIntensityRange` Args: lower: lower intensity percentile. @@ -902,8 +918,8 @@ def __init__( self, lower: float, upper: float, - b_min: float, - b_max: float, + b_min: Optional[float], + b_max: Optional[float], clip: bool = False, relative: bool = False, dtype: DtypeLike = np.float32, @@ -930,15 +946,15 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: b_max = self.b_max if self.relative: + if (self.b_min is None) or (self.b_max is None): + raise ValueError("If it is relative, b_min and b_max should not be None.") b_min = ((self.b_max - self.b_min) * (self.lower / 100.0)) + self.b_min b_max = ((self.b_max - self.b_min) * (self.upper / 100.0)) + self.b_min - scalar = ScaleIntensityRange(a_min=a_min, a_max=a_max, b_min=b_min, b_max=b_max, clip=False, dtype=self.dtype) + scalar = ScaleIntensityRange( + a_min=a_min, a_max=a_max, b_min=b_min, b_max=b_max, clip=self.clip, dtype=self.dtype + ) img = scalar(img) - - if self.clip: - img = clip(img, self.b_min, self.b_max) - return img diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 3689d86689..c33cad85a5 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -506,7 +506,7 @@ def __init__( minv: minimum value of output data. maxv: maximum value of output data. factor: factor scale by ``v = v * (1 + factor)``. In order to use - this parameter, please set `minv` and `maxv` into None. + this parameter, please set both `minv` and `maxv` into None. channel_wise: if True, scale on each channel separately. Please ensure that the first dimension represents the channel of the image if True. dtype: output data type, if None, same as input image. defaults to float32. @@ -723,8 +723,8 @@ def __init__( keys: KeysCollection, a_min: float, a_max: float, - b_min: float, - b_max: float, + b_min: Optional[float] = None, + b_max: Optional[float] = None, clip: bool = False, dtype: DtypeLike = np.float32, allow_missing_keys: bool = False, @@ -839,8 +839,8 @@ def __init__( keys: KeysCollection, lower: float, upper: float, - b_min: float, - b_max: float, + b_min: Optional[float], + b_max: Optional[float], clip: bool = False, relative: bool = False, dtype: DtypeLike = np.float32, diff --git a/monai/transforms/utils.py b/monai/transforms/utils.py index 536a0b91ee..db15bba225 100644 --- a/monai/transforms/utils.py +++ b/monai/transforms/utils.py @@ -150,12 +150,13 @@ def zero_margins(img: np.ndarray, margin: int) -> bool: def rescale_array( arr: NdarrayOrTensor, - minv: float = 0.0, - maxv: float = 1.0, + minv: Optional[float] = 0.0, + maxv: Optional[float] = 1.0, dtype: Optional[Union[DtypeLike, torch.dtype]] = np.float32, ) -> NdarrayOrTensor: """ Rescale the values of numpy array `arr` to be from `minv` to `maxv`. + If either `minv` or `maxv` is None, it returns `(a - min_a) / (max_a - min_a)`. Args: arr: input array to rescale. @@ -170,14 +171,16 @@ def rescale_array( maxa = arr.max() if mina == maxa: - return arr * minv + return arr * minv if minv is not None else arr norm = (arr - mina) / (maxa - mina) # normalize the array first + if (minv is None) or (maxv is None): + return norm return (norm * (maxv - minv)) + minv # rescale by minv and maxv, which is the normalized array by default def rescale_instance_array( - arr: np.ndarray, minv: float = 0.0, maxv: float = 1.0, dtype: DtypeLike = np.float32 + arr: np.ndarray, minv: Optional[float] = 0.0, maxv: Optional[float] = 1.0, dtype: DtypeLike = np.float32 ) -> np.ndarray: """ Rescale each array slice along the first dimension of `arr` independently. diff --git a/monai/transforms/utils_pytorch_numpy_unification.py b/monai/transforms/utils_pytorch_numpy_unification.py index 15ae8fb111..b6695d937b 100644 --- a/monai/transforms/utils_pytorch_numpy_unification.py +++ b/monai/transforms/utils_pytorch_numpy_unification.py @@ -77,7 +77,7 @@ def clip(a: NdarrayOrTensor, a_min, a_max) -> NdarrayOrTensor: if isinstance(a, np.ndarray): result = np.clip(a, a_min, a_max) else: - result = torch.clip(a, a_min, a_max) + result = torch.clamp(a, a_min, a_max) return result diff --git a/tests/test_cachedataset_persistent_workers.py b/tests/test_cachedataset_persistent_workers.py index d727074bf9..6cdd0ada99 100644 --- a/tests/test_cachedataset_persistent_workers.py +++ b/tests/test_cachedataset_persistent_workers.py @@ -19,15 +19,16 @@ @SkipIfBeforePyTorchVersion((1, 7)) class TestTransformsWCacheDatasetAndPersistentWorkers(unittest.TestCase): def test_duplicate_transforms(self): - im, _ = create_test_image_2d(128, 128, num_seg_classes=1, channel_dim=0) - data = [{"img": im} for _ in range(2)] + data = [{"img": create_test_image_2d(128, 128, num_seg_classes=1, channel_dim=0)[0]} for _ in range(2)] # at least 1 deterministic followed by at least 1 random transform = Compose([Spacingd("img", pixdim=(1, 1)), RandAffined("img", prob=1.0)]) # cachedataset and data loader w persistent_workers train_ds = CacheDataset(data, transform, cache_num=1) - train_loader = DataLoader(train_ds, num_workers=2, persistent_workers=True) + # num_workers > 1 may fail randomly with 21.09 on A100 test node + # https://github.com/Project-MONAI/MONAI/issues/3283 + train_loader = DataLoader(train_ds, num_workers=1, persistent_workers=True) b1 = next(iter(train_loader)) b2 = next(iter(train_loader)) diff --git a/tests/test_scale_intensity.py b/tests/test_scale_intensity.py index cf57b4e3a4..33f4bc759b 100644 --- a/tests/test_scale_intensity.py +++ b/tests/test_scale_intensity.py @@ -14,6 +14,7 @@ import numpy as np from monai.transforms import ScaleIntensity +from monai.transforms.utils import rescale_array from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose @@ -35,6 +36,13 @@ def test_factor_scale(self): expected = p((self.imt * (1 + 0.1)).astype(np.float32)) assert_allclose(result, p(expected), rtol=1e-7, atol=0) + def test_max_none(self): + for p in TEST_NDARRAYS: + scaler = ScaleIntensity(minv=0.0, maxv=None, factor=0.1) + result = scaler(p(self.imt)) + expected = rescale_array(p(self.imt), minv=0.0, maxv=None) + assert_allclose(result, expected, rtol=1e-3, atol=1e-3) + def test_int(self): """integers should be handled by converting them to floats first.""" for p in TEST_NDARRAYS: diff --git a/tests/test_scale_intensity_range.py b/tests/test_scale_intensity_range.py index f3e971b2ea..2384532776 100644 --- a/tests/test_scale_intensity_range.py +++ b/tests/test_scale_intensity_range.py @@ -26,6 +26,14 @@ def test_image_scale_intensity_range(self): expected = (((self.imt - 20) / 88) * 30 + 50).astype(np.uint8) assert_allclose(scaled, p(expected)) + def test_image_scale_intensity_range_none_clip(self): + scaler = ScaleIntensityRange(a_min=20, a_max=108, b_min=None, b_max=80, clip=True, dtype=np.uint8) + for p in TEST_NDARRAYS: + scaled = scaler(p(self.imt)) + self.assertTrue(scaled.dtype, np.uint8) + expected = (np.clip((self.imt - 20) / 88, None, 80)).astype(np.uint8) + assert_allclose(scaled, p(expected)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_scale_intensity_range_percentiles.py b/tests/test_scale_intensity_range_percentiles.py index 786e4299b5..22a5506fb9 100644 --- a/tests/test_scale_intensity_range_percentiles.py +++ b/tests/test_scale_intensity_range_percentiles.py @@ -52,6 +52,13 @@ def test_relative_scaling(self): result = scaler(p(img)) assert_allclose(result, p(expected_img), rtol=1e-4) + scaler = ScaleIntensityRangePercentiles( + lower=lower, upper=upper, b_min=b_min, b_max=b_max, relative=True, clip=True + ) + for p in TEST_NDARRAYS: + result = scaler(p(img)) + assert_allclose(result, p(np.clip(expected_img, expected_b_min, expected_b_max)), rtol=1e-4) + def test_invalid_instantiation(self): self.assertRaises(ValueError, ScaleIntensityRangePercentiles, lower=-10, upper=99, b_min=0, b_max=255) self.assertRaises(ValueError, ScaleIntensityRangePercentiles, lower=101, upper=99, b_min=0, b_max=255) diff --git a/tests/test_scale_intensity_range_percentilesd.py b/tests/test_scale_intensity_range_percentilesd.py index 9d0fe8284a..ac2118d99f 100644 --- a/tests/test_scale_intensity_range_percentilesd.py +++ b/tests/test_scale_intensity_range_percentilesd.py @@ -14,14 +14,12 @@ import numpy as np from monai.transforms.intensity.dictionary import ScaleIntensityRangePercentilesd -from tests.utils import NumpyImageTestCase2D +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose class TestScaleIntensityRangePercentilesd(NumpyImageTestCase2D): def test_scaling(self): img = self.imt - data = {} - data["img"] = img lower = 10 upper = 99 b_min = 0 @@ -32,9 +30,12 @@ def test_scaling(self): expected = (img - a_min) / (a_max - a_min) expected = (expected * (b_max - b_min)) + b_min - scaler = ScaleIntensityRangePercentilesd(keys=data.keys(), lower=lower, upper=upper, b_min=b_min, b_max=b_max) - - self.assertTrue(np.allclose(expected, scaler(data)["img"])) + for p in TEST_NDARRAYS: + data = {"img": p(img)} + scaler = ScaleIntensityRangePercentilesd( + keys=data.keys(), lower=lower, upper=upper, b_min=b_min, b_max=b_max + ) + assert_allclose(p(expected), scaler(data)["img"]) def test_relative_scaling(self): img = self.imt @@ -55,7 +56,7 @@ def test_relative_scaling(self): expected_img = (img - expected_a_min) / (expected_a_max - expected_a_min) expected_img = (expected_img * (expected_b_max - expected_b_min)) + expected_b_min - self.assertTrue(np.allclose(expected_img, scaler(data)["img"])) + np.testing.assert_allclose(expected_img, scaler(data)["img"]) def test_invalid_instantiation(self): self.assertRaises( @@ -70,6 +71,9 @@ def test_invalid_instantiation(self): self.assertRaises( ValueError, ScaleIntensityRangePercentilesd, keys=["img"], lower=30, upper=1000, b_min=0, b_max=255 ) + with self.assertRaises(ValueError): + s = ScaleIntensityRangePercentilesd(keys=["img"], lower=30, upper=90, b_min=None, b_max=20, relative=True) + s(self.imt) if __name__ == "__main__": diff --git a/tests/test_scale_intensity_ranged.py b/tests/test_scale_intensity_ranged.py index dc064a7708..a4b7f17b04 100644 --- a/tests/test_scale_intensity_ranged.py +++ b/tests/test_scale_intensity_ranged.py @@ -25,6 +25,14 @@ def test_image_scale_intensity_ranged(self): expected = expected * 30 + 50 assert_allclose(scaled[key], p(expected)) + def test_image_scale_intensity_ranged_none(self): + key = "img" + scaler = ScaleIntensityRanged(keys=key, a_min=20, a_max=108, b_min=None, b_max=None) + for p in TEST_NDARRAYS: + scaled = scaler({key: p(self.imt)}) + expected = (self.imt - 20) / 88 + assert_allclose(scaled[key], p(expected)) + if __name__ == "__main__": unittest.main()