batchgenerators 0.25.3__tar.gz → 0.25.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/PKG-INFO +1 -1
  2. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/augmentations/spatial_transformations.py +16 -12
  3. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/augmentations/utils.py +212 -27
  4. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/transforms/spatial_transforms.py +22 -10
  5. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators.egg-info/PKG-INFO +1 -1
  6. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators.egg-info/SOURCES.txt +1 -0
  7. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/pyproject.toml +1 -1
  8. batchgenerators-0.25.5/tests/test_seg_resampling.py +282 -0
  9. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/LICENSE +0 -0
  10. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/README.md +0 -0
  11. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/__init__.py +0 -0
  12. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/augmentations/__init__.py +0 -0
  13. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/augmentations/color_augmentations.py +0 -0
  14. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/augmentations/crop_and_pad_augmentations.py +0 -0
  15. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/augmentations/noise_augmentations.py +0 -0
  16. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/augmentations/normalizations.py +0 -0
  17. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/augmentations/resample_augmentations.py +0 -0
  18. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/dataloading/__init__.py +0 -0
  19. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/dataloading/data_loader.py +0 -0
  20. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/dataloading/dataset.py +0 -0
  21. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/dataloading/multi_threaded_augmenter.py +0 -0
  22. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/dataloading/nondet_multi_threaded_augmenter.py +0 -0
  23. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/dataloading/single_threaded_augmenter.py +0 -0
  24. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/datasets/__init__.py +0 -0
  25. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/datasets/cifar.py +0 -0
  26. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/examples/__init__.py +0 -0
  27. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/examples/brats2017/__init__.py +0 -0
  28. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/examples/brats2017/brats2017_dataloader_2D.py +0 -0
  29. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/examples/brats2017/brats2017_dataloader_3D.py +0 -0
  30. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/examples/brats2017/brats2017_preprocessing.py +0 -0
  31. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/examples/brats2017/config.py +0 -0
  32. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/examples/cifar10.py +0 -0
  33. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/examples/multithreaded_dataloading.py +0 -0
  34. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/transforms/__init__.py +0 -0
  35. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/transforms/abstract_transforms.py +0 -0
  36. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/transforms/channel_selection_transforms.py +0 -0
  37. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/transforms/color_transforms.py +0 -0
  38. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/transforms/crop_and_pad_transforms.py +0 -0
  39. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/transforms/local_transforms.py +0 -0
  40. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/transforms/noise_transforms.py +0 -0
  41. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/transforms/resample_transforms.py +0 -0
  42. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/transforms/sample_normalization_transforms.py +0 -0
  43. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/transforms/utility_transforms.py +0 -0
  44. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/utilities/__init__.py +0 -0
  45. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/utilities/custom_types.py +0 -0
  46. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/utilities/data_splitting.py +0 -0
  47. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators/utilities/file_and_folder_operations.py +0 -0
  48. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators.egg-info/dependency_links.txt +0 -0
  49. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators.egg-info/requires.txt +0 -0
  50. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/batchgenerators.egg-info/top_level.txt +0 -0
  51. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/setup.cfg +0 -0
  52. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/setup.py +0 -0
  53. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/tests/test_DataLoader.py +0 -0
  54. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/tests/test_augment_zoom.py +0 -0
  55. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/tests/test_axis_mirroring.py +0 -0
  56. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/tests/test_color_augmentations.py +0 -0
  57. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/tests/test_crop.py +0 -0
  58. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/tests/test_multithreaded_augmenter.py +0 -0
  59. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/tests/test_normalizations.py +0 -0
  60. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/tests/test_random_crop.py +0 -0
  61. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/tests/test_resample_augmentations.py +0 -0
  62. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/tests/test_sanity.py +0 -0
  63. {batchgenerators-0.25.3 → batchgenerators-0.25.5}/tests/test_spatial_transformations.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchgenerators
3
- Version: 0.25.3
3
+ Version: 0.25.5
4
4
  Summary: Data augmentation toolkit
5
5
  Author-email: "Division of Medical Image Computing, German Cancer Research Center AND Applied Computer Vision Lab, Helmholtz Imaging Platform" <f.isensee@dkfz-heidelberg.de>
6
6
  License: Apache License
@@ -45,7 +45,7 @@ def augment_rot90(sample_data, sample_seg, num_rot=(1, 2, 3), axes=(0, 1, 2)):
45
45
  return sample_data, sample_seg
46
46
 
47
47
 
48
- def augment_resize(sample_data, sample_seg, target_size, order=3, order_seg=1):
48
+ def augment_resize(sample_data, sample_seg, target_size, order=3, order_seg=1, *, seg_tiebreak='nearest'):
49
49
  """
50
50
  Reshapes data (and seg) to target_size
51
51
  :param sample_data: np.ndarray or list/tuple of np.ndarrays, must be (c, x, y(, z))) (if list/tuple then each entry
@@ -71,14 +71,15 @@ def augment_resize(sample_data, sample_seg, target_size, order=3, order_seg=1):
71
71
  if sample_seg is not None:
72
72
  target_seg = np.ones([sample_seg.shape[0]] + target_size_here)
73
73
  for c in range(sample_seg.shape[0]):
74
- target_seg[c] = resize_segmentation(sample_seg[c], target_size_here, order_seg)
74
+ target_seg[c] = resize_segmentation(sample_seg[c], target_size_here, order_seg,
75
+ seg_tiebreak=seg_tiebreak)
75
76
  else:
76
77
  target_seg = None
77
78
 
78
79
  return sample_data, target_seg
79
80
 
80
81
 
81
- def augment_zoom(sample_data, sample_seg, zoom_factors, order=3, order_seg=1):
82
+ def augment_zoom(sample_data, sample_seg, zoom_factors, order=3, order_seg=1, *, seg_tiebreak='nearest'):
82
83
  """
83
84
  zooms data (and seg) by factor zoom_factors
84
85
  :param sample_data: np.ndarray or list/tuple of np.ndarrays, must be (c, x, y(, z))) (if list/tuple then each entry
@@ -107,7 +108,8 @@ def augment_zoom(sample_data, sample_seg, zoom_factors, order=3, order_seg=1):
107
108
  if sample_seg is not None:
108
109
  target_seg = np.ones([sample_seg.shape[0]] + target_shape_here)
109
110
  for c in range(sample_seg.shape[0]):
110
- target_seg[c] = resize_segmentation(sample_seg[c], target_shape_here, order_seg)
111
+ target_seg[c] = resize_segmentation(sample_seg[c], target_shape_here, order_seg,
112
+ seg_tiebreak=seg_tiebreak)
111
113
  else:
112
114
  target_seg = None
113
115
 
@@ -192,7 +194,8 @@ def augment_spatial(data, seg, patch_size, patch_center_dist_from_border=30,
192
194
  do_scale=True, scale=(0.75, 1.25), border_mode_data='nearest', border_cval_data=0, order_data=3,
193
195
  border_mode_seg='constant', border_cval_seg=0, order_seg=0, random_crop=True, p_el_per_sample=1,
194
196
  p_scale_per_sample=1, p_rot_per_sample=1, independent_scale_for_each_axis=False,
195
- p_rot_per_axis: float = 1, p_independent_scale_per_axis: int = 1):
197
+ p_rot_per_axis: float = 1, p_independent_scale_per_axis: int = 1,
198
+ *, seg_tiebreak: str = 'nearest'):
196
199
  dim = len(patch_size)
