batchgeneratorsv2 0.3.5__tar.gz → 0.3.6__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 (64) hide show
  1. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/PKG-INFO +1 -1
  2. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/spatial.py +199 -78
  3. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2.egg-info/PKG-INFO +1 -1
  4. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2.egg-info/SOURCES.txt +2 -1
  5. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/pyproject.toml +1 -1
  6. batchgeneratorsv2-0.3.6/tests/test_seg_sampling.py +383 -0
  7. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/LICENSE +0 -0
  8. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/benchmarks/__init__.py +0 -0
  9. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/benchmarks/bg_comparison/__init__.py +0 -0
  10. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/benchmarks/bg_comparison/nnUNet_pipeline_bg.py +0 -0
  11. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/benchmarks/bg_comparison/nnUNet_pipeline_here.py +0 -0
  12. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/benchmarks/unique_values.py +0 -0
  13. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/dataloading/__init__.py +0 -0
  14. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/helpers/__init__.py +0 -0
  15. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/helpers/fft_conv.py +0 -0
  16. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/helpers/scalar_type.py +0 -0
  17. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/__init__.py +0 -0
  18. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/base/__init__.py +0 -0
  19. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/base/basic_transform.py +0 -0
  20. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/__init__.py +0 -0
  21. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/brightness.py +0 -0
  22. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/contrast.py +0 -0
  23. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/gamma.py +0 -0
  24. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/gaussian_noise.py +0 -0
  25. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/inversion.py +0 -0
  26. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/random_clip.py +0 -0
  27. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/local/__init__.py +0 -0
  28. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/local/brightness_gradient.py +0 -0
  29. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/local/local_contrast.py +0 -0
  30. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/local/local_gamma.py +0 -0
  31. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/local/local_smoothing.py +0 -0
  32. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/local/local_transform.py +0 -0
  33. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/nnunet/__init__.py +0 -0
  34. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/nnunet/random_binary_operator.py +0 -0
  35. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/nnunet/remove_connected_components.py +0 -0
  36. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/nnunet/seg_to_onehot.py +0 -0
  37. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/__init__.py +0 -0
  38. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/blank_rectangle.py +0 -0
  39. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/gaussian_blur.py +0 -0
  40. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/median_filter.py +0 -0
  41. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/rician.py +0 -0
  42. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/sharpen.py +0 -0
  43. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/__init__.py +0 -0
  44. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/channel_misalignment.py +0 -0
  45. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/low_resolution.py +0 -0
  46. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/mirroring.py +0 -0
  47. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/rot90.py +0 -0
  48. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/transpose.py +0 -0
  49. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/__init__.py +0 -0
  50. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/compose.py +0 -0
  51. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/cropping.py +0 -0
  52. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/deep_supervision_downsampling.py +0 -0
  53. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/move_channels.py +0 -0
  54. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/nnunet_masking.py +0 -0
  55. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/pseudo2d.py +0 -0
  56. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/random.py +0 -0
  57. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/remove_label.py +0 -0
  58. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/seg_to_regions.py +0 -0
  59. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2.egg-info/dependency_links.txt +0 -0
  60. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2.egg-info/requires.txt +0 -0
  61. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2.egg-info/top_level.txt +0 -0
  62. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/readme.md +0 -0
  63. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/setup.cfg +0 -0
  64. {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchgeneratorsv2
3
- Version: 0.3.5
3
+ Version: 0.3.6
4
4
  Summary: Batchgenerators but better
5
5
  Author: Helmholtz Imaging Applied Computer Vision Lab
6
6
  Author-email: Fabian Isensee <f.isensee@dkfz-heidelberg.de>
@@ -13,6 +13,9 @@ from batchgeneratorsv2.transforms.base.basic_transform import BasicTransform
13
13
  from batchgeneratorsv2.transforms.utils.cropping import crop_tensor
14
14
 
15
15
 
16
+ SEG_TIEBREAKS = ('nearest', 'lowest', 'highest')
17
+
18
+
16
19
  class SpatialTransform(BasicTransform):
17
20
  def __init__(self,
18
21
  patch_size: Tuple[int, ...],
@@ -28,7 +31,7 @@ class SpatialTransform(BasicTransform):
28
31
  p_scaling: float = 0,
29
32
  scaling: RandomScalar = (0.7, 1.3),
30
33
  p_synchronize_scaling_across_axes: float = 0,
31
- bg_style_seg_sampling: bool = True,
34
+ bg_style_seg_sampling: bool = False,
32
35
  mode_seg: str = 'bilinear',
33
36
  border_mode_seg: str = "zeros",
34
37
  center_deformation: bool = True,
@@ -36,17 +39,29 @@ class SpatialTransform(BasicTransform):
36
39
  padding_mode_image: str = "zeros",
37
40
  padding_value_seg: float = 0,
38
41
  padding_value_image: float = 0,
39
- align_corners: bool = False
42
+ align_corners: bool = False,
43
+ *,
44
+ seg_tiebreak: str = 'nearest'
40
45
  ):
41
46
  """
42
47
  magnitude must be given in pixels!
43
48
  deformation scale is given as a paercentage of the edge length
44
49
 
45
50
  padding_mode_image: see torch grid_sample documentation. This currently applies to image and regression target
46
- because both call self._apply_to_image. Can be "zeros", "constant", "reflection", "border"
51
+ because both call self._apply_to_image. Can be "zeros", "constant", "reflection", "border". "constant" pads
52
+ with padding_value_image and resamples the padded image, exactly as "zeros" does with 0.
47
53
 
48
54
  border_mode_seg: can be "zeros", "constant", "reflection", "border". padding values are only considered for
49
- the corresponding "constant" modes.
55
+ the corresponding "constant" modes. "zeros" and "constant" pad the segmentation with a label (0 or
56
+ padding_value_seg) and resample the padded segmentation, so the image ends at the outer edge of its
57
+ border pixels: a sample point up to half a pixel past the last pixel centre still gets that pixel's
58
+ label, and one further out gets the padding label wherever the padding outweighs the image.
59
+
60
+ seg_tiebreak: how to settle voxels where two labels share the top interpolated score, which is what
61
+ every boundary voxel becomes when a sample point falls exactly between two labels. 'nearest' takes
62
+ the nearest neighbour label (symmetric in the labels, and the only option that is a function of the
63
+ geometry rather than of the label values), 'lowest' keeps the smallest label (plain argmax, matches
64
+ nnU-Net's resample_torch), 'highest' keeps the largest. Only used when mode_seg != 'nearest'.
50
65
  """
51
66
  super().__init__()
52
67
  self.patch_size = patch_size
@@ -65,6 +80,9 @@ class SpatialTransform(BasicTransform):
65
80
  self.p_synchronize_scaling_across_axes = p_synchronize_scaling_across_axes
66
81
  self.p_synchronize_def_scale_across_axes = p_synchronize_def_scale_across_axes
67
82
  self.bg_style_seg_sampling = bg_style_seg_sampling
83
+ if seg_tiebreak not in SEG_TIEBREAKS:
84
+ raise ValueError(f'unknown seg_tiebreak: {seg_tiebreak}. Must be one of {SEG_TIEBREAKS}')
85
+ self.seg_tiebreak = seg_tiebreak
68
86
  self.mode_seg = mode_seg
69
87
  self.border_mode_seg = border_mode_seg
70
88
  self.center_deformation = center_deformation
@@ -103,19 +121,24 @@ class SpatialTransform(BasicTransform):
103
121
  return padding_mode
104
122
  raise RuntimeError(f'Unknown pad mode: {padding_mode}')
105
123
 
106
- @staticmethod
107
- def _requires_constant_padding_fixup(padding_mode: str, padding_value: float) -> bool:
108
- return padding_mode == 'constant' and padding_value != 0
109
-
110
- def _compute_out_of_bounds_mask(self, grid: torch.Tensor, spatial_shape: Tuple[int, ...]) -> torch.Tensor:
124
+ def _kernel_bound(self, spatial_shape: Tuple[int, ...]) -> np.ndarray:
125
+ """
126
+ Per grid axis, the largest |grid coordinate| at which the self.mode_seg kernel still draws on pixels
127
+ inside the image only (bilinear reaches one pixel, bicubic two). Axis k of the grid is spatial axis
128
+ dim - 1 - k, which is how grid_sample reads it (x is the last spatial axis).
129
+ """
130
+ margin = {'bilinear': 0, 'bicubic': 1}[self.mode_seg]
131
+ size = np.array(spatial_shape[::-1], dtype=float)
132
+ # pixel coordinate x < margin or x > size - 1 - margin, as a bound on |grid coordinate| (symmetric)
111
133
  if self.align_corners:
112
- lo = grid.new_tensor(-1.)
113
- hi = grid.new_tensor(1.)
114
- else:
115
- size = grid.new_tensor(spatial_shape)
116
- lo = -1 + 1 / size
117
- hi = 1 - 1 / size
118
- return ((grid < lo) | (grid > hi)).any(dim=-1)
134
+ return 1 - 2 * margin / np.maximum(size - 1, 1e-12)
135
+ return 1 - (2 * margin + 1) / size
136
+
137
+ def _may_reach_outside(self, grid: torch.Tensor, spatial_shape: Tuple[int, ...]) -> bool:
138
+ """False guarantees that no kernel reaches outside. One contiguous pass over the grid, under 1 ms at 128^3."""
139
+ bound = self._kernel_bound(spatial_shape).min()
140
+ gmin, gmax = torch.aminmax(grid)
141
+ return not (-gmin.item() <= bound and gmax.item() <= bound)
119
142
 
120
143
  def get_parameters(self, **data_dict) -> dict:
121
144
  dim = data_dict['image'].ndim - 1
@@ -260,16 +283,19 @@ class SpatialTransform(BasicTransform):
260
283
  )
