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.
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/PKG-INFO +2 -4
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/color_augmentations.py +6 -17
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/crop_and_pad_augmentations.py +1 -1
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/spatial_transformations.py +11 -25
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/utils.py +59 -33
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/dataloading/multi_threaded_augmenter.py +33 -12
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/dataloading/nondet_multi_threaded_augmenter.py +73 -9
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/cifar10.py +20 -52
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/spatial_transforms.py +33 -63
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators.egg-info/PKG-INFO +2 -4
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators.egg-info/SOURCES.txt +7 -1
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators.egg-info/requires.txt +1 -3
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/pyproject.toml +2 -4
- batchgenerators-0.25.6/tests/test_augmenter_failures.py +155 -0
- batchgenerators-0.25.6/tests/test_augmenter_shutdown.py +53 -0
- batchgenerators-0.25.6/tests/test_channel_translation.py +74 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_color_augmentations.py +53 -0
- batchgenerators-0.25.6/tests/test_random_crop.py +153 -0
- batchgenerators-0.25.6/tests/test_resize_transforms.py +88 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_spatial_transformations.py +62 -0
- batchgenerators-0.25.6/tests/test_worker_seeding.py +68 -0
- batchgenerators-0.25.6/tests/test_worker_signals.py +38 -0
- batchgenerators-0.25.5/tests/test_random_crop.py +0 -76
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/LICENSE +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/README.md +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/__init__.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/__init__.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/noise_augmentations.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/normalizations.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/augmentations/resample_augmentations.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/dataloading/__init__.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/dataloading/data_loader.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/dataloading/dataset.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/dataloading/single_threaded_augmenter.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/datasets/__init__.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/datasets/cifar.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/__init__.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/brats2017/__init__.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/brats2017/brats2017_dataloader_2D.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/brats2017/brats2017_dataloader_3D.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/brats2017/brats2017_preprocessing.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/brats2017/config.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/examples/multithreaded_dataloading.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/__init__.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/abstract_transforms.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/channel_selection_transforms.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/color_transforms.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/crop_and_pad_transforms.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/local_transforms.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/noise_transforms.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/resample_transforms.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/sample_normalization_transforms.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/transforms/utility_transforms.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/utilities/__init__.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/utilities/custom_types.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/utilities/data_splitting.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators/utilities/file_and_folder_operations.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators.egg-info/dependency_links.txt +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/batchgenerators.egg-info/top_level.txt +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/setup.cfg +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/setup.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_DataLoader.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_augment_zoom.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_axis_mirroring.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_crop.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_multithreaded_augmenter.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_normalizations.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_resample_augmentations.py +0 -0
- {batchgenerators-0.25.5 → batchgenerators-0.25.6}/tests/test_sanity.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
trans_data = data[:,
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
np.
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
276
|
-
del nn_idx,
|
|
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
|
-
|
|
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
|
|
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("
|
|
190
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
207
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
+
logger.debug("MultiThreadedGenerator: destructor was called")
|
|
336
400
|
self._finish(timeout=2, force=True)
|
|
337
401
|
|
|
338
402
|
|