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.
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/PKG-INFO +1 -1
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/spatial.py +199 -78
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2.egg-info/PKG-INFO +1 -1
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2.egg-info/SOURCES.txt +2 -1
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/pyproject.toml +1 -1
- batchgeneratorsv2-0.3.6/tests/test_seg_sampling.py +383 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/LICENSE +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/benchmarks/__init__.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/benchmarks/bg_comparison/__init__.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/benchmarks/bg_comparison/nnUNet_pipeline_bg.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/benchmarks/bg_comparison/nnUNet_pipeline_here.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/benchmarks/unique_values.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/dataloading/__init__.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/helpers/__init__.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/helpers/fft_conv.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/helpers/scalar_type.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/__init__.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/base/__init__.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/base/basic_transform.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/__init__.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/brightness.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/contrast.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/gamma.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/gaussian_noise.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/inversion.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/random_clip.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/local/__init__.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/local/brightness_gradient.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/local/local_contrast.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/local/local_gamma.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/local/local_smoothing.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/local/local_transform.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/nnunet/__init__.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/nnunet/random_binary_operator.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/nnunet/remove_connected_components.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/nnunet/seg_to_onehot.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/__init__.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/blank_rectangle.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/gaussian_blur.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/median_filter.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/rician.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/sharpen.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/__init__.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/channel_misalignment.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/low_resolution.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/mirroring.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/rot90.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/transpose.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/__init__.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/compose.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/cropping.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/deep_supervision_downsampling.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/move_channels.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/nnunet_masking.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/pseudo2d.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/random.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/remove_label.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/seg_to_regions.py +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2.egg-info/dependency_links.txt +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2.egg-info/requires.txt +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2.egg-info/top_level.txt +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/readme.md +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/setup.cfg +0 -0
- {batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/setup.py +0 -0
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/spatial.py
RENAMED
|
@@ -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 =
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
271
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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]
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
|
@@ -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
|
|
@@ -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()
|
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/benchmarks/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/benchmarks/unique_values.py
RENAMED
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/dataloading/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/helpers/scalar_type.py
RENAMED
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/__init__.py
RENAMED
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/base/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/intensity/gamma.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/local/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/nnunet/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/rician.py
RENAMED
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/noise/sharpen.py
RENAMED
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/spatial/rot90.py
RENAMED
|
File without changes
|
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/__init__.py
RENAMED
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/compose.py
RENAMED
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/cropping.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/pseudo2d.py
RENAMED
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2/transforms/utils/random.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
{batchgeneratorsv2-0.3.5 → batchgeneratorsv2-0.3.6}/batchgeneratorsv2.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|