261
284
 
262
285
  grid = params['grid']
286
+ # 'constant' pads with padding_value_image and resamples the padded image, like 'zeros' does with 0 and
287
+ # like the segmentation path does with its padding label. grid_sample only pads with zeros, and it is
288
+ # linear in its input, so sampling (img - v) and adding v back is exactly constant padding with v.
289
+ shift = float(self.padding_value_image) if self.padding_mode_image == 'constant' else 0.
263
290
  result = grid_sample(
264
- img[None],
291
+ (img - shift)[None] if shift != 0 else img[None],
265
292
  grid[None],
266
293
  mode=self.mode_image,
267
294
  padding_mode=self._get_grid_sample_padding_mode(self.padding_mode_image),
268
295
  align_corners=self.align_corners,
269
296
  )[0]
270
- if self._requires_constant_padding_fixup(self.padding_mode_image, self.padding_value_image):
271
- out_of_bounds_mask = self._compute_out_of_bounds_mask(grid, img.shape[1:])
272
- result.masked_fill_(out_of_bounds_mask.unsqueeze(0), self.padding_value_image)
297
+ if shift != 0:
298
+ result += shift
273
299
  return result
274
300
 
275
301
  def _apply_to_segmentation(self, segmentation: torch.Tensor, **params) -> torch.Tensor:
@@ -289,81 +315,176 @@ class SpatialTransform(BasicTransform):
289
315
 
290
316
  grid = params['grid']
291
317
  grid_sample_padding_mode = self._get_grid_sample_padding_mode(self.border_mode_seg)
318
+ # 'zeros' and 'constant' pad the segmentation with a label: 0, or padding_value_seg. Outside the image is
319
+ # then treated exactly like pixels of that label - pad first, then resample - so the image ends at the
320
+ # edge of its outermost pixels, not at their centres. A sample point half a pixel past the last pixel
321
+ # centre still belongs to that pixel, and an augmented patch reaching past the crop does not cut the
322
+ # segmentation short. That is also what the image path does with zeros padding. 'border' and
323
+ # 'reflection' never leave the image.
324
+ pads = grid_sample_padding_mode == 'zeros'
325
+ pad_label = self.padding_value_seg if self.border_mode_seg == 'constant' else 0
292
326
 
293
327
  if self.mode_seg == 'nearest':
294
- result_seg = grid_sample(
295
- segmentation[None].float(),
328
+ # grid_sample only pads with zeros; shifting by the padding label makes that padding the label
329
+ shift = float(pad_label) if pads else 0.
330
+ result_seg = (grid_sample(
331
+ segmentation[None].float() - shift,
296
332
  grid[None],
297
333
  mode=self.mode_seg,
298
334
  padding_mode=grid_sample_padding_mode,
299
335
  align_corners=self.align_corners
300
- )[0].to(segmentation.dtype)
336
+ )[0] + shift).to(segmentation.dtype)
301
337
  else:
