acvl-utils 0.2.7__py3-none-any.whl

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.
acvl_utils/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,93 @@
1
+ from typing import Tuple
2
+
3
+ import torch
4
+ from torch.nn import functional as F
5
+
6
+
7
+ def maybe_resample_on_gpu(data: torch.Tensor,
8
+ target_shape: Tuple[int, ...],
9
+ return_type=torch.half,
10
+ compute_device: str = 'cuda:0', result_device: str = 'cuda:0',
11
+ compute_precision=torch.half,
12
+ fallback_compute_device: str = 'cpu', fallback_result_device: str = 'cpu',
13
+ fallback_compute_precision=torch.float,
14
+ verbose: bool = True) -> torch.Tensor:
15
+ """
16
+ channel-wise resampling function. Most useful for softmax resampling. Tries three things:
17
+ - resample the entire array at once on compute_device (fastest but uses the most memory). returned tensor will be on result_device
18
+ - failing that it will resample channel-wise on compute_device. returned tensor will be on fallback_result_device
19
+ - failing that it will resample channel-wise on fallback_device. returned tensor will be on fallback_result_device
20
+
21
+ data must be a 4d tensor of shape (c, x, y, z). It will be resampled to shape (tx, ty, tz), so the returned tensor
22
+ will have shape (c, tx, ty, tz)
23
+
24
+ returned tensor will be on result_device/fallback_result_device
25
+ returned tensor will have dtype return_type. half recommended if memory is scarce
26
+
27
+ fallback_compute_precision must be torch.float for CPU (half not implemented as of 11/2022)
28
+
29
+ this function does not use with torch.no_grad()!! You need to do this externally
30
+
31
+ Defaults for compute_device, result_device, compute_precision, fallback_compute_device, fallback_result_device,
32
+ fallback_compute_precision are good and should only be deviated from by experts.
33
+ """
34
+
35
+ def _maybe_empty_cache():
36
+ if torch.cuda.is_available(): torch.cuda.empty_cache()
37
+
38
+ def _allocate_result_tensor(shape, dtype, device, fallback_device) -> torch.Tensor:
39
+ try:
40
+ softmax_resampled = torch.zeros(shape, dtype=dtype, device=device)
41
+ except RuntimeError:
42
+ if verbose:
43
+ print(f'could not allocate result tensor of shape {(data.shape[0], *target_shape)} on device '
44
+ f'{result_device}. Using fallback {fallback_device}')
45
+ assert fallback_device != device, 'Allocation on device failed and fallback_device=device so there is no ' \
46
+ 'point attempting this. Duh.'
47
+ softmax_resampled = torch.zeros(shape, dtype=dtype, device=fallback_device)
48
+ finally:
49
+ _maybe_empty_cache()
50
+ return softmax_resampled
51
+
52
+ _maybe_empty_cache()
53
+
54
+ if all([i == j for i, j in zip(data.shape[1:], target_shape)]):
55
+ try:
56
+ return data.to(result_device)
57
+ except RuntimeError:
58
+ return data.to(fallback_result_device)
59
+
60
+ try:
61
+ # try all in one
62
+ result_tensor = \
63
+ F.interpolate(data.to(compute_device).type(compute_precision)[None], target_shape, mode='trilinear')[0].type(
64
+ return_type).to(result_device)
65
+ except RuntimeError:
66
+ if verbose:
67
+ print(f'Resampling all in one failed, attempting channel-wise on compute_device {compute_device}')
68
+ try:
69
+ _maybe_empty_cache()
70
+ # allocate result tensor
71
+ result_tensor = _allocate_result_tensor((data.shape[0], *target_shape), return_type, result_device,
72
+ fallback_result_device)
73
+ for c in range(data.shape[0]):
74
+ result_tensor[c] = F.interpolate(data[c][None, None].to(compute_device).type(compute_precision),
75
+ size=target_shape,
76
+ mode='trilinear')[0, 0].to(result_tensor.device)
77
+ except RuntimeError:
78
+ if verbose:
79
+ print(f'Resampling channel-wise on compute_device {compute_device} failed, attempting chanel-wise '
80
+ f'on fallback {fallback_compute_device}')
81
+ _maybe_empty_cache()
82
+ # allocate result tensor
83
+ result_tensor = _allocate_result_tensor((data.shape[0], *target_shape), return_type, fallback_result_device,
84
+ fallback_result_device)
85
+ for c in range(data.shape[0]):
86
+ result_tensor[c] = F.interpolate(data[c][None, None].to(fallback_compute_device).type(fallback_compute_precision),
87
+ size=target_shape,
88
+ mode='trilinear')[0, 0].to(result_tensor.device)
89
+ finally:
90
+ _maybe_empty_cache()
91
+ finally:
92
+ _maybe_empty_cache()
93
+ return result_tensor
@@ -0,0 +1,66 @@
1
+ import numbers
2
+
3
+
4
+ def slicer(arr, slices):
5
+ """
6
+ A dynamic N-dimensional array slicer that returns a tuple that can be used for slicing an N-dimensional array. Works exactly as Python and Numpy slicing, only with different syntax.
7
+ The conventional slicing method has the drawback that one must know the dimensionality of the array beforehand. By contrast, this slicer can be adapted dynamically at runtime.
8
+ :param arr: The array that should be sliced.
9
+ :param slices: A list of slices that obeys the syntax described below.
10
+ :return A tuple that can be used for slicing the array.
11
+
12
+ Use case example - Center crop augmentation for an N-dimensional image:
13
+ Imaging you have an N-dimensional array. You don't know what dimension it has. You want to crop each dimension to the center:
14
+ image = ...
15
+ print(image.shape) # (32, 32) (48, 32, 32) or ...
16
+ indices = comp_indices(image, width=8)
17
+ # crop = image[???] # Not possible to define a crop with arbitrary dimensions with regular slicing. Instead ...
18
+ if len(image.shape) == 2:
19
+ crop = image[indices[0][0]:indices[0][1], indices[1][0]:indices[1][1]] # image[10:22, 10:22])
20
+ elif len(image.shape) == 3:
21
+ crop = image[indices[0][0]:indices[0][1], indices[1][0]:indices[1][1], indices[2][0]:indices[2][1]] # image[18:30, 10:22, 10:22]
22
+ elif ...
23
+ As you see, this cannot be done with regular slicing as the slicing syntax needs to be hardcoded.
24
+ You would need to create an if case for each possible dimension.
25
+ However, this slicer function enables you to do just that dynamically for data with arbitrary dimensions:
26
+ image = ...
27
+ print(image.shape) # (32, 32) (48, 32, 32) or ...
28
+ indices = comp_indices(image, width=8)
29
+ crop = data[slicer(data, indices)] # Center crop the data
30
+
31
+ Syntax:
32
+ - ... -> None
33
+ - : -> [None]
34
+ - i -> i
35
+ - i:j -> [i, j]
36
+ - i: -> [i, None]
37
+ - :j -> [None, j]
38
+
39
+ Syntax examples:
40
+ - sub_arr = arr[5] -> sub_arr = arr[slicer(arr, [5])]
41
+ - sub_arr = arr[...] -> sub_arr = arr[slicer(arr, [None])]
42
+ - sub_arr = arr[..., 5] -> sub_arr = arr[slicer(arr, [None, 5])]
43
+ - sub_arr = arr[5:9] -> sub_arr = arr[slicer(arr, [[5, 9]])]
44
+ - sub_arr = arr[14:30, 12:17] -> sub_arr = arr[slicer(arr, [[14, 30], [12, 17]])]
45
+ - sub_arr = arr[14:30, 12:17, ...] -> sub_arr = arr[slicer(arr, [[14, 30], [12, 17], None])]
46
+ - sub_arr = arr[14:30, 12:17, :] -> sub_arr = arr[slicer(arr, [[14, 30], [12, 17], [None]])]
47
+ - sub_arr = arr[14:30, :-17, :] -> sub_arr = arr[slicer(arr, [[14, 30], [None, -17], [None]])]
48
+ - arr[5] = 7 -> arr[slicer(arr, [5])] = 7
49
+ - arr[15:30, 12:19] = np.zeros((15, 7)) -> arr[slicer(arr, [[15, 30], [12, 19]])] = np.zeros((15, 7))
50
+
51
+ This function was inspired by the following stackoverflow thread: https://stackoverflow.com/questions/24398708/slicing-a-numpy-array-along-a-dynamically-specified-axis/37729566#37729566
52
+ """
53
+ slc = [slice(None)] * len(arr.shape)
54
+ axis = 0
55
+ for i in range(len(slices)):
56
+ if slices[i] is None: # Ellipsis: Take all values from multiple axes: array[..., ???]
57
+ axis = len(arr.shape) - (len(slices) - 1)
58
+ elif isinstance(slices[i], numbers.Number): # Take single value from a single axis: array[???, i, ???]
59
+ slc[axis] = slices[i]
60
+ axis += 1
61
+ elif len(slices[i]) == 1 and slices[i][0] is None: # Colon: Take all values from a single axis: array[???, :, ???]
62
+ axis += 1
63
+ else: # Take all values from the range i to j on a single axis: array[???, i:j, ???]. i or j can also be None
64
+ slc[axis] = slice(slices[i][0], slices[i][1])
65
+ axis += 1
66
+ return tuple(slc)
File without changes
@@ -0,0 +1,489 @@
1
+ from copy import deepcopy
2
+ from typing import Tuple
3
+ from typing import Union, List
4
+
5
+ import blosc2
6
+ import numpy as np
7
+ import torch
8
+ import torch.nn.functional as F
9
+
10
+
11
+ def pad_bbox(bounding_box: Union[List[List[int]], Tuple[Tuple[int, int]]], pad_amount: Union[int, List[int]],
12
+ array_shape: Tuple[int, ...] = None) -> List[List[int]]:
13
+ """
14
+ Pads a bounding box by a specified amount and optionally ensures that it stays within the bounds of an array.
15
+
16
+ Parameters:
17
+ -----------
18
+ bounding_box : Union[List[List[int]], Tuple[Tuple[int, int]]]
19
+ The input bounding box, represented as a list or tuple of coordinate pairs. Each pair corresponds to
20
+ one dimension and should have the form `[start, end]`, where `start` is inclusive and `end` is exclusive.
21
+
22
+ pad_amount : Union[int, List[int]]
23
+ The amount of padding to apply to each dimension of the bounding box.
24
+ - If an integer is provided, the same padding is applied to all dimensions.
25
+ - If a list is provided, each element specifies the padding for the corresponding dimension.
26
+
27
+ array_shape : Tuple[int, ...], optional
28
+ The shape of the array to which the bounding box is applied.
29
+ If provided, the padded bounding box will be clipped to stay within these bounds.
30
+
31
+ Returns:
32
+ --------
33
+ List[List[int]]
34
+ A new bounding box with the specified padding applied. Each dimension will be represented as a pair `[start, end]`.
35
+
36
+ Notes:
37
+ ------
38
+ - The input `bounding_box` is converted to a list to ensure it can be modified without affecting the original data.
39
+ - The padding is applied symmetrically, reducing the `start` coordinate and increasing the `end` coordinate.
40
+ - If `array_shape` is provided, the padded bounding box will not exceed the valid indices of the array.
41
+
42
+ Examples:
43
+ ---------
44
+ 1. Padding a bounding box without array shape constraints:
45
+ ```python
46
+ bbox = [[2, 10], [5, 15]]
47
+ padded_bbox = pad_bbox(bbox, pad_amount=2)
48
+ # Result: [[0, 12], [3, 17]]
49
+ ```
50
+
51
+ 2. Padding with array shape constraints:
52
+ ```python
53
+ bbox = [[2, 10], [5, 15]]
54
+ array_shape = (12, 20)
55
+ padded_bbox = pad_bbox(bbox, pad_amount=2, array_shape=array_shape)
56
+ # Result: [[0, 12], [3, 17]]
57
+ ```
58
+
59
+ 3. Using dimension-specific padding:
60
+ ```python
61
+ bbox = [[2, 10], [5, 15]]
62
+ padded_bbox = pad_bbox(bbox, pad_amount=[3, 1])
63
+ # Result: [[0, 13], [4, 16]]
64
+ ```
65
+ """
66
+ if isinstance(bounding_box, tuple):
67
+ # convert to list
68
+ bounding_box = [list(i) for i in bounding_box]
69
+ else:
70
+ # needed because otherwise we overwrite input which could have unforseen consequences
71
+ bounding_box = deepcopy(bounding_box)
72
+
73
+ if isinstance(pad_amount, int):
74
+ pad_amount = [pad_amount] * len(bounding_box)
75
+
76
+ for i in range(len(bounding_box)):
77
+ new_values = [max(0, bounding_box[i][0] - pad_amount[i]), bounding_box[i][1] + pad_amount[i]]
78
+ if array_shape is not None:
79
+ new_values[1] = min(array_shape[i], new_values[1])
80
+ bounding_box[i] = new_values
81
+
82
+ return bounding_box
83
+
84
+
85
+ def regionprops_bbox_to_proper_bbox(regionprops_bbox: Tuple[int, ...]) -> List[List[int]]:
86
+ """
87
+ regionprops_bbox is what you get from `from skimage.measure import regionprops`
88
+ """
89
+ dim = len(regionprops_bbox) // 2
90
+ return [[regionprops_bbox[i], regionprops_bbox[i + dim]] for i in range(dim)]
91
+
92
+
93
+ def bounding_box_to_slice(bounding_box: List[List[int]]):
94
+ """
95
+ ALL bounding boxes in acvl_utils and nnU-Netv2 are half open interval [start, end)!
96
+ - Alignment with Python Slicing
97
+ - Ease of Subdivision
98
+ - Consistency in Multi-Dimensional Arrays
99
+ - Precedent in Computer Graphics
100
+ https://chatgpt.com/share/679203ec-3fbc-8013-a003-13a7adfb1e73
101
+ """
102
+ return tuple([slice(*i) for i in bounding_box])
103
+
104
+
105
+ def crop_to_bbox(array: np.ndarray, bounding_box: List[List[int]]):
106
+ """
107
+ ALL bounding boxes in acvl_utils and nnU-Netv2 are half open interval [start, end)!
108
+ - Alignment with Python Slicing
109
+ - Ease of Subdivision
110
+ - Consistency in Multi-Dimensional Arrays
111
+ - Precedent in Computer Graphics
112
+ https://chatgpt.com/share/679203ec-3fbc-8013-a003-13a7adfb1e73
113
+ """
114
+ assert len(bounding_box) == len(array.shape), f"Dimensionality of bbox and array do not match. bbox has length " \
115
+ f"{len(bounding_box)} while array has dimension {len(array.shape)}"
116
+ slicer = bounding_box_to_slice(bounding_box)
117
+ return array[slicer]
118
+
119
+
120
+ def get_bbox_from_mask(mask: np.ndarray) -> List[List[int]]:
121
+ """
122
+ ALL bounding boxes in acvl_utils and nnU-Netv2 are half open interval [start, end)!
123
+ - Alignment with Python Slicing
124
+ - Ease of Subdivision
125
+ - Consistency in Multi-Dimensional Arrays
126
+ - Precedent in Computer Graphics
127
+ https://chatgpt.com/share/679203ec-3fbc-8013-a003-13a7adfb1e73
128
+
129
+ this implementation uses less ram than the np.where one and is faster as well IF we expect the bounding box to
130
+ be close to the image size. If it's not it's likely slower!
131
+
132
+ :param mask:
133
+ :param outside_value:
134
+ :return:
135
+ """
136
+ Z, X, Y = mask.shape
137
+ minzidx, maxzidx, minxidx, maxxidx, minyidx, maxyidx = 0, Z, 0, X, 0, Y
138
+ zidx = list(range(Z))
139
+ for z in zidx:
140
+ if np.any(mask[z]):
141
+ minzidx = z
142
+ break
143
+ for z in zidx[::-1]:
144
+ if np.any(mask[z]):
145
+ maxzidx = z + 1
146
+ break
147
+
148
+ xidx = list(range(X))
149
+ for x in xidx:
150
+ if np.any(mask[:, x]):
151
+ minxidx = x
152
+ break
153
+ for x in xidx[::-1]:
154
+ if np.any(mask[:, x]):
155
+ maxxidx = x + 1
156
+ break
157
+
158
+ yidx = list(range(Y))
159
+ for y in yidx:
160
+ if np.any(mask[:, :, y]):
161
+ minyidx = y
162
+ break
163
+ for y in yidx[::-1]:
164
+ if np.any(mask[:, :, y]):
165
+ maxyidx = y + 1
166
+ break
167
+ return [[minzidx, maxzidx], [minxidx, maxxidx], [minyidx, maxyidx]]
168
+
169
+
170
+ def int_bbox(bbox):
171
+ bbox2 = [[int(i) for i in row] for row in bbox] # Convert all values to int
172
+ if bbox2 != bbox: # Directly compare the original and casted versions
173
+ raise RuntimeError(f"Invalid bbox encountered. Cannot be safely cast to int: {bbox}. Casting result: {bbox2}")
174
+ return bbox2
175
+
176
+
177
+ def get_bbox_from_mask_npwhere(mask: np.ndarray) -> List[List[int]]:
178
+ """
179
+ ALL bounding boxes in acvl_utils and nnU-Netv2 are half open interval [start, end)!
180
+ - Alignment with Python Slicing
181
+ - Ease of Subdivision
182
+ - Consistency in Multi-Dimensional Arrays
183
+ - Precedent in Computer Graphics
184
+ https://chatgpt.com/share/679203ec-3fbc-8013-a003-13a7adfb1e73
185
+ """
186
+ where = np.array(np.where(mask))
187
+ mins = np.min(where, 1)
188
+ maxs = np.max(where, 1) + 1
189
+ return [[i, j] for i, j in zip(mins, maxs)]
190
+
191
+
192
+ def crop_and_pad_nd(
193
+ image: Union[torch.Tensor, np.ndarray, blosc2.ndarray.NDArray],
194
+ bbox: List[List[int]],
195
+ pad_value=0,
196
+ pad_mode: str = 'constant',
197
+ allow_hacky_np_workaround_for_reflect: bool = True,
198
+ cast_cropped_to = None,
199
+ ) -> Union[torch.Tensor, np.ndarray]:
200
+ """
201
+ Crops a bounding box directly specified by bbox, adhering to the half-open interval [start, end).
202
+ If the bounding box extends beyond the image boundaries, the cropped area is padded
203
+ to maintain the desired size. Initial dimensions not included in bbox remain unaffected.
204
+
205
+ CAREFUL: When allow_hacky_np_workaround_for_reflect=True, pad_mode is 'reflect', the input is torch and the
206
+ padding exceeds the dimension of the tensor in the respective dimension (for example paddding with 80 on a dim with
207
+ shape 32) torch will not be able to perform this operation. We fall back to CPU (!!) numpy padding for this!
208
+
209
+ ALL bounding boxes in acvl_utils and nnU-Netv2 are half open interval [start, end)!
210
+ - Alignment with Python Slicing
211
+ - Ease of Subdivision
212
+ - Consistency in Multi-Dimensional Arrays
213
+ - Precedent in Computer Graphics
214
+ https://chatgpt.com/share/679203ec-3fbc-8013-a003-13a7adfb1e73
215
+
216
+ Parameters:
217
+ - image: N-dimensional torch.Tensor, np.ndarray, or blosc2.ndarray.NDArray representing the image.
218
+ - bbox: List of [[dim_min, dim_max], ...] defining the bounding box for the last dimensions.
219
+ Each dimension follows the half-open interval [start, end).
220
+ - pad_value: Value used for padding when bbox extends beyond image boundaries.
221
+ - pad_mode: Padding mode, one of 'constant', 'reflect', or 'replicate' (alias for 'edge').
222
+ - allow_hacky_np_workaround_for_reflect: If True, will perform numpy workaround if torch.pad doesn't work. Only
223
+ happening if pad_mode is 'reflect'
224
+ - cast_cropped_to: Cast the cropped image to this dtype. This needs to be a numpy dtype like np.float32 for blosc2
225
+ ndarrays and np.adarray. For torch this needs to be a torch dtype like torch.float32
226
+
227
+ Returns:
228
+ - Cropped and padded patch of the requested bounding box size, as the same type as `image`.
229
+ """
230
+
231
+ assert pad_mode in ['constant', 'reflect', 'replicate', 'edge'], "Unsupported pad_mode."
232
+
233
+ # make sure bounding boxes are int and not uint. Otherwise we may get underflow
234
+ bbox = int_bbox(bbox)
235
+
236
+ # Determine the number of dimensions to crop based on bbox
237
+ crop_dims = len(bbox)
238
+ img_shape = image.shape
239
+ num_dims = len(img_shape)
240
+
241
+ # Initialize the crop and pad specifications for each dimension
242
+ slices = []
243
+ padding = []
244
+ output_shape = list(img_shape[:num_dims - crop_dims]) # Initial dimensions remain as in the original image
245
+ target_shape = output_shape + [max_val - min_val for min_val, max_val in bbox] # Half-open interval
246
+
247
+ # Iterate through dimensions, applying bbox to the last `crop_dims` dimensions
248
+ for i in range(num_dims):
249
+ if i < num_dims - crop_dims:
250
+ # For initial dimensions not covered by bbox, include the entire dimension
251
+ slices.append(slice(None))
252
+ padding.append([0, 0])
253
+ output_shape.append(img_shape[i])
254
+ else:
255
+ # For dimensions specified in bbox, directly use the min and max bounds
256
+ dim_idx = i - (num_dims - crop_dims) # Index within bbox
257
+
258
+ min_val = bbox[dim_idx][0]
259
+ max_val = bbox[dim_idx][1] # This is exclusive by definition
260
+
261
+ # Check if the bounding box is completely outside the image bounds
262
+ if max_val <= 0 or min_val >= img_shape[i]:
263
+ # If outside bounds, the result is all padding: pad_value in the target dtype (0 for the other pad
264
+ # modes, there is nothing to replicate or reflect)
265
+ fill = pad_value if pad_mode == 'constant' else 0
266
+ dtype = cast_cropped_to if cast_cropped_to is not None else image.dtype
267
+ if isinstance(image, torch.Tensor):
268
+ return torch.full(target_shape, fill, dtype=dtype, device=image.device)
269
+ elif isinstance(image, (np.ndarray, blosc2.ndarray.NDArray)):
270
+ return np.full(target_shape, np.asarray(fill), dtype=dtype)
271
+
272
+ # Calculate valid cropping ranges within image bounds (half-open interval)
273
+ valid_min = max(min_val, 0)
274
+ valid_max = min(max_val, img_shape[i]) # Exclusive upper bound
275
+ slices.append(slice(valid_min, valid_max))
276
+
277
+ # Calculate padding needed for this dimension
278
+ pad_before = max(0, -min_val)
279
+ pad_after = max(0, max_val - img_shape[i])
280
+ padding.append([pad_before, pad_after])
281
+
282
+ # Define the shape based on the bbox range in this dimension
283
+ output_shape.append(max_val - min_val)
284
+
285
+ # numpy and blosc2 with constant padding: allocate the output once, in the target dtype, and write the crop into
286
+ # it (casting on the way). Casting the crop first and padding it afterwards would allocate and copy it twice.
287
+ # This is what np.pad does internally, so the result is identical
288
+ if pad_mode == 'constant' and np.any(padding) and np.ndim(pad_value) == 0 and \
289
+ isinstance(image, (np.ndarray, blosc2.ndarray.NDArray)):
290
+ padded_cropped = np.empty(target_shape, dtype=cast_cropped_to if cast_cropped_to is not None else image.dtype)
291
+ padded_cropped[tuple(slice(b, s - a) for (b, a), s in zip(padding, target_shape))] = image[tuple(slices)]
292
+ # a numpy scalar like np.pad uses: -1 into uint8 then wraps around as it did there instead of raising
293
+ fill = np.asarray(pad_value)[()]
294
+ for d, (pad_before, pad_after) in enumerate(padding):
295
+ if pad_before > 0:
296
+ padded_cropped[(slice(None),) * d + (slice(None, pad_before),)] = fill
297
+ if pad_after > 0:
298
+ padded_cropped[(slice(None),) * d + (slice(target_shape[d] - pad_after, None),)] = fill
299
+ return padded_cropped
300
+
301
+ # Crop the valid part of the bounding box
302
+ cropped = image[tuple(slices)]
303
+ if cast_cropped_to is not None:
304
+ if isinstance(cropped, torch.Tensor):
305
+ cropped = cropped.to(cast_cropped_to)
306
+ elif isinstance(cropped, (np.ndarray, blosc2.ndarray.NDArray)):
307
+ cropped = cropped[:].astype(cast_cropped_to, copy=False)
308
+ else:
309
+ raise ValueError(f'Unsupported image type {type(image)}')
310
+
311
+ # Apply padding to the cropped patch
312
+ if np.any(padding):
313
+ # torch will not allow reflection padding if the amount of padding exceeds the shape in that dimension. Numpy will happily do that. I am annoyed. Implement a numpy fallback for that
314
+ was_torch = False
315
+ if allow_hacky_np_workaround_for_reflect and pad_mode == 'reflect' and isinstance(image, torch.Tensor):
316
+ for d in range(-len(padding), 0):
317
+ if max(padding[d]) < cropped.shape[d]:
318
+ device = cropped.device
319
+ cropped = cropped.cpu().numpy()
320
+ was_torch = True
321
+ print('used numpy workaround')
322
+ break
323
+
324
+ if isinstance(cropped, torch.Tensor):
325
+ if pad_mode == 'edge':
326
+ pad_mode = 'replicate'
327
+ if pad_mode in ['replicate', 'reflect']:
328
+ # pytorch is weird: https://github.com/pytorch/pytorch/issues/147506
329
+ # getting NotImplementedError: Only 2D, 3D, 4D, 5D padding with non-constant padding are supported for now
330
+
331
+ # when padding a 2d array because batch/color channel are expected. This is not documented.
332
+ # wonky workaround that may work, or not. Adding fake dimension. This is fiddly because our
333
+ # image/cropped may have more dimensions than we want to pad and may even have too many dimensions for
334
+ # torch to be happy
335
+
336
+ # ok so pytorch works most reliably if the length of the padding is 2 less than the ndim of cropped
337
+ while padding[0] == [0, 0] and len(padding) > cropped.ndim - 2:
338
+ padding = padding[1:]
339
+ # now check whether cropped.ndim is too small
340
+ n_fake_dims = len(padding) + 2 - cropped.ndim
341
+ if n_fake_dims > 0:
342
+ for i in range(n_fake_dims):
343
+ cropped.unsqueeze_(0)
344
+ # check if we need to add fake padding
345
+ while len(padding) < cropped.ndim - 2:
346
+ padding = [0, 0] + padding
347
+ assert cropped.ndim < 6, 'Torch padding with replicate/reflect works with 3D images at most'
348
+
349
+ flattened_padding = [p for sublist in reversed(padding) for p in sublist] # Flatten in reverse order for PyTorch
350
+ try:
351
+ padded_cropped = F.pad(cropped, flattened_padding, mode=pad_mode, value=pad_value)
352
+ except Exception as e:
353
+ print('Failed torch pad')
354
+ print('cropped', cropped.shape)
355
+ print('cropped device', cropped.device)
356
+ print('cropped type', cropped.dtype)
357
+ print('flattened_padding', flattened_padding)
358
+ print('pad mode', pad_mode)
359
+ print('pad value', pad_value)
360
+ print('image shape', img_shape)
361
+ print('bbox', bbox)
362
+ raise e
363
+
364
+ if pad_mode in ['replicate', 'reflect'] and n_fake_dims > 0:
365
+ for i in range(n_fake_dims):
366
+ padded_cropped.squeeze_(0)
367
+ elif isinstance(cropped, (np.ndarray, blosc2.ndarray.NDArray)):
368
+ if pad_mode == 'replicate':
369
+ pad_mode = 'edge'
370
+ if pad_mode == 'edge' or pad_mode == 'reflect':
371
+ kwargs = {}
372
+ else:
373
+ kwargs = {'constant_values': pad_value}
374
+ pad_width = [(p[0], p[1]) for p in padding]
375
+ padded_cropped = np.pad(cropped, pad_width=pad_width, mode=pad_mode, **kwargs)
376
+ if was_torch:
377
+ padded_cropped = torch.from_numpy(padded_cropped).to(device)
378
+ else:
379
+ raise ValueError(f'Unsupported image type {type(image)}')
380
+ else:
381
+ padded_cropped = cropped
382
+
383
+ return padded_cropped
384
+
385
+
386
+ def insert_crop_into_image(
387
+ image: Union[torch.Tensor, np.ndarray],
388
+ crop: Union[torch.Tensor, np.ndarray],
389
+ bbox: List[List[int]]
390
+ ) -> Union[torch.Tensor, np.ndarray]:
391
+ """
392
+ Inserts a cropped patch back into the original image at the position specified by bbox.
393
+ If the bounding box extends beyond the image boundaries, only the valid portions are inserted.
394
+ If the bounding box lies entirely outside the image, the original image is returned.
395
+
396
+ Parameters:
397
+ - image: Original N-dimensional torch.Tensor or np.ndarray to which the crop will be inserted.
398
+ - crop: Cropped patch of the image to be reinserted. May have additional dimensions compared to bbox.
399
+ - bbox: List of [[dim_min, dim_max], ...] defining the bounding box for the last dimensions of the crop in the original image.
400
+
401
+ Returns:
402
+ - image: The original image with the crop reinserted at the specified location (modified in-place).
403
+ """
404
+ # make sure bounding boxes are int and not uint. Otherwise we may get underflow
405
+ bbox = int_bbox(bbox)
406
+
407
+ # Ensure that bbox only applies to the last len(bbox) dimensions of crop and image
408
+ num_dims = len(image.shape)
409
+ crop_dims = len(crop.shape)
410
+ bbox_dims = len(bbox)
411
+
412
+ if crop_dims < bbox_dims:
413
+ raise ValueError("Bounding box dimensions cannot exceed crop dimensions.")
414
+
415
+ # Validate that non-cropped leading dimensions match between image and crop
416
+ leading_dims = num_dims - bbox_dims
417
+ if image.shape[:leading_dims] != crop.shape[:leading_dims]:
418
+ raise ValueError("Leading dimensions of crop and image must match.")
419
+
420
+ # Check if the bounding box lies completely outside the image bounds for each cropped dimension
421
+ for i in range(bbox_dims):
422
+ min_val, max_val = bbox[i]
423
+ dim_idx = leading_dims + i # Corresponding dimension in the image
424
+
425
+ if max_val <= 0 or min_val >= image.shape[dim_idx]:
426
+ # If completely out of bounds in any dimension, return the original image
427
+ return image
428
+
429
+ # Prepare slices for inserting the crop into the original image
430
+ image_slices = []
431
+ crop_slices = []
432
+
433
+ # Iterate over all dimensions, applying bbox only to the last len(bbox) dimensions
434
+ for i in range(num_dims):
435
+ if i < leading_dims:
436
+ # For leading dimensions, use entire dimension (slice(None)) and validate shape
437
+ image_slices.append(slice(None))
438
+ crop_slices.append(slice(None))
439
+ else:
440
+ # For dimensions specified by bbox, calculate the intersection with image bounds
441
+ dim_idx = i - leading_dims
442
+ min_val, max_val = bbox[dim_idx]
443
+
444
+ crop_start = max(0, -min_val) # Start of the crop within the valid area
445
+ image_start = max(0, min_val) # Start of the image where the crop will be inserted
446
+ image_end = min(max_val, image.shape[i]) # Exclude upper bound by using max_val directly
447
+
448
+ # Adjusted range for insertion
449
+ crop_end = crop_start + (image_end - image_start)
450
+
451
+ # Append slices for both image and crop insertion ranges
452
+ image_slices.append(slice(image_start, image_end))
453
+ crop_slices.append(slice(crop_start, crop_end))
454
+
455
+ # Insert the valid part of the crop back into the original image
456
+ if isinstance(image, torch.Tensor):
457
+ image[tuple(image_slices)] = crop[tuple(crop_slices)]
458
+ elif isinstance(image, np.ndarray):
459
+ image[tuple(image_slices)] = crop[tuple(crop_slices)]
460
+ else:
461
+ raise ValueError(f"Unsupported image type {type(image)}")
462
+
463
+ return image
464
+
465
+
466
+ if __name__ == '__main__':
467
+ # bbox = [[32, 64], [21, 46]]
468
+ # bbox_padded = pad_bbox(bbox, 3)
469
+ # slicer = bounding_box_to_slice(bbox_padded)
470
+
471
+ # Failed torch pad
472
+ # cropped torch.Size([1, 1, 112, 100, 149])
473
+ # cropped device cpu
474
+ # cropped type torch.uint8
475
+ # flattened_padding [6, 0, 0, 0]
476
+ # pad mode replicate
477
+ # pad value 0
478
+
479
+
480
+ # cropped = torch.ones((1, 1, 112, 100, 149), dtype=torch.uint8, device='cpu')
481
+ # pad_mode = 'replicate'
482
+ # flattened_padding = [0, 0, 6, 0, 0, 0]
483
+ # pad_value = 0
484
+ # padded_cropped = F.pad(cropped, flattened_padding, mode=pad_mode, value=pad_value)
485
+
486
+ image = torch.rand((1, 192, 192, 192))
487
+ bbox = [[0, 195], [-1, 204], [-1, 192]]
488
+ ret = crop_and_pad_nd(image, bbox, pad_mode='replicate')
489
+ print(ret.shape)