torch_em.transform.raw
1from typing import Union, Optional, Tuple, Dict, Callable 2 3import numpy as np 4 5import torch 6from torchvision import transforms 7 8 9# 10# normalization functions 11# 12 13 14TORCH_DTYPES = { 15 "float16": torch.float16, 16 "float32": torch.float32, 17 "float64": torch.float64, 18 "complex64": torch.complex64, 19 "complex128": torch.complex128, 20 "uint8": torch.uint8, 21 "int8": torch.int8, 22 "int16": torch.int16, 23 "int32": torch.int32, 24 "int64": torch.int64, 25 "bool": torch.bool, 26} 27"""@private 28""" 29 30 31def cast(inpt: Union[np.ndarray, torch.tensor], typestring: torch.dtype): 32 """@private 33 """ 34 if torch.is_tensor(inpt): 35 assert typestring in TORCH_DTYPES, f"{typestring} not in TORCH_DTYPES" 36 return inpt.to(TORCH_DTYPES[typestring]) 37 return inpt.astype(typestring) 38 39 40def standardize( 41 raw: np.ndarray, 42 mean: Optional[float] = None, 43 std: Optional[float] = None, 44 axis: Optional[Union[int, Tuple[int, ...]]] = None, 45 eps: float = 1e-7, 46) -> np.ndarray: 47 """Standardize the input data by subtracting its mean and dividing by its standard deviation. 48 49 Args: 50 raw: The input data. 51 mean: The mean value. If None, it will be computed from the data. 52 std: The standard deviation. If None, it will be computed from the data. 53 axis: The axis along which to compute the mean and standard deviation. 54 eps: The epsilon value for numerical stability. 55 56 Returns: 57 The standardized input data. 58 """ 59 raw = cast(raw, "float32") 60 mean = raw.mean(axis=axis, keepdims=True) if mean is None else mean 61 raw -= mean 62 63 std = raw.std(axis=axis, keepdims=True) if std is None else std 64 raw /= (std + eps) 65 return raw 66 67 68def _normalize_torch(tensor, minval, maxval, axis, eps): 69 if axis: # torch returns torch.return_types.min or torch.return_types.max 70 minval = torch.amin(tensor, dim=axis, keepdim=True) if minval is None else minval 71 tensor -= minval 72 73 maxval = torch.amax(tensor, dim=axis, keepdim=True) if maxval is None else maxval 74 tensor /= (maxval + eps) 75 76 return tensor 77 78 # keepdim can only be used in combination with dim 79 minval = tensor.min() if minval is None else minval 80 tensor -= minval 81 82 maxval = tensor.max() if maxval is None else maxval 83 tensor /= (maxval + eps) 84 85 return tensor 86 87 88def normalize( 89 raw: Union[torch.tensor, np.ndarray], 90 minval: Optional[float] = None, 91 maxval: Optional[float] = None, 92 axis: Optional[Union[int, Tuple[int, ...]]] = None, 93 eps: float = 1e-7, 94) -> np.ndarray: 95 """Normalize the input data so that it is in range [0, 1]. 96 97 Args: 98 raw: The input data. 99 minval: The minimum data value. If None, it will be computed from the data. 100 maxval: The maximum data value. If None, it will be computed from the data. 101 axis: The axis along which to compute the min and max value. 102 eps: The epsilon value for numerical stability. 103 104 Returns: 105 The normalized input data. 106 """ 107 raw = cast(raw, "float32") 108 if torch.is_tensor(raw): 109 return _normalize_torch(raw, minval=minval, maxval=maxval, axis=axis, eps=eps) 110 111 minval = raw.min(axis=axis, keepdims=True) if minval is None else minval 112 raw -= minval 113 114 maxval = raw.max(axis=axis, keepdims=True) if maxval is None else maxval 115 raw /= (maxval + eps) 116 return raw 117 118 119def normalize_percentile( 120 raw: np.ndarray, 121 lower: float = 1.0, 122 upper: float = 99.0, 123 axis: Optional[Union[int, Tuple[int, ...]]] = None, 124 eps: float = 1e-7, 125) -> np.ndarray: 126 """Normalize the input data based on percentile values. 127 128 Args: 129 raw: The input data. 130 lower: The lower percentile. 131 upper: The upper percentile. 132 axis: The axis along which to compute the percentiles. 133 eps: The epsilon value for numerical stability. 134 135 Returns: 136 The normalized input data. 137 """ 138 v_lower = np.percentile(raw, lower, axis=axis, keepdims=True) 139 v_upper = np.percentile(raw, upper, axis=axis, keepdims=True) - v_lower 140 return normalize(raw, v_lower, v_upper, eps=eps) 141 142 143class RandomPercentileNormalization: 144 """Normalize inputs with randomly sampled percentile bounds. 145 146 By default, the lower and upper percentiles are sampled uniformly from 147 ``lower_percentile_bounds`` and ``upper_percentile_bounds``. If no upper bounds are given, 148 they are inferred by mirroring the lower bounds around 50. Normal (Gaussian) sampling can be 149 enabled explicitly with ``distribution="normal"`` and 150 ``distribution_kwargs={"mean": ..., "std": ...}``. The sampled percentile intensities are 151 mapped to 0 and 1, and values outside them are clipped, so the output is always in ``[0, 1]``. 152 153 Examples: 154 Uniform sampling with the default percentile bounds and reproducible random draws: 155 156 ```python 157 normalization = RandomPercentileNormalization(seed=42) 158 ``` 159 160 Normal sampling with explicit distribution parameters: 161 162 ```python 163 normalization = RandomPercentileNormalization( 164 distribution="normal", 165 distribution_kwargs={"mean": 2.0, "std": 1.0}, 166 seed=42, 167 ) 168 ``` 169 170 Args: 171 lower_percentile_bounds: Inclusive clipping bounds for the lower percentile. 172 upper_percentile_bounds: Inclusive clipping bounds for the upper percentile. If None, the 173 bounds are inferred by mirroring ``lower_percentile_bounds`` around 50. 174 distribution: Sampling distribution for the percentiles. Supported values are 175 ``"uniform"`` and ``"normal"``. 176 distribution_kwargs: Parameters for normal sampling, which must contain exactly 177 ``{"mean": ..., "std": ...}``. The upper percentile uses the mirrored normal 178 distribution. Uniform sampling does not take additional parameters. 179 rounding_decimals: Number of decimal places used to round sampled percentiles. Set to None 180 to disable rounding. 181 axis: Axes over which to compute the intensity percentiles. 182 seed: Optional non-negative integer seed for reproducible sampling. NumPy integer types 183 are also supported. Each DataLoader worker derives a distinct stream from this seed. 184 By default, the global NumPy random state is used, which respects DataLoader worker seeding. 185 eps: Epsilon used for numerical stability during normalization. 186 """ 187 188 def __init__( 189 self, 190 lower_percentile_bounds: Tuple[float, float] = (0.0, 5.0), 191 upper_percentile_bounds: Optional[Tuple[float, float]] = None, 192 distribution: str = "uniform", 193 distribution_kwargs: Optional[Dict[str, float]] = None, 194 rounding_decimals: Optional[int] = 1, 195 axis: Optional[Union[int, Tuple[int, ...]]] = None, 196 seed: Optional[int] = None, 197 eps: float = 1e-7, 198 ): 199 lower_percentile_bounds = self._validate_bounds(lower_percentile_bounds, upper=False) 200 if upper_percentile_bounds is None: 201 upper_percentile_bounds = tuple(100.0 - bound for bound in reversed(lower_percentile_bounds)) 202 upper_percentile_bounds = self._validate_bounds(upper_percentile_bounds, upper=True) 203 if distribution not in ("uniform", "normal"): 204 raise ValueError("distribution must be 'uniform' or 'normal'.") 205 206 if distribution == "uniform": 207 if distribution_kwargs is not None: 208 raise ValueError("Uniform sampling does not accept distribution_kwargs.") 209 else: 210 if not isinstance(distribution_kwargs, dict) or set(distribution_kwargs) != {"mean", "std"}: 211 raise ValueError("Normal sampling requires exactly the distribution_kwargs 'mean' and 'std'.") 212 mean, std = float(distribution_kwargs["mean"]), float(distribution_kwargs["std"]) 213 if not np.isfinite(mean) or not lower_percentile_bounds[0] <= mean <= lower_percentile_bounds[1]: 214 raise ValueError("The normal distribution mean must be finite and within lower_percentile_bounds.") 215 if not np.isfinite(std) or std < 0.0: 216 raise ValueError("The normal distribution std must be finite and non-negative.") 217 distribution_kwargs = {"mean": mean, "std": std} 218 219 if rounding_decimals is not None and ( 220 not isinstance(rounding_decimals, int) or isinstance(rounding_decimals, bool) or rounding_decimals < 0 221 ): 222 raise ValueError("rounding_decimals must be a non-negative integer or None.") 223 if not np.isfinite(eps) or eps <= 0.0: 224 raise ValueError("eps must be finite and greater than zero.") 225 if seed is not None: 226 if not isinstance(seed, (int, np.integer)) or isinstance(seed, bool): 227 raise TypeError("seed must be an integer or None.") 228 if seed < 0: 229 raise ValueError("seed must be non-negative.") 230 seed = int(seed) 231 232 self.lower_percentile_bounds = lower_percentile_bounds 233 self.upper_percentile_bounds = upper_percentile_bounds 234 self.distribution = distribution 235 self.distribution_kwargs = distribution_kwargs 236 self.rounding_decimals = rounding_decimals 237 self.axis = axis 238 self.seed = seed 239 self.eps = float(eps) 240 self._random_generator = None 241 self._random_generator_worker_id = None 242 243 @staticmethod 244 def _validate_bounds(values, upper): 245 name = "upper_percentile_bounds" if upper else "lower_percentile_bounds" 246 if not isinstance(values, (tuple, list)) or len(values) != 2: 247 raise ValueError(f"{name} must contain exactly two values.") 248 lower_bound, upper_bound = (float(value) for value in values) 249 finite = np.isfinite(lower_bound) and np.isfinite(upper_bound) 250 if upper: 251 valid = 50.0 < lower_bound <= upper_bound <= 100.0 252 interval = "(50, 100]" 253 else: 254 valid = 0.0 <= lower_bound <= upper_bound < 50.0 255 interval = "[0, 50)" 256 if not finite or not valid: 257 raise ValueError(f"{name} must be a finite interval within {interval}.") 258 return lower_bound, upper_bound 259 260 def _round(self, value): 261 return float(value) if self.rounding_decimals is None else round(float(value), self.rounding_decimals) 262 263 def _get_random_generator(self): 264 if self.seed is None: 265 return np.random 266 267 worker_info = torch.utils.data.get_worker_info() 268 worker_id = None if worker_info is None else worker_info.id 269 if self._random_generator is None or self._random_generator_worker_id != worker_id: 270 seed_sequence = np.random.SeedSequence([self.seed, 0 if worker_id is None else worker_id]) 271 self._random_generator = np.random.default_rng(seed_sequence) 272 self._random_generator_worker_id = worker_id 273 return self._random_generator 274 275 def sample_percentiles(self) -> Tuple[float, float]: 276 """Sample and return a valid ``(lower, upper)`` percentile pair.""" 277 random_generator = self._get_random_generator() 278 if self.distribution == "uniform": 279 lower = random_generator.uniform(*self.lower_percentile_bounds) 280 upper = random_generator.uniform(*self.upper_percentile_bounds) 281 else: 282 mean = self.distribution_kwargs["mean"] 283 std = self.distribution_kwargs["std"] 284 lower = mean if std == 0.0 else random_generator.normal(mean, std) 285 upper = 100.0 - (mean if std == 0.0 else random_generator.normal(mean, std)) 286 287 # Normal distribution tails may leave the configured percentile interval. 288 lower = float(np.clip(self._round(lower), *self.lower_percentile_bounds)) 289 upper = float(np.clip(self._round(upper), *self.upper_percentile_bounds)) 290 return lower, upper 291 292 def __call__(self, raw: Union[np.ndarray, torch.tensor]) -> Union[np.ndarray, torch.tensor]: 293 lower, upper = self.sample_percentiles() 294 normalized = normalize_percentile(raw, lower=lower, upper=upper, axis=self.axis, eps=self.eps) 295 if torch.is_tensor(normalized): 296 return torch.clamp(normalized, min=0.0, max=1.0) 297 return np.clip(normalized, 0.0, 1.0) 298 299 300# 301# Intensity Augmentations / Noise Augmentations. 302# 303 304# modified from https://github.com/kreshuklab/spoco/blob/main/spoco/transforms.py 305class RandomContrast: 306 """Transformation to adjust contrast by scaling image to `mean + alpha * (image - mean)`. 307 308 Args: 309 alpha: Minimal and maximal alpha value for adjusting the contrast. 310 The value for the transformation will be drawn uniformly from the corresponding interval. 311 mean: Mean value for the image data. 312 clip_kwargs: Keyword arguments for clipping the data after the contrast augmentation. 313 """ 314 def __init__( 315 self, alpha: Tuple[float, float] = (0.5, 2), mean: float = 0.5, clip_kwargs: Dict = {"a_min": 0, "a_max": 1} 316 ): 317 self.alpha = alpha 318 self.mean = mean 319 self.clip_kwargs = clip_kwargs 320 321 def __call__(self, img: np.ndarray) -> np.ndarray: 322 """Apply the augmentation to data. 323 324 Args: 325 img: The input image. 326 327 Returns: 328 The transformed image. 329 """ 330 alpha = np.random.uniform(self.alpha[0], self.alpha[1]) 331 result = self.mean + alpha * (img - self.mean) 332 if self.clip_kwargs: 333 return np.clip(result, **self.clip_kwargs) 334 return result 335 336 337class AdditiveGaussianNoise: 338 """Transformation to add random Gaussian noise to image. 339 340 Args: 341 scale: Scale for the noise. 342 clip_kwargs: Keyword arguments for clipping the data after the transformation. 343 """ 344 def __init__(self, scale: Tuple[float, float] = (0.0, 0.3), clip_kwargs: Dict = {"a_min": 0, "a_max": 1}): 345 self.scale = scale 346 self.clip_kwargs = clip_kwargs 347 348 def __call__(self, img: np.ndarray) -> np.ndarray: 349 """Apply the augmentation to data. 350 351 Args: 352 img: The input image. 353 354 Returns: 355 The transformed image. 356 """ 357 std = np.random.uniform(self.scale[0], self.scale[1]) 358 gaussian_noise = np.random.normal(0, std, size=img.shape) 359 360 if self.clip_kwargs: 361 return np.clip(img + gaussian_noise, 0, 1) 362 363 return img + gaussian_noise 364 365 366class AdditivePoissonNoise: 367 """Transformation to add random additive Poisson noise to image. 368 369 Args: 370 lam: Lambda value for the Poisson transformation. 371 clip_kwargs: Keyword arguments for clipping the data after the transformation. 372 """ 373 # Not sure if Poisson noise like this does make sense for data that is already normalized 374 def __init__(self, lam: Tuple[float, float] = (0.0, 0.1), clip_kwargs: Dict = {"a_min": 0, "a_max": 1}): 375 self.lam = lam 376 self.clip_kwargs = clip_kwargs 377 378 def __call__(self, img: np.ndarray) -> np.ndarray: 379 """Apply the augmentation to data. 380 381 Args: 382 img: The input image. 383 384 Returns: 385 The transformed image. 386 """ 387 lam = np.random.uniform(self.lam[0], self.lam[1]) 388 poisson_noise = np.random.poisson(lam, size=img.shape) / lam 389 if self.clip_kwargs: 390 return np.clip(img + poisson_noise, 0, 1) 391 return img + poisson_noise 392 393 394class PoissonNoise: 395 """Transformation to add random data-dependant Poisson noise to image. 396 397 Args: 398 multiplier: Multiplicative factors for deriving the lambda factor from the data. 399 The factor used for the transformation will be uniformly sampled form the range of this parameter. 400 clip_kwargs: Keyword arguments for clipping the data after the transformation. 401 """ 402 def __init__(self, multiplier: Tuple[float, float] = (5.0, 10.0), clip_kwargs: Dict = {"a_min": 0, "a_max": 1}): 403 self.multiplier = multiplier 404 self.clip_kwargs = clip_kwargs 405 406 def __call__(self, img: np.ndarray) -> np.ndarray: 407 """Apply the augmentation to data. 408 409 Args: 410 img: The input image. 411 412 Returns: 413 The transformed image. 414 """ 415 multiplier = np.random.uniform(self.multiplier[0], self.multiplier[1]) 416 offset = img.min() 417 poisson_noise = np.random.poisson((img - offset) * multiplier) 418 419 if isinstance(img, torch.Tensor): 420 poisson_noise = torch.Tensor(poisson_noise) 421 poisson_noise = poisson_noise / multiplier + offset 422 423 if self.clip_kwargs: 424 return np.clip(poisson_noise, **self.clip_kwargs) 425 return poisson_noise 426 427 428class GaussianBlur: 429 """Transformation to blur the image with a randomly drawn sigma value. 430 431 Args: 432 sigma: The sigma value for the transformation. 433 The value used in the transformation will be uniformly drawn from the range specified here. 434 """ 435 def __init__(self, sigma: Tuple[float, float] = (0.0, 3.0)): 436 self.sigma = sigma 437 438 def __call__(self, img: np.ndarray) -> np.ndarray: 439 """Apply the augmentation to data. 440 441 Args: 442 img: The input image. 443 444 Returns: 445 The transformed image. 446 """ 447 # Sample the sigma value. Note that we switch the bounds to ensure zero is excluded from sampling. 448 sigma = np.random.uniform(self.sigma[1], self.sigma[0]) 449 # Determine the kernel size based on the sigma value. 450 kernel_size = int(2 * np.ceil(3 * sigma) + 1) 451 if isinstance(img, np.ndarray): 452 img = torch.from_numpy(img) 453 454 return transforms.GaussianBlur(kernel_size, sigma=sigma)(img) 455 456 457# 458# Default Transformation: Apply intensity augmentations and normalize. 459# 460 461class RawTransform: 462 """The transformation for raw data during training. 463 464 Args: 465 normalizer: The normalization function. 466 augmentation1: Intensity augmentation applied before the normalization. 467 augmentation2: Intensity augmentation applied after the normalization. 468 """ 469 def __init__( 470 self, normalizer: Callable, augmentation1: Optional[Callable] = None, augmentation2: Optional[Callable] = None 471 ): 472 self.normalizer = normalizer 473 self.augmentation1 = augmentation1 474 self.augmentation2 = augmentation2 475 476 def __call__(self, raw: np.ndarray) -> np.ndarray: 477 """Apply the raw transformation. 478 479 Args: 480 raw: The raw data. 481 482 Returns: 483 The transformed raw data. 484 """ 485 if self.augmentation1 is not None: 486 raw = self.augmentation1(raw) 487 488 raw = self.normalizer(raw) 489 490 if self.augmentation2 is not None: 491 raw = self.augmentation2(raw) 492 return raw 493 494 495def get_raw_transform( 496 normalizer: Callable = standardize, 497 augmentation1: Optional[Callable] = None, 498 augmentation2: Optional[Callable] = None 499) -> Callable: 500 """Get the raw transformation. 501 502 Args: 503 normalizer: The normalization function. 504 augmentation1: Intensity augmentation applied before the normalization. 505 augmentation2: Intensity augmentation applied after the normalization. 506 507 Returns: 508 The raw transformation. 509 """ 510 return RawTransform(normalizer, augmentation1=augmentation1, augmentation2=augmentation2) 511 512 513def get_default_mean_teacher_augmentations( 514 p: float = 0.3, 515 norm: Optional[Callable] = None, 516 blur_kwargs: Optional[Dict] = None, 517 poisson_kwargs: Optional[Dict] = None, 518 gaussian_kwargs: Optional[Dict] = None, 519) -> Callable: 520 """Get the default augmentations for mean teacher training. 521 522 The default values for the augmentations are designed for an image with pixel values in range [0, 1]. 523 By default, a normalization transformation is applied for this reason. 524 525 Args: 526 p: The probability for applying the individual intensity transformations. 527 norm: The noromaization function. 528 blur_kwargs: The keyword arguments for `GaussianBlur`. 529 poisson_kwargs: The keyword arguments for `PoissonNoise`. 530 gaussian_kwargs: The keyword arguments for `AdditiveGaussianNoise`. 531 532 Returns: 533 The raw transformation with augmentations. 534 """ 535 if norm is None: 536 norm = normalize 537 538 aug1 = transforms.Compose([ 539 norm, 540 transforms.RandomApply([GaussianBlur(**({} if blur_kwargs is None else blur_kwargs))], p=p), 541 transforms.RandomApply([PoissonNoise(**({} if poisson_kwargs is None else poisson_kwargs))], p=p/2), 542 transforms.RandomApply([AdditiveGaussianNoise(**({} if gaussian_kwargs is None else gaussian_kwargs))], p=p/2), 543 ]) 544 545 aug2 = transforms.RandomApply([RandomContrast(clip_kwargs={"a_min": 0, "a_max": 1})], p=p) 546 return get_raw_transform(normalizer=norm, augmentation1=aug1, augmentation2=aug2)
41def standardize( 42 raw: np.ndarray, 43 mean: Optional[float] = None, 44 std: Optional[float] = None, 45 axis: Optional[Union[int, Tuple[int, ...]]] = None, 46 eps: float = 1e-7, 47) -> np.ndarray: 48 """Standardize the input data by subtracting its mean and dividing by its standard deviation. 49 50 Args: 51 raw: The input data. 52 mean: The mean value. If None, it will be computed from the data. 53 std: The standard deviation. If None, it will be computed from the data. 54 axis: The axis along which to compute the mean and standard deviation. 55 eps: The epsilon value for numerical stability. 56 57 Returns: 58 The standardized input data. 59 """ 60 raw = cast(raw, "float32") 61 mean = raw.mean(axis=axis, keepdims=True) if mean is None else mean 62 raw -= mean 63 64 std = raw.std(axis=axis, keepdims=True) if std is None else std 65 raw /= (std + eps) 66 return raw
Standardize the input data by subtracting its mean and dividing by its standard deviation.
Arguments:
- raw: The input data.
- mean: The mean value. If None, it will be computed from the data.
- std: The standard deviation. If None, it will be computed from the data.
- axis: The axis along which to compute the mean and standard deviation.
- eps: The epsilon value for numerical stability.
Returns:
The standardized input data.
89def normalize( 90 raw: Union[torch.tensor, np.ndarray], 91 minval: Optional[float] = None, 92 maxval: Optional[float] = None, 93 axis: Optional[Union[int, Tuple[int, ...]]] = None, 94 eps: float = 1e-7, 95) -> np.ndarray: 96 """Normalize the input data so that it is in range [0, 1]. 97 98 Args: 99 raw: The input data. 100 minval: The minimum data value. If None, it will be computed from the data. 101 maxval: The maximum data value. If None, it will be computed from the data. 102 axis: The axis along which to compute the min and max value. 103 eps: The epsilon value for numerical stability. 104 105 Returns: 106 The normalized input data. 107 """ 108 raw = cast(raw, "float32") 109 if torch.is_tensor(raw): 110 return _normalize_torch(raw, minval=minval, maxval=maxval, axis=axis, eps=eps) 111 112 minval = raw.min(axis=axis, keepdims=True) if minval is None else minval 113 raw -= minval 114 115 maxval = raw.max(axis=axis, keepdims=True) if maxval is None else maxval 116 raw /= (maxval + eps) 117 return raw
Normalize the input data so that it is in range [0, 1].
Arguments:
- raw: The input data.
- minval: The minimum data value. If None, it will be computed from the data.
- maxval: The maximum data value. If None, it will be computed from the data.
- axis: The axis along which to compute the min and max value.
- eps: The epsilon value for numerical stability.
Returns:
The normalized input data.
120def normalize_percentile( 121 raw: np.ndarray, 122 lower: float = 1.0, 123 upper: float = 99.0, 124 axis: Optional[Union[int, Tuple[int, ...]]] = None, 125 eps: float = 1e-7, 126) -> np.ndarray: 127 """Normalize the input data based on percentile values. 128 129 Args: 130 raw: The input data. 131 lower: The lower percentile. 132 upper: The upper percentile. 133 axis: The axis along which to compute the percentiles. 134 eps: The epsilon value for numerical stability. 135 136 Returns: 137 The normalized input data. 138 """ 139 v_lower = np.percentile(raw, lower, axis=axis, keepdims=True) 140 v_upper = np.percentile(raw, upper, axis=axis, keepdims=True) - v_lower 141 return normalize(raw, v_lower, v_upper, eps=eps)
Normalize the input data based on percentile values.
Arguments:
- raw: The input data.
- lower: The lower percentile.
- upper: The upper percentile.
- axis: The axis along which to compute the percentiles.
- eps: The epsilon value for numerical stability.
Returns:
The normalized input data.
144class RandomPercentileNormalization: 145 """Normalize inputs with randomly sampled percentile bounds. 146 147 By default, the lower and upper percentiles are sampled uniformly from 148 ``lower_percentile_bounds`` and ``upper_percentile_bounds``. If no upper bounds are given, 149 they are inferred by mirroring the lower bounds around 50. Normal (Gaussian) sampling can be 150 enabled explicitly with ``distribution="normal"`` and 151 ``distribution_kwargs={"mean": ..., "std": ...}``. The sampled percentile intensities are 152 mapped to 0 and 1, and values outside them are clipped, so the output is always in ``[0, 1]``. 153 154 Examples: 155 Uniform sampling with the default percentile bounds and reproducible random draws: 156 157 ```python 158 normalization = RandomPercentileNormalization(seed=42) 159 ``` 160 161 Normal sampling with explicit distribution parameters: 162 163 ```python 164 normalization = RandomPercentileNormalization( 165 distribution="normal", 166 distribution_kwargs={"mean": 2.0, "std": 1.0}, 167 seed=42, 168 ) 169 ``` 170 171 Args: 172 lower_percentile_bounds: Inclusive clipping bounds for the lower percentile. 173 upper_percentile_bounds: Inclusive clipping bounds for the upper percentile. If None, the 174 bounds are inferred by mirroring ``lower_percentile_bounds`` around 50. 175 distribution: Sampling distribution for the percentiles. Supported values are 176 ``"uniform"`` and ``"normal"``. 177 distribution_kwargs: Parameters for normal sampling, which must contain exactly 178 ``{"mean": ..., "std": ...}``. The upper percentile uses the mirrored normal 179 distribution. Uniform sampling does not take additional parameters. 180 rounding_decimals: Number of decimal places used to round sampled percentiles. Set to None 181 to disable rounding. 182 axis: Axes over which to compute the intensity percentiles. 183 seed: Optional non-negative integer seed for reproducible sampling. NumPy integer types 184 are also supported. Each DataLoader worker derives a distinct stream from this seed. 185 By default, the global NumPy random state is used, which respects DataLoader worker seeding. 186 eps: Epsilon used for numerical stability during normalization. 187 """ 188 189 def __init__( 190 self, 191 lower_percentile_bounds: Tuple[float, float] = (0.0, 5.0), 192 upper_percentile_bounds: Optional[Tuple[float, float]] = None, 193 distribution: str = "uniform", 194 distribution_kwargs: Optional[Dict[str, float]] = None, 195 rounding_decimals: Optional[int] = 1, 196 axis: Optional[Union[int, Tuple[int, ...]]] = None, 197 seed: Optional[int] = None, 198 eps: float = 1e-7, 199 ): 200 lower_percentile_bounds = self._validate_bounds(lower_percentile_bounds, upper=False) 201 if upper_percentile_bounds is None: 202 upper_percentile_bounds = tuple(100.0 - bound for bound in reversed(lower_percentile_bounds)) 203 upper_percentile_bounds = self._validate_bounds(upper_percentile_bounds, upper=True) 204 if distribution not in ("uniform", "normal"): 205 raise ValueError("distribution must be 'uniform' or 'normal'.") 206 207 if distribution == "uniform": 208 if distribution_kwargs is not None: 209 raise ValueError("Uniform sampling does not accept distribution_kwargs.") 210 else: 211 if not isinstance(distribution_kwargs, dict) or set(distribution_kwargs) != {"mean", "std"}: 212 raise ValueError("Normal sampling requires exactly the distribution_kwargs 'mean' and 'std'.") 213 mean, std = float(distribution_kwargs["mean"]), float(distribution_kwargs["std"]) 214 if not np.isfinite(mean) or not lower_percentile_bounds[0] <= mean <= lower_percentile_bounds[1]: 215 raise ValueError("The normal distribution mean must be finite and within lower_percentile_bounds.") 216 if not np.isfinite(std) or std < 0.0: 217 raise ValueError("The normal distribution std must be finite and non-negative.") 218 distribution_kwargs = {"mean": mean, "std": std} 219 220 if rounding_decimals is not None and ( 221 not isinstance(rounding_decimals, int) or isinstance(rounding_decimals, bool) or rounding_decimals < 0 222 ): 223 raise ValueError("rounding_decimals must be a non-negative integer or None.") 224 if not np.isfinite(eps) or eps <= 0.0: 225 raise ValueError("eps must be finite and greater than zero.") 226 if seed is not None: 227 if not isinstance(seed, (int, np.integer)) or isinstance(seed, bool): 228 raise TypeError("seed must be an integer or None.") 229 if seed < 0: 230 raise ValueError("seed must be non-negative.") 231 seed = int(seed) 232 233 self.lower_percentile_bounds = lower_percentile_bounds 234 self.upper_percentile_bounds = upper_percentile_bounds 235 self.distribution = distribution 236 self.distribution_kwargs = distribution_kwargs 237 self.rounding_decimals = rounding_decimals 238 self.axis = axis 239 self.seed = seed 240 self.eps = float(eps) 241 self._random_generator = None 242 self._random_generator_worker_id = None 243 244 @staticmethod 245 def _validate_bounds(values, upper): 246 name = "upper_percentile_bounds" if upper else "lower_percentile_bounds" 247 if not isinstance(values, (tuple, list)) or len(values) != 2: 248 raise ValueError(f"{name} must contain exactly two values.") 249 lower_bound, upper_bound = (float(value) for value in values) 250 finite = np.isfinite(lower_bound) and np.isfinite(upper_bound) 251 if upper: 252 valid = 50.0 < lower_bound <= upper_bound <= 100.0 253 interval = "(50, 100]" 254 else: 255 valid = 0.0 <= lower_bound <= upper_bound < 50.0 256 interval = "[0, 50)" 257 if not finite or not valid: 258 raise ValueError(f"{name} must be a finite interval within {interval}.") 259 return lower_bound, upper_bound 260 261 def _round(self, value): 262 return float(value) if self.rounding_decimals is None else round(float(value), self.rounding_decimals) 263 264 def _get_random_generator(self): 265 if self.seed is None: 266 return np.random 267 268 worker_info = torch.utils.data.get_worker_info() 269 worker_id = None if worker_info is None else worker_info.id 270 if self._random_generator is None or self._random_generator_worker_id != worker_id: 271 seed_sequence = np.random.SeedSequence([self.seed, 0 if worker_id is None else worker_id]) 272 self._random_generator = np.random.default_rng(seed_sequence) 273 self._random_generator_worker_id = worker_id 274 return self._random_generator 275 276 def sample_percentiles(self) -> Tuple[float, float]: 277 """Sample and return a valid ``(lower, upper)`` percentile pair.""" 278 random_generator = self._get_random_generator() 279 if self.distribution == "uniform": 280 lower = random_generator.uniform(*self.lower_percentile_bounds) 281 upper = random_generator.uniform(*self.upper_percentile_bounds) 282 else: 283 mean = self.distribution_kwargs["mean"] 284 std = self.distribution_kwargs["std"] 285 lower = mean if std == 0.0 else random_generator.normal(mean, std) 286 upper = 100.0 - (mean if std == 0.0 else random_generator.normal(mean, std)) 287 288 # Normal distribution tails may leave the configured percentile interval. 289 lower = float(np.clip(self._round(lower), *self.lower_percentile_bounds)) 290 upper = float(np.clip(self._round(upper), *self.upper_percentile_bounds)) 291 return lower, upper 292 293 def __call__(self, raw: Union[np.ndarray, torch.tensor]) -> Union[np.ndarray, torch.tensor]: 294 lower, upper = self.sample_percentiles() 295 normalized = normalize_percentile(raw, lower=lower, upper=upper, axis=self.axis, eps=self.eps) 296 if torch.is_tensor(normalized): 297 return torch.clamp(normalized, min=0.0, max=1.0) 298 return np.clip(normalized, 0.0, 1.0)
Normalize inputs with randomly sampled percentile bounds.
By default, the lower and upper percentiles are sampled uniformly from
lower_percentile_bounds and upper_percentile_bounds. If no upper bounds are given,
they are inferred by mirroring the lower bounds around 50. Normal (Gaussian) sampling can be
enabled explicitly with distribution="normal" and
distribution_kwargs={"mean": ..., "std": ...}. The sampled percentile intensities are
mapped to 0 and 1, and values outside them are clipped, so the output is always in [0, 1].
Examples:
Uniform sampling with the default percentile bounds and reproducible random draws:
normalization = RandomPercentileNormalization(seed=42)Normal sampling with explicit distribution parameters:
normalization = RandomPercentileNormalization( distribution="normal", distribution_kwargs={"mean": 2.0, "std": 1.0}, seed=42, )
Arguments:
- lower_percentile_bounds: Inclusive clipping bounds for the lower percentile.
- upper_percentile_bounds: Inclusive clipping bounds for the upper percentile. If None, the
bounds are inferred by mirroring
lower_percentile_boundsaround 50. - distribution: Sampling distribution for the percentiles. Supported values are
"uniform"and"normal". - distribution_kwargs: Parameters for normal sampling, which must contain exactly
{"mean": ..., "std": ...}. The upper percentile uses the mirrored normal distribution. Uniform sampling does not take additional parameters. - rounding_decimals: Number of decimal places used to round sampled percentiles. Set to None to disable rounding.
- axis: Axes over which to compute the intensity percentiles.
- seed: Optional non-negative integer seed for reproducible sampling. NumPy integer types are also supported. Each DataLoader worker derives a distinct stream from this seed. By default, the global NumPy random state is used, which respects DataLoader worker seeding.
- eps: Epsilon used for numerical stability during normalization.
189 def __init__( 190 self, 191 lower_percentile_bounds: Tuple[float, float] = (0.0, 5.0), 192 upper_percentile_bounds: Optional[Tuple[float, float]] = None, 193 distribution: str = "uniform", 194 distribution_kwargs: Optional[Dict[str, float]] = None, 195 rounding_decimals: Optional[int] = 1, 196 axis: Optional[Union[int, Tuple[int, ...]]] = None, 197 seed: Optional[int] = None, 198 eps: float = 1e-7, 199 ): 200 lower_percentile_bounds = self._validate_bounds(lower_percentile_bounds, upper=False) 201 if upper_percentile_bounds is None: 202 upper_percentile_bounds = tuple(100.0 - bound for bound in reversed(lower_percentile_bounds)) 203 upper_percentile_bounds = self._validate_bounds(upper_percentile_bounds, upper=True) 204 if distribution not in ("uniform", "normal"): 205 raise ValueError("distribution must be 'uniform' or 'normal'.") 206 207 if distribution == "uniform": 208 if distribution_kwargs is not None: 209 raise ValueError("Uniform sampling does not accept distribution_kwargs.") 210 else: 211 if not isinstance(distribution_kwargs, dict) or set(distribution_kwargs) != {"mean", "std"}: 212 raise ValueError("Normal sampling requires exactly the distribution_kwargs 'mean' and 'std'.") 213 mean, std = float(distribution_kwargs["mean"]), float(distribution_kwargs["std"]) 214 if not np.isfinite(mean) or not lower_percentile_bounds[0] <= mean <= lower_percentile_bounds[1]: 215 raise ValueError("The normal distribution mean must be finite and within lower_percentile_bounds.") 216 if not np.isfinite(std) or std < 0.0: 217 raise ValueError("The normal distribution std must be finite and non-negative.") 218 distribution_kwargs = {"mean": mean, "std": std} 219 220 if rounding_decimals is not None and ( 221 not isinstance(rounding_decimals, int) or isinstance(rounding_decimals, bool) or rounding_decimals < 0 222 ): 223 raise ValueError("rounding_decimals must be a non-negative integer or None.") 224 if not np.isfinite(eps) or eps <= 0.0: 225 raise ValueError("eps must be finite and greater than zero.") 226 if seed is not None: 227 if not isinstance(seed, (int, np.integer)) or isinstance(seed, bool): 228 raise TypeError("seed must be an integer or None.") 229 if seed < 0: 230 raise ValueError("seed must be non-negative.") 231 seed = int(seed) 232 233 self.lower_percentile_bounds = lower_percentile_bounds 234 self.upper_percentile_bounds = upper_percentile_bounds 235 self.distribution = distribution 236 self.distribution_kwargs = distribution_kwargs 237 self.rounding_decimals = rounding_decimals 238 self.axis = axis 239 self.seed = seed 240 self.eps = float(eps) 241 self._random_generator = None 242 self._random_generator_worker_id = None
276 def sample_percentiles(self) -> Tuple[float, float]: 277 """Sample and return a valid ``(lower, upper)`` percentile pair.""" 278 random_generator = self._get_random_generator() 279 if self.distribution == "uniform": 280 lower = random_generator.uniform(*self.lower_percentile_bounds) 281 upper = random_generator.uniform(*self.upper_percentile_bounds) 282 else: 283 mean = self.distribution_kwargs["mean"] 284 std = self.distribution_kwargs["std"] 285 lower = mean if std == 0.0 else random_generator.normal(mean, std) 286 upper = 100.0 - (mean if std == 0.0 else random_generator.normal(mean, std)) 287 288 # Normal distribution tails may leave the configured percentile interval. 289 lower = float(np.clip(self._round(lower), *self.lower_percentile_bounds)) 290 upper = float(np.clip(self._round(upper), *self.upper_percentile_bounds)) 291 return lower, upper
Sample and return a valid (lower, upper) percentile pair.
306class RandomContrast: 307 """Transformation to adjust contrast by scaling image to `mean + alpha * (image - mean)`. 308 309 Args: 310 alpha: Minimal and maximal alpha value for adjusting the contrast. 311 The value for the transformation will be drawn uniformly from the corresponding interval. 312 mean: Mean value for the image data. 313 clip_kwargs: Keyword arguments for clipping the data after the contrast augmentation. 314 """ 315 def __init__( 316 self, alpha: Tuple[float, float] = (0.5, 2), mean: float = 0.5, clip_kwargs: Dict = {"a_min": 0, "a_max": 1} 317 ): 318 self.alpha = alpha 319 self.mean = mean 320 self.clip_kwargs = clip_kwargs 321 322 def __call__(self, img: np.ndarray) -> np.ndarray: 323 """Apply the augmentation to data. 324 325 Args: 326 img: The input image. 327 328 Returns: 329 The transformed image. 330 """ 331 alpha = np.random.uniform(self.alpha[0], self.alpha[1]) 332 result = self.mean + alpha * (img - self.mean) 333 if self.clip_kwargs: 334 return np.clip(result, **self.clip_kwargs) 335 return result
Transformation to adjust contrast by scaling image to mean + alpha * (image - mean).
Arguments:
- alpha: Minimal and maximal alpha value for adjusting the contrast. The value for the transformation will be drawn uniformly from the corresponding interval.
- mean: Mean value for the image data.
- clip_kwargs: Keyword arguments for clipping the data after the contrast augmentation.
338class AdditiveGaussianNoise: 339 """Transformation to add random Gaussian noise to image. 340 341 Args: 342 scale: Scale for the noise. 343 clip_kwargs: Keyword arguments for clipping the data after the transformation. 344 """ 345 def __init__(self, scale: Tuple[float, float] = (0.0, 0.3), clip_kwargs: Dict = {"a_min": 0, "a_max": 1}): 346 self.scale = scale 347 self.clip_kwargs = clip_kwargs 348 349 def __call__(self, img: np.ndarray) -> np.ndarray: 350 """Apply the augmentation to data. 351 352 Args: 353 img: The input image. 354 355 Returns: 356 The transformed image. 357 """ 358 std = np.random.uniform(self.scale[0], self.scale[1]) 359 gaussian_noise = np.random.normal(0, std, size=img.shape) 360 361 if self.clip_kwargs: 362 return np.clip(img + gaussian_noise, 0, 1) 363 364 return img + gaussian_noise
Transformation to add random Gaussian noise to image.
Arguments:
- scale: Scale for the noise.
- clip_kwargs: Keyword arguments for clipping the data after the transformation.
367class AdditivePoissonNoise: 368 """Transformation to add random additive Poisson noise to image. 369 370 Args: 371 lam: Lambda value for the Poisson transformation. 372 clip_kwargs: Keyword arguments for clipping the data after the transformation. 373 """ 374 # Not sure if Poisson noise like this does make sense for data that is already normalized 375 def __init__(self, lam: Tuple[float, float] = (0.0, 0.1), clip_kwargs: Dict = {"a_min": 0, "a_max": 1}): 376 self.lam = lam 377 self.clip_kwargs = clip_kwargs 378 379 def __call__(self, img: np.ndarray) -> np.ndarray: 380 """Apply the augmentation to data. 381 382 Args: 383 img: The input image. 384 385 Returns: 386 The transformed image. 387 """ 388 lam = np.random.uniform(self.lam[0], self.lam[1]) 389 poisson_noise = np.random.poisson(lam, size=img.shape) / lam 390 if self.clip_kwargs: 391 return np.clip(img + poisson_noise, 0, 1) 392 return img + poisson_noise
Transformation to add random additive Poisson noise to image.
Arguments:
- lam: Lambda value for the Poisson transformation.
- clip_kwargs: Keyword arguments for clipping the data after the transformation.
395class PoissonNoise: 396 """Transformation to add random data-dependant Poisson noise to image. 397 398 Args: 399 multiplier: Multiplicative factors for deriving the lambda factor from the data. 400 The factor used for the transformation will be uniformly sampled form the range of this parameter. 401 clip_kwargs: Keyword arguments for clipping the data after the transformation. 402 """ 403 def __init__(self, multiplier: Tuple[float, float] = (5.0, 10.0), clip_kwargs: Dict = {"a_min": 0, "a_max": 1}): 404 self.multiplier = multiplier 405 self.clip_kwargs = clip_kwargs 406 407 def __call__(self, img: np.ndarray) -> np.ndarray: 408 """Apply the augmentation to data. 409 410 Args: 411 img: The input image. 412 413 Returns: 414 The transformed image. 415 """ 416 multiplier = np.random.uniform(self.multiplier[0], self.multiplier[1]) 417 offset = img.min() 418 poisson_noise = np.random.poisson((img - offset) * multiplier) 419 420 if isinstance(img, torch.Tensor): 421 poisson_noise = torch.Tensor(poisson_noise) 422 poisson_noise = poisson_noise / multiplier + offset 423 424 if self.clip_kwargs: 425 return np.clip(poisson_noise, **self.clip_kwargs) 426 return poisson_noise
Transformation to add random data-dependant Poisson noise to image.
Arguments:
- multiplier: Multiplicative factors for deriving the lambda factor from the data. The factor used for the transformation will be uniformly sampled form the range of this parameter.
- clip_kwargs: Keyword arguments for clipping the data after the transformation.
429class GaussianBlur: 430 """Transformation to blur the image with a randomly drawn sigma value. 431 432 Args: 433 sigma: The sigma value for the transformation. 434 The value used in the transformation will be uniformly drawn from the range specified here. 435 """ 436 def __init__(self, sigma: Tuple[float, float] = (0.0, 3.0)): 437 self.sigma = sigma 438 439 def __call__(self, img: np.ndarray) -> np.ndarray: 440 """Apply the augmentation to data. 441 442 Args: 443 img: The input image. 444 445 Returns: 446 The transformed image. 447 """ 448 # Sample the sigma value. Note that we switch the bounds to ensure zero is excluded from sampling. 449 sigma = np.random.uniform(self.sigma[1], self.sigma[0]) 450 # Determine the kernel size based on the sigma value. 451 kernel_size = int(2 * np.ceil(3 * sigma) + 1) 452 if isinstance(img, np.ndarray): 453 img = torch.from_numpy(img) 454 455 return transforms.GaussianBlur(kernel_size, sigma=sigma)(img)
Transformation to blur the image with a randomly drawn sigma value.
Arguments:
- sigma: The sigma value for the transformation. The value used in the transformation will be uniformly drawn from the range specified here.
462class RawTransform: 463 """The transformation for raw data during training. 464 465 Args: 466 normalizer: The normalization function. 467 augmentation1: Intensity augmentation applied before the normalization. 468 augmentation2: Intensity augmentation applied after the normalization. 469 """ 470 def __init__( 471 self, normalizer: Callable, augmentation1: Optional[Callable] = None, augmentation2: Optional[Callable] = None 472 ): 473 self.normalizer = normalizer 474 self.augmentation1 = augmentation1 475 self.augmentation2 = augmentation2 476 477 def __call__(self, raw: np.ndarray) -> np.ndarray: 478 """Apply the raw transformation. 479 480 Args: 481 raw: The raw data. 482 483 Returns: 484 The transformed raw data. 485 """ 486 if self.augmentation1 is not None: 487 raw = self.augmentation1(raw) 488 489 raw = self.normalizer(raw) 490 491 if self.augmentation2 is not None: 492 raw = self.augmentation2(raw) 493 return raw
The transformation for raw data during training.
Arguments:
- normalizer: The normalization function.
- augmentation1: Intensity augmentation applied before the normalization.
- augmentation2: Intensity augmentation applied after the normalization.
496def get_raw_transform( 497 normalizer: Callable = standardize, 498 augmentation1: Optional[Callable] = None, 499 augmentation2: Optional[Callable] = None 500) -> Callable: 501 """Get the raw transformation. 502 503 Args: 504 normalizer: The normalization function. 505 augmentation1: Intensity augmentation applied before the normalization. 506 augmentation2: Intensity augmentation applied after the normalization. 507 508 Returns: 509 The raw transformation. 510 """ 511 return RawTransform(normalizer, augmentation1=augmentation1, augmentation2=augmentation2)
Get the raw transformation.
Arguments:
- normalizer: The normalization function.
- augmentation1: Intensity augmentation applied before the normalization.
- augmentation2: Intensity augmentation applied after the normalization.
Returns:
The raw transformation.
514def get_default_mean_teacher_augmentations( 515 p: float = 0.3, 516 norm: Optional[Callable] = None, 517 blur_kwargs: Optional[Dict] = None, 518 poisson_kwargs: Optional[Dict] = None, 519 gaussian_kwargs: Optional[Dict] = None, 520) -> Callable: 521 """Get the default augmentations for mean teacher training. 522 523 The default values for the augmentations are designed for an image with pixel values in range [0, 1]. 524 By default, a normalization transformation is applied for this reason. 525 526 Args: 527 p: The probability for applying the individual intensity transformations. 528 norm: The noromaization function. 529 blur_kwargs: The keyword arguments for `GaussianBlur`. 530 poisson_kwargs: The keyword arguments for `PoissonNoise`. 531 gaussian_kwargs: The keyword arguments for `AdditiveGaussianNoise`. 532 533 Returns: 534 The raw transformation with augmentations. 535 """ 536 if norm is None: 537 norm = normalize 538 539 aug1 = transforms.Compose([ 540 norm, 541 transforms.RandomApply([GaussianBlur(**({} if blur_kwargs is None else blur_kwargs))], p=p), 542 transforms.RandomApply([PoissonNoise(**({} if poisson_kwargs is None else poisson_kwargs))], p=p/2), 543 transforms.RandomApply([AdditiveGaussianNoise(**({} if gaussian_kwargs is None else gaussian_kwargs))], p=p/2), 544 ]) 545 546 aug2 = transforms.RandomApply([RandomContrast(clip_kwargs={"a_min": 0, "a_max": 1})], p=p) 547 return get_raw_transform(normalizer=norm, augmentation1=aug1, augmentation2=aug2)
Get the default augmentations for mean teacher training.
The default values for the augmentations are designed for an image with pixel values in range [0, 1]. By default, a normalization transformation is applied for this reason.
Arguments:
- p: The probability for applying the individual intensity transformations.
- norm: The noromaization function.
- blur_kwargs: The keyword arguments for
GaussianBlur. - poisson_kwargs: The keyword arguments for
PoissonNoise. - gaussian_kwargs: The keyword arguments for
AdditiveGaussianNoise.
Returns:
The raw transformation with augmentations.