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 +0 -0
- acvl_utils/array_manipulation/__init__.py +0 -0
- acvl_utils/array_manipulation/resampling.py +93 -0
- acvl_utils/array_manipulation/slicer.py +66 -0
- acvl_utils/cropping_and_padding/__init__.py +0 -0
- acvl_utils/cropping_and_padding/bounding_boxes.py +489 -0
- acvl_utils/cropping_and_padding/padding.py +107 -0
- acvl_utils/instance_segmentation/__init__.py +0 -0
- acvl_utils/instance_segmentation/instance_as_semantic_seg.py +522 -0
- acvl_utils/instance_segmentation/instance_matching.py +126 -0
- acvl_utils/miscellaneous/__init__.py +0 -0
- acvl_utils/miscellaneous/ptqdm.py +46 -0
- acvl_utils/miscellaneous/test_all_images_same.py +89 -0
- acvl_utils/morphology/__init__.py +0 -0
- acvl_utils/morphology/gpu_binary_morphology.py +144 -0
- acvl_utils/morphology/morphology_helper.py +194 -0
- acvl_utils/morphology/slice_interpolation.py +65 -0
- acvl_utils-0.2.7.dist-info/METADATA +46 -0
- acvl_utils-0.2.7.dist-info/RECORD +22 -0
- acvl_utils-0.2.7.dist-info/WHEEL +5 -0
- acvl_utils-0.2.7.dist-info/licenses/LICENCE +201 -0
- acvl_utils-0.2.7.dist-info/top_level.txt +1 -0
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)
|