197
200
  seg_result = None
198
201
  if seg is not None:
@@ -277,7 +280,7 @@ def augment_spatial(data, seg, patch_size, patch_center_dist_from_border=30,
277
280
  for channel_id in range(seg.shape[1]):
278
281
  seg_result[sample_id, channel_id] = interpolate_img(seg[sample_id, channel_id], coords, order_seg,
279
282
  border_mode_seg, cval=border_cval_seg,
280
- is_seg=True)
283
+ is_seg=True, seg_tiebreak=seg_tiebreak)
281
284
  else:
282
285
  if seg is None:
283
286
  s = None
@@ -300,7 +303,8 @@ def augment_spatial_2(data, seg, patch_size, patch_center_dist_from_border=30,
300
303
  do_scale=True, scale=(0.75, 1.25), border_mode_data='nearest', border_cval_data=0, order_data=3,
301
304
  border_mode_seg='constant', border_cval_seg=0, order_seg=0, random_crop=True, p_el_per_sample=1,
302
305
  p_scale_per_sample=1, p_rot_per_sample=1, independent_scale_for_each_axis=False,
303
- p_rot_per_axis: float = 1, p_independent_scale_per_axis: float = 1):
306
+ p_rot_per_axis: float = 1, p_independent_scale_per_axis: float = 1,
307
+ *, seg_tiebreak: str = 'nearest'):
304
308
  """
305
309
 
306
310
  :param data:
@@ -444,7 +448,7 @@ def augment_spatial_2(data, seg, patch_size, patch_center_dist_from_border=30,
444
448
  for channel_id in range(seg.shape[1]):
445
449
  seg_result[sample_id, channel_id] = interpolate_img(seg[sample_id, channel_id], coords, order_seg,
446
450
  border_mode_seg, cval=border_cval_seg,
447
- is_seg=True)
451
+ is_seg=True, seg_tiebreak=seg_tiebreak)
448
452
  else:
449
453
  if seg is None:
450
454
  s = None
@@ -549,7 +553,7 @@ def augment_misalign(data, seg, data_size,
549
553
  do_transl=False,
550
554
  border_mode_data='constant', border_cval_data=0,
551
555
  border_mode_seg='constant', border_cval_seg=0,
552
- order_data=3, order_seg=0):
556
+ order_data=3, order_seg=0, *, seg_tiebreak='nearest'):
553
557
 
554
558
  dim = len(data_size)
555
559
 
@@ -587,7 +591,7 @@ def augment_misalign(data, seg, data_size,
587
591
  if channel_id in im_channels_2_misalign:
588
592
  seg[sample_id, channel_id] = interpolate_img(seg[sample_id, channel_id], coords, order_seg,
589
593
  border_mode_seg, cval=border_cval_seg,
590
- is_seg=True)
594
+ is_seg=True, seg_tiebreak=seg_tiebreak)
591
595
 
592
596
  if do_rotation and np.random.uniform() < p_rot_per_sample:
593
597
  coords = create_zero_centered_coordinate_mesh(data_size)
@@ -620,7 +624,7 @@ def augment_misalign(data, seg, data_size,
620
624
  if channel_id in im_channels_2_misalign:
621
625
  seg[sample_id, channel_id] = interpolate_img(seg[sample_id, channel_id], coords, order_seg,
622
626
  border_mode_seg, cval=border_cval_seg,
623
- is_seg=True)
627
+ is_seg=True, seg_tiebreak=seg_tiebreak)
624
628
 
625
629
  if do_transl and np.random.uniform() < p_transl_per_sample:
626
630
  coords = create_zero_centered_coordinate_mesh(data_size)
@@ -654,5 +658,5 @@ def augment_misalign(data, seg, data_size,
654
658
  if channel_id in label_channels_2_misalign:
655
659
  seg[sample_id, channel_id] = interpolate_img(seg[sample_id, channel_id], coords, order_seg,
656
660
  border_mode_seg, cval=border_cval_seg,
657
- is_seg=True)
661
+ is_seg=True, seg_tiebreak=seg_tiebreak)
658
662
  return data, seg
@@ -16,11 +16,15 @@
16
16
  import random
17
17
  import numpy as np
18
18
  from copy import deepcopy
19
- from scipy.ndimage import map_coordinates, fourier_gaussian
20
- from scipy.ndimage.filters import gaussian_filter, gaussian_gradient_magnitude
21
- from scipy.ndimage.morphology import grey_dilation
19
+ from scipy.ndimage import (
20
+ fourier_gaussian,
21
+ gaussian_filter,
22
+ gaussian_gradient_magnitude,
23
+ grey_dilation,
24
+ map_coordinates,
25
+ )
26
+ from scipy.ndimage import label as lb
22
27
  from skimage.transform import resize
23
- from scipy.ndimage.measurements import label as lb
24
28
  import pandas as pd
25
29
 
26
30
 
@@ -43,6 +47,24 @@ def create_zero_centered_coordinate_mesh(shape):
43
47
  return coords
44
48
 
45
49
 
50
+ def unique_labels(arr: np.ndarray) -> np.ndarray:
51
+ """
52
+ np.unique(arr): the distinct values, ascending.
53
+
54
+ Which implementation is cheaper depends on the dtype. pd.unique hashes where np.unique sorts, and
55
+ on a label map hashing wins for integers (1.3x at int8) but loses for floats (4.5x slower at
56
+ float32, 2.9x at float64). Segmentations travel through this library as float32 (augment_spatial
57
+ allocates seg_result as float32), so the float branch is the common one. Either way this is
58
+ milliseconds against one map_coordinates per label - consistency, not a speedup.
59
+
60
+ Order matters to the callers: it fixes the channel order of a one hot encoding, and
61
+ _resample_seg_by_argmax resolves a strict `>` chain to the first label attaining the maximum.
62
+ """
63
+ if arr.dtype.kind in 'iub':
64
+ return np.sort(pd.unique(arr.ravel()))
65
+ return np.unique(arr)
66
+
67
+
46
68
  def convert_seg_image_to_one_hot_encoding(image, classes=None):
47
69
  '''
48
70
  image must be either (x, y, z) or (x, y)
@@ -50,7 +72,7 @@ def convert_seg_image_to_one_hot_encoding(image, classes=None):
50
72
  Example (3D): if input is of shape (x, y, z), the output will ne of shape (n_classes, x, y, z)
51
73
  '''
52
74
  if classes is None:
53
- classes = np.unique(image)
75
+ classes = unique_labels(image)
54
76
  out_image = np.zeros([len(classes)]+list(image.shape), dtype=image.dtype)
55
77
  for i, c in enumerate(classes):
56
78
  out_image[i][image == c] = 1
@@ -62,7 +84,7 @@ def convert_seg_image_to_one_hot_encoding_batched(image, classes=None):
62
84
  same as convert_seg_image_to_one_hot_encoding, but expects image to be (b, x, y, z) or (b, x, y)
63
85
  '''
64
86
  if classes is None:
65
- classes = np.unique(image)
87
+ classes = unique_labels(image)
66
88
  output_shape = [image.shape[0]] + [len(classes)] + list(image.shape[1:])
67
89
  out_image = np.zeros(output_shape, dtype=image.dtype)
68
90
  for b in range(image.shape[0]):
@@ -142,14 +164,165 @@ def uncenter_coords(coords):
142
164
  return coords
143
165
 
144
166
 
145
- def interpolate_img(img, coords, order=3, mode='nearest', cval=0.0, is_seg=False):
167
+ SEG_TIEBREAKS = ('nearest', 'lowest', 'highest')
168
+
169
+
170
+ def _resample_seg_by_argmax(labels, score_fn, nearest_fn, out_shape, dtype, seg_tiebreak,
171
+ empty_value=None):
172
+ """
173
+ Resample a segmentation by interpolating each label's indicator and taking the per-voxel argmax over
174
+ labels. The argmax is accumulated incrementally, so the (n_labels, *out_shape) score stack is never
175
+ materialized.
176
+
177
+ This replaces the `interpolated >= 0.5` assignment these functions used to do, which had two problems:
178
+
179
+ * it can leave a voxel unwritten. Where more than two labels meet, no single indicator has to reach
180
+ 0.5 (four labels meeting at a sample point give 0.25 each), and for spline orders >= 2 skimage's
181
+ clip=True destroys the sum-to-one property outright - on random four-label 2d data at order=3,
182
+ 6.2 % of output voxels had no label at 0.5 or above. Those voxels kept whatever the result array
183
+ was initialized with (0), which need not be a label that occurs in the input at all.
184
+ * where several labels do pass 0.5 it assigned in ascending label order, so the largest label won by
185
+ being written last. That is a decision about label values, not about geometry.
186
+
187
+ seg_tiebreak decides the voxels where the top score is shared. That is not a rare case: whenever a
188
+ sample point falls exactly between two labels - which an even integer resize factor does at every
189
+ boundary voxel - the two indicators are exactly 0.5.
190
+
191
+ 'nearest' take the nearest neighbour label, if it is one of the tied labels. Symmetric in the labels,
192
+ and the only option that is a function of the geometry rather than of the label values.
193
+ Where three or more labels meet the nearest neighbour can be a label that lost; such a
194
+ voxel keeps the smallest of the tied labels, as with 'lowest'. Matches nnU-Net's
195
+ resample_torch seg_tiebreak='nearest'.
196
+ 'lowest' keep the smallest label, i.e. plain argmax semantics.
197
+ 'highest' keep the largest label. This is the direction the old code leaned, but it is NOT
198
+ bit-identical to it: the old code compared each label against 0.5, not against the other
199
+ labels, so at orders >= 2 the two disagree wherever clipping broke sum-to-one.
200
+
201
+ Only exact ties are resolved by seg_tiebreak; near ties go to the larger score, as they should.
202
+
203
+ empty_value, if given, is written where every label scores zero. That happens only outside the image,
204
+ and only for padding modes that put something constant there, so this is how the caller's cval keeps
205
+ reaching those voxels now that the result is no longer zero-initialized.
206
+ """
207
+ if seg_tiebreak not in SEG_TIEBREAKS:
208
+ raise ValueError('unknown seg_tiebreak: %s. Must be one of %s' % (seg_tiebreak, str(SEG_TIEBREAKS)))
209
+ out_shape = tuple(out_shape)
210
+ if len(labels) == 0:
211
+ # an empty input: there is nothing to sample, so every output voxel is outside the image. Returning here
212
+ # also keeps the nearest neighbour sample below from being asked of an empty array, which fails.
213
+ return np.full(out_shape, 0 if empty_value is None else empty_value, dtype=dtype)
214
+ if len(labels) == 1 and empty_value is None:
215
+ # nothing to interpolate, and this also keeps the loop below from having to handle a missing `best`
216
+ return np.full(out_shape, labels[0], dtype=dtype)
217
+
218
+ # Memory: the scores are float64 (that is what the interpolators return, and ties are decided by exact
219
+ # equality on them), so `best` and the current `score` are the two large buffers and everything else is
220
+ # kept small. The running winner and the nearest neighbour are held as int8/int16 indices into `labels`
221
+ # and mapped to label values once at the end, the per-label masks are three reused bool buffers, and the
222
+ # previous score is released before the next one is allocated. Without that, peak memory was about twice
223
+ # the bytes per output voxel.
224
+ idx_dtype = _label_index_dtype(len(labels))
225
+ win = np.zeros(out_shape, dtype=idx_dtype)
226
+ best = None
227
+ better = np.empty(out_shape, dtype=bool)
228
+ # 'nearest': the nearest neighbour label settles a tie only if it is one of the tied labels. Where three
229
+ # or more labels meet it need not be - a 2x2x2 block [3,1,1,1,2,2,2,3] sampled at its centre scores
230
+ # 1 and 2 at 0.375 each and 3 at 0.25, and its nearest neighbour is a 3. So instead of marking tied
231
+ # voxels, track whether the nearest neighbour's label currently has the top score: a label that takes
232
+ # the lead strictly decides that afresh, one that draws level can only add to it. Where it holds at the
233
+ # end the nearest neighbour is the answer (it changes nothing where its label won outright), and where
234
+ # it does not the argmax winner stands.
235
+ nearest = seg_tiebreak == 'nearest'
236
+ if nearest:
237
+ nn_idx = _nearest_label_index(labels, nearest_fn(), idx_dtype)
238
+ nn_top = np.empty(out_shape, dtype=bool)
239
+ is_nn = np.empty(out_shape, dtype=bool)
240
+ level = np.empty(out_shape, dtype=bool)
241
+ for i, c in enumerate(labels):
242
+ score = score_fn(c)
243
+ if best is None:
244
+ # the first label is the running winner everywhere until something beats it. Inside the image the
245
+ # indicators sum to ~1, so this is only ever a starting point; outside it every label scores zero
246
+ # and empty_value (if the caller gave one) has the final say below.
247
+ best = score
248
+ if nearest:
249
+ np.equal(nn_idx, 0, out=nn_top)
250
+ np.greater(score, 0, out=level)
251
+ nn_top &= level
252
+ del score
253
+ continue
254
+ if nearest:
255
+ np.equal(nn_idx, i, out=is_nn)
256
+ # `score > 0` guard: two labels that both score zero at a voxel are not tied for the win there,
257
+ # they are simply both absent, and some other label will claim it. `better` is free until it is
258
+ # computed below, so it holds that guard meanwhile.
259
+ np.equal(score, best, out=level)
260
+ np.greater(score, 0, out=better)
261
+ level &= better
262
+ level &= is_nn
263
+ nn_top |= level
264
+ # '>=' lets the later (larger) label take ties, '>' leaves them with the earlier (smaller) one
265
+ (np.greater_equal if seg_tiebreak == 'highest' else np.greater)(score, best, out=better)
266
+ if nearest:
267
+ np.copyto(nn_top, is_nn, where=better)
268
+ np.copyto(win, i, where=better)
269
+ np.maximum(best, score, out=best)
270
+ del score
271
+
272
+ if nearest:
273
+ # copyto rather than win[nn_top] = nn_idx[nn_top]: nn_top holds nearly everywhere (also wherever the
274
+ # nearest neighbour's label won outright), and the indexed form copies all of those out first
275
+ np.copyto(win, nn_idx, where=nn_top)
276
+ del nn_idx, nn_top, is_nn, level
277
+ del better
278
+ empty = best <= 0 if empty_value is not None else None
279
+ del best
280
+ result = labels.astype(dtype, copy=False)[win]
281
+ if empty is not None:
282
+ result[empty] = empty_value
283
+ return result
284
+
285
+
286
+ def _label_index_dtype(n_labels):
287
+ # signed, so that -1 can mean 'not a label'
288
+ for t in (np.int8, np.int16, np.int32):
289
+ if n_labels <= np.iinfo(t).max:
290
+ return t
291
+ return np.int64
292
+
293
+
294
+ def _nearest_label_index(labels, nn, idx_dtype, chunk=1 << 22):
295
+ """
296
+ The index into `labels` of each voxel of the nearest neighbour sample `nn` (float64), or -1 where it is not
297
+ a label (cval outside the image). `labels` is sorted, so this is a searchsorted, verified with the same
298
+ `==` that compared `nn` with each label before. Chunked, so that its int64 temporaries stay small.
299
+ """
300
+ out = np.empty(nn.shape, dtype=idx_dtype)
301
+ flat_nn, flat_out = nn.reshape(-1), out.reshape(-1)
302
+ last = len(labels) - 1
303
+ for s in range(0, flat_nn.size, chunk):
304
+ v = flat_nn[s:s + chunk]
305
+ idx = np.minimum(np.searchsorted(labels, v), last)
306
+ idx[labels[idx] != v] = -1
307
+ flat_out[s:s + chunk] = idx
308
+ return out
309
+
310
+
311
+ def interpolate_img(img, coords, order=3, mode='nearest', cval=0.0, is_seg=False, *, seg_tiebreak='nearest'):
146
312
  if is_seg and order != 0:
147
- unique_labels = np.unique(img)
148
- result = np.zeros(coords.shape[1:], img.dtype)
149
- for i, c in enumerate(unique_labels):
150
- res_new = map_coordinates((img == c).astype(float), coords, order=order, mode=mode, cval=cval)
151
- result[res_new >= 0.5] = c
152
- return result
313
+ return _resample_seg_by_argmax(
314
+ unique_labels(img),
315
+ # the indicators are padded with 0, not cval: outside the image no label is present. Padding them
316
+ # with cval raised every label's score by the same amount there, so for cval > 0 a voxel entirely
317
+ # outside was a tie among all labels rather than empty, and empty_value never reached it.
318
+ lambda c: map_coordinates((img == c).astype(float), coords, order=order, mode=mode, cval=0.0),
319
+ lambda: map_coordinates(img.astype(float), coords, order=0, mode=mode, cval=cval),
320
+ coords.shape[1:], img.dtype, seg_tiebreak,
321
+ # outside the image the caller asked for cval, and with the old zero-initialized result that is
322
+ # what those voxels happened to get whenever cval was 0. Keep it, explicitly and for any cval.
323
+ # Converted the way order 0 converts it, so a cval the dtype cannot hold (-1 in uint8) comes out
324
+ # the same at every order instead of raising here.
325
+ empty_value=np.array(cval, dtype=float).astype(img.dtype) if mode == 'constant' else None)
153
326
  else:
154
327
  return map_coordinates(img.astype(float), coords, order=order, mode=mode, cval=cval).astype(img.dtype)
155
328
 
@@ -581,29 +754,34 @@ def transpose_channels(batch):
581
754
  raise ValueError("wrong dimensions in transpose_channel generator!")
582
755
 
583
756
 
584
- def resize_segmentation(segmentation, new_shape, order=3):
757
+ def resize_segmentation(segmentation, new_shape, order=3, *, seg_tiebreak='nearest'):
585
758
  '''
586
- Resizes a segmentation map. Supports all orders (see skimage documentation). Will transform segmentation map to one
587
- hot encoding which is resized and transformed back to a segmentation map.
588
- This prevents interpolation artifacts ([0, 0, 2] -> [0, 1, 2])
759
+ Resizes a segmentation map. Supports all orders (see skimage documentation). Each label's indicator is
760
+ resized on its own and the per-voxel winner is the label with the largest interpolated indicator, so no
761
+ intermediate label value can be produced ([0, 0, 2] -> [0, 1, 2]).
589
762
  :param segmentation:
590
763
  :param new_shape:
591
764
  :param order:
765
+ :param seg_tiebreak: how to settle voxels where two labels share the top score ('nearest', 'lowest' or
766
+ 'highest'). See _resample_seg_by_argmax. 'nearest' is the only geometric choice and is the default.
592
767
  :return:
593
768
  '''
594
769
  tpe = segmentation.dtype
595
770
  assert len(segmentation.shape) == len(new_shape), "new shape must have same dimensionality as segmentation"
771
+ if segmentation.size == 0:
772
+ # skimage cannot resize an empty array (scipy's zoom divides 0 by 0), at any order. Zeros of new_shape is
773
+ # what orders >= 1 returned before the argmax, from their zero-initialized result.
774
+ return np.zeros(new_shape, dtype=tpe)
596
775
  if order == 0:
597
776
  return resize(segmentation.astype(float), new_shape, order, mode="edge", clip=True, anti_aliasing=False).astype(tpe)
598
777
  else:
599
- reshaped = np.zeros(new_shape, dtype=segmentation.dtype)
600
-
601
- unique_labels = np.sort(pd.unique(segmentation.ravel()))
602
- for i, c in enumerate(unique_labels):
603
- mask = segmentation == c
604
- reshaped_multihot = resize(mask.astype(float), new_shape, order, mode="edge", clip=True, anti_aliasing=False)
605
- reshaped[reshaped_multihot >= 0.5] = c
606
- return reshaped
778
+ return _resample_seg_by_argmax(
779
+ unique_labels(segmentation),
780
+ lambda c: resize((segmentation == c).astype(float), new_shape, order, mode="edge", clip=True,
781
+ anti_aliasing=False),
782
+ lambda: resize(segmentation.astype(float), new_shape, 0, mode="edge", clip=True,
783
+ anti_aliasing=False),
784
+ new_shape, tpe, seg_tiebreak)
607
785
 
608
786
 
609
787
  def resize_multichannel_image(multichannel_image, new_shape, order=3):
@@ -616,11 +794,18 @@ def resize_multichannel_image(multichannel_image, new_shape, order=3):
616
794
  :return:
617
795
  '''
618
796
  tpe = multichannel_image.dtype
797
+ is_int = np.issubdtype(tpe, np.integer)
619
798
  new_shp = [multichannel_image.shape[0]] + list(new_shape)
620
- result = np.zeros(new_shp, dtype=multichannel_image.dtype)
799
+ # Integer input is accumulated in float and rounded at the end. Writing the interpolated float
800
+ # straight into an integer array truncates towards zero, which shifts every voxel that did not land
801
+ # exactly on a sample point down by up to one intensity step. Floating point input keeps its own
802
+ # dtype, so nothing about that path changes and the buffer does not grow.
803
+ result = np.zeros(new_shp, dtype=float if is_int else tpe)
621
804
  for i in range(multichannel_image.shape[0]):
622
805
  result[i] = resize(multichannel_image[i].astype(float), new_shape, order, clip=True, anti_aliasing=False)
623
- return result.astype(tpe)
806
+ if is_int:
807
+ np.rint(result, out=result)
808
+ return result.astype(tpe, copy=False)
624
809
 
625
810
 
626
811
  def get_range_val(value, rnd_type="uniform"):
@@ -61,7 +61,7 @@ class Rot90Transform(AbstractTransform):
61
61
 
62
62
  class ZoomTransform(AbstractTransform):
63
63
  def __init__(self, zoom_factors=1, order=3, order_seg=1, concatenate_list=False, data_key="data",
64
- label_key="seg"):
64
+ label_key="seg", *, seg_tiebreak='nearest'):
65
65
  """
