batchgenerators 0.25.5__tar.gz → 0.25.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 (70) hide show
  1. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/PKG-INFO +2 -4
  2. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/color_augmentations.py +6 -17
  3. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/crop_and_pad_augmentations.py +1 -1
  4. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/spatial_transformations.py +11 -25
  5. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/utils.py +59 -33
  6. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/dataloading/multi_threaded_augmenter.py +33 -12
  7. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/dataloading/nondet_multi_threaded_augmenter.py +73 -9
  8. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/cifar10.py +20 -52
  9. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/spatial_transforms.py +33 -63
  10. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators.egg-info/PKG-INFO +2 -4
  11. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators.egg-info/SOURCES.txt +7 -1
  12. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators.egg-info/requires.txt +1 -3
  13. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/pyproject.toml +2 -4
  14. batchgenerators-0.25.6/tests/test_augmenter_failures.py +155 -0
  15. batchgenerators-0.25.6/tests/test_augmenter_shutdown.py +53 -0
  16. batchgenerators-0.25.6/tests/test_channel_translation.py +74 -0
  17. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_color_augmentations.py +53 -0
  18. batchgenerators-0.25.6/tests/test_random_crop.py +153 -0
  19. batchgenerators-0.25.6/tests/test_resize_transforms.py +88 -0
  20. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_spatial_transformations.py +62 -0
  21. batchgenerators-0.25.6/tests/test_worker_seeding.py +68 -0
  22. batchgenerators-0.25.6/tests/test_worker_signals.py +38 -0
  23. batchgenerators-0.25.5/tests/test_random_crop.py +0 -76
  24. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/LICENSE +0 -0
  25. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/README.md +0 -0
  26. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/__init__.py +0 -0
  27. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/__init__.py +0 -0
  28. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/noise_augmentations.py +0 -0
  29. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/normalizations.py +0 -0
  30. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/resample_augmentations.py +0 -0
  31. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/dataloading/__init__.py +0 -0
  32. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/dataloading/data_loader.py +0 -0
  33. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/dataloading/dataset.py +0 -0
  34. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/dataloading/single_threaded_augmenter.py +0 -0
  35. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/datasets/__init__.py +0 -0
  36. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/datasets/cifar.py +0 -0
  37. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/__init__.py +0 -0
  38. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/brats2017/__init__.py +0 -0
  39. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/brats2017/brats2017_dataloader_2D.py +0 -0
  40. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/brats2017/brats2017_dataloader_3D.py +0 -0
  41. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/brats2017/brats2017_preprocessing.py +0 -0
  42. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/brats2017/config.py +0 -0
  43. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/multithreaded_dataloading.py +0 -0
  44. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/__init__.py +0 -0
  45. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/abstract_transforms.py +0 -0
  46. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/channel_selection_transforms.py +0 -0
  47. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/color_transforms.py +0 -0
  48. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/crop_and_pad_transforms.py +0 -0
  49. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/local_transforms.py +0 -0
  50. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/noise_transforms.py +0 -0
  51. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/resample_transforms.py +0 -0
  52. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/sample_normalization_transforms.py +0 -0
  53. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/utility_transforms.py +0 -0
  54. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/utilities/__init__.py +0 -0
  55. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/utilities/custom_types.py +0 -0
  56. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/utilities/data_splitting.py +0 -0
  57. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/utilities/file_and_folder_operations.py +0 -0
  58. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators.egg-info/dependency_links.txt +0 -0
  59. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators.egg-info/top_level.txt +0 -0
  60. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/setup.cfg +0 -0
  61. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/setup.py +0 -0
  62. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_DataLoader.py +0 -0
  63. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_augment_zoom.py +0 -0
  64. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_axis_mirroring.py +0 -0
  65. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_crop.py +0 -0
  66. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_multithreaded_augmenter.py +0 -0
  67. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_normalizations.py +0 -0
  68. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_resample_augmentations.py +0 -0
  69. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_sanity.py +0 -0
  70. {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_seg_resampling.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batchgenerators
3
- Version: 0.25.5
3
+ Version: 0.25.6
4
4
  Summary: Data augmentation toolkit
5
5
  Author-email: "Division of Medical Image Computing, German Cancer Research Center AND Applied Computer Vision Lab, Helmholtz Imaging Platform" <f.isensee@dkfz-heidelberg.de>
6
6
  License: Apache License
@@ -210,13 +210,11 @@ Keywords: data augmentation,deep learning,image segmentation,image classificatio
210
210
  Description-Content-Type: text/markdown
211
211
  License-File: LICENSE
212
212
  Requires-Dist: pillow>=7.1.2
213
- Requires-Dist: numpy>=1.10.2
213
+ Requires-Dist: numpy>=1.17
214
214
  Requires-Dist: scipy
215
215
  Requires-Dist: scikit-image
216
216
  Requires-Dist: scikit-learn
217
- Requires-Dist: future
218
217
  Requires-Dist: pandas
219
- Requires-Dist: unittest2
220
218
  Requires-Dist: threadpoolctl
221
219
  Dynamic: license-file
222
220
 
@@ -17,7 +17,8 @@ from builtins import range
17
17
  from typing import Tuple, Union, Callable
18
18
 
19
19
  import numpy as np
20
- from batchgenerators.augmentations.utils import general_cc_var_num_channels, illumination_jitter
20
+ from batchgenerators.augmentations.utils import general_cc_var_num_channels, illumination_jitter, \
21
+ sample_factor_around_one
21
22
 
22
23
 
23
24
  def augment_contrast(data_sample: np.ndarray,
@@ -29,10 +30,7 @@ def augment_contrast(data_sample: np.ndarray,
29
30
  if callable(contrast_range):
30
31
  factor = contrast_range()
31
32
  else:
32
- if np.random.random() < 0.5 and contrast_range[0] < 1:
33
- factor = np.random.uniform(contrast_range[0], 1)
34
- else:
35
- factor = np.random.uniform(max(contrast_range[0], 1), contrast_range[1])
33
+ factor = sample_factor_around_one(contrast_range)
36
34
 
37
35
  for c in range(data_sample.shape[0]):
38
36
  if np.random.uniform() < p_per_channel:
@@ -52,10 +50,7 @@ def augment_contrast(data_sample: np.ndarray,
52
50
  if callable(contrast_range):
53
51
  factor = contrast_range()
54
52
  else:
55
- if np.random.random() < 0.5 and contrast_range[0] < 1:
56
- factor = np.random.uniform(contrast_range[0], 1)
57
- else:
58
- factor = np.random.uniform(max(contrast_range[0], 1), contrast_range[1])
53
+ factor = sample_factor_around_one(contrast_range)
59
54
 
60
55
  mn = data_sample[c].mean()
61
56
  if preserve_range:
@@ -114,10 +109,7 @@ def augment_gamma(data_sample, gamma_range=(0.5, 2), invert_image=False, epsilon
114
109
  if retain_stats_here:
115
110
  mn = data_sample.mean()
116
111
  sd = data_sample.std()
117
- if np.random.random() < 0.5 and gamma_range[0] < 1:
118
- gamma = np.random.uniform(gamma_range[0], 1)
119
- else:
120
- gamma = np.random.uniform(max(gamma_range[0], 1), gamma_range[1])
112
+ gamma = sample_factor_around_one(gamma_range)
121
113
  minm = data_sample.min()
122
114
  rnge = data_sample.max() - minm
123
115
  data_sample = np.power(((data_sample - minm) / float(rnge + epsilon)), gamma) * rnge + minm
@@ -131,10 +123,7 @@ def augment_gamma(data_sample, gamma_range=(0.5, 2), invert_image=False, epsilon
131
123
  if retain_stats_here:
132
124
  mn = data_sample[c].mean()
133
125
  sd = data_sample[c].std()
134
- if np.random.random() < 0.5 and gamma_range[0] < 1:
135
- gamma = np.random.uniform(gamma_range[0], 1)
136
- else:
137
- gamma = np.random.uniform(max(gamma_range[0], 1), gamma_range[1])
126
+ gamma = sample_factor_around_one(gamma_range)
138
127
  minm = data_sample[c].min()
139
128
  rnge = data_sample[c].max() - minm
140
129
  data_sample[c] = np.power(((data_sample[c] - minm) / float(rnge + epsilon)), gamma) * float(rnge + epsilon) + minm
@@ -33,7 +33,7 @@ def get_lbs_for_random_crop(crop_size, data_shape, margins):
33
33
  lbs = []
34
34
  for i in range(len(data_shape) - 2):
35
35
  if data_shape[i+2] - crop_size[i] - margins[i] > margins[i]:
36
- lbs.append(np.random.randint(margins[i], data_shape[i+2] - crop_size[i] - margins[i]))
36
+ lbs.append(np.random.randint(margins[i], data_shape[i+2] - crop_size[i] - margins[i] + 1))
37
37
  else:
38
38
  lbs.append((data_shape[i+2] - crop_size[i]) // 2)
39
39
  return lbs
@@ -21,7 +21,7 @@ from batchgenerators.augmentations.utils import create_zero_centered_coordinate_
21
21
  interpolate_img, \
22
22
  rotate_coords_2d, rotate_coords_3d, scale_coords, resize_segmentation, resize_multichannel_image, \
23
23
  elastic_deform_coordinates_2, \
24
- get_organ_gradient_field, ignore_anatomy
24
+ get_organ_gradient_field, ignore_anatomy, sample_factor_around_one
25
25
  from batchgenerators.augmentations.crop_and_pad_augmentations import random_crop as random_crop_aug
26
26
  from batchgenerators.augmentations.crop_and_pad_augmentations import center_crop as center_crop_aug
27
27
 
@@ -143,8 +143,8 @@ def augment_channel_translation(data, const_channel=0, max_shifts=None):
143
143
 
144
144
  shape = data.shape
145
145
 
146
- const_data = data[:, [const_channel]]
147
- trans_data = data[:, [i for i in range(shape[1]) if i != const_channel]]
146
+ trans_channels = [i for i in range(shape[1]) if i != const_channel]
147
+ trans_data = data[:, trans_channels]
148
148
 
149
149
  # iterate the batch dimension
150
150
  for j in range(shape[0]):
@@ -161,7 +161,7 @@ def augment_channel_translation(data, const_channel=0, max_shifts=None):
161
161
 
162
162
  # iterate the image dimensions, randomly draw shifts/translations
163
163
  for i, v in enumerate(dims):
164
- rand_shift = np.random.choice(list(range(-max_shifts[v], max_shifts[v], 1)))
164
+ rand_shift = np.random.choice(list(range(-max_shifts[v], max_shifts[v] + 1)))
165
165
 
166
166
  if rand_shift > 0:
167
167
  ixs[v] = {'lo': 0, 'hi': -rand_shift}
@@ -184,7 +184,9 @@ def augment_channel_translation(data, const_channel=0, max_shifts=None):
184
184
 
185
185
  trans_data[j] = slice
186
186
 
187
- data_return = np.concatenate([const_data, trans_data], axis=1)
187
+ # the constant channel keeps its position
188
+ data_return = data.copy()
189
+ data_return[:, trans_channels] = trans_data
188
190
  return data_return
189
191
 
190
192
 
@@ -249,17 +251,9 @@ def augment_spatial(data, seg, patch_size, patch_center_dist_from_border=30,
249
251
 
250
252
  if do_scale and np.random.uniform() < p_scale_per_sample:
251
253
  if independent_scale_for_each_axis and np.random.uniform() < p_independent_scale_per_axis:
252
- sc = []
253
- for _ in range(dim):
254
- if np.random.random() < 0.5 and scale[0] < 1:
255
- sc.append(np.random.uniform(scale[0], 1))
256
- else:
257
- sc.append(np.random.uniform(max(scale[0], 1), scale[1]))
254
+ sc = [sample_factor_around_one(scale) for _ in range(dim)]
258
255
  else:
259
- if np.random.random() < 0.5 and scale[0] < 1:
260
- sc = np.random.uniform(scale[0], 1)
261
- else:
262
- sc = np.random.uniform(max(scale[0], 1), scale[1])
256
+ sc = sample_factor_around_one(scale)
263
257
 
264
258
  coords = scale_coords(coords, sc)
265
259
  modified_coords = True
@@ -413,17 +407,9 @@ def augment_spatial_2(data, seg, patch_size, patch_center_dist_from_border=30,
413
407
 
414
408
  if do_scale and np.random.uniform() < p_scale_per_sample:
415
409
  if independent_scale_for_each_axis and np.random.uniform() < p_independent_scale_per_axis:
416
- sc = []
417
- for _ in range(dim):
418
- if np.random.random() < 0.5 and scale[0] < 1:
419
- sc.append(np.random.uniform(scale[0], 1))
420
- else:
421
- sc.append(np.random.uniform(max(scale[0], 1), scale[1]))
410
+ sc = [sample_factor_around_one(scale) for _ in range(dim)]
422
411
  else:
423
- if np.random.random() < 0.5 and scale[0] < 1:
424
- sc = np.random.uniform(scale[0], 1)
425
- else:
426
- sc = np.random.uniform(max(scale[0], 1), scale[1])
412
+ sc = sample_factor_around_one(scale)
427
413
 
428
414
  coords = scale_coords(coords, sc)
429
415
  modified_coords = True
@@ -17,6 +17,7 @@ import random
17
17
  import numpy as np
18
18
  from copy import deepcopy
19
19
  from scipy.ndimage import (
20
+ find_objects,
20
21
  fourier_gaussian,
21
22
  gaussian_filter,
22
23
  gaussian_gradient_magnitude,
@@ -156,6 +157,19 @@ def scale_coords(coords, scale):
156
157
  return coords
157
158
 
158
159
 
160
+ def sample_factor_around_one(factor_range):
161
+ """
162
+ Draws a factor (scale, contrast, gamma) from factor_range. If the range contains one, factors below and above one
163
+ are equally likely, however asymmetric the range is around one: (0.5, 2) shrinks as often as it enlarges. A range
164
+ that ends at or below one, or starts at or above one, is sampled uniformly.
165
+ """
166
+ if np.random.random() < 0.5 and factor_range[0] < 1:
167
+ return np.random.uniform(factor_range[0], min(1, factor_range[1]))
168
+ if factor_range[1] <= 1:
169
+ return np.random.uniform(factor_range[0], factor_range[1])
170
+ return np.random.uniform(max(factor_range[0], 1), factor_range[1])
171
+
172
+
159
173
  def uncenter_coords(coords):
160
174
  shp = coords.shape[1:]
161
175
  coords = deepcopy(coords)
@@ -227,15 +241,22 @@ def _resample_seg_by_argmax(labels, score_fn, nearest_fn, out_shape, dtype, seg_
227
241
  better = np.empty(out_shape, dtype=bool)
228
242
  # 'nearest': the nearest neighbour label settles a tie only if it is one of the tied labels. Where three
229
243
  # or more labels meet it need not be - a 2x2x2 block [3,1,1,1,2,2,2,3] sampled at its centre scores
230
- # 1 and 2 at 0.375 each and 3 at 0.25, and its nearest neighbour is a 3. So instead of marking tied
231
- # voxels, track whether the nearest neighbour's label currently has the top score: a label that takes
232
- # the lead strictly decides that afresh, one that draws level can only add to it. Where it holds at the
233
- # end the nearest neighbour is the answer (it changes nothing where its label won outright), and where
234
- # it does not the argmax winner stands.
244
+ # 1 and 2 at 0.375 each and 3 at 0.25, and its nearest neighbour is a 3. So the nearest neighbour settles
245
+ # a voxel exactly where its own label ends up with the top score (it changes nothing where that label
246
+ # won outright), and elsewhere the argmax winner stands. That is decided in two halves:
247
+ # * at the nearest neighbour's own turn in the loop: does its label take the lead, or draw level with a
248
+ # positive score? -> nn_cand
249
+ # * after the loop: did no later label take the lead from it? The running winner only ever changes to a
250
+ # later label, and only on a strict improvement, so that is exactly win <= nn_idx.
251
+ # The first half only looks at voxels whose nearest neighbour is the current label, i.e. inside that
252
+ # label's bounding box in nn_idx, typically a few % of the volume, and nothing has to be tracked through
253
+ # the loop for the other labels.
235
254
  nearest = seg_tiebreak == 'nearest'
236
255
  if nearest:
237
256
  nn_idx = _nearest_label_index(labels, nearest_fn(), idx_dtype)
238
- nn_top = np.empty(out_shape, dtype=bool)
257
+ # boxes of the label indices >= 1 (find_objects does not report index 0, nor -1 = 'not a label')
258
+ nn_boxes = find_objects(nn_idx)
259
+ nn_cand = np.zeros(out_shape, dtype=bool)
239
260
  is_nn = np.empty(out_shape, dtype=bool)
240
261
  level = np.empty(out_shape, dtype=bool)
241
262
  for i, c in enumerate(labels):
@@ -246,34 +267,39 @@ def _resample_seg_by_argmax(labels, score_fn, nearest_fn, out_shape, dtype, seg_
246
267
  # and empty_value (if the caller gave one) has the final say below.
247
268
  best = score
248
269
  if nearest:
249
- np.equal(nn_idx, 0, out=nn_top)
250
- np.greater(score, 0, out=level)
251
- nn_top &= level
270
+ # `score > 0` guard: a label that scores zero at a voxel is absent there, not leading
271
+ np.equal(nn_idx, 0, out=is_nn)
272
+ np.greater(score, 0, out=nn_cand)
273
+ nn_cand &= is_nn
252
274
  del score
253
275
  continue
254
- if nearest:
255
- np.equal(nn_idx, i, out=is_nn)
256
- # `score > 0` guard: two labels that both score zero at a voxel are not tied for the win there,
257
- # they are simply both absent, and some other label will claim it. `better` is free until it is
258
- # computed below, so it holds that guard meanwhile.
259
- np.equal(score, best, out=level)
260
- np.greater(score, 0, out=better)
261
- level &= better
262
- level &= is_nn
263
- nn_top |= level
264
276
  # '>=' lets the later (larger) label take ties, '>' leaves them with the earlier (smaller) one
265
277
  (np.greater_equal if seg_tiebreak == 'highest' else np.greater)(score, best, out=better)
266
278
  if nearest:
267
- np.copyto(nn_top, is_nn, where=better)
279
+ box = nn_boxes[i - 1] if i - 1 < len(nn_boxes) else None
280
+ if box is not None:
281
+ # nn_cand = is_nn and (takes the lead, or draws level with a positive score), against `best`
282
+ # before it is updated. `score > 0` guard on drawing level: two labels that both score zero at
283
+ # a voxel are not tied for the win there, they are simply both absent.
284
+ is_nn_b, level_b, score_b = is_nn[box], level[box], score[box]
285
+ np.greater(score_b, 0, out=level_b)
286
+ np.greater_equal(score_b, best[box], out=is_nn_b)
287
+ level_b &= is_nn_b
288
+ level_b |= better[box]
289
+ np.equal(nn_idx[box], i, out=is_nn_b)
290
+ level_b &= is_nn_b
291
+ nn_cand[box] |= level_b
268
292
  np.copyto(win, i, where=better)
269
293
  np.maximum(best, score, out=best)
270
294
  del score
271
295
 
272
296
  if nearest:
273
- # copyto rather than win[nn_top] = nn_idx[nn_top]: nn_top holds nearly everywhere (also wherever the
297
+ np.less_equal(win, nn_idx, out=level)
298
+ level &= nn_cand
299
+ # copyto rather than win[level] = nn_idx[level]: this holds nearly everywhere (also wherever the
274
300
  # nearest neighbour's label won outright), and the indexed form copies all of those out first
275
- np.copyto(win, nn_idx, where=nn_top)
276
- del nn_idx, nn_top, is_nn, level
301
+ np.copyto(win, nn_idx, where=level)
302
+ del nn_idx, nn_cand, is_nn, level
277
303
  del better
278
304
  empty = best <= 0 if empty_value is not None else None
279
305
  del best
@@ -401,21 +427,21 @@ def random_crop_3D_image(img, crop_size):
401
427
  img.shape), "If you provide a list/tuple as center crop make sure it has the same len as your data has dims (3d)"
402
428
 
403
429
  if crop_size[0] < img.shape[0]:
404
- lb_x = np.random.randint(0, img.shape[0] - crop_size[0])
430
+ lb_x = np.random.randint(0, img.shape[0] - crop_size[0] + 1)
405
431
  elif crop_size[0] == img.shape[0]:
406
432
  lb_x = 0
407
433
  else:
408
434
  raise ValueError("crop_size[0] must be smaller or equal to the images x dimension")
409
435
 
410
436
  if crop_size[1] < img.shape[1]:
411
- lb_y = np.random.randint(0, img.shape[1] - crop_size[1])
437
+ lb_y = np.random.randint(0, img.shape[1] - crop_size[1] + 1)
412
438
  elif crop_size[1] == img.shape[1]:
413
439
  lb_y = 0
414
440
  else:
415
441
  raise ValueError("crop_size[1] must be smaller or equal to the images y dimension")
416
442
 
417
443
  if crop_size[2] < img.shape[2]:
418
- lb_z = np.random.randint(0, img.shape[2] - crop_size[2])
444
+ lb_z = np.random.randint(0, img.shape[2] - crop_size[2] + 1)
419
445
  elif crop_size[2] == img.shape[2]:
420
446
  lb_z = 0
421
447
  else:
@@ -432,21 +458,21 @@ def random_crop_3D_image_batched(img, crop_size):
432
458
  img.shape) - 2), "If you provide a list/tuple as center crop make sure it has the same len as your data has dims (3d)"
433
459
 
434
460
  if crop_size[0] < img.shape[2]:
435
- lb_x = np.random.randint(0, img.shape[2] - crop_size[0])
461
+ lb_x = np.random.randint(0, img.shape[2] - crop_size[0] + 1)
436
462
  elif crop_size[0] == img.shape[2]:
437
463
  lb_x = 0
438
464
  else:
439
465
  raise ValueError("crop_size[0] must be smaller or equal to the images x dimension")
440
466
 
441
467
  if crop_size[1] < img.shape[3]:
442
- lb_y = np.random.randint(0, img.shape[3] - crop_size[1])
468
+ lb_y = np.random.randint(0, img.shape[3] - crop_size[1] + 1)
443
469
  elif crop_size[1] == img.shape[3]:
444
470
  lb_y = 0
445
471
  else:
446
472
  raise ValueError("crop_size[1] must be smaller or equal to the images y dimension")
447
473
 
448
474
  if crop_size[2] < img.shape[4]:
449
- lb_z = np.random.randint(0, img.shape[4] - crop_size[2])
475
+ lb_z = np.random.randint(0, img.shape[4] - crop_size[2] + 1)
450
476
  elif crop_size[2] == img.shape[4]:
451
477
  lb_z = 0
452
478
  else:
@@ -463,14 +489,14 @@ def random_crop_2D_image(img, crop_size):
463
489
  img.shape), "If you provide a list/tuple as center crop make sure it has the same len as your data has dims (2d)"
464
490
 
465
491
  if crop_size[0] < img.shape[0]:
466
- lb_x = np.random.randint(0, img.shape[0] - crop_size[0])
492
+ lb_x = np.random.randint(0, img.shape[0] - crop_size[0] + 1)
467
493
  elif crop_size[0] == img.shape[0]:
468
494
  lb_x = 0
469
495
  else:
470
496
  raise ValueError("crop_size[0] must be smaller or equal to the images x dimension")
471
497
 
472
498
  if crop_size[1] < img.shape[1]:
473
- lb_y = np.random.randint(0, img.shape[1] - crop_size[1])
499
+ lb_y = np.random.randint(0, img.shape[1] - crop_size[1] + 1)
474
500
  elif crop_size[1] == img.shape[1]:
475
501
  lb_y = 0
476
502
  else:
@@ -487,14 +513,14 @@ def random_crop_2D_image_batched(img, crop_size):
487
513
  img.shape) - 2), "If you provide a list/tuple as center crop make sure it has the same len as your data has dims (2d)"
488
514
 
489
515
  if crop_size[0] < img.shape[2]:
490
- lb_x = np.random.randint(0, img.shape[2] - crop_size[0])
516
+ lb_x = np.random.randint(0, img.shape[2] - crop_size[0] + 1)
491
517
  elif crop_size[0] == img.shape[2]:
492
518
  lb_x = 0
493
519
  else:
494
520
  raise ValueError("crop_size[0] must be smaller or equal to the images x dimension")
495
521
 
496
522
  if crop_size[1] < img.shape[3]:
497
- lb_y = np.random.randint(0, img.shape[3] - crop_size[1])
523
+ lb_y = np.random.randint(0, img.shape[3] - crop_size[1] + 1)
498
524
  elif crop_size[1] == img.shape[3]:
499
525
  lb_y = 0
500
526
  else:
@@ -25,22 +25,32 @@ from multiprocessing import Event
25
25
  from time import sleep, time
26
26
  from threadpoolctl import threadpool_limits
27
27
 
28
- from batchgenerators.dataloading.nondet_multi_threaded_augmenter import pin_memory_of_all_eligible_items_in_dict
28
+ from batchgenerators.dataloading.nondet_multi_threaded_augmenter import pin_memory_of_all_eligible_items_in_dict, \
29
+ init_worker, ParentWatch
29
30
 
30
31
  try:
31
32
  import torch
32
33
  except ImportError:
33
34
  torch = None
34
35
 
36
+ logger = logging.getLogger(__name__)
37
+
35
38
 
36
39
  def producer(queue, data_loader, transform, thread_id, seed, abort_event,
37
40
  pause_event=None, wait_time: float = 0.02):
38
- np.random.seed(seed)
41
+ init_worker(seed)
39
42
  data_loader.set_thread_id(thread_id)
40
43
  item = None
44
+ parent = ParentWatch()
41
45
 
42
46
  try:
43
47
  while not abort_event.is_set():
48
+ # Nobody reads the queue once the parent is gone, so don't let the exit wait for the queue's feeder thread
49
+ # either.
50
+ if parent.parent_died():
51
+ queue.cancel_join_thread()
52
+ return
53
+
44
54
  # When paused, hold any in-flight item and stop interacting with the
45
55
  # queue. This guarantees no new bytes hit the pipe after pause is
46
56
  # observed, which is what _finish() relies on for safe shutdown.
@@ -106,6 +116,13 @@ def results_loop(in_queues: List[Queue], out_queue: thrQueue, abort_event: Event
106
116
  item = current_queue.get(timeout=wait_time)
107
117
  except Empty:
108
118
  continue # retry the same queue; abort_event re-checked at top of loop
119
+ except Exception:
120
+ # A worker can still put a batch during shutdown and exit right after. Torch tensors are unpickled
121
+ # by fetching their file descriptor from the sending process, so that batch may fail to load. It
122
+ # would be discarded anyway.
123
+ if abort_event.is_set():
124
+ return
125
+ raise
109
126
  # if we do pin memory, do it now, otherwise skip this. The isinstance(dict) guard keeps the 'end'
110
127
  # string sentinel from reaching the pinning logic.
111
128
  if do_pin_memory:
@@ -128,6 +145,10 @@ def results_loop(in_queues: List[Queue], out_queue: thrQueue, abort_event: Event
128
145
  except KeyboardInterrupt:
129
146
  abort_event.set()
130
147
  raise KeyboardInterrupt
148
+ except Exception:
149
+ # the main thread only watches abort_event, without it next() would wait for batches forever
150
+ abort_event.set()
151
+ raise
131
152
 
132
153
 
133
154
  class MultiThreadedAugmenter(object):
@@ -143,7 +164,7 @@ class MultiThreadedAugmenter(object):
143
164
  num_cached_per_queue (int): number of batches cached per process (each process has its own
144
165
  multiprocessing.Queue). We found 2 to be ideal.
145
166
  seeds (list of int): one seed for each worker. Must have len(num_processes).
146
- If None then seeds = range(num_processes)
167
+ If None, every worker seeds itself from the OS and runs are not reproducible
147
168
  pin_memory (bool): set to True if all torch tensors in data_dict are to be pinned. Pytorch only.
148
169
  timeout (int): How long do we wait for the background workers to do stuff? If timeout seconds have passed and
149
170
  self.__get_next_item still has not gotten an item from the workers we will perform a check whether all
@@ -186,8 +207,8 @@ class MultiThreadedAugmenter(object):
186
207
  while True:
187
208
  if self.abort_event.is_set():
188
209
  self._finish()
189
- raise RuntimeError("One or more background workers are no longer alive. Exiting. Please check the "
190
- "print statements above for the actual error message")
210
+ raise RuntimeError("A background worker or the thread collecting their batches failed. Exiting. Please "
211
+ "check the output above for the actual error message")
191
212
 
192
213
  try:
193
214
  return self.pin_memory_queue.get(timeout=self.wait_time)
@@ -206,7 +227,7 @@ class MultiThreadedAugmenter(object):
206
227
  if self._end_ctr == self.num_processes:
207
228
  self._end_ctr = 0
208
229
  self._queue_ctr = 0
209
- logging.debug("MultiThreadedGenerator: finished data generation")
230
+ logger.debug("MultiThreadedGenerator: finished data generation")
210
231
  raise StopIteration
211
232
 
212
233
  item = self.__get_next_item()
@@ -214,7 +235,7 @@ class MultiThreadedAugmenter(object):
214
235
  return item
215
236
 
216
237
  except KeyboardInterrupt:
217
- logging.error("MultiThreadedGenerator: caught exception: {}".format(sys.exc_info()))
238
+ logger.error("MultiThreadedGenerator: caught exception: {}".format(sys.exc_info()))
218
239
  self.abort_event.set()
219
240
  self._finish()
220
241
  raise KeyboardInterrupt
@@ -225,7 +246,7 @@ class MultiThreadedAugmenter(object):
225
246
  self.abort_event.clear()
226
247
  self.pause_event.clear()
227
248
 
228
- logging.debug("starting workers")
249
+ logger.debug("starting workers")
229
250
  self._queue_ctr = 0
230
251
  self._end_ctr = 0
231
252
 
@@ -258,8 +279,8 @@ class MultiThreadedAugmenter(object):
258
279
 
259
280
  self.was_initialized = True
260
281
  else:
261
- logging.debug("MultiThreadedGenerator Warning: start() has been called but it has already been "
262
- "initialized previously")
282
+ logger.debug("MultiThreadedGenerator Warning: start() has been called but it has already been "
283
+ "initialized previously")
263
284
 
264
285
  def _finish(self, timeout=10, force=False):
265
286
  """Shut down workers and the pin-memory thread.
@@ -303,7 +324,7 @@ class MultiThreadedAugmenter(object):
303
324
  # feeder threads need pipe space to flush before the worker
304
325
  # process can fully exit; draining concurrently unblocks them.
305
326
  if len(self._processes) > 0:
306
- logging.debug("MultiThreadedGenerator: shutting down workers...")
327
+ logger.debug("MultiThreadedGenerator: shutting down workers...")
307
328
  deadline = time() + timeout
308
329
  drain_tick = max(self.wait_time, 0.01)
309
330
  while time() < deadline and any(p.is_alive() for p in self._processes):
@@ -348,7 +369,7 @@ class MultiThreadedAugmenter(object):
348
369
  self._start()
349
370
 
350
371
  def __del__(self):
351
- logging.debug("MultiThreadedGenerator: destructor was called")
372
+ logger.debug("MultiThreadedGenerator: destructor was called")
352
373
  # Interpreter shutdown may have already torn down parts of
353
374
  # multiprocessing; take the fast path with a short timeout.
354
375
  self._finish(timeout=2, force=True)
@@ -20,9 +20,13 @@ import threading
20
20
  from builtins import range
21
21
  from multiprocessing import Process
22
22
  from multiprocessing import Queue
23
+ from multiprocessing import parent_process
23
24
  from queue import Queue as thrQueue, Full, Empty
24
25
  import numpy as np
25
26
  import logging
27
+ import os
28
+ import random
29
+ import signal
26
30
  from multiprocessing import Event
27
31
  from time import sleep, time
28
32
 
@@ -34,23 +38,76 @@ try:
34
38
  except ImportError:
35
39
  torch = None
36
40
 
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ def init_worker(seed):
45
+ """
46
+ Prepares a freshly started background worker: restores the default SIGTERM handler and seeds numpy, random and
47
+ torch.
48
+
49
+ Forked workers inherit the parent's signal handlers. A parent that catches SIGTERM must not keep its workers alive
50
+ when a scheduler such as SLURM sends SIGTERM to every process of the job.
51
+
52
+ Forked workers also inherit the parent's torch RNG state. Without a seed they would all draw the same numbers, so
53
+ torch is reseeded from the OS, as np.random.seed(None) does for numpy (random reseeds itself after a fork).
54
+ With a seed, torch gets a seed derived from it, not the seed itself: numpy and torch both use MT19937, and with the
55
+ same seed torch.rand would return the numbers numpy draws.
56
+ """
57
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
58
+
59
+ np.random.seed(seed)
60
+ if seed is not None:
61
+ # random.seed refuses numpy integers
62
+ random.seed(int(seed))
63
+ if torch is not None:
64
+ if seed is not None:
65
+ torch.manual_seed(int(np.random.SeedSequence(int(seed)).generate_state(1)[0]))
66
+ else:
67
+ torch.seed()
68
+
69
+
70
+ class ParentWatch(object):
71
+ """
72
+ Tells a background worker whether the process that started it has died. A parent that is killed (SIGKILL, the OOM
73
+ killer) never sets abort_event, so its workers have to notice on their own.
74
+
75
+ parent_process().is_alive() watches a pipe whose other end the parent holds open. Under fork, every process the
76
+ parent forks later inherits that end too and keeps it open after the parent is gone. Under fork and spawn the
77
+ parent is also the worker's OS parent, so the worker is reparented when the parent dies and os.getppid() changes,
78
+ whoever holds the pipe. Under forkserver the OS parent is the fork server, and only the pipe is watched.
79
+ """
80
+
81
+ def __init__(self):
82
+ self.parent = parent_process()
83
+ self.watch_ppid = self.parent is not None and os.getppid() == self.parent.pid
84
+
85
+ def parent_died(self) -> bool:
86
+ if self.parent is None:
87
+ return False
88
+ return (self.watch_ppid and os.getppid() != self.parent.pid) or not self.parent.is_alive()
89
+
37
90
 
38
91
  def producer(queue: Queue, data_loader, transform, thread_id: int, seed,
39
92
  abort_event: Event, pause_event=None, wait_time: float = 0.02):
40
93
  if torch is not None:
41
94
  torch.set_num_threads(1)
42
- if seed is not None:
43
- torch.manual_seed(seed)
44
-
45
- np.random.seed(seed)
95
+ init_worker(seed)
46
96
 
47
97
  # the producer will set the abort event if something happens
48
98
  with threadpool_limits(1, None):
49
99
  data_loader.set_thread_id(thread_id)
50
100
  item = None
101
+ parent = ParentWatch()
51
102
 
52
103
  try:
53
104
  while not abort_event.is_set():
105
+ # Nobody reads the queue once the parent is gone, so don't let the exit wait for the queue's feeder
106
+ # thread either.
107
+ if parent.parent_died():
108
+ queue.cancel_join_thread()
109
+ return
110
+
54
111
  # When paused, stop producing and stop putting. This is the
55
112
  # handshake _finish() uses to drain queues safely.
56
113
  if pause_event is not None and pause_event.is_set():
@@ -137,6 +194,13 @@ def results_loop(in_queue: Queue, out_queue: thrQueue, abort_event: Event,
137
194
  item = in_queue.get(timeout=wait_time)
138
195
  except Empty:
139
196
  continue # abort_event/worker liveness re-checked at top of loop
197
+ except Exception:
198
+ # A worker can still put a batch during shutdown and exit right after. Torch tensors are unpickled
199
+ # by fetching their file descriptor from the sending process, so that batch may fail to load. It
200
+ # would be discarded anyway.
201
+ if abort_event.is_set():
202
+ return
203
+ raise
140
204
  if do_pin_memory:
141
205
  item = pin_memory_of_all_eligible_items_in_dict(item)
142
206
 
@@ -203,8 +267,8 @@ class NonDetMultiThreadedAugmenter(object):
203
267
  if self.abort_event.is_set():
204
268
  # the results loop checks for dead workers and will set the abort event if necessary
205
269
  self._finish()
206
- raise RuntimeError("One or more background workers are no longer alive. Exiting. Please check the "
207
- "print statements above for the actual error message")
270
+ raise RuntimeError("A background worker or the thread collecting their batches failed. Exiting. Please "
271
+ "check the output above for the actual error message")
208
272
 
209
273
  try:
210
274
  item = self.results_loop_queue.get(timeout=self.wait_time)
@@ -229,7 +293,7 @@ class NonDetMultiThreadedAugmenter(object):
229
293
  self.abort_event = Event()
230
294
  self.pause_event = Event()
231
295
 
232
- logging.debug("starting workers")
296
+ logger.debug("starting workers")
233
297
  if isinstance(self.generator, DataLoader):
234
298
  self.generator.was_initialized = False
235
299
 
@@ -258,7 +322,7 @@ class NonDetMultiThreadedAugmenter(object):
258
322
 
259
323
  self.initialized = True
260
324
  else:
261
- logging.debug("MultiThreadedGenerator Warning: start() has been called but workers are already running")
325
+ logger.debug("MultiThreadedGenerator Warning: start() has been called but workers are already running")
262
326
 
263
327
  def _finish(self, timeout=10, force=False):
264
328
  """Graceful shutdown — same pause-drain-exit handshake as MTA.
@@ -332,7 +396,7 @@ class NonDetMultiThreadedAugmenter(object):
332
396
  self._start()
333
397
 
334
398
  def __del__(self):
335
- logging.debug("MultiThreadedGenerator: destructor was called")
399
+ logger.debug("MultiThreadedGenerator: destructor was called")
336
400
  self._finish(timeout=2, force=True)
337
401
 
338
402