Skip to content

Commit 24fc86f

Browse files
authored
Merge pull request #515 from FZJ-INM1-BDA/fix_region_get_boundingbox
`provider.boundingbox` -> `provider.get_boundingbox()`
2 parents da431a7 + 2500bd7 commit 24fc86f

File tree

11 files changed

+178
-61
lines changed

11 files changed

+178
-61
lines changed

e2e/core/test_region.py

+17
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,20 @@ def test_related_region_hemisphere():
106106
all_related_reg = [reg for reg in reg.get_related_regions()]
107107
assert any("left" in ass.assigned_structure.name for ass in all_related_reg)
108108
assert any("right" in ass.assigned_structure.name for ass in all_related_reg)
109+
110+
111+
spaces = ['mni152', 'colin27']
112+
113+
114+
@pytest.mark.parametrize("space", spaces)
115+
def test_boundingbox(space):
116+
hoc1_l = siibra.get_region('julich', 'hoc1 left')
117+
hoc1_r = siibra.get_region('julich', 'hoc1 right')
118+
bbox_l = hoc1_l.get_bounding_box(space)
119+
bbox_r = hoc1_r.get_bounding_box(space)
120+
assert bbox_l != bbox_r, "Left and right hoc1 should not have the same bounding boxes"
121+
122+
v_l = hoc1_l.get_regional_map(space)
123+
v_r = hoc1_r.get_regional_map(space)
124+
assert bbox_l == v_l.get_boundingbox(clip=True, background=0.0), "Boundingbox of regional mask should be the same as bouding mask of the regions"
125+
assert bbox_r == v_r.get_boundingbox(clip=True, background=0.0), "Boundingbox of regional mask should be the same as bouding mask of the regions"

examples/02_maps_and_templates/004_access_bigbrain.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@
117117
# %%
118118
# Let's fetch a crop inside hoc5 at full resolution. We intersect the bounding
119119
# box of hoc5l and the section.
120-
hoc5_bbox = hoc5l.get_bounding_box('bigbrain').intersection(section1402.boundingbox)
120+
hoc5_bbox = hoc5l.get_bounding_box('bigbrain').intersection(section1402)
121121
print(f"Size of the bounding box: {hoc5_bbox.shape}")
122122

123123
# this is quite large, so we shrink it

siibra/core/region.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from . import concept, structure, space as _space, parcellation as _parcellation
1818
from .assignment import Qualification, AnatomicalAssignment
1919