66
66
  Zooms 'data' (and 'seg') by zoom_factors
67
67
  :param zoom_factors: int or list/tuple of int
@@ -81,6 +81,7 @@ class ZoomTransform(AbstractTransform):
81
81
  self.data_key = data_key
82
82
  self.label_key = label_key
83
83
  self.order = order
84
+ self.seg_tiebreak = seg_tiebreak
84
85
  self.zoom_factors = zoom_factors
85
86
 
86
87
  def __call__(self, **data_dict):
@@ -105,7 +106,8 @@ class ZoomTransform(AbstractTransform):
105
106
  sample_seg = None
106
107
  if seg is not None:
107
108
  sample_seg = seg[b]
108
- res_data, res_seg = augment_zoom(data[b], sample_seg, self.zoom_factors, self.order, self.order_seg)
109
+ res_data, res_seg = augment_zoom(data[b], sample_seg, self.zoom_factors, self.order, self.order_seg,
110
+ seg_tiebreak=self.seg_tiebreak)
109
111
  results.append((res_data, res_seg))
110
112
 
111
113
  if concatenate:
@@ -123,7 +125,7 @@ class ZoomTransform(AbstractTransform):
123
125
  class ResizeTransform(AbstractTransform):
124
126
 
125
127
  def __init__(self, target_size, order=3, order_seg=1, concatenate_list=False, data_key="data",
126
- label_key="seg"):
128
+ label_key="seg", *, seg_tiebreak='nearest'):
127
129
  """
128
130
  Reshapes 'data' (and 'seg') to target_size
129
131
  :param target_size: int or list/tuple of int
@@ -143,6 +145,7 @@ class ResizeTransform(AbstractTransform):
143
145
  self.data_key = data_key
144
146
  self.label_key = label_key
145
147
  self.order = order
148
+ self.seg_tiebreak = seg_tiebreak
146
149
  self.target_size = target_size
147
150
 
148
151
  def __call__(self, **data_dict):
@@ -167,7 +170,8 @@ class ResizeTransform(AbstractTransform):
167
170
  sample_seg = None
168
171
  if seg is not None:
169
172
  sample_seg = seg[b]
170
- res_data, res_seg = augment_resize(data[b], sample_seg, self.target_size, self.order, self.order_seg)
173
+ res_data, res_seg = augment_resize(data[b], sample_seg, self.target_size, self.order, self.order_seg,
174
+ seg_tiebreak=self.seg_tiebreak)
171
175
  results.append((res_data, res_seg))
172
176
 
173
177
  if concatenate:
@@ -303,7 +307,8 @@ class SpatialTransform(AbstractTransform):
303
307
  do_scale=True, scale=(0.75, 1.25), border_mode_data='nearest', border_cval_data=0, order_data=3,
304
308
  border_mode_seg='constant', border_cval_seg=0, order_seg=0, random_crop=True, data_key="data",
305
309
  label_key="seg", p_el_per_sample=1, p_scale_per_sample=1, p_rot_per_sample=1,
306
- independent_scale_for_each_axis=False, p_rot_per_axis:float=1, p_independent_scale_per_axis: int=1):
310
+ independent_scale_for_each_axis=False, p_rot_per_axis:float=1, p_independent_scale_per_axis: int=1,
311
+ *, seg_tiebreak: str='nearest'):
307
312
  self.independent_scale_for_each_axis = independent_scale_for_each_axis
308
313
  self.p_rot_per_sample = p_rot_per_sample
309
314
  self.p_scale_per_sample = p_scale_per_sample
@@ -330,6 +335,7 @@ class SpatialTransform(AbstractTransform):
330
335
  self.random_crop = random_crop
331
336
  self.p_rot_per_axis = p_rot_per_axis
332
337
  self.p_independent_scale_per_axis = p_independent_scale_per_axis
338
+ self.seg_tiebreak = seg_tiebreak
333
339
 
334
340
  def __call__(self, **data_dict):
335
341
  data = data_dict.get(self.data_key)
@@ -358,7 +364,8 @@ class SpatialTransform(AbstractTransform):
358
364
  p_rot_per_sample=self.p_rot_per_sample,
359
365
  independent_scale_for_each_axis=self.independent_scale_for_each_axis,
360
366
  p_rot_per_axis=self.p_rot_per_axis,
361
- p_independent_scale_per_axis=self.p_independent_scale_per_axis)
367
+ p_independent_scale_per_axis=self.p_independent_scale_per_axis,
368
+ seg_tiebreak=self.seg_tiebreak)
362
369
  data_dict[self.data_key] = ret_val[0]
363
370
  if seg is not None:
364
371
  data_dict[self.label_key] = ret_val[1]
@@ -419,7 +426,8 @@ class SpatialTransform_2(AbstractTransform):
419
426
  do_scale=True, scale=(0.75, 1.25), border_mode_data='nearest', border_cval_data=0, order_data=3,
420
427
  border_mode_seg='constant', border_cval_seg=0, order_seg=0, random_crop=True, data_key="data",
421
428
  label_key="seg", p_el_per_sample=1, p_scale_per_sample=1, p_rot_per_sample=1,
422
- independent_scale_for_each_axis=False, p_rot_per_axis:float=1, p_independent_scale_per_axis: float=1):
429
+ independent_scale_for_each_axis=False, p_rot_per_axis:float=1, p_independent_scale_per_axis: float=1,
430
+ *, seg_tiebreak: str='nearest'):
423
431
  self.p_rot_per_sample = p_rot_per_sample
424
432
  self.p_scale_per_sample = p_scale_per_sample
425
433
  self.p_el_per_sample = p_el_per_sample
@@ -445,6 +453,7 @@ class SpatialTransform_2(AbstractTransform):
445
453
  self.p_independent_scale_per_axis = p_independent_scale_per_axis
446
454
  self.independent_scale_for_each_axis = independent_scale_for_each_axis
447
455
  self.p_rot_per_axis = p_rot_per_axis
456
+ self.seg_tiebreak = seg_tiebreak
448
457
 
449
458
  def __call__(self, **data_dict):
450
459
  data = data_dict.get(self.data_key)
@@ -473,7 +482,8 @@ class SpatialTransform_2(AbstractTransform):
473
482
  p_rot_per_sample=self.p_rot_per_sample,
474
483
  independent_scale_for_each_axis=self.independent_scale_for_each_axis,
475
484
  p_rot_per_axis=self.p_rot_per_axis,
476
- p_independent_scale_per_axis=self.p_independent_scale_per_axis)
485
+ p_independent_scale_per_axis=self.p_independent_scale_per_axis,
486
+ seg_tiebreak=self.seg_tiebreak)
477
487
 
478
488
  data_dict[self.data_key] = ret_val[0]
479
489
  if seg is not None:
@@ -633,8 +643,9 @@ class MisalignTransform(AbstractTransform):
633
643
  p_transl_per_sample=0.1, p_transl_per_dir=1.0,
634
644
  border_mode_data='constant', border_cval_data=0,
635
645
  border_mode_seg='constant', border_cval_seg=0,
636
- order_data=3, order_seg=0):
646
+ order_data=3, order_seg=0, *, seg_tiebreak='nearest'):
637
647
 
648
+ self.seg_tiebreak = seg_tiebreak
638
649
  self.data_key = data_key
639
650
  self.label_key = label_key
640
651
 
@@ -709,7 +720,8 @@ class MisalignTransform(AbstractTransform):
709
720
  border_cval_data=self.border_cval_data,
710
721
  order_seg=self.order_seg,
711
722
  border_mode_seg=self.border_mode_seg,
712
- border_cval_seg=self.border_cval_seg)
723
+ border_cval_seg=self.border_cval_seg,
724
+ seg_tiebreak=self.seg_tiebreak)
713
725
 
714
726
  data_dict[self.data_key] = ret_val[0]
715
727
  if seg is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchgenerators
3
- Version: 0.25.3
3
+ Version: 0.25.5
4
4
  Summary: Data augmentation toolkit
5
5
  Author-email: "Division of Medical Image Computing, German Cancer Research Center AND Applied Computer Vision Lab, Helmholtz Imaging Platform" <f.isensee@dkfz-heidelberg.de>
6
6
  License: Apache License
@@ -58,4 +58,5 @@ tests/test_normalizations.py
58
58
  tests/test_random_crop.py
59
59
  tests/test_resample_augmentations.py
60
60
  tests/test_sanity.py
61
+ tests/test_seg_resampling.py
61
62
  tests/test_spatial_transformations.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "batchgenerators"
7
- version = "0.25.3"
7
+ version = "0.25.5"
8
8
  description = "Data augmentation toolkit"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -0,0 +1,282 @@
1
+ import unittest
2
+
3
+ import numpy as np
4
+ from skimage.transform import resize
5
+
6
+ from batchgenerators.augmentations.utils import (interpolate_img, resize_multichannel_image,
7
+ resize_segmentation)
8
+
9
+
10
+ def nearest(seg, new_shape):
11
+ return resize(seg.astype(float), new_shape, 0, mode="edge", clip=True, anti_aliasing=False).astype(seg.dtype)
12
+
13
+
14
+ def label_scores(seg, new_shape, order):
15
+ labels = np.sort(np.unique(seg))
16
+ return labels, np.stack([resize((seg == c).astype(float), new_shape, order, mode="edge", clip=True,
17
+ anti_aliasing=False) for c in labels])
18
+
19
+
20
+ class TestResizeSegmentation(unittest.TestCase):
21
+ """
22
+ resize_segmentation and interpolate_img resize a segmentation by interpolating each label's indicator and
23
+ picking a winner per voxel. They used to assign with `interpolated >= 0.5` into a zero-initialized result,
24
+ which could leave a voxel unwritten (it then kept a 0 that need not be a label of the input) and, where
25
+ several labels passed 0.5, handed the voxel to whichever label was written last, i.e. the largest.
26
+ """
27
+
28
+ def test_no_label_is_invented(self):
29
+ """Four labels meeting at an output centre score 0.25 each, so the old rule wrote nothing there."""
30
+ seg = np.array([[1, 2, 1, 1],
31
+ [3, 4, 1, 1],
32
+ [1, 1, 1, 1],
33
+ [1, 1, 1, 1]], dtype=np.uint8)
34
+ labels, scores = label_scores(seg, (2, 2), 1)
35
+ self.assertLess(scores[:, 0, 0].max(), 0.5, 'this test is only meaningful if no label reaches 0.5')
36
+ for tiebreak in ('nearest', 'lowest', 'highest'):
37
+ out = resize_segmentation(seg, (2, 2), order=1, seg_tiebreak=tiebreak)
38
+ self.assertTrue(set(np.unique(out).tolist()) <= set(np.unique(seg).tolist()),
39
+ f'{np.unique(out)} contains a label that is not in the input ({tiebreak})')
40
+
41
+ def test_no_label_is_invented_randomized(self):
42
+ """At order 3 skimage's clip=True breaks sum-to-one outright, so the old rule missed whole voxels."""
43
+ rng = np.random.RandomState(3)
44
+ missed_by_the_old_rule = 0
45
+ for _ in range(60):
46
+ shape = tuple(rng.randint(6, 14) for _ in range(2))
47
+ seg = rng.randint(1, 5, shape).astype(np.uint8) # labels 1..4, the input never contains 0
48
+ new_shape = tuple(max(int(round(s / f)), 2)
49
+ for s, f in zip(shape, rng.choice([1.5, 2., 2.5, 3.], 2)))
50
+ labels, scores = label_scores(seg, new_shape, 3)
51
+ missed_by_the_old_rule += int(((scores >= 0.5).sum(0) == 0).sum())
52
+ for tiebreak in ('nearest', 'lowest', 'highest'):
53
+ out = resize_segmentation(seg, new_shape, order=3, seg_tiebreak=tiebreak)
54
+ self.assertTrue(np.isin(out, labels).all(),
55
+ f'{np.unique(out)} vs input labels {labels} ({tiebreak})')
56
+ self.assertGreater(missed_by_the_old_rule, 0, 'the old rule should have left voxels unwritten here')
57
+
58
+ def test_nearest_tiebreak_is_symmetric_in_the_label_values(self):
59
+ """
60
+ An even integer factor puts an output centre exactly between two labels at every boundary voxel, and
61
+ an exact tie carries no information about which label belongs there. 'nearest' answers it from the
62
+ geometry, so relabelling moves the same decision with the labels; 'lowest' and 'highest' answer it
63
+ from the label values, so they do not.
64
+ """
65
+ for lo, hi in ((1, 2), (2, 7), (3, 4)):
66
+ forward = np.full((8, 2), lo, dtype=np.uint8)
67
+ forward[5:] = hi # boundary lands on the output centre x = 4.5
68
+ swapped = np.full((8, 2), hi, dtype=np.uint8)
69
+ swapped[5:] = lo
70
+ remap = {lo: hi, hi: lo}
71
+
72
+ a = resize_segmentation(forward, (4, 2), order=1, seg_tiebreak='nearest')
73
+ b = resize_segmentation(swapped, (4, 2), order=1, seg_tiebreak='nearest')
74
+ self.assertTrue(np.array_equal(np.vectorize(remap.get)(a), b),
75
+ f"'nearest' is not symmetric for ({lo}, {hi}): {a[:, 0]} vs {b[:, 0]}")
76
+
77
+ # the tie is at output index 2; 'lowest' and 'highest' take it by label value
78
+ self.assertEqual(resize_segmentation(forward, (4, 2), 1, seg_tiebreak='lowest')[2, 0], lo)
79
+ self.assertEqual(resize_segmentation(forward, (4, 2), 1, seg_tiebreak='highest')[2, 0], hi)
80
+
81
+ def test_nearest_tiebreak_matches_a_nearest_neighbour_resize(self):
82
+ seg = np.full((8, 2), 1, dtype=np.uint8)
83
+ seg[5:] = 2
84
+ self.assertTrue(np.array_equal(resize_segmentation(seg, (4, 2), order=1, seg_tiebreak='nearest'),
85
+ nearest(seg, (4, 2))))
86
+
87
+ def test_nearest_tiebreak_only_picks_among_the_tied_labels(self):
88
+ """
89
+ Where three or more labels meet, the nearest neighbour can be a label that lost: halving this 2x2x2
90
+ block scores 1 and 2 at 0.375 each and 3 at 0.25, and the nearest neighbour of the centre is a 3.
91
+ """
92
+ block = np.array([3, 1, 1, 1, 2, 2, 2, 3], dtype=np.int16).reshape(2, 2, 2)
93
+ seg = np.tile(block, (4, 4, 4))
94
+ labels, scores = label_scores(seg, (4, 4, 4), 1)
95
+ self.assertTrue(np.allclose(scores[:, 0, 0, 0], [0.375, 0.375, 0.25]))
96
+ self.assertEqual(int(nearest(seg, (4, 4, 4))[0, 0, 0]), 3, 'the nearest neighbour has to be the loser')
97
+ for tiebreak, expected in (('nearest', 1), ('lowest', 1), ('highest', 2)):
98
+ out = resize_segmentation(seg, (4, 4, 4), order=1, seg_tiebreak=tiebreak)
99
+ self.assertTrue((out == expected).all(), f'{np.unique(out)} ({tiebreak})')
100
+
101
+ def test_winner_has_the_top_score_randomized(self):
102
+ """whatever settles a tie, the label a voxel gets must be one that attains the maximum score there"""
103
+ rng = np.random.RandomState(4)
104
+ for _ in range(40):
105
+ shape = tuple(rng.randint(4, 9) for _ in range(3))
106
+ seg = rng.randint(0, rng.randint(2, 6), shape).astype(np.int16)
107
+ new_shape = tuple(max(s // 2, 1) for s in shape)
108
+ labels, scores = label_scores(seg, new_shape, 1)
109
+ top = scores.max(0)
110
+ for tiebreak in ('nearest', 'lowest', 'highest'):
111
+ out = resize_segmentation(seg, new_shape, order=1, seg_tiebreak=tiebreak)
112
+ own = np.take_along_axis(scores, np.searchsorted(labels, out)[None], 0)[0]
113
+ self.assertTrue((own == top).all(), f'{int((own != top).sum())} voxels ({tiebreak})')
114
+
115
+ def test_block_aligned_roundtrip_is_exact(self):
116
+ """00001111 -> 0011 -> 00001111: down and up share the block grid, so nothing may move."""
117
+ seg = np.array([0, 0, 0, 0, 1, 1, 1, 1], dtype=np.uint8)[:, None].repeat(2, axis=1)
118
+ for tiebreak in ('nearest', 'lowest', 'highest'):
119
+ down = resize_segmentation(seg, (4, 2), order=1, seg_tiebreak=tiebreak)
120
+ back = resize_segmentation(down, (8, 2), order=1, seg_tiebreak=tiebreak)
121
+ self.assertTrue(np.array_equal(back, seg), f'tiebreak={tiebreak}')
122
+
123
+ def test_single_label_is_passed_through(self):
124
+ seg = np.full((8, 6), 3, dtype=np.uint8)
125
+ out = resize_segmentation(seg, (4, 3), order=3)
126
+ self.assertTrue(np.array_equal(out, np.full((4, 3), 3, dtype=np.uint8)))
127
+
128
+ def test_order_zero_is_untouched(self):
129
+ rng = np.random.RandomState(0)
130
+ seg = rng.randint(0, 5, (9, 7)).astype(np.uint8)
131
+ self.assertTrue(np.array_equal(resize_segmentation(seg, (5, 4), order=0), nearest(seg, (5, 4))))
132
+
133
+ def test_dtype_is_preserved(self):
134
+ for dtype in (np.uint8, np.int16, np.int32):
135
+ seg = np.array([1, 1, 1, 1, 2, 2, 2, 2], dtype=dtype)[:, None].repeat(2, axis=1)
136
+ self.assertEqual(resize_segmentation(seg, (4, 2), order=1).dtype, dtype)
137
+
138
+ def test_unknown_tiebreak_is_rejected(self):
139
+ seg = np.zeros((8, 8), dtype=np.uint8)
140
+ seg[4:] = 1
141
+ with self.assertRaises(ValueError):
142
+ resize_segmentation(seg, (4, 4), order=1, seg_tiebreak='bogus')
143
+
144
+ def test_empty_input_gives_zeros(self):
145
+ """
146
+ An empty segmentation came back as zeros of new_shape (the zero-initialized result) at orders >= 1. The
147
+ argmax broke that: it takes the nearest neighbour sample up front, which skimage cannot compute for an
148
+ empty array (scipy's zoom divides 0 by 0), and 'lowest'/'highest' indexed an empty label list. Order 0
149
+ failed the same way even before.
150
+ """
151
+ for order in (0, 1, 3):
152
+ for tiebreak in ('nearest', 'lowest', 'highest'):
153
+ for in_shape, out_shape in (((0, 4, 4), (0, 8, 8)), ((0, 4, 4), (2, 8, 8)), ((0, 5), (0, 10))):
154
+ out = resize_segmentation(np.zeros(in_shape, dtype=np.int16), out_shape, order=order,
155
+ seg_tiebreak=tiebreak)
156
+ msg = f'{in_shape} -> {out_shape}, order {order}, {tiebreak}'
157
+ self.assertEqual(out.shape, out_shape, msg)
158
+ self.assertEqual(out.dtype, np.int16, msg)
159
+ self.assertFalse(out.any(), msg)
160
+ out = resize_segmentation(np.ones((4, 4, 4), dtype=np.int16), (0, 8, 8), order=order,
161
+ seg_tiebreak=tiebreak)
162
+ self.assertEqual(out.shape, (0, 8, 8))
163
+
164
+
165
+ class TestInterpolateImg(unittest.TestCase):
166
+ """interpolate_img is the same rule on the augment_spatial path, and had the same two problems."""
167
+
168
+ @staticmethod
169
+ def _coords(xs, ys):
170
+ return np.stack(np.meshgrid(np.array(xs, dtype=float), np.array(ys, dtype=float), indexing='ij'))
171
+
172
+ def test_no_label_is_invented(self):
173
+ img = np.array([[1, 2, 1, 1],
174
+ [3, 4, 1, 1],
175
+ [1, 1, 1, 1],
176
+ [1, 1, 1, 1]], dtype=np.uint8)
177
+ coords = self._coords([0.5, 2.5], [0.5, 2.5])
178
+ for tiebreak in ('nearest', 'lowest', 'highest'):
179
+ out = interpolate_img(img, coords, order=1, is_seg=True, seg_tiebreak=tiebreak)
180
+ self.assertTrue(set(np.unique(out).tolist()) <= set(np.unique(img).tolist()),
181
+ f'{np.unique(out)} contains a label that is not in the input ({tiebreak})')
182
+
183
+ def test_nearest_tiebreak_is_symmetric_in_the_label_values(self):
184
+ coords = self._coords([4.5], [0.0])
185
+ for lo, hi in ((1, 2), (2, 7)):
186
+ forward = np.full((8, 2), lo, dtype=np.uint8)
187
+ forward[5:] = hi
188
+ swapped = np.full((8, 2), hi, dtype=np.uint8)
189
+ swapped[5:] = lo
190
+ a = interpolate_img(forward, coords, order=1, is_seg=True, seg_tiebreak='nearest')
191
+ b = interpolate_img(swapped, coords, order=1, is_seg=True, seg_tiebreak='nearest')
192
+ self.assertEqual({lo: hi, hi: lo}[int(a.ravel()[0])], int(b.ravel()[0]))
193
+ self.assertEqual(int(interpolate_img(forward, coords, 1, is_seg=True, seg_tiebreak='lowest').ravel()[0]), lo)
194
+ self.assertEqual(int(interpolate_img(forward, coords, 1, is_seg=True, seg_tiebreak='highest').ravel()[0]), hi)
195
+
196
+ def test_constant_padding_still_uses_cval_outside_the_image(self):
197
+ """
198
+ The old code zero-initialized the result, so voxels sampled outside the image happened to come back
199
+ as 0 - right whenever cval was 0, and only then. Now that the result carries a real label from the
200
+ start, cval has to be written there deliberately.
201
+ """
202
+ img = np.array([[1, 1, 2, 2]] * 4, dtype=np.uint8) # the input does not contain 0
203
+ coords = self._coords([-5., 1.5, 9.], [2.5]) # outside, inside (clear of the 1/2 tie), outside
204
+ for cval in (0.0, 7.0):
205
+ for tiebreak in ('nearest', 'lowest', 'highest'):
206
+ out = interpolate_img(img, coords, order=1, mode='constant', cval=cval, is_seg=True,
207
+ seg_tiebreak=tiebreak).ravel()
208
+ self.assertEqual(int(out[0]), int(cval), tiebreak)
209
+ self.assertEqual(int(out[2]), int(cval), tiebreak)
210
+ self.assertEqual(int(out[1]), 2, tiebreak)
211
+ # a cval the dtype cannot hold comes out as order 0 converts it, instead of raising
212
+ for order in (0, 1, 3):
213
+ out = interpolate_img(img, coords, order=order, mode='constant', cval=-1, is_seg=True).ravel()
214
+ self.assertEqual(int(out[0]), int(np.array(-1.).astype(np.uint8)), f'order {order}')
215
+ # padding modes with no 'outside' must be left alone
216
+ self.assertTrue((interpolate_img(img, coords, order=1, mode='nearest', is_seg=True) == 2).all())
217
+
218
+ def test_empty_input_gives_the_outside_value(self):
219
+ """With no image to sample from every point is outside it: cval for 'constant', 0 otherwise."""
220
+ img = np.zeros((0, 4), dtype=np.uint8)
221
+ coords = self._coords([0.5, 1.5], [0.5, 2.5])
222
+ for order in (1, 3):
223
+ for tiebreak in ('nearest', 'lowest', 'highest'):
224
+ out = interpolate_img(img, coords, order=order, mode='nearest', is_seg=True, seg_tiebreak=tiebreak)
225
+ self.assertEqual(out.shape, (2, 2))
226
+ self.assertEqual(out.dtype, np.uint8)
227
+ self.assertFalse(out.any(), f'order {order}, {tiebreak}')
228
+ for cval in (0., 7.):
229
+ out = interpolate_img(img, coords, order=order, mode='constant', cval=cval, is_seg=True,
230
+ seg_tiebreak=tiebreak)
231
+ self.assertTrue((out == cval).all(), f'order {order}, {tiebreak}, cval {cval}')
232
+
233
+ def test_order_zero_and_non_seg_are_untouched(self):
234
+ img = np.arange(16, dtype=np.uint8).reshape(4, 4)
235
+ coords = self._coords([0.5, 2.5], [0.5, 2.5])
236
+ self.assertTrue(np.array_equal(interpolate_img(img, coords, order=0, is_seg=True),
237
+ interpolate_img(img, coords, order=0, is_seg=False)))
238
+
239
+
240
+ class TestResizeMultichannelImage(unittest.TestCase):
241
+ """
242
+ The image counterpart in the same module. It has no per-label reduction and therefore none of the
243
+ tie-break problem, but it used to allocate its output with the *input* dtype and assign the
244
+ interpolated float into it, which truncates towards zero for integer input.
245
+ """
246
+
247
+ def test_integer_input_is_rounded_not_truncated(self):
248
+ img = (np.arange(8, dtype=np.uint8) * 30)[None, :, None].repeat(2, axis=2)
249
+ exact = resize(img[0].astype(float), (4, 2), 3, clip=True, anti_aliasing=False)
250
+ got = resize_multichannel_image(img, (4, 2), 3)
251
+ self.assertTrue(np.array_equal(got[0], np.rint(exact).astype(np.uint8)))
252
+ self.assertFalse(np.array_equal(got[0], np.trunc(exact).astype(np.uint8)),
253
+ 'this test needs a case where rounding and truncation disagree')
254
+
255
+ def test_integer_input_has_no_downward_bias(self):
256
+ """truncation costs about half an intensity step per voxel, everywhere, in one direction"""
257
+ rng = np.random.RandomState(0)
258
+ bias = []
259
+ for _ in range(50):
260
+ a = rng.randint(0, 256, (1, 16, 16)).astype(np.uint8)
261
+ exact = resize(a[0].astype(float), (9, 9), 3, clip=True, anti_aliasing=False)
262
+ bias.append((resize_multichannel_image(a, (9, 9), 3)[0].astype(float) - exact).mean())
263
+ self.assertLess(abs(float(np.mean(bias))), 0.02)
264
+
265
+ def test_float_input_is_unchanged(self):
266
+ """the only path our pipelines use, and it must stay bit-identical"""
267
+ rng = np.random.RandomState(0)
268
+ for dtype in (np.float32, np.float64):
269
+ a = rng.rand(2, 16, 16).astype(dtype)
270
+ expected = np.stack([resize(a[i].astype(float), (9, 9), 3, clip=True,
271
+ anti_aliasing=False).astype(dtype) for i in range(2)])
272
+ self.assertTrue(np.array_equal(resize_multichannel_image(a, (9, 9), 3), expected))
273
+
274
+ def test_dtype_is_preserved(self):
275
+ rng = np.random.RandomState(0)
276
+ for dtype in (np.uint8, np.int16, np.int32, np.float32, np.float64):
277
+ a = (rng.rand(1, 8, 8) * 100).astype(dtype)
278
+ self.assertEqual(resize_multichannel_image(a, (4, 4), 3).dtype, dtype)
279
+
280
+
281
+ if __name__ == '__main__':
282
+ unittest.main()