siibra 0.5a2__py3-none-any.whl → 1.0.0a1__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.
Potentially problematic release.
This version of siibra might be problematic. Click here for more details.
- siibra/VERSION +1 -1
- siibra/__init__.py +20 -12
- siibra/commons.py +145 -90
- siibra/configuration/__init__.py +1 -1
- siibra/configuration/configuration.py +22 -17
- siibra/configuration/factory.py +177 -128
- siibra/core/__init__.py +1 -8
- siibra/core/{relation_qualification.py → assignment.py} +17 -14
- siibra/core/atlas.py +66 -35
- siibra/core/concept.py +81 -39
- siibra/core/parcellation.py +83 -67
- siibra/core/region.py +569 -263
- siibra/core/space.py +7 -39
- siibra/core/structure.py +111 -0
- siibra/exceptions.py +63 -0
- siibra/experimental/__init__.py +19 -0
- siibra/experimental/contour.py +61 -0
- siibra/experimental/cortical_profile_sampler.py +57 -0
- siibra/experimental/patch.py +98 -0
- siibra/experimental/plane3d.py +256 -0
- siibra/explorer/__init__.py +16 -0
- siibra/explorer/url.py +112 -52
- siibra/explorer/util.py +31 -9
- siibra/features/__init__.py +73 -8
- siibra/features/anchor.py +75 -196
- siibra/features/connectivity/__init__.py +1 -1
- siibra/features/connectivity/functional_connectivity.py +2 -2
- siibra/features/connectivity/regional_connectivity.py +99 -10
- siibra/features/connectivity/streamline_counts.py +1 -1
- siibra/features/connectivity/streamline_lengths.py +1 -1
- siibra/features/connectivity/tracing_connectivity.py +1 -1
- siibra/features/dataset/__init__.py +1 -1
- siibra/features/dataset/ebrains.py +3 -3
- siibra/features/feature.py +219 -110
- siibra/features/image/__init__.py +1 -1
- siibra/features/image/image.py +21 -13
- siibra/features/image/sections.py +1 -1
- siibra/features/image/volume_of_interest.py +1 -1
- siibra/features/tabular/__init__.py +1 -1
- siibra/features/tabular/bigbrain_intensity_profile.py +24 -13
- siibra/features/tabular/cell_density_profile.py +111 -69
- siibra/features/tabular/cortical_profile.py +82 -16
- siibra/features/tabular/gene_expression.py +117 -6
- siibra/features/tabular/layerwise_bigbrain_intensities.py +7 -9
- siibra/features/tabular/layerwise_cell_density.py +9 -24
- siibra/features/tabular/receptor_density_fingerprint.py +11 -6
- siibra/features/tabular/receptor_density_profile.py +12 -15
- siibra/features/tabular/regional_timeseries_activity.py +74 -18
- siibra/features/tabular/tabular.py +17 -8
- siibra/livequeries/__init__.py +1 -7
- siibra/livequeries/allen.py +139 -77
- siibra/livequeries/bigbrain.py +104 -128
- siibra/livequeries/ebrains.py +7 -4
- siibra/livequeries/query.py +1 -2
- siibra/locations/__init__.py +32 -25
- siibra/locations/boundingbox.py +153 -127
- siibra/locations/location.py +45 -80
- siibra/locations/point.py +97 -83
- siibra/locations/pointcloud.py +349 -0
- siibra/retrieval/__init__.py +1 -1
- siibra/retrieval/cache.py +107 -13
- siibra/retrieval/datasets.py +9 -14
- siibra/retrieval/exceptions/__init__.py +2 -1
- siibra/retrieval/repositories.py +147 -53
- siibra/retrieval/requests.py +64 -29
- siibra/vocabularies/__init__.py +2 -2
- siibra/volumes/__init__.py +7 -9
- siibra/volumes/parcellationmap.py +396 -253
- siibra/volumes/providers/__init__.py +20 -0
- siibra/volumes/providers/freesurfer.py +113 -0
- siibra/volumes/{gifti.py → providers/gifti.py} +29 -18
- siibra/volumes/{neuroglancer.py → providers/neuroglancer.py} +204 -92
- siibra/volumes/{nifti.py → providers/nifti.py} +64 -44
- siibra/volumes/providers/provider.py +107 -0
- siibra/volumes/sparsemap.py +159 -260
- siibra/volumes/volume.py +720 -152
- {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/METADATA +25 -28
- siibra-1.0.0a1.dist-info/RECORD +84 -0
- {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/WHEEL +1 -1
- siibra/locations/pointset.py +0 -198
- siibra-0.5a2.dist-info/RECORD +0 -74
- {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/LICENSE +0 -0
- {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/top_level.txt +0 -0
siibra/locations/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright 2018-
|
|
1
|
+
# Copyright 2018-2024
|
|
2
2
|
# Institute of Neuroscience and Medicine (INM-1), Forschungszentrum Jülich GmbH
|
|
3
3
|
|
|
4
4
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
# limitations under the License.
|
|
15
15
|
"""Handles spatial concepts and spatial operation like warping between spaces."""
|
|
16
16
|
|
|
17
|
-
from .location import
|
|
17
|
+
from .location import Location
|
|
18
18
|
from .point import Point
|
|
19
|
-
from .
|
|
19
|
+
from .pointcloud import PointCloud, from_points
|
|
20
20
|
from .boundingbox import BoundingBox
|
|
21
21
|
|
|
22
22
|
|
|
@@ -36,11 +36,11 @@ def reassign_union(loc0: 'Location', loc1: 'Location') -> 'Location':
|
|
|
36
36
|
Returns
|
|
37
37
|
-------
|
|
38
38
|
Location
|
|
39
|
-
- Point U Point =
|
|
40
|
-
- Point U
|
|
41
|
-
-
|
|
39
|
+
- Point U Point = PointCloud
|
|
40
|
+
- Point U PointCloud = PointCloud
|
|
41
|
+
- PointCloud U PointCloud = PointCloud
|
|
42
42
|
- BoundingBox U BoundingBox = BoundingBox
|
|
43
|
-
- BoundingBox U
|
|
43
|
+
- BoundingBox U PointCloud = BoundingBox
|
|
44
44
|
- BoundingBox U Point = BoundingBox
|
|
45
45
|
- WholeBrain U Location = NotImplementedError
|
|
46
46
|
(all operations are commutative)
|
|
@@ -48,32 +48,39 @@ def reassign_union(loc0: 'Location', loc1: 'Location') -> 'Location':
|
|
|
48
48
|
if loc0 is None or loc1 is None:
|
|
49
49
|
return loc0 or loc1
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
# All location types should be unionable among each other and this should
|
|
52
|
+
# be implemented here to avoid code repetition. Volumes are the only type of
|
|
53
|
+
# location that has its own union method since it is not a part of locations
|
|
54
|
+
# module and to avoid importing Volume here.
|
|
55
|
+
if not all(
|
|
56
|
+
isinstance(loc, (Point, PointCloud, BoundingBox)) for loc in [loc0, loc1]
|
|
57
|
+
):
|
|
58
|
+
try:
|
|
59
|
+
return loc1.union(loc0)
|
|
60
|
+
except Exception:
|
|
61
|
+
raise NotImplementedError(f"There are no union method for {(loc0.__class__.__name__, loc1.__class__.__name__)}")
|
|
53
62
|
|
|
54
|
-
|
|
63
|
+
# convert Points to PointClouds
|
|
64
|
+
loc0, loc1 = [
|
|
65
|
+
from_points([loc]) if isinstance(loc, Point) else loc
|
|
66
|
+
for loc in [loc0, loc1]
|
|
67
|
+
]
|
|
55
68
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
PointSet([loc0], space=loc0.space, sigma_mm=loc0.sigma), loc1_w
|
|
59
|
-
)
|
|
69
|
+
# adopt the space of the first location
|
|
70
|
+
loc1_w = loc1.warp(loc0.space)
|
|
60
71
|
|
|
61
|
-
if isinstance(loc0,
|
|
62
|
-
if isinstance(loc1_w,
|
|
63
|
-
points =
|
|
64
|
-
return
|
|
65
|
-
points,
|
|
66
|
-
space=loc0.space,
|
|
67
|
-
sigma_mm=[p.sigma for p in points],
|
|
68
|
-
)
|
|
72
|
+
if isinstance(loc0, PointCloud):
|
|
73
|
+
if isinstance(loc1_w, PointCloud):
|
|
74
|
+
points = list(dict.fromkeys([*loc0, *loc1_w]))
|
|
75
|
+
return from_points(points)
|
|
69
76
|
if isinstance(loc1_w, BoundingBox):
|
|
70
77
|
return reassign_union(loc0.boundingbox, loc1_w)
|
|
71
78
|
|
|
72
79
|
if isinstance(loc0, BoundingBox) and isinstance(loc1_w, BoundingBox):
|
|
73
|
-
|
|
80
|
+
coordinates = [loc0.minpoint, loc0.maxpoint, loc1_w.minpoint, loc1_w.maxpoint]
|
|
74
81
|
return BoundingBox(
|
|
75
|
-
point1=[min(p[i] for p in
|
|
76
|
-
point2=[max(p[i] for p in
|
|
82
|
+
point1=[min(p[i] for p in coordinates) for i in range(3)],
|
|
83
|
+
point2=[max(p[i] for p in coordinates) for i in range(3)],
|
|
77
84
|
space=loc0.space,
|
|
78
85
|
sigma_mm=[loc0.minpoint.sigma, loc0.maxpoint.sigma]
|
|
79
86
|
)
|
siibra/locations/boundingbox.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright 2018-
|
|
1
|
+
# Copyright 2018-2024
|
|
2
2
|
# Institute of Neuroscience and Medicine (INM-1), Forschungszentrum Jülich GmbH
|
|
3
3
|
|
|
4
4
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
@@ -14,14 +14,19 @@
|
|
|
14
14
|
# limitations under the License.
|
|
15
15
|
"""A box defined by two farthest corner coordinates on a specific space."""
|
|
16
16
|
|
|
17
|
-
from . import point,
|
|
17
|
+
from . import point, pointcloud, location
|
|
18
18
|
|
|
19
19
|
from ..commons import logger
|
|
20
|
+
from ..exceptions import SpaceWarpingFailedError
|
|
20
21
|
|
|
22
|
+
from itertools import product
|
|
21
23
|
import hashlib
|
|
22
24
|
import numpy as np
|
|
23
|
-
from typing import Union
|
|
24
|
-
|
|
25
|
+
from typing import TYPE_CHECKING, Union
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from ..core.structure import BrainStructure
|
|
28
|
+
from nibabel import Nifti1Image
|
|
29
|
+
from ..core.space import Space
|
|
25
30
|
|
|
26
31
|
|
|
27
32
|
class BoundingBox(location.Location):
|
|
@@ -32,13 +37,18 @@ class BoundingBox(location.Location):
|
|
|
32
37
|
from the two corner points.
|
|
33
38
|
"""
|
|
34
39
|
|
|
35
|
-
def __init__(
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
point1,
|
|
43
|
+
point2,
|
|
44
|
+
space: Union[str, 'Space'] = None,
|
|
45
|
+
minsize: float = None,
|
|
46
|
+
sigma_mm=None
|
|
47
|
+
):
|
|
36
48
|
"""
|
|
37
49
|
Construct a new bounding box spanned by two 3D coordinates
|
|
38
50
|
in the given reference space.
|
|
39
51
|
|
|
40
|
-
TODO allow to pass sigma for the points, if tuples
|
|
41
|
-
|
|
42
52
|
Parameters
|
|
43
53
|
----------
|
|
44
54
|
point1 : Point or 3-tuple
|
|
@@ -53,6 +63,7 @@ class BoundingBox(location.Location):
|
|
|
53
63
|
sigma_mm : float, or list of float
|
|
54
64
|
Optional standard deviation of the spanning point locations.
|
|
55
65
|
"""
|
|
66
|
+
# TODO: allow to pass sigma for the points, if tuples
|
|
56
67
|
location.Location.__init__(self, space)
|
|
57
68
|
xyz1 = point.Point.parse(point1)
|
|
58
69
|
xyz2 = point.Point.parse(point2)
|
|
@@ -65,6 +76,7 @@ class BoundingBox(location.Location):
|
|
|
65
76
|
s1, s2 = sigma_mm
|
|
66
77
|
else:
|
|
67
78
|
raise ValueError(f"Cannot interpret sigma_mm parameter value {sigma_mm} for bounding box")
|
|
79
|
+
self.sigma_mm = [s1, s2]
|
|
68
80
|
self.minpoint = point.Point([min(xyz1[i], xyz2[i]) for i in range(3)], space, sigma_mm=s1)
|
|
69
81
|
self.maxpoint = point.Point([max(xyz1[i], xyz2[i]) for i in range(3)], space, sigma_mm=s2)
|
|
70
82
|
if minsize is not None:
|
|
@@ -72,129 +84,83 @@ class BoundingBox(location.Location):
|
|
|
72
84
|
if self.shape[d] < minsize:
|
|
73
85
|
self.maxpoint[d] = self.minpoint[d] + minsize
|
|
74
86
|
|
|
87
|
+
if self.volume == 0:
|
|
88
|
+
logger.warning(f"Zero-volume bounding box from points {point1} and {point2} in {self.space} space.")
|
|
89
|
+
|
|
75
90
|
@property
|
|
76
91
|
def id(self) -> str:
|
|
77
92
|
return hashlib.md5(str(self).encode("utf-8")).hexdigest()
|
|
78
93
|
|
|
79
94
|
@property
|
|
80
|
-
def volume(self):
|
|
95
|
+
def volume(self) -> float:
|
|
96
|
+
"""The volume of the boundingbox in mm^3"""
|
|
81
97
|
return np.prod(self.shape)
|
|
82
98
|
|
|
83
99
|
@property
|
|
84
|
-
def center(self):
|
|
100
|
+
def center(self) -> 'point.Point':
|
|
85
101
|
return self.minpoint + (self.maxpoint - self.minpoint) / 2
|
|
86
102
|
|
|
87
103
|
@property
|
|
88
|
-
def shape(self):
|
|
104
|
+
def shape(self) -> float:
|
|
105
|
+
"""The distances of the diagonal points in each axis. (Accounts for sigma)."""
|
|
89
106
|
return tuple(
|
|
90
107
|
(self.maxpoint + self.maxpoint.sigma)
|
|
91
108
|
- (self.minpoint - self.minpoint.sigma)
|
|
92
109
|
)
|
|
93
110
|
|
|
94
111
|
@property
|
|
95
|
-
def is_planar(self):
|
|
112
|
+
def is_planar(self) -> bool:
|
|
96
113
|
return any(d == 0 for d in self.shape)
|
|
97
114
|
|
|
98
|
-
@staticmethod
|
|
99
|
-
def _determine_bounds(A, threshold=0):
|
|
100
|
-
"""
|
|
101
|
-
Bounding box of nonzero values in a 3D array.
|
|
102
|
-
https://stackoverflow.com/questions/31400769/bounding-box-of-numpy-array
|
|
103
|
-
"""
|
|
104
|
-
x = np.any(A > threshold, axis=(1, 2))
|
|
105
|
-
y = np.any(A > threshold, axis=(0, 2))
|
|
106
|
-
z = np.any(A > threshold, axis=(0, 1))
|
|
107
|
-
nzx, nzy, nzz = [np.where(v) for v in (x, y, z)]
|
|
108
|
-
if any(len(nz[0]) == 0 for nz in [nzx, nzy, nzz]):
|
|
109
|
-
# empty array
|
|
110
|
-
return None
|
|
111
|
-
xmin, xmax = nzx[0][[0, -1]]
|
|
112
|
-
ymin, ymax = nzy[0][[0, -1]]
|
|
113
|
-
zmin, zmax = nzz[0][[0, -1]]
|
|
114
|
-
return np.array([[xmin, xmax + 1], [ymin, ymax + 1], [zmin, zmax + 1], [1, 1]])
|
|
115
|
-
|
|
116
|
-
@classmethod
|
|
117
|
-
def from_image(cls, image: Nifti1Image, space, ignore_affine=False, threshold=0):
|
|
118
|
-
"""Construct a bounding box from a nifti image"""
|
|
119
|
-
bounds = cls._determine_bounds(image.get_fdata(), threshold=threshold)
|
|
120
|
-
if bounds is None:
|
|
121
|
-
return None
|
|
122
|
-
if ignore_affine:
|
|
123
|
-
target_space = None
|
|
124
|
-
else:
|
|
125
|
-
bounds = np.dot(image.affine, bounds)
|
|
126
|
-
target_space = space
|
|
127
|
-
return BoundingBox(point1=bounds[:3, 0], point2=bounds[:3, 1], space=target_space)
|
|
128
|
-
|
|
129
115
|
def __str__(self):
|
|
130
116
|
if self.space is None:
|
|
131
117
|
return (
|
|
132
|
-
f"Bounding box from ({','.join(f'{v:.2f}' for v in self.minpoint)})
|
|
133
|
-
f"to ({','.join(f'{v:.2f}' for v in self.maxpoint)})
|
|
118
|
+
f"Bounding box from ({','.join(f'{v:.2f}' for v in self.minpoint)})mm "
|
|
119
|
+
f"to ({','.join(f'{v:.2f}' for v in self.maxpoint)})mm"
|
|
134
120
|
)
|
|
135
121
|
else:
|
|
136
122
|
return (
|
|
137
|
-
f"Bounding box from ({','.join(f'{v:.2f}' for v in self.minpoint)})
|
|
123
|
+
f"Bounding box from ({','.join(f'{v:.2f}' for v in self.minpoint)})mm "
|
|
138
124
|
f"to ({','.join(f'{v:.2f}' for v in self.maxpoint)})mm in {self.space.name} space"
|
|
139
125
|
)
|
|
140
126
|
|
|
141
|
-
def
|
|
142
|
-
"""Returns true if the bounding box contains the given location."""
|
|
143
|
-
if isinstance(other, point.Point):
|
|
144
|
-
return (other >= self.minpoint) and (other <= self.maxpoint)
|
|
145
|
-
elif isinstance(other, pointset.PointSet):
|
|
146
|
-
return all(self.contains(p) for p in other)
|
|
147
|
-
elif isinstance(other, boundingbox.BoundingBox):
|
|
148
|
-
return all([
|
|
149
|
-
other.minpoint >= self.minpoint,
|
|
150
|
-
other.maxpoint <= self.maxpoint
|
|
151
|
-
])
|
|
152
|
-
elif isinstance(other, Nifti1Image):
|
|
153
|
-
return self.contains(BoundingBox.from_image(other, space=self.space))
|
|
154
|
-
else:
|
|
155
|
-
raise NotImplementedError(
|
|
156
|
-
f"Cannot test containedness of {type(other)} in {self.__class__.__name__}"
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
def contained_in(self, other: Union[location.Location, Nifti1Image]):
|
|
160
|
-
if isinstance(other, location.Location):
|
|
161
|
-
return other.contains(self)
|
|
162
|
-
elif isinstance(other, Nifti1Image):
|
|
163
|
-
return self.contained_in(BoundingBox.from_image(other, space=self.space))
|
|
164
|
-
else:
|
|
165
|
-
raise RuntimeError(f"Cannot test containedness of {self} in type {other.__class__}")
|
|
166
|
-
|
|
167
|
-
def intersects(self, other: Union[location.Location, Nifti1Image]):
|
|
168
|
-
intersection = self.intersection(other)
|
|
169
|
-
if intersection is None:
|
|
170
|
-
return False
|
|
171
|
-
else:
|
|
172
|
-
return intersection.volume > 0
|
|
173
|
-
|
|
174
|
-
def intersection(self, other, dims=[0, 1, 2], threshold=0):
|
|
127
|
+
def intersection(self, other: 'BrainStructure', dims=[0, 1, 2]):
|
|
175
128
|
"""Computes the intersection of this bounding box with another one.
|
|
176
129
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
other: BrainStructure
|
|
133
|
+
dims: List[int], default: all three
|
|
134
|
+
Dimensions where the intersection should be computed
|
|
135
|
+
(applies only to bounding boxes). Along dimensions not listed,
|
|
136
|
+
the union is applied instead.
|
|
184
137
|
"""
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
138
|
+
# TODO: process the sigma values o the points
|
|
139
|
+
if isinstance(other, BoundingBox):
|
|
140
|
+
try:
|
|
141
|
+
return self._intersect_bbox(other, dims)
|
|
142
|
+
except SpaceWarpingFailedError:
|
|
143
|
+
return other._intersect_bbox(self, dims) # TODO: check this mechanism carefully
|
|
144
|
+
if isinstance(other, point.Point):
|
|
145
|
+
warped = other.warp(self.space)
|
|
146
|
+
return other if self.minpoint <= warped <= self.maxpoint else None
|
|
147
|
+
if isinstance(other, pointcloud.PointCloud):
|
|
148
|
+
points_inside = [p for p in other if self.intersects(p)]
|
|
149
|
+
if len(points_inside) == 0:
|
|
150
|
+
return None
|
|
151
|
+
result = pointcloud.PointCloud(
|
|
152
|
+
points_inside,
|
|
153
|
+
space=other.space,
|
|
154
|
+
sigma_mm=[p.sigma for p in points_inside]
|
|
192
155
|
)
|
|
156
|
+
return result[0] if len(result) == 1 else result # if PointCloud has single point return as a Point
|
|
157
|
+
|
|
158
|
+
return other.intersection(self)
|
|
193
159
|
|
|
194
|
-
def _intersect_bbox(self, other, dims=[0, 1, 2]):
|
|
160
|
+
def _intersect_bbox(self, other: 'BoundingBox', dims=[0, 1, 2]):
|
|
195
161
|
warped = other.warp(self.space)
|
|
196
162
|
|
|
197
|
-
# Determine the intersecting
|
|
163
|
+
# Determine the intersecting bounsding box by sorting
|
|
198
164
|
# the coordinates of both bounding boxes for each dimension,
|
|
199
165
|
# and fetching the second and third coordinate after sorting.
|
|
200
166
|
# If those belong to a minimum and maximum point,
|
|
@@ -222,14 +188,20 @@ class BoundingBox(location.Location):
|
|
|
222
188
|
result_minpt.append(A[dim])
|
|
223
189
|
result_maxpt.append(B[dim])
|
|
224
190
|
|
|
191
|
+
if result_minpt == result_maxpt:
|
|
192
|
+
return result_minpt
|
|
193
|
+
|
|
225
194
|
bbox = BoundingBox(
|
|
226
195
|
point1=point.Point(result_minpt, self.space),
|
|
227
196
|
point2=point.Point(result_maxpt, self.space),
|
|
228
197
|
space=self.space,
|
|
229
198
|
)
|
|
230
|
-
return bbox if bbox.volume > 0 else None
|
|
231
199
|
|
|
232
|
-
|
|
200
|
+
if bbox.volume == 0 and sum(cmin == cmax for cmin, cmax in zip(result_minpt, result_maxpt)) == 2:
|
|
201
|
+
return None
|
|
202
|
+
return bbox
|
|
203
|
+
|
|
204
|
+
def _intersect_mask(self, mask: 'Nifti1Image', threshold=0):
|
|
233
205
|
"""Intersect this bounding box with an image mask. Returns None if they do not intersect.
|
|
234
206
|
|
|
235
207
|
TODO process the sigma values o the points
|
|
@@ -268,8 +240,8 @@ class BoundingBox(location.Location):
|
|
|
268
240
|
)
|
|
269
241
|
else:
|
|
270
242
|
return self._intersect_bbox(
|
|
271
|
-
|
|
272
|
-
.
|
|
243
|
+
pointcloud
|
|
244
|
+
.PointCloud(XYZ, space=self.space, sigma_mm=voxel_size.max())
|
|
273
245
|
.boundingbox
|
|
274
246
|
)
|
|
275
247
|
|
|
@@ -284,6 +256,35 @@ class BoundingBox(location.Location):
|
|
|
284
256
|
sigma_mm=[self.minpoint.sigma, self.maxpoint.sigma]
|
|
285
257
|
)
|
|
286
258
|
|
|
259
|
+
@property
|
|
260
|
+
def corners(self):
|
|
261
|
+
"""
|
|
262
|
+
Returns all 8 corners of the box as a pointcloud.
|
|
263
|
+
|
|
264
|
+
Note
|
|
265
|
+
----
|
|
266
|
+
x0, y0, z0 = self.minpoint
|
|
267
|
+
x1, y1, z1 = self.maxpoint
|
|
268
|
+
all_corners = [
|
|
269
|
+
(x0, y0, z0),
|
|
270
|
+
(x1, y0, z0),
|
|
271
|
+
(x0, y1, z0),
|
|
272
|
+
(x1, y1, z0),
|
|
273
|
+
(x0, y0, z1),
|
|
274
|
+
(x1, y0, z1),
|
|
275
|
+
(x0, y1, z1),
|
|
276
|
+
(x1, y1, z1)
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
TODO: deal with sigma. Currently, returns the mean of min and max point.
|
|
280
|
+
"""
|
|
281
|
+
xs, ys, zs = zip(self.minpoint, self.maxpoint)
|
|
282
|
+
return pointcloud.PointCloud(
|
|
283
|
+
coordinates=[[x, y, z] for x, y, z in product(xs, ys, zs)],
|
|
284
|
+
space=self.space,
|
|
285
|
+
sigma_mm=np.mean([self.minpoint.sigma, self.maxpoint.sigma])
|
|
286
|
+
)
|
|
287
|
+
|
|
287
288
|
def warp(self, space):
|
|
288
289
|
"""Returns a new bounding box obtained by warping the
|
|
289
290
|
min- and maxpoint of this one into the new target space.
|
|
@@ -296,27 +297,10 @@ class BoundingBox(location.Location):
|
|
|
296
297
|
return self
|
|
297
298
|
else:
|
|
298
299
|
try:
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
)
|
|
304
|
-
except ValueError:
|
|
305
|
-
logger.debug(f"Warping {str(self)} to {spaceobj.name} not successful.")
|
|
306
|
-
return None
|
|
307
|
-
|
|
308
|
-
def fetch_regional_map(self):
|
|
309
|
-
"""Generate a volumetric binary mask of this
|
|
310
|
-
bounding box in the reference template space."""
|
|
311
|
-
tpl = self.space.get_template().fetch()
|
|
312
|
-
arr = np.zeros(tpl.shape, dtype="uint8")
|
|
313
|
-
bbvox = self.transform(np.linalg.inv(tpl.affine))
|
|
314
|
-
arr[
|
|
315
|
-
int(bbvox.minpoint[0]): int(bbvox.maxpoint[0]),
|
|
316
|
-
int(bbvox.minpoint[1]): int(bbvox.maxpoint[2]),
|
|
317
|
-
int(bbvox.minpoint[2]): int(bbvox.maxpoint[2]),
|
|
318
|
-
] = 1
|
|
319
|
-
return Nifti1Image(arr, tpl.affine)
|
|
300
|
+
warped_corners = self.corners.warp(spaceobj)
|
|
301
|
+
except SpaceWarpingFailedError:
|
|
302
|
+
raise SpaceWarpingFailedError(f"Warping {str(self)} to {spaceobj.name} not successful.")
|
|
303
|
+
return warped_corners.boundingbox
|
|
320
304
|
|
|
321
305
|
def transform(self, affine: np.ndarray, space=None):
|
|
322
306
|
"""Returns a new bounding box obtained by transforming the
|
|
@@ -335,12 +319,9 @@ class BoundingBox(location.Location):
|
|
|
335
319
|
"""
|
|
336
320
|
from ..core.space import Space
|
|
337
321
|
spaceobj = Space.get_instance(space)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
space=space,
|
|
342
|
-
sigma_mm=[self.minpoint.sigma, self.maxpoint.sigma] # TODO: error propagation
|
|
343
|
-
)
|
|
322
|
+
result = self.corners.transform(affine, spaceobj).boundingbox
|
|
323
|
+
result.sigma_mm = [self.minpoint.sigma, self.maxpoint.sigma] # TODO: error propagation
|
|
324
|
+
return result
|
|
344
325
|
|
|
345
326
|
def shift(self, offset):
|
|
346
327
|
return self.__class__(
|
|
@@ -379,7 +360,7 @@ class BoundingBox(location.Location):
|
|
|
379
360
|
x1, y1, z1 = self.maxpoint
|
|
380
361
|
|
|
381
362
|
# set of 8 corner points in source space
|
|
382
|
-
corners1 =
|
|
363
|
+
corners1 = pointcloud.PointCloud(
|
|
383
364
|
[
|
|
384
365
|
(x0, y0, z0),
|
|
385
366
|
(x0, y0, z1),
|
|
@@ -426,3 +407,48 @@ class BoundingBox(location.Location):
|
|
|
426
407
|
def __iter__(self):
|
|
427
408
|
"""Iterate the min- and maxpoint of this bounding box."""
|
|
428
409
|
return iter((self.minpoint, self.maxpoint))
|
|
410
|
+
|
|
411
|
+
def __eq__(self, other: 'BoundingBox'):
|
|
412
|
+
if not isinstance(other, BoundingBox):
|
|
413
|
+
return False
|
|
414
|
+
return self.minpoint == other.minpoint and self.maxpoint == other.maxpoint
|
|
415
|
+
|
|
416
|
+
def __hash__(self):
|
|
417
|
+
return super().__hash__()
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _determine_bounds(masked_array: np.ndarray, background: float = 0.0):
|
|
421
|
+
"""
|
|
422
|
+
Bounding box of nonzero (background) values in a 3D array.
|
|
423
|
+
|
|
424
|
+
https://stackoverflow.com/questions/31400769/bounding-box-of-numpy-array
|
|
425
|
+
"""
|
|
426
|
+
x = np.any(masked_array != background, axis=(1, 2))
|
|
427
|
+
y = np.any(masked_array != background, axis=(0, 2))
|
|
428
|
+
z = np.any(masked_array != background, axis=(0, 1))
|
|
429
|
+
nzx, nzy, nzz = [np.where(v) for v in (x, y, z)]
|
|
430
|
+
if any(len(nz[0]) == 0 for nz in [nzx, nzy, nzz]):
|
|
431
|
+
# empty array
|
|
432
|
+
return None
|
|
433
|
+
xmin, xmax = nzx[0][[0, -1]]
|
|
434
|
+
ymin, ymax = nzy[0][[0, -1]]
|
|
435
|
+
zmin, zmax = nzz[0][[0, -1]]
|
|
436
|
+
return np.array([[xmin, xmax + 1], [ymin, ymax + 1], [zmin, zmax + 1], [1, 1]])
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def from_array(
|
|
440
|
+
array: np.ndarray,
|
|
441
|
+
background: Union[int, float] = 0.0,
|
|
442
|
+
space: "Space" = None
|
|
443
|
+
) -> BoundingBox:
|
|
444
|
+
"""
|
|
445
|
+
Find the bounding box of non-background values for any 3D array.
|
|
446
|
+
|
|
447
|
+
Parameters
|
|
448
|
+
----------
|
|
449
|
+
array: np.ndarray
|
|
450
|
+
background: int or float, default: 0.0
|
|
451
|
+
space: Space, default: None
|
|
452
|
+
"""
|
|
453
|
+
bounds = _determine_bounds(array, background)
|
|
454
|
+
return BoundingBox(point1=bounds[:3, 0], point2=bounds[:3, 1], space=space)
|
siibra/locations/location.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright 2018-
|
|
1
|
+
# Copyright 2018-2024
|
|
2
2
|
# Institute of Neuroscience and Medicine (INM-1), Forschungszentrum Jülich GmbH
|
|
3
3
|
|
|
4
4
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
@@ -12,15 +12,21 @@
|
|
|
12
12
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
13
|
# See the License for the specific language governing permissions and
|
|
14
14
|
# limitations under the License.
|
|
15
|
-
"""
|
|
15
|
+
"""Concepts that have primarily spatial meaning."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from ..core.structure import BrainStructure
|
|
16
20
|
|
|
17
21
|
import numpy as np
|
|
18
|
-
from abc import
|
|
19
|
-
|
|
20
|
-
from typing import Union
|
|
22
|
+
from abc import abstractmethod
|
|
23
|
+
|
|
24
|
+
from typing import TYPE_CHECKING, Union, Dict
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from siibra.core.space import Space
|
|
21
27
|
|
|
22
28
|
|
|
23
|
-
class Location(
|
|
29
|
+
class Location(BrainStructure):
|
|
24
30
|
"""
|
|
25
31
|
Abstract base class for locations in a given reference space.
|
|
26
32
|
"""
|
|
@@ -37,26 +43,23 @@ class Location(ABC):
|
|
|
37
43
|
|
|
38
44
|
# The id of BigBrain reference space
|
|
39
45
|
BIGBRAIN_ID = "minds/core/referencespace/v1.0.0/a1655b99-82f1-420f-a3c2-fe80fd4c8588"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
coordinates into the reference space of this Bounding Box.
|
|
58
|
-
"""
|
|
59
|
-
raise NotImplementedError
|
|
46
|
+
_MASK_MEMO = {} # cache region masks for Location._assign_region()
|
|
47
|
+
_ASSIGNMENT_CACHE = {} # caches assignment results, see Region.assign()
|
|
48
|
+
|
|
49
|
+
def __init__(self, spacespec: Union[str, Dict[str, str], "Space"]):
|
|
50
|
+
self._space_spec = spacespec
|
|
51
|
+
self._space_cached = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def space(self):
|
|
55
|
+
if self._space_cached is None:
|
|
56
|
+
from ..core.space import Space
|
|
57
|
+
if isinstance(self._space_spec, dict):
|
|
58
|
+
spec = self._space_spec.get("@id") or self._space_spec.get("name")
|
|
59
|
+
self._space_cached = Space.get_instance(spec)
|
|
60
|
+
else:
|
|
61
|
+
self._space_cached = Space.get_instance(self._space_spec)
|
|
62
|
+
return self._space_cached
|
|
60
63
|
|
|
61
64
|
@abstractmethod
|
|
62
65
|
def warp(self, space):
|
|
@@ -80,26 +83,26 @@ class Location(ABC):
|
|
|
80
83
|
"""
|
|
81
84
|
pass
|
|
82
85
|
|
|
83
|
-
@
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
over the coordinates associated with the location."""
|
|
87
|
-
pass
|
|
86
|
+
@property
|
|
87
|
+
def species(self):
|
|
88
|
+
return None if self.space is None else self.space.species
|
|
88
89
|
|
|
89
90
|
def __str__(self):
|
|
90
|
-
if self.space is None
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
f"[{','.join(str(l) for l in iter(self))}]"
|
|
94
|
-
)
|
|
95
|
-
else:
|
|
96
|
-
return (
|
|
97
|
-
f"{self.__class__.__name__} in {self.space.name} "
|
|
98
|
-
f"[{','.join(str(l) for l in iter(self))}]"
|
|
99
|
-
)
|
|
91
|
+
space_str = "" if self.space is None else f" in {self.space.name}"
|
|
92
|
+
coord_str = "" if len(self) == 0 else f" [{','.join(str(pt) for pt in iter(self))}]"
|
|
93
|
+
return f"{self.__class__.__name__}{space_str}{coord_str}"
|
|
100
94
|
|
|
101
95
|
def __repr__(self):
|
|
102
|
-
|
|
96
|
+
spacespec = f"'{self.space.id}'" if self.space else None
|
|
97
|
+
return f"<{self.__class__.__name__}({[point.__repr__() for point in self]}), space={spacespec}>"
|
|
98
|
+
|
|
99
|
+
def __hash__(self) -> int:
|
|
100
|
+
return hash(self.__repr__())
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def __eq__(self):
|
|
104
|
+
"""Required to provide comparison and making the object hashable"""
|
|
105
|
+
raise NotImplementedError
|
|
103
106
|
|
|
104
107
|
@staticmethod
|
|
105
108
|
def union(loc0: 'Location', loc1: 'Location') -> 'Location':
|
|
@@ -110,41 +113,3 @@ class Location(ABC):
|
|
|
110
113
|
raise NotImplementedError(
|
|
111
114
|
"This method is designed to be reassigned at the module level"
|
|
112
115
|
)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
class WholeBrain(Location):
|
|
116
|
-
"""
|
|
117
|
-
Trivial location class for formally representing
|
|
118
|
-
location in a particular reference space, which
|
|
119
|
-
is not further specified.
|
|
120
|
-
"""
|
|
121
|
-
|
|
122
|
-
def intersection(self, mask: Nifti1Image) -> bool:
|
|
123
|
-
"""
|
|
124
|
-
Required for abstract class Location
|
|
125
|
-
"""
|
|
126
|
-
return True
|
|
127
|
-
|
|
128
|
-
def __init__(self, space=None):
|
|
129
|
-
Location.__init__(self, space)
|
|
130
|
-
|
|
131
|
-
def intersects(self, *_args, **_kwargs):
|
|
132
|
-
"""Always true for whole brain features"""
|
|
133
|
-
return True
|
|
134
|
-
|
|
135
|
-
def warp(self, space):
|
|
136
|
-
"""Generates a new whole brain location
|
|
137
|
-
in another reference space."""
|
|
138
|
-
return self.__class__(space)
|
|
139
|
-
|
|
140
|
-
def transform(self, affine: np.ndarray, space=None):
|
|
141
|
-
"""Does nothing."""
|
|
142
|
-
pass
|
|
143
|
-
|
|
144
|
-
def __iter__(self):
|
|
145
|
-
"""To be implemented in derived classes to return an iterator
|
|
146
|
-
over the coordinates associated with the location."""
|
|
147
|
-
yield from ()
|
|
148
|
-
|
|
149
|
-
def __str__(self):
|
|
150
|
-
return f"{self.__class__.__name__} in {self.space.name}"
|