20-
from ..locations import location, boundingbox, point, pointset
20+
from ..locations import location, point, pointset
2121
from ..volumes import parcellationmap, volume
2222
from ..commons import (
2323
logger,
@@ -535,7 +535,7 @@ def get_regional_map(
535535
# provided by 'via_space'. Now transform the affine to match the
536536
# desired target space.
537537
intermediary_result = result
538-
transform = intermediary_result.boundingbox.estimate_affine(space)
538+
transform = intermediary_result.get_boundingbox(clip=True, background=0.0).estimate_affine(space)
539539
result = volume.from_array(
540540
imgdata,
541541
np.dot(transform, region_img.affine),
@@ -682,6 +682,7 @@ def get_bounding_box(
682682
space: _space.Space,
683683
maptype: MapType = MapType.LABELLED,
684684
threshold_statistical=None,
685+
**fetch_kwargs
685686
):
686687
"""Compute the bounding box of this region in the given space.
687688
@@ -705,7 +706,7 @@ def get_bounding_box(
705706
mask = self.get_regional_map(
706707
spaceobj, maptype=maptype, threshold=threshold_statistical
707708
)
708-
return mask.boundingbox
709+
return mask.get_boundingbox(clip=True, background=0.0, **fetch_kwargs)
709710
except (RuntimeError, ValueError):
710711
for other_space in self.parcellation.spaces - spaceobj:
711712
try:
@@ -718,7 +719,7 @@ def get_bounding_box(
718719
f"No bounding box for {self.name} defined in {spaceobj.name}, "
719720
f"will warp the bounding box from {other_space.name} instead."
720721
)
721-
bbox = mask.boundingbox
722+
bbox = mask.get_boundingbox(clip=True, background=0.0, **fetch_kwargs)
722723
if bbox is not None:
723724
return bbox.warp(spaceobj)
724725
except RuntimeError:

siibra/features/image/image.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def location(self):
4242
Loads the bounding box only if required, since it demands image data access.
4343
"""
4444
if self._location_cached is None:
45-
self._location_cached = self.volume.boundingbox
45+
self._location_cached = self.volume.get_boundingbox(clip=False) # use unclipped to preseve exisiting behaviour
4646
return self._location_cached
4747

4848
@property

siibra/locations/boundingbox.py

+42-20
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@
2121

2222
import hashlib
2323
import numpy as np
24-
from typing import TYPE_CHECKING
24+
from typing import TYPE_CHECKING, Union
2525
if TYPE_CHECKING:
2626
from ..core.structure import BrainStructure
2727
from nibabel import Nifti1Image
28+
from ..core.space import Space
2829

2930

3031
class BoundingBox(location.Location):
@@ -35,7 +36,14 @@ class BoundingBox(location.Location):
3536
from the two corner points.
3637
"""
3738

38-
def __init__(self, point1, point2, space=None, minsize: float = None, sigma_mm=None):
39+
def __init__(
40+
self,
41+
point1,
42+
point2,
43+
space: Union[str, 'Space'] = None,
44+
minsize: float = None,
45+
sigma_mm=None
46+
):
3947
"""
4048
Construct a new bounding box spanned by two 3D coordinates
4149
in the given reference space.
@@ -99,24 +107,6 @@ def shape(self) -> float:
99107
def is_planar(self) -> bool:
100108
return any(d == 0 for d in self.shape)
101109

102-
@staticmethod
103-
def _determine_bounds(A, threshold=0):
104-
"""
105-
Bounding box of nonzero values in a 3D array.
106-
https://stackoverflow.com/questions/31400769/bounding-box-of-numpy-array
107-
"""
108-
x = np.any(A > threshold, axis=(1, 2))
109-
y = np.any(A > threshold, axis=(0, 2))
110-
z = np.any(A > threshold, axis=(0, 1))
111-
nzx, nzy, nzz = [np.where(v) for v in (x, y, z)]
112-
if any(len(nz[0]) == 0 for nz in [nzx, nzy, nzz]):
113-
# empty array
114-
return None
115-
xmin, xmax = nzx[0][[0, -1]]
116-
ymin, ymax = nzy[0][[0, -1]]
117-
zmin, zmax = nzz[0][[0, -1]]
118-
return np.array([[xmin, xmax + 1], [ymin, ymax + 1], [zmin, zmax + 1], [1, 1]])
119-
120110
def __str__(self):
121111
if self.space is None:
122112
return (
@@ -390,3 +380,35 @@ def __eq__(self, other: 'BoundingBox'):
390380

391381
def __hash__(self):
392382
return super().__hash__()
383+
384+
385+
def _determine_bounds(array: np.ndarray, threshold=0):
386+
"""
387+
Bounding box of nonzero values in a 3D array.
388+
https://stackoverflow.com/questions/31400769/bounding-box-of-numpy-array
389+
"""
390+
x = np.any(array > threshold, axis=(1, 2))
391+
y = np.any(array > threshold, axis=(0, 2))
392+
z = np.any(array > threshold, axis=(0, 1))
393+
nzx, nzy, nzz = [np.where(v) for v in (x, y, z)]
394+
if any(len(nz[0]) == 0 for nz in [nzx, nzy, nzz]):
395+
# empty array
396+
return None
397+
xmin, xmax = nzx[0][[0, -1]]
398+
ymin, ymax = nzy[0][[0, -1]]
399+
zmin, zmax = nzz[0][[0, -1]]
400+
return np.array([[xmin, xmax + 1], [ymin, ymax + 1], [zmin, zmax + 1], [1, 1]])
401+
402+
403+
def from_array(array: np.ndarray, threshold=0, space: "Space" = None) -> "BoundingBox":
404+
"""
405+
Find the bounding box of an array.
406+
407+
Parameters
408+
----------
409+
array : np.ndarray
410+
threshold : int, default: 0
411+
space : Space, default: None
412+
"""
413+
bounds = _determine_bounds(array, threshold)
414+
return BoundingBox(bounds[:3, 0], bounds[:3, 1], space=space)

siibra/volumes/providers/gifti.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ def __init__(self, url: Union[str, Dict[str, str]], volume=None):
4444
def _url(self) -> Union[str, Dict[str, str]]:
4545
return self._init_url
4646

47-
@property
48-
def boundingbox(self) -> _boundingbox.BoundingBox:
47+
def get_boundingbox(self, clip=False, background=0.0, **fetch_kwargs) -> '_boundingbox.BoundingBox':
48+
"""
49+
Bounding box calculation is not yet implemented for meshes.
50+
"""
4951
raise NotImplementedError(
5052
f"Bounding box access to {self.__class__.__name__} objects not yet implemented."
5153
)
@@ -143,10 +145,9 @@ def fetch(self, fragment: str = None, **kwargs):
143145

144146
return {"labels": np.hstack(labels)}
145147

146-
@property
147-
def boundingbox(self) -> _boundingbox.BoundingBox:
148+
def get_boundingbox(self, clip=False, background=0.0) -> '_boundingbox.BoundingBox':
148149
raise NotImplementedError(
149-
f"Bounding boxes of {self.__class__.__name__} objects not defined."
150+
f"Bounding box access to {self.__class__.__name__} objects not yet implemented."
150151
)
151152

152153
@property

siibra/volumes/providers/neuroglancer.py

+37-10
Original file line numberDiff line numberDiff line change
@@ -118,22 +118,47 @@ def fetch(
118118

119119
return result
120120

121-
@property
122-
def boundingbox(self):
121+
def get_boundingbox(self, clip=False, background=0, **fetch_kwargs) -> "_boundingbox.BoundingBox":
123122
"""
124-
Return the bounding box in physical coordinates
125-
of the union of fragments in this neuroglancer volume.
123+
Return the bounding box in physical coordinates of the union of
124+
fragments in this neuroglancer volume.
125+
126+
Parameters
127+
----------
128+
clip: bool, default: True
129+
Whether to clip the background of the volume.
130+
background: float, default: 0.0
131+
The background value to clip.
132+
Note
133+
----
134+
To use it, clip must be True.
135+
fetch_kwargs:
136+
key word arguments that are used for fetchin volumes,
137+
such as voi or resolution_mm.
126138
"""
127139
bbox = None
128140
for frag in self._fragments.values():
129-
next_bbox = _boundingbox.BoundingBox((0, 0, 0), frag.shape, space=None) \
130-
.transform(frag.affine)
141+
if len(frag.shape) > 3:
142+
logger.warning(
143+
f"N-D Neuroglancer volume has shape {frag.shape}, but "
144+
f"bounding box considers only {frag.shape[:3]}"
145+
)
146+
if clip:
147+
img = frag.fetch(**fetch_kwargs)
148+
next_bbox = _boundingbox.from_array(
149+
np.asanyarray(img.dataobj), threshold=background, space=None
150+
).transform(img.affine) # use the affine of the image matching fetch_kwargs
151+
else:
152+
shape = frag.shape[:3]
153+
next_bbox = _boundingbox.BoundingBox(
154+
(0, 0, 0), shape, space=None
155+
).transform(frag.affine)
131156
bbox = next_bbox if bbox is None else bbox.union(next_bbox)
132157
return bbox
133158

134159
def _merge_fragments(self) -> nib.Nifti1Image:
135160
# TODO this only performs nearest neighbor interpolation, optimized for float types.
136-
bbox = self.boundingbox
161+
bbox = self.get_boundingbox(clip=False, background=0.0)
137162
num_conflicts = 0
138163
result = None
139164

@@ -486,10 +511,12 @@ def __init__(self, resource: Union[str, dict], volume=None):
486511
def _url(self) -> Union[str, Dict[str, str]]:
487512
return self._init_url
488513

489-
@property
490-
def boundingbox(self) -> _boundingbox.BoundingBox:
514+
def get_boundingbox(self, clip=False, background=0.0, **fetch_kwargs) -> '_boundingbox.BoundingBox':
515+
"""
516+
Bounding box calculation is not yet implemented for meshes.
517+
"""
491518
raise NotImplementedError(
492-
f"Fast bounding box access to {self.__class__.__name__} objects not yet implemented."
519+
f"Bounding box access to {self.__class__.__name__} objects not yet implemented."
493520
)
494521

495522
def _get_fragment_info(self, meshindex: int) -> Dict[str, Tuple[str, ]]:

siibra/volumes/providers/nifti.py

+30-8
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,28 @@ def _url(self) -> Union[str, Dict[str, str]]:
6565
def fragments(self):
6666
return [k for k in self._img_loaders if k is not None]
6767

68-
@property
69-
def boundingbox(self):
68+
def get_boundingbox(self, clip=True, background=0, **fetch_kwargs) -> "_boundingbox.BoundingBox":
7069
"""
71-
Return the bounding box in physical coordinates
72-
of the union of fragments in this nifti volume.
70+
Return the bounding box in physical coordinates of the union of
71+
fragments in this nifti volume.
72+
73+
Parameters
74+
----------
75+
clip : bool, default: True
76+
Whether to clip the background of the volume.
77+
background : float, default: 0.0
78+
The background value to clip.
79+
Note
80+
----
81+
To use it, clip must be True.
82+
fetch_kwargs:
83+
Not used
7384
"""
85+
if fetch_kwargs:
86+
logger.warning(
87+
"`volume.fetch()` keyword arguments supplied. Nifti volumes"
88+
" cannot pass them for bounding box calculation."
89+
)
7490
bbox = None
7591
for loader in self._img_loaders.values():
7692
img = loader()
@@ -79,15 +95,21 @@ def boundingbox(self):
7995
f"N-D NIfTI volume has shape {img.shape}, but "
8096
f"bounding box considers only {img.shape[:3]}"
8197
)
82-
shape = img.shape[:3]
83-
next_bbox = _boundingbox.BoundingBox((0, 0, 0), shape, space=None) \
84-
.transform(img.affine)
98+
if clip:
99+
next_bbox = _boundingbox.from_array(
100+
np.asanyarray(img.dataobj), threshold=background, space=None
101+
).transform(img.affine)
102+
else:
103+
shape = img.shape[:3]
104+
next_bbox = _boundingbox.BoundingBox(
105+
(0, 0, 0), shape, space=None
106+
).transform(img.affine)
85107
bbox = next_bbox if bbox is None else bbox.union(next_bbox)
86108
return bbox
87109

88110
def _merge_fragments(self) -> nib.Nifti1Image:
89111
# TODO this only performs nearest neighbor interpolation, optimized for float types.
90-
bbox = self.boundingbox
112+
bbox = self.get_boundingbox(clip=False, background=0.0)
91113
num_conflicts = 0
92114
result = None
93115

siibra/volumes/providers/provider.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,8 @@ def __init_subclass__(cls, srctype: str) -> None:
3535
VolumeProvider._SUBCLASSES.append(cls)
3636
return super().__init_subclass__()
3737

38-
@property
3938
@abstractmethod
40-
def boundingbox(self) -> "BoundingBox":
39+
def get_boundingbox(self, clip=True, background=0.0) -> "BoundingBox":
4140
raise NotImplementedError
4241

4342
@property
@@ -84,9 +83,8 @@ def __init__(self, parent_provider: VolumeProvider, z: int):
8483
self.srctype = parent_provider.srctype
8584
self.z = z
8685

87-
@property
88-
def boundingbox(self) -> "BoundingBox":
89-
return self.provider.boundingbox
86+
def get_boundingbox(self, clip=True, background=0.0, **fetch_kwargs) -> "BoundingBox":
87+
return self.provider.get_boundingbox(clip=clip, background=background, **fetch_kwargs)
9088

9189
def fetch(self, **kwargs):
9290
# activate caching at the caller using "with SubvolumeProvider.UseCaching():""

0 commit comments

Comments
 (0)