338
+ # Both branches compute the same thing: the per-voxel argmax over the interpolated one-hot label
339
+ # channels, with self.seg_tiebreak settling voxels where the top score is shared. They are
340
+ # bit-identical (tests/test_seg_sampling.py pins that) and differ only in time and memory:
341
+ #
342
+ # bg_style_seg_sampling=True materializes the (n_labels, *patch_size) float16 score stack and
343
+ # argmaxes it in one go.
344
+ # bg_style_seg_sampling=False keeps a single running score volume and folds the argmax into the
345
+ # label loop.
346
+ #
347
+ # False is the default because it is the better of the two on both counts. Measured on a 64^3
348
+ # patch: 0.51x the time at 3 labels, 0.68x at 12, 0.71x at 40, against 0.5 MB of score buffer
349
+ # instead of 1.5 / 6 / 20 MB. The stack only looked cheaper while the old code was allowed to
350
+ # skip the background label and to answer the two-label case with a single grid_sample - the
351
+ # first is not compatible with an argmax, and the second now sits in front of both branches,
352
+ # where it makes them identical at two labels. True is kept only so callers that pass it
353
+ # explicitly keep working; there is no longer a reason to choose it.
354
+ #
355
+ # The float16 score representation and scale_factor are load-bearing for that bit-identity: float16
356
+ # has a spacing of 0.5 at this magnitude, so it collapses near ties into exact ones, and both
357
+ # branches have to collapse the same ones.
358
+ #
359
+ # Neither branch uses the old `interpolated >= 0.5` assignment. That rule could leave a voxel
360
+ # unwritten - where three or more labels meet, no single indicator has to reach 0.5 - and the voxel
361
+ # then kept the zero result_seg was initialized with, which need not be a label of the input at all.
362
+ scale_factor = 1000
363
+ half = 0.5 * scale_factor
302
364
  result_seg = torch.zeros((segmentation.shape[0], *self.patch_size), dtype=segmentation.dtype)
303
- if self.bg_style_seg_sampling:
304
- for c in range(segmentation.shape[0]):
305
- labels = torch.from_numpy(np.sort(pd.unique(segmentation[c].numpy().ravel())))
306
- # result_seg is zero-initialized, so when the lowest label is the 0 background we never have to
307
- # write it: any voxel it would claim is already 0, and being the lowest label it can only be
308
- # overwritten by (never overwrite) the foreground labels processed after it. Skipping it saves a
309
- # full grid_sample per channel. Output is bit-identical.
310
- bg_is_zero = bool(labels[0] == 0)
311
- # if we only have 2 labels then we can save compute time
312
- if len(labels) == 2:
313
- out = grid_sample(
314
- ((segmentation[c] == labels[1]).float())[None, None],
365
+ nn_seg = None # nearest neighbour sample, computed on first use
366
+
367
+ def _nearest():
368
+ # the nearest neighbour of the padded segmentation, i.e. what mode_seg='nearest' returns. Where it
369
+ # is not one of the tied labels it is not used (see use_nn), so it can never invent a label.
370
+ nonlocal nn_seg
371
+ if nn_seg is None:
372
+ shift = float(pad_label) if pads else 0.
373
+ nn_seg = (grid_sample(
374
+ segmentation[None].float() - shift,
375
+ grid[None],
376
+ mode='nearest',
377
+ padding_mode=grid_sample_padding_mode,
378
+ align_corners=self.align_corners
379
+ )[0] + shift).to(segmentation.dtype)
380
+ return nn_seg
381
+
382
+ # The padding label enters the argmax only for patches whose kernel reaches outside the image, which
383
+ # _may_reach_outside establishes cheaply; the usual patch stays inside, and its scores are computed
384
+ # exactly as they would be without padding.
385
+ may_reach_outside = pads and self._may_reach_outside(grid, segmentation.shape[1:])
386
+ # every label's indicator is built into the same input-sized float buffer (torch.eq writes 0. / 1.
387
+ # straight into it) rather than allocating a bool and a float tensor afresh per label: one pass over
388
+ # the input per label instead of two. Allocated on first use, so single-label channels never need it
389
+ ind_buf = []
390
+
391
+ for c in range(segmentation.shape[0]):
392
+ labels = torch.from_numpy(np.sort(pd.unique(segmentation[c].numpy().ravel())))
393
+ if may_reach_outside:
394
+ labels = torch.unique(torch.cat((labels, torch.tensor([pad_label]).to(labels.dtype))))
395
+ if len(labels) == 1:
396
+ result_seg[c] = labels[0].item()
397
+ continue
398
+
399
+ def _score(u):
400
+ if not ind_buf:
401
+ ind_buf.append(torch.empty(segmentation.shape[1:], dtype=torch.float32))
402
+ ind = ind_buf[0]
403
+ torch.eq(segmentation[c], u, out=ind)
404
+ if may_reach_outside and u == pad_label:
405
+ # the padding label's indicator is 1 outside the image: sample (ind - 1) with zeros padding
406
+ # and add the 1 back
407
+ return grid_sample(
408
+ ind.sub_(1).mul_(scale_factor)[None, None],
315
409
  grid[None],
316
410
  mode=self.mode_seg,
317
411
  padding_mode=grid_sample_padding_mode,
318
412
  align_corners=self.align_corners
319
- )[0][0] >= 0.5
320
- result_seg[c][out] = labels[1]
321
- if not bg_is_zero:
322
- result_seg[c][~out] = labels[0]
413
+ )[0][0].add_(scale_factor).to(torch.float16)
414
+ return grid_sample(
415
+ ind.mul_(scale_factor)[None, None],
416
+ grid[None],
417
+ mode=self.mode_seg,
418
+ padding_mode=grid_sample_padding_mode,
419
+ align_corners=self.align_corners
420
+ )[0][0].to(torch.float16)
421
+
422
+ if len(labels) == 2:
423
+ # Shared by both branches, so they cannot drift apart here. grid_sample is linear in its
424
+ # input and does not clip, and with the padding label counted the indicators partition the
425
+ # padded image, so the two scores sum to scale_factor everywhere: the second label is the
426
+ # argmax exactly where its own score passes half of it, and one grid_sample answers the whole
427
+ # channel instead of two.
428
+ hi = _score(labels[1])
429
+ better = (hi >= half) if self.seg_tiebreak == 'highest' else (hi > half)
430
+ # with two labels the nearest neighbour is always one of them, so every tie is its to settle
431
+ use_nn = (hi == half) if self.seg_tiebreak == 'nearest' else None
432
+ result_seg[c] = labels[0].item()
433
+ result_seg[c][better] = labels[1]
434
+ elif self.bg_style_seg_sampling:
435
+ scores = torch.empty((len(labels), *self.patch_size), dtype=torch.float16)
436
+ for i, u in enumerate(labels):
437
+ scores[i] = _score(u)
438
+ if self.seg_tiebreak == 'highest':
439
+ # argmax reports the first maximum, so reverse the stack to get the last one
440
+ winner = len(labels) - 1 - scores.flip(0).argmax(0)
323
441
  else:
324
- for u in (labels[1:] if bg_is_zero else labels):
325
- result_seg[c][
326
- grid_sample(
327
- ((segmentation[c] == u).float())[None, None],
328
- grid[None],
329
- mode=self.mode_seg,
330
- padding_mode=grid_sample_padding_mode,
331
- align_corners=self.align_corners
332
- )[0][0] >= 0.5] = u
333
- else:
334
- # Per-voxel argmax over the interpolated one-hot label channels, computed incrementally so the
335
- # (num_labels, *patch_size) stack is never materialized: only a running best value and the winning
336
- # label (written straight into result_seg) are kept. This equals threshold-then-argmax sampling
337
- # because the interpolated channels form a convex combination (they sum to <= scale_factor per
338
- # voxel, so at most one label can exceed 0.7 * scale_factor). Load-bearing for bit-identical
339
- # results: the running comparison is done in float16, and the strict > keeps the lowest label
340
- # index on ties, exactly like torch.argmax(0) over a float16 stack.
341
- scale_factor = 1000
342
- for c in range(segmentation.shape[0]):
343
- labels = torch.from_numpy(np.sort(pd.unique(segmentation[c].numpy().ravel())))
442
+ winner = scores.argmax(0)
443
+ result_seg[c] = labels[winner].to(result_seg.dtype)
444
+ if self.seg_tiebreak == 'nearest':
445
+ # The nearest neighbour settles a tie only if its label is one of the tied ones: where
446
+ # three or more labels meet it can be a label that lost.
447
+ own = scores.gather(0, torch.searchsorted(labels, _nearest()[c])[None])[0]
448
+ use_nn = own == scores.max(0).values
449
+ del own
450
+ else:
451
+ use_nn = None
452
+ del scores
453
+ else:
344
454
  best_val = None
455
+ if self.seg_tiebreak == 'nearest':
456
+ # The nearest neighbour's label settles a voxel where it has the top score: where it
457
+ # won outright that changes nothing, where it is tied it settles the tie. Where three
458
+ # or more labels meet it can be a label that lost, and then the argmax winner stands.
459
+ # So keep the score of the nearest neighbour's label per voxel and compare it with the
460
+ # top score once, at the end. -inf never equals a score: a voxel whose nearest
461
+ # neighbour is not among the labels keeps the argmax.
462
+ nn_c = _nearest()[c]
463
+ nn_score = torch.full(nn_c.shape, float('-inf'), dtype=torch.float16)
464
+ is_nn = torch.empty(nn_c.shape, dtype=torch.bool)
465
+ else:
466
+ nn_c = None
467
+ better = torch.empty(tuple(self.patch_size), dtype=torch.bool)
345
468
  for u in labels:
346
- onehot = (segmentation[c] == u).float()
347
- onehot *= scale_factor
348
- cur = grid_sample(
349
- onehot[None, None],
350
- grid[None],
351
- mode=self.mode_seg,
352
- padding_mode=grid_sample_padding_mode,
353
- align_corners=self.align_corners
354
- )[0][0].to(torch.float16)
469
+ cur = _score(u)
470
+ if nn_c is not None:
471
+ torch.eq(nn_c, u, out=is_nn)
472
+ torch.where(is_nn, cur, nn_score, out=nn_score)
355
473
  if best_val is None:
356
474
  best_val = cur
357
- if u != 0: # result_seg is zero-initialized, so filling with 0 would be a no-op
358
- result_seg[c] = u
359
- else:
360
- better = cur > best_val
361
- torch.maximum(best_val, cur, out=best_val)
362
- result_seg[c][better] = u
363
-
364
- if self._requires_constant_padding_fixup(self.border_mode_seg, self.padding_value_seg):
365
- out_of_bounds_mask = self._compute_out_of_bounds_mask(grid, segmentation.shape[1:])
366
- result_seg.masked_fill_(out_of_bounds_mask.unsqueeze(0), self.padding_value_seg)
475
+ result_seg[c] = u.item()
476
+ continue
477
+ # '>=' lets the later (larger) label take ties, '>' leaves them with the earlier one
478
+ (torch.ge if self.seg_tiebreak == 'highest' else torch.gt)(cur, best_val, out=better)
479
+ torch.maximum(best_val, cur, out=best_val)
480
+ result_seg[c][better] = u
481
+ use_nn = (nn_score == best_val) if nn_c is not None else None
482
+
483
+ if use_nn is not None and torch.any(use_nn):
484
+ # where the nearest neighbour's label won outright this changes nothing. torch.where rather
485
+ # than indexing: use_nn holds nearly everywhere, and indexing materializes an index per voxel
486
+ torch.where(use_nn, _nearest()[c], result_seg[c], out=result_seg[c])
487
+
367
488
  del grid
368
489
  return result_seg.contiguous()
369
490
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchgeneratorsv2
3
- Version: 0.3.5
3
+ Version: 0.3.6
4
4
  Summary: Batchgenerators but better
5
5
  Author: Helmholtz Imaging Applied Computer Vision Lab
6
6
  Author-email: Fabian Isensee <f.isensee@dkfz-heidelberg.de>
@@ -58,4 +58,5 @@ batchgeneratorsv2/transforms/utils/nnunet_masking.py
58
58
  batchgeneratorsv2/transforms/utils/pseudo2d.py
59
59
  batchgeneratorsv2/transforms/utils/random.py
60
60
  batchgeneratorsv2/transforms/utils/remove_label.py
61
- batchgeneratorsv2/transforms/utils/seg_to_regions.py
61
+ batchgeneratorsv2/transforms/utils/seg_to_regions.py
62
+ tests/test_seg_sampling.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "batchgeneratorsv2"
3
- version = "0.3.5"
3
+ version = "0.3.6"
4
4
  requires-python = ">=3.9"
5
5
  description = "Batchgenerators but better"
6
6
  readme = "readme.md"
@@ -0,0 +1,383 @@
1
+ import unittest
2
+
3
+ import numpy as np
4
+ import torch
5
+ from torch.nn.functional import grid_sample
6
+
7
+ from batchgeneratorsv2.transforms.spatial.spatial import SpatialTransform
8
+
9
+
10
+ def grid_coord(x, n):
11
+ """normalized grid coordinate that samples exactly at input coordinate x (align_corners=False)"""
12
+ return (2.0 * x + 1.0) / n - 1.0
13
+
14
+
15
+ def blobs(rng, shape, n_labels):
16
+ v = sum(np.roll(rng.rand(*shape), rng.randint(0, 5), axis=rng.randint(0, len(shape))) for _ in range(3))
17
+ q = np.quantile(v, np.linspace(0, 1, n_labels + 1)[1:-1]) if n_labels > 1 else []
18
+ return np.digitize(v, q).astype(np.int16)
19
+
20
+
21
+ def transform(patch_size, tiebreak, bg_style, **kwargs):
22
+ kwargs.setdefault('random_crop', False)
23
+ return SpatialTransform(patch_size=patch_size, patch_center_dist_from_border=0,
24
+ bg_style_seg_sampling=bg_style, seg_tiebreak=tiebreak, mode_seg='bilinear', **kwargs)
25
+
26
+
27
+ def sample_row(row, tiebreak, bg_style):
28
+ """sample a length-8 label row at x = 0.5, 2.5, 4.5, 6.5, i.e. straight through the 2x downsampling grid"""
29
+ seg = torch.tensor([[row]], dtype=torch.int16)
30
+ grid = torch.tensor([[[grid_coord(x, 8), 0.0] for x in (0.5, 2.5, 4.5, 6.5)]])
31
+ t = transform((1, 4), tiebreak, bg_style)
32
+ return t._apply_to_segmentation(seg, grid=grid, center_location_in_pixels=None).numpy().ravel()
33
+
34
+
35
+ def padded_argmax(seg, grid, mode, pad_label, tiebreak='lowest', margin=8):
36
+ """
37
+ The definition of 'zeros' / 'constant' padding for a segmentation, computed independently of
38
+ SpatialTransform: pad the channel with `margin` pixels of pad_label, move the grid to the padded image,
39
+ and take the argmax there, where no sample point is outside any more.
40
+ """
41
+ shape = seg.shape[1:]
42
+ padded = torch.nn.functional.pad(seg[0][None].float(), [margin] * (2 * len(shape)), value=float(pad_label))[0]
43
+ g = grid.clone()
44
+ for k, n in enumerate(shape[::-1]): # grid axis k is spatial axis dim - 1 - k
45
+ x = ((g[..., k] + 1) * n - 1) / 2 + margin
46
+ g[..., k] = (2 * x + 1) / (n + 2 * margin) - 1
47
+ labels = torch.unique(padded).to(seg.dtype)
48
+ scores = torch.stack([grid_sample(((padded == u).float() * 1000)[None, None], g[None], mode=mode,
49
+ padding_mode='border', align_corners=False)[0, 0].to(torch.float16)
50
+ for u in labels])
51
+ if tiebreak == 'highest':
52
+ return labels[len(labels) - 1 - scores.flip(0).argmax(0)]
53
+ return labels[scores.argmax(0)]
54
+
55
+
56
+ class TestSegSampling(unittest.TestCase):
57
+ """
58
+ SpatialTransform resamples a segmentation by interpolating each label's indicator and picking a winner
59
+ per voxel. Both bg_style_seg_sampling branches must implement the same rule - the per-voxel argmax, with
60
+ seg_tiebreak settling exact ties - and must never write a label that is not in the input.
61
+ """
62
+
63
+ def test_branches_agree_exactly(self):
64
+ for i in range(30):
65
+ rng = np.random.RandomState(1000 + i)
66
+ seg = blobs(rng, (32, 32), rng.randint(1, 8))
67
+ if rng.rand() < 0.4: # label values need not start at zero
68
+ seg = seg + rng.randint(1, 4)
69
+ for tiebreak in ('nearest', 'lowest', 'highest'):
70
+ out = []
71
+ for bg_style in (True, False):
72
+ torch.manual_seed(i)
73
+ np.random.seed(i)
74
+ t = transform((24, 24), tiebreak, bg_style, random_crop=True, p_elastic_deform=1.0,
75
+ p_rotation=1.0, rotation=(0, 2 * np.pi), p_scaling=1.0, scaling=(0.7, 1.4))
76
+ s = torch.from_numpy(seg)[None]
77
+ out.append(t(**{'image': s.float(), 'segmentation': s})['segmentation'].numpy())
78
+ self.assertTrue(np.array_equal(out[0], out[1]),
79
+ f'branches disagree on {np.sum(out[0] != out[1])} voxels (i={i}, {tiebreak})')
80
+
81
+ def test_no_label_is_invented(self):
82
+ """
83
+ The regression that motivated the rewrite. The old rule assigned with `interpolated >= 0.5` into a
84
+ zero-initialized result, so a voxel that no label claimed kept the 0 - which need not occur in the
85
+ input. Four labels meeting at a sample point give 0.25 each and no label claims it.
86
+ """
87
+ seg = torch.tensor([[[1, 2], [3, 4]]], dtype=torch.int16)
88
+ grid = torch.tensor([[[0.0, 0.0]]]) # the exact centre of the 2x2 block
89
+ for bg_style in (True, False):
90
+ for tiebreak in ('nearest', 'lowest', 'highest'):
91
+ out = transform((1, 1), tiebreak, bg_style)._apply_to_segmentation(
92
+ seg, grid=grid, center_location_in_pixels=None).numpy()
93
+ self.assertTrue(set(np.unique(out).tolist()) <= {1, 2, 3, 4},
94
+ f'produced a label that is not in the input: {np.unique(out)} '
95
+ f'(bg_style={bg_style}, {tiebreak})')
96
+
97
+ def test_no_label_is_invented_under_augmentation(self):
98
+ # 'border' padding, so no voxel is outside the image: with zeros padding the outside is label 0 by
99
+ # definition, and test_zeros_padding_is_label_zero covers that
100
+ for i in range(20):
101
+ rng = np.random.RandomState(2000 + i)
102
+ seg = blobs(rng, (32, 32), rng.randint(2, 8)) + rng.randint(1, 4) # never contains 0
103
+ present = set(np.unique(seg).tolist())
104
+ for bg_style in (True, False):
105
+ for tiebreak in ('nearest', 'lowest', 'highest'):
106
+ torch.manual_seed(i)
107
+ np.random.seed(i)
108
+ t = transform((24, 24), tiebreak, bg_style, random_crop=True, p_elastic_deform=1.0,
109
+ p_rotation=1.0, rotation=(0, 2 * np.pi), p_scaling=1.0, scaling=(0.7, 1.4),
110
+ border_mode_seg='border')
111
+ s = torch.from_numpy(seg)[None]
112
+ out = t(**{'image': s.float(), 'segmentation': s})['segmentation'].numpy()
113
+ self.assertTrue(set(np.unique(out).tolist()) <= present,
114
+ f'invented {sorted(set(np.unique(out).tolist()) - present)} '
115
+ f'(i={i}, bg_style={bg_style}, {tiebreak})')
116
+
117
+ def test_nearest_tiebreak_is_symmetric_in_the_label_values(self):
118
+ """
119
+ Sampling exactly between two labels is an exact tie, and it is not a rare case: it is what an even
120
+ integer downsampling factor does at every boundary voxel. 'nearest' has to answer it from the
121
+ geometry, so relabelling must move the same decision with the labels. 'lowest' and 'highest' answer
122
+ it from the label values, so they do not.
123
+ """
124
+ for lo, hi in ((1, 2), (2, 7), (3, 4)):
125
+ forward = [lo] * 5 + [hi] * 3
126
+ swapped = [hi] * 5 + [lo] * 3
127
+ remap = {lo: hi, hi: lo}
128
+ for bg_style in (True, False):
129
+ a = sample_row(forward, 'nearest', bg_style)
130
+ b = sample_row(swapped, 'nearest', bg_style)
131
+ self.assertTrue(np.array_equal([remap[v] for v in a], list(b)),
132
+ f"'nearest' is not symmetric for ({lo}, {hi}): {a} vs {b}")
133
+ # the tied voxel is the third one (x = 4.5, exactly between index 4 and index 5)
134
+ self.assertEqual(sample_row(forward, 'lowest', bg_style)[2], lo)
135
+ self.assertEqual(sample_row(forward, 'highest', bg_style)[2], hi)
136
+
137
+ def test_nearest_tiebreak_matches_a_nearest_neighbour_sample(self):
138
+ row = [1] * 5 + [2] * 3
139
+ seg = torch.tensor([[row]], dtype=torch.int16)
140
+ grid = torch.tensor([[[grid_coord(x, 8), 0.0] for x in (0.5, 2.5, 4.5, 6.5)]])
141
+ nn = grid_sample(seg[None].float(), grid[None], mode='nearest', padding_mode='border',
142
+ align_corners=False)[0].numpy().ravel()
143
+ for bg_style in (True, False):
144
+ self.assertTrue(np.array_equal(sample_row(row, 'nearest', bg_style), nn.astype(np.int16)))
145
+
146
+ def test_single_label_is_passed_through(self):
147
+ for bg_style in (True, False):
148
+ self.assertTrue(np.array_equal(sample_row([7] * 8, 'nearest', bg_style), np.full(4, 7)))
149
+
150
+ def test_3d(self):
151
+ rng = np.random.RandomState(7)
152
+ seg = torch.from_numpy(blobs(rng, (20, 20, 20), 5))[None]
153
+ out = []
154
+ for bg_style in (True, False):
155
+ torch.manual_seed(0)
156
+ np.random.seed(0)
157
+ t = transform((16, 16, 16), 'nearest', bg_style, random_crop=True, p_rotation=1.0,
158
+ rotation=(0, 2 * np.pi), p_scaling=1.0, scaling=(0.7, 1.4))
159
+ out.append(t(**{'image': seg.float(), 'segmentation': seg})['segmentation'].numpy())
160
+ self.assertTrue(np.array_equal(out[0], out[1]))
161
+ self.assertEqual(out[0].shape, (1, 16, 16, 16))
162
+
163
+ def test_zeros_padding_is_label_zero(self):
164
+ """
165
+ With zeros padding the outside of the image is label 0, whatever labels the channel holds, as it is on
166
+ the crop path and with mode_seg='nearest'. The argmax alone would hand a point entirely outside to
167
+ labels[0] (labels[-1] for 'highest'), i.e. to whichever label happens to be the smallest in this
168
+ channel, so the result would depend on which labels are present. Points only partly outside still
169
+ take the argmax over the labels they do see.
170
+ """
171
+ n = 8
172
+ # x = -3 and x = 10 lie entirely outside, x = -0.4 and x = 7.4 in the band that is partly outside
173
+ xs = (-3.0, -0.4, 3.5, 7.4, 10.0)
174
+ grid = torch.tensor([[[grid_coord(x, n), 0.0] for x in xs]])
175
+ for row in ([1] * 4 + [2] * 4, [1] * 3 + [2] * 2 + [3] * 3, [5] * 4 + [9] * 4):
176
+ seg = torch.tensor([[row]], dtype=torch.int16)
177
+ for border_mode in ('zeros', 'constant'):
178
+ for bg_style in (True, False):
179
+ for tiebreak in ('nearest', 'lowest', 'highest'):
180
+ t = transform((1, len(xs)), tiebreak, bg_style, border_mode_seg=border_mode,
181
+ padding_value_seg=0)
182
+ out = t._apply_to_segmentation(seg, grid=grid, center_location_in_pixels=None).numpy().ravel()
183
+ msg = f'{row} {border_mode} bg_style={bg_style} {tiebreak}: {out}'
184
+ self.assertEqual((out[0], out[4]), (0, 0), msg)
185
+ self.assertEqual((out[1], out[3]), (row[0], row[-1]), msg)
186
+ t = SpatialTransform(patch_size=(1, len(xs)), patch_center_dist_from_border=0, random_crop=False,
187
+ mode_seg='nearest')
188
+ out = t._apply_to_segmentation(seg, grid=grid, center_location_in_pixels=None).numpy().ravel()
189
+ self.assertEqual((out[0], out[4]), (0, 0))
190
+
191
+ def test_two_labels_match_the_full_argmax(self):
192
+ """
193
+ Two labels take a shortcut (one grid_sample, `hi > half`) that is only the argmax where the two scores
194
+ sum to scale_factor. Check it against an explicit argmax with sample points inside, across and beyond
195
+ the border, for every padding mode and for bicubic, whose kernel reaches one pixel further. For 'zeros'
196
+ and 'constant' the reference pads the segmentation for real and resamples the padded image.
197
+ """
198
+ rng = np.random.RandomState(3)
199
+ for i in range(10):
200
+ seg = torch.from_numpy(blobs(rng, (16, 16), 2) + rng.randint(0, 3))[None]
201
+ labels = torch.unique(seg)
202
+ # sample points everywhere, including a margin outside the image
203
+ grid = torch.from_numpy(rng.uniform(-1.3, 1.3, size=(12, 12, 2))).float()
204
+ for padding, value in (('zeros', 0), ('constant', -1), ('border', 0), ('reflection', 0)):
205
+ for mode in ('bilinear', 'bicubic'):
206
+ t = transform((12, 12), 'lowest', False, border_mode_seg=padding, padding_value_seg=value)
207
+ t.mode_seg = mode
208
+ out = t._apply_to_segmentation(seg, grid=grid, center_location_in_pixels=None)[0]
209
+ if padding in ('zeros', 'constant'):
210
+ ref = padded_argmax(seg, grid, mode, value).to(out.dtype)
211
+ else:
212
+ scores = torch.stack([
213
+ grid_sample(((seg[0] == u).float() * 1000)[None, None], grid[None], mode=mode,
214
+ padding_mode=padding, align_corners=False)[0, 0].to(torch.float16)
215
+ for u in labels])
216
+ ref = labels[scores.argmax(0)].to(out.dtype)
217
+ self.assertTrue(torch.equal(out, ref),
218
+ f'{int((out != ref).sum())} voxels differ (i={i}, {padding}, {mode})')
219
+
220
+ def test_nearest_tiebreak_only_picks_among_the_tied_labels(self):
221
+ """
222
+ Where three or more labels meet, the nearest neighbour can be a label that lost: the centre of this
223
+ 2x2x2 block scores 1 and 2 at 0.375 each and 3 at 0.25, and its nearest neighbour is a 3.
224
+ """
225
+ block = torch.tensor([3, 1, 1, 1, 2, 2, 2, 3], dtype=torch.int16).reshape(2, 2, 2)
226
+ seg = block.repeat(4, 4, 4)[None]
227
+ centres = [grid_coord(2 * i + 0.5, 8) for i in range(4)]
228
+ grid = torch.stack(torch.meshgrid(*[torch.tensor(centres)] * 3, indexing='ij')[::-1], dim=-1).float()
229
+ for bg_style in (True, False):
230
+ for tiebreak, expected in (('nearest', 1), ('lowest', 1), ('highest', 2)):
231
+ t = transform((4, 4, 4), tiebreak, bg_style)
232
+ out = t._apply_to_segmentation(seg, grid=grid, center_location_in_pixels=None)
233
+ self.assertTrue((out == expected).all(), f'{torch.unique(out).tolist()} ({tiebreak}, {bg_style})')
234
+
235
+ def test_winner_has_the_top_score(self):
236
+ """
237
+ Whatever settles a tie, the label a voxel gets must attain the maximum (float16) score there. Random
238
+ labels sampled at the centres of 2x2x2 blocks put three or more labels into an exact tie at many voxels;
239
+ random rotations and scalings of smooth blobs cover the general case.
240
+ """
241
+ rng = np.random.RandomState(8)
242
+ centres = torch.tensor([grid_coord(2 * i + 0.5, 12) for i in range(6)])
243
+ block_grid = torch.stack(torch.meshgrid(*[centres] * 3, indexing='ij')[::-1], dim=-1).float()
244
+ for i in range(12):
245
+ if i % 2:
246
+ seg = torch.from_numpy(rng.randint(0, rng.randint(3, 6), (12, 12, 12)).astype(np.int16))[None]
247
+ else:
248
+ seg = torch.from_numpy(blobs(rng, (12, 12, 12), rng.randint(3, 7)))[None]
249
+ labels = torch.unique(seg)
250
+ for bg_style in (True, False):
251
+ for tiebreak in ('nearest', 'lowest', 'highest'):
252
+ if i % 2:
253
+ t = transform((6, 6, 6), tiebreak, bg_style, border_mode_seg='border')
254
+ grid = block_grid
255
+ else:
256
+ torch.manual_seed(i)
257
+ np.random.seed(i)
258
+ t = transform((10, 10, 10), tiebreak, bg_style, border_mode_seg='border', p_rotation=1.0,
259
+ rotation=(0, 2 * np.pi), p_scaling=1.0, scaling=(0.7, 1.4))
260
+ grid = t.get_parameters(image=seg.float(), segmentation=seg)['grid']
261
+ out = t._apply_to_segmentation(seg, grid=grid, center_location_in_pixels=None)[0]
262
+ scores = torch.stack([grid_sample(((seg[0] == u).float() * 1000)[None, None], grid[None],
263
+ mode='bilinear', padding_mode='border',
264
+ align_corners=False)[0, 0].to(torch.float16)
265
+ for u in labels])
266
+ own = scores.gather(0, torch.searchsorted(labels, out)[None])[0]
267
+ self.assertTrue(torch.equal(own, scores.max(0).values),
268
+ f'{int((own != scores.max(0).values).sum())} voxels ({tiebreak}, {bg_style}, i={i})')
269
+
270
+ def test_may_reach_outside(self):
271
+ """
272
+ _may_reach_outside decides whether the padding label has to enter the argmax at all. False must
273
+ guarantee that no kernel draws on a pixel outside the image, on a non-cubic image so that pairing a
274
+ grid axis with the wrong spatial axis shows; bicubic reaches one pixel further than bilinear.
275
+ """
276
+ rng = np.random.RandomState(5)
277
+ for shape in ((6, 11), (5, 9, 14)):
278
+ for reach in (0.3, 0.6, 0.8, 0.9, 1.0, 1.2):
279
+ grid = torch.from_numpy(rng.uniform(-reach, reach, size=(7, 8, 9)[:len(shape)] + (len(shape),))).float()
280
+ for align_corners in (False, True):
281
+ for mode in ('bilinear', 'bicubic'):
282
+ t = SpatialTransform(patch_size=grid.shape[:-1], patch_center_dist_from_border=0,
283
+ random_crop=False, mode_seg=mode, align_corners=align_corners)
284
+ if len(shape) == 3 and mode == 'bicubic':
285
+ continue # grid_sample has no 3D bicubic
286
+ # a kernel that draws on the padding shows up as weight missing from a sampled ones image
287
+ w_in = grid_sample(torch.ones((1, 1, *shape)), grid[None], mode=mode, padding_mode='zeros',
288
+ align_corners=align_corners)[0, 0]
289
+ reaches = bool((torch.abs(w_in - 1) > 1e-5).any())
290
+ if not t._may_reach_outside(grid, shape):
291
+ self.assertFalse(reaches, f'{shape} {reach} {align_corners} {mode}')
292
+ if reach > 1.0: # past +-1 is outside even with align_corners, where +-1 is a pixel centre
293
+ self.assertTrue(t._may_reach_outside(grid, shape))
294
+
295
+ def test_padding_is_a_label(self):
296
+ """
297
+ 'zeros' and 'constant' pad the segmentation with a label and resample the padded segmentation. On a row
298
+ of four pixels, x = 3.3 lies inside the last pixel (past its centre), x = 3.7 is 0.2 px beyond the image
299
+ with 70 % of a linear kernel in the padding, and x = 5 is entirely outside. Single-label channels are
300
+ padded too.
301
+ """
302
+ xs = [3.3, 3.7, 5.0, -3.0]
303
+ grid = torch.tensor([[[grid_coord(x, 4), grid_coord(0.5, 2)] for x in xs]]).float()
304
+ for row in ([1, 1, 2, 2], [1, 3, 2, 2], [2, 2, 2, 2]):
305
+ seg = torch.tensor([row] * 2, dtype=torch.int16)[None]
306
+ for border_mode, pad in (('zeros', 0), ('constant', -1)):
307
+ for mode_seg in ('nearest', 'bilinear'):
308
+ for bg_style in (True, False):
309
+ t = SpatialTransform((1, 4), 0, False, mode_seg=mode_seg, border_mode_seg=border_mode,
310
+ padding_value_seg=pad, bg_style_seg_sampling=bg_style)
311
+ out = t._apply_to_segmentation(seg, grid=grid, center_location_in_pixels=None).ravel()
312
+ self.assertEqual(out.tolist(), [2, pad, pad, pad], f'{row} {border_mode} {mode_seg} {bg_style}')
313
+
314
+ def test_transform_padding_equals_dataloader_padding(self):
315
+ """
316
+ Padding by the transform must be indistinguishable from the segmentation having been padded with that
317
+ label beforehand, which is what nnU-Net's dataloader does at real image borders: rotated and scaled
318
+ patches that reach past the crop must not cut the segmentation short.
319
+ """
320
+ rng = np.random.RandomState(12)
321
+ for i in range(8):
322
+ shape = (10, 12, 14)
323
+ seg = torch.from_numpy(blobs(rng, shape, rng.randint(2, 5)))[None]
324
+ grid = torch.from_numpy(rng.uniform(-1.25, 1.25, size=(6, 7, 8, 3))).float()
325
+ for pad in (0, -1):
326
+ for bg_style in (True, False):
327
+ for tiebreak in ('lowest', 'highest'):
328
+ t = transform((6, 7, 8), tiebreak, bg_style, border_mode_seg='constant', padding_value_seg=pad)
329
+ out = t._apply_to_segmentation(seg, grid=grid, center_location_in_pixels=None)[0]
330
+ ref = padded_argmax(seg, grid, 'bilinear', pad, tiebreak).to(out.dtype)
331
+ self.assertTrue(torch.equal(out, ref), f'{int((out != ref).sum())} voxels (i={i}, {pad}, '
332
+ f'{bg_style}, {tiebreak})')
333
+
334
+ def test_constant_image_padding_is_padding(self):
335
+ """
336
+ 'constant' must be the image padded with padding_value_image and resampled, like 'zeros' with 0: checked
337
+ against an image that is padded for real, with sample points inside, across and beyond the border.
338
+ """
339
+ rng = np.random.RandomState(6)
340
+ margin = 8
341
+ for shape in ((6, 11), (5, 9, 14)):
342
+ img = torch.from_numpy(rng.rand(2, *shape)).float()
343
+ grid = torch.from_numpy(rng.uniform(-1.3, 1.3, size=(7, 8, 9)[:len(shape)] + (len(shape),))).float()
344
+ g = grid.clone()
345
+ for k, n in enumerate(shape[::-1]): # grid axis k is spatial axis dim - 1 - k
346
+ g[..., k] = (2 * (((g[..., k] + 1) * n - 1) / 2 + margin) + 1) / (n + 2 * margin) - 1
347
+ for value in (0., -1., 5.):
348
+ padded = torch.nn.functional.pad(img, [margin] * (2 * len(shape)), value=value)
349
+ for mode in ('bilinear', 'nearest') + (('bicubic',) if len(shape) == 2 else ()):
350
+ t = SpatialTransform(patch_size=grid.shape[:-1], patch_center_dist_from_border=0, random_crop=False,
351
+ mode_image=mode, padding_mode_image='constant', padding_value_image=value)
352
+ out = t._apply_to_image(img, grid=grid, center_location_in_pixels=None)
353
+ ref = grid_sample(padded[None], g[None], mode=mode, padding_mode='border', align_corners=False)[0]
354
+ self.assertTrue(torch.allclose(out, ref, atol=1e-5), f'{shape} {value} {mode}: '
355
+ f'{float((out - ref).abs().max())}')
356
+ # 'zeros' must not have moved at all
357
+ t = SpatialTransform(patch_size=grid.shape[:-1], patch_center_dist_from_border=0, random_crop=False)
358
+ self.assertTrue(torch.equal(t._apply_to_image(img, grid=grid, center_location_in_pixels=None),
359
+ grid_sample(img[None], grid[None], mode='bilinear', padding_mode='zeros',
360
+ align_corners=False)[0]))
361
+
362
+ def test_constant_padding_keeps_the_image(self):
363
+ """
364
+ With nnU-Net's border_mode_seg='constant' and a non-zero padding value, sampling a non-cubic
365
+ segmentation exactly at its own pixel centres must return it unchanged - no padding value inside.
366
+ """
367
+ rng = np.random.RandomState(7)
368
+ for shape in ((6, 20), (4, 9, 24)):
369
+ seg = torch.from_numpy(blobs(rng, shape, 3))[None]
370
+ axes = [torch.tensor([grid_coord(x, n) for x in range(n)]) for n in shape]
371
+ grid = torch.stack(torch.meshgrid(*axes, indexing='ij')[::-1], dim=-1).float()
372
+ t = transform(shape, 'nearest', False, border_mode_seg='constant', padding_value_seg=-1)
373
+ out = t._apply_to_segmentation(seg, grid=grid, center_location_in_pixels=None)
374
+ self.assertTrue(torch.equal(out, seg), f'{int((out != seg).sum())} voxels changed ({shape})')
375
+
376
+ def test_unknown_tiebreak_is_rejected(self):
377
+ with self.assertRaises(ValueError):
378
+ SpatialTransform(patch_size=(4, 4), patch_center_dist_from_border=0, random_crop=False,
379
+ seg_tiebreak='bogus')
380
+
381
+
382
+ if __name__ == '__main__':
383
+ unittest.main()