siibra 1.0a1__1-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 -0
- siibra/__init__.py +164 -0
- siibra/commons.py +823 -0
- siibra/configuration/__init__.py +17 -0
- siibra/configuration/configuration.py +189 -0
- siibra/configuration/factory.py +589 -0
- siibra/core/__init__.py +16 -0
- siibra/core/assignment.py +110 -0
- siibra/core/atlas.py +239 -0
- siibra/core/concept.py +308 -0
- siibra/core/parcellation.py +387 -0
- siibra/core/region.py +1223 -0
- siibra/core/space.py +131 -0
- 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 +17 -0
- siibra/explorer/url.py +222 -0
- siibra/explorer/util.py +87 -0
- siibra/features/__init__.py +117 -0
- siibra/features/anchor.py +224 -0
- siibra/features/connectivity/__init__.py +33 -0
- siibra/features/connectivity/functional_connectivity.py +57 -0
- siibra/features/connectivity/regional_connectivity.py +494 -0
- siibra/features/connectivity/streamline_counts.py +27 -0
- siibra/features/connectivity/streamline_lengths.py +27 -0
- siibra/features/connectivity/tracing_connectivity.py +30 -0
- siibra/features/dataset/__init__.py +17 -0
- siibra/features/dataset/ebrains.py +90 -0
- siibra/features/feature.py +970 -0
- siibra/features/image/__init__.py +27 -0
- siibra/features/image/image.py +115 -0
- siibra/features/image/sections.py +26 -0
- siibra/features/image/volume_of_interest.py +88 -0
- siibra/features/tabular/__init__.py +24 -0
- siibra/features/tabular/bigbrain_intensity_profile.py +77 -0
- siibra/features/tabular/cell_density_profile.py +298 -0
- siibra/features/tabular/cortical_profile.py +322 -0
- siibra/features/tabular/gene_expression.py +257 -0
- siibra/features/tabular/layerwise_bigbrain_intensities.py +62 -0
- siibra/features/tabular/layerwise_cell_density.py +95 -0
- siibra/features/tabular/receptor_density_fingerprint.py +192 -0
- siibra/features/tabular/receptor_density_profile.py +110 -0
- siibra/features/tabular/regional_timeseries_activity.py +294 -0
- siibra/features/tabular/tabular.py +139 -0
- siibra/livequeries/__init__.py +19 -0
- siibra/livequeries/allen.py +352 -0
- siibra/livequeries/bigbrain.py +197 -0
- siibra/livequeries/ebrains.py +145 -0
- siibra/livequeries/query.py +49 -0
- siibra/locations/__init__.py +91 -0
- siibra/locations/boundingbox.py +454 -0
- siibra/locations/location.py +115 -0
- siibra/locations/point.py +344 -0
- siibra/locations/pointcloud.py +349 -0
- siibra/retrieval/__init__.py +27 -0
- siibra/retrieval/cache.py +233 -0
- siibra/retrieval/datasets.py +389 -0
- siibra/retrieval/exceptions/__init__.py +27 -0
- siibra/retrieval/repositories.py +769 -0
- siibra/retrieval/requests.py +659 -0
- siibra/vocabularies/__init__.py +45 -0
- siibra/vocabularies/gene_names.json +29176 -0
- siibra/vocabularies/receptor_symbols.json +210 -0
- siibra/vocabularies/region_aliases.json +460 -0
- siibra/volumes/__init__.py +23 -0
- siibra/volumes/parcellationmap.py +1279 -0
- siibra/volumes/providers/__init__.py +20 -0
- siibra/volumes/providers/freesurfer.py +113 -0
- siibra/volumes/providers/gifti.py +165 -0
- siibra/volumes/providers/neuroglancer.py +736 -0
- siibra/volumes/providers/nifti.py +266 -0
- siibra/volumes/providers/provider.py +107 -0
- siibra/volumes/sparsemap.py +468 -0
- siibra/volumes/volume.py +892 -0
- siibra-1.0.0a1.dist-info/LICENSE +201 -0
- siibra-1.0.0a1.dist-info/METADATA +160 -0
- siibra-1.0.0a1.dist-info/RECORD +84 -0
- siibra-1.0.0a1.dist-info/WHEEL +5 -0
- siibra-1.0.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# Copyright 2018-2024
|
|
2
|
+
# Institute of Neuroscience and Medicine (INM-1), Forschungszentrum Jülich GmbH
|
|
3
|
+
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""A box defined by two farthest corner coordinates on a specific space."""
|
|
16
|
+
|
|
17
|
+
from . import point, pointcloud, location
|
|
18
|
+
|
|
19
|
+
from ..commons import logger
|
|
20
|
+
from ..exceptions import SpaceWarpingFailedError
|
|
21
|
+
|
|
22
|
+
from itertools import product
|
|
23
|
+
import hashlib
|
|
24
|
+
import numpy as np
|
|
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
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BoundingBox(location.Location):
|
|
33
|
+
"""
|
|
34
|
+
A 3D axis-aligned bounding box spanned by two 3D corner points.
|
|
35
|
+
The box does not necessarily store the given points,
|
|
36
|
+
instead it computes the real minimum and maximum points
|
|
37
|
+
from the two corner points.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
point1,
|
|
43
|
+
point2,
|
|
44
|
+
space: Union[str, 'Space'] = None,
|
|
45
|
+
minsize: float = None,
|
|
46
|
+
sigma_mm=None
|
|
47
|
+
):
|
|
48
|
+
"""
|
|
49
|
+
Construct a new bounding box spanned by two 3D coordinates
|
|
50
|
+
in the given reference space.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
point1 : Point or 3-tuple
|
|
55
|
+
Startpoint given in mm of the given space
|
|
56
|
+
point2 : Point or 3-tuple
|
|
57
|
+
Endpoint given in mm of the given space
|
|
58
|
+
space : reference space (id, name, or Space)
|
|
59
|
+
The reference space
|
|
60
|
+
minsize : float
|
|
61
|
+
Minimum size along each dimension. If not None, the maxpoint will
|
|
62
|
+
be adjusted to match the minimum size, if needed.
|
|
63
|
+
sigma_mm : float, or list of float
|
|
64
|
+
Optional standard deviation of the spanning point locations.
|
|
65
|
+
"""
|
|
66
|
+
# TODO: allow to pass sigma for the points, if tuples
|
|
67
|
+
location.Location.__init__(self, space)
|
|
68
|
+
xyz1 = point.Point.parse(point1)
|
|
69
|
+
xyz2 = point.Point.parse(point2)
|
|
70
|
+
if sigma_mm is None:
|
|
71
|
+
s1, s2 = 0, 0
|
|
72
|
+
elif isinstance(sigma_mm, float):
|
|
73
|
+
s1, s2 = sigma_mm, sigma_mm
|
|
74
|
+
elif isinstance(sigma_mm, list):
|
|
75
|
+
assert len(sigma_mm) == 2
|
|
76
|
+
s1, s2 = sigma_mm
|
|
77
|
+
else:
|
|
78
|
+
raise ValueError(f"Cannot interpret sigma_mm parameter value {sigma_mm} for bounding box")
|
|
79
|
+
self.sigma_mm = [s1, s2]
|
|
80
|
+
self.minpoint = point.Point([min(xyz1[i], xyz2[i]) for i in range(3)], space, sigma_mm=s1)
|
|
81
|
+
self.maxpoint = point.Point([max(xyz1[i], xyz2[i]) for i in range(3)], space, sigma_mm=s2)
|
|
82
|
+
if minsize is not None:
|
|
83
|
+
for d in range(3):
|
|
84
|
+
if self.shape[d] < minsize:
|
|
85
|
+
self.maxpoint[d] = self.minpoint[d] + minsize
|
|
86
|
+
|
|
87
|
+
if self.volume == 0:
|
|
88
|
+
logger.warning(f"Zero-volume bounding box from points {point1} and {point2} in {self.space} space.")
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def id(self) -> str:
|
|
92
|
+
return hashlib.md5(str(self).encode("utf-8")).hexdigest()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def volume(self) -> float:
|
|
96
|
+
"""The volume of the boundingbox in mm^3"""
|
|
97
|
+
return np.prod(self.shape)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def center(self) -> 'point.Point':
|
|
101
|
+
return self.minpoint + (self.maxpoint - self.minpoint) / 2
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def shape(self) -> float:
|
|
105
|
+
"""The distances of the diagonal points in each axis. (Accounts for sigma)."""
|
|
106
|
+
return tuple(
|
|
107
|
+
(self.maxpoint + self.maxpoint.sigma)
|
|
108
|
+
- (self.minpoint - self.minpoint.sigma)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def is_planar(self) -> bool:
|
|
113
|
+
return any(d == 0 for d in self.shape)
|
|
114
|
+
|
|
115
|
+
def __str__(self):
|
|
116
|
+
if self.space is None:
|
|
117
|
+
return (
|
|
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"
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
return (
|
|
123
|
+
f"Bounding box from ({','.join(f'{v:.2f}' for v in self.minpoint)})mm "
|
|
124
|
+
f"to ({','.join(f'{v:.2f}' for v in self.maxpoint)})mm in {self.space.name} space"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def intersection(self, other: 'BrainStructure', dims=[0, 1, 2]):
|
|
128
|
+
"""Computes the intersection of this bounding box with another one.
|
|
129
|
+
|
|
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.
|
|
137
|
+
"""
|
|
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]
|
|
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)
|
|
159
|
+
|
|
160
|
+
def _intersect_bbox(self, other: 'BoundingBox', dims=[0, 1, 2]):
|
|
161
|
+
warped = other.warp(self.space)
|
|
162
|
+
|
|
163
|
+
# Determine the intersecting bounsding box by sorting
|
|
164
|
+
# the coordinates of both bounding boxes for each dimension,
|
|
165
|
+
# and fetching the second and third coordinate after sorting.
|
|
166
|
+
# If those belong to a minimum and maximum point,
|
|
167
|
+
# no matter of which bounding box,
|
|
168
|
+
# we have a nonzero intersection in that dimension.
|
|
169
|
+
minpoints = [b.minpoint for b in (self, warped)]
|
|
170
|
+
maxpoints = [b.maxpoint for b in (self, warped)]
|
|
171
|
+
allpoints = minpoints + maxpoints
|
|
172
|
+
result_minpt = []
|
|
173
|
+
result_maxpt = []
|
|
174
|
+
|
|
175
|
+
for dim in range(3):
|
|
176
|
+
|
|
177
|
+
if dim not in dims:
|
|
178
|
+
# do not intersect in this dimension, so take the union instead
|
|
179
|
+
result_minpt.append(min(p[dim] for p in allpoints))
|
|
180
|
+
result_maxpt.append(max(p[dim] for p in allpoints))
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
A, B = sorted(allpoints, key=lambda P: P[dim])[1:3]
|
|
184
|
+
if (A in maxpoints) or (B in minpoints):
|
|
185
|
+
# no intersection in this dimension
|
|
186
|
+
return None
|
|
187
|
+
else:
|
|
188
|
+
result_minpt.append(A[dim])
|
|
189
|
+
result_maxpt.append(B[dim])
|
|
190
|
+
|
|
191
|
+
if result_minpt == result_maxpt:
|
|
192
|
+
return result_minpt
|
|
193
|
+
|
|
194
|
+
bbox = BoundingBox(
|
|
195
|
+
point1=point.Point(result_minpt, self.space),
|
|
196
|
+
point2=point.Point(result_maxpt, self.space),
|
|
197
|
+
space=self.space,
|
|
198
|
+
)
|
|
199
|
+
|
|
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):
|
|
205
|
+
"""Intersect this bounding box with an image mask. Returns None if they do not intersect.
|
|
206
|
+
|
|
207
|
+
TODO process the sigma values o the points
|
|
208
|
+
|
|
209
|
+
NOTE: The affine matrix of the image must be set to warp voxels
|
|
210
|
+
coordinates into the reference space of this Bounding Box.
|
|
211
|
+
"""
|
|
212
|
+
# nonzero voxel coordinates
|
|
213
|
+
X, Y, Z = np.where(mask.get_fdata() > threshold)
|
|
214
|
+
h = np.ones(len(X))
|
|
215
|
+
|
|
216
|
+
# array of homogenous physical nonzero voxel coordinates
|
|
217
|
+
coords = np.dot(mask.affine, np.vstack((X, Y, Z, h)))[:3, :].T
|
|
218
|
+
minpoint = [min(self.minpoint[i], self.maxpoint[i]) for i in range(3)]
|
|
219
|
+
maxpoint = [max(self.minpoint[i], self.maxpoint[i]) for i in range(3)]
|
|
220
|
+
minpt_voxel = np.dot(np.linalg.inv(mask.affine), np.r_[minpoint, 1])[:3]
|
|
221
|
+
voxel_size = np.diff(
|
|
222
|
+
np.dot(
|
|
223
|
+
mask.affine,
|
|
224
|
+
np.vstack((np.r_[minpt_voxel, 1], np.r_[minpt_voxel + 1, 1])).T
|
|
225
|
+
)[:3, :]
|
|
226
|
+
).squeeze()
|
|
227
|
+
# use voxel size for the test to intersect voxels properly with verythin bounding boxes
|
|
228
|
+
inside = np.logical_and.reduce([
|
|
229
|
+
coords >= minpoint - voxel_size / 2,
|
|
230
|
+
coords <= maxpoint + voxel_size / 2
|
|
231
|
+
]).min(1)
|
|
232
|
+
XYZ = coords[inside, :3]
|
|
233
|
+
if XYZ.shape[0] == 0:
|
|
234
|
+
return None
|
|
235
|
+
elif XYZ.shape[0] == 1: # reflect voxel size in resulting point set
|
|
236
|
+
return self._intersect_bbox(
|
|
237
|
+
point
|
|
238
|
+
.Point(XYZ.flatten(), space=self.space, sigma_mm=voxel_size.max())
|
|
239
|
+
.boundingbox
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
return self._intersect_bbox(
|
|
243
|
+
pointcloud
|
|
244
|
+
.PointCloud(XYZ, space=self.space, sigma_mm=voxel_size.max())
|
|
245
|
+
.boundingbox
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def clip(self, xyzmax, xyzmin=(0, 0, 0)):
|
|
249
|
+
"""
|
|
250
|
+
Returns a new bounding box obtained by clipping at the given maximum coordinate.
|
|
251
|
+
"""
|
|
252
|
+
return self.intersection(
|
|
253
|
+
BoundingBox(
|
|
254
|
+
point.Point(xyzmin, self.space), point.Point(xyzmax, self.space), self.space
|
|
255
|
+
),
|
|
256
|
+
sigma_mm=[self.minpoint.sigma, self.maxpoint.sigma]
|
|
257
|
+
)
|
|
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
|
+
|
|
288
|
+
def warp(self, space):
|
|
289
|
+
"""Returns a new bounding box obtained by warping the
|
|
290
|
+
min- and maxpoint of this one into the new target space.
|
|
291
|
+
|
|
292
|
+
TODO process the sigma values o the points
|
|
293
|
+
"""
|
|
294
|
+
from ..core.space import Space
|
|
295
|
+
spaceobj = Space.get_instance(space)
|
|
296
|
+
if spaceobj == self.space:
|
|
297
|
+
return self
|
|
298
|
+
else:
|
|
299
|
+
try:
|
|
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
|
|
304
|
+
|
|
305
|
+
def transform(self, affine: np.ndarray, space=None):
|
|
306
|
+
"""Returns a new bounding box obtained by transforming the
|
|
307
|
+
min- and maxpoint of this one with the given affine matrix.
|
|
308
|
+
|
|
309
|
+
TODO process the sigma values o the points
|
|
310
|
+
|
|
311
|
+
Parameters
|
|
312
|
+
----------
|
|
313
|
+
affine : numpy 4x4 ndarray
|
|
314
|
+
affine matrix
|
|
315
|
+
space : reference space (str, Space, or None)
|
|
316
|
+
Target reference space which is reached after
|
|
317
|
+
applying the transform. Note that the consistency
|
|
318
|
+
of this cannot be checked and is up to the user.
|
|
319
|
+
"""
|
|
320
|
+
from ..core.space import Space
|
|
321
|
+
spaceobj = Space.get_instance(space)
|
|
322
|
+
result = self.corners.transform(affine, spaceobj).boundingbox
|
|
323
|
+
result.sigma_mm = [self.minpoint.sigma, self.maxpoint.sigma] # TODO: error propagation
|
|
324
|
+
return result
|
|
325
|
+
|
|
326
|
+
def shift(self, offset):
|
|
327
|
+
return self.__class__(
|
|
328
|
+
point1=self.minpoint + offset,
|
|
329
|
+
point2=self.maxpoint + offset,
|
|
330
|
+
space=self.space,
|
|
331
|
+
sigma_mm=[self.minpoint.sigma, self.maxpoint.sigma]
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def zoom(self, ratio: float):
|
|
335
|
+
"""
|
|
336
|
+
Create a new bounding box by zooming this one around its center.
|
|
337
|
+
"""
|
|
338
|
+
c = self.center
|
|
339
|
+
self.maxpoint - c
|
|
340
|
+
return self.__class__(
|
|
341
|
+
point1=(self.minpoint - c) * ratio + c,
|
|
342
|
+
point2=(self.maxpoint - c) * ratio + c,
|
|
343
|
+
space=self.space,
|
|
344
|
+
sigma_mm=[self.minpoint.sigma * ratio, self.maxpoint.sigma * ratio]
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def estimate_affine(self, space):
|
|
348
|
+
"""
|
|
349
|
+
Computes an affine transform which approximates
|
|
350
|
+
the nonlinear warping of the eight corner points
|
|
351
|
+
to the desired target space.
|
|
352
|
+
The transform is estimated using a least squares
|
|
353
|
+
solution to A*x = b, where A is the matrix of
|
|
354
|
+
point coefficients in the space of this bounding box,
|
|
355
|
+
and b are the target coefficients in the given space
|
|
356
|
+
after calling the nonlinear warping.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
x0, y0, z0 = self.minpoint
|
|
360
|
+
x1, y1, z1 = self.maxpoint
|
|
361
|
+
|
|
362
|
+
# set of 8 corner points in source space
|
|
363
|
+
corners1 = pointcloud.PointCloud(
|
|
364
|
+
[
|
|
365
|
+
(x0, y0, z0),
|
|
366
|
+
(x0, y0, z1),
|
|
367
|
+
(x0, y1, z0),
|
|
368
|
+
(x0, y1, z1),
|
|
369
|
+
(x1, y0, z0),
|
|
370
|
+
(x1, y0, z1),
|
|
371
|
+
(x1, y1, z0),
|
|
372
|
+
(x1, y1, z1)
|
|
373
|
+
], self.space
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# coefficient matrix from original points
|
|
377
|
+
A = np.hstack(
|
|
378
|
+
[
|
|
379
|
+
[np.kron(np.eye(3), np.r_[tuple(c), 1])]
|
|
380
|
+
for c in corners1
|
|
381
|
+
]).squeeze()
|
|
382
|
+
|
|
383
|
+
# righthand side from warped points
|
|
384
|
+
corners2 = corners1.warp(space)
|
|
385
|
+
b = np.hstack(corners2.as_list())
|
|
386
|
+
|
|
387
|
+
# least squares solution
|
|
388
|
+
x, res, rank, s = np.linalg.lstsq(A, b, rcond=None)
|
|
389
|
+
affine = np.vstack([x.reshape((3, 4)), np.array([0, 0, 0, 1])])
|
|
390
|
+
|
|
391
|
+
# test
|
|
392
|
+
errors = []
|
|
393
|
+
for c1, c2 in zip(list(corners1), list(corners2)):
|
|
394
|
+
errors.append(
|
|
395
|
+
np.linalg.norm(
|
|
396
|
+
np.dot(affine, np.r_[tuple(c1), 1])
|
|
397
|
+
- np.r_[tuple(c2), 1]
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
logger.debug(
|
|
401
|
+
f"Average projection error under linear approximation "
|
|
402
|
+
f"was {np.mean(errors):.2f} pixel"
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
return affine
|
|
406
|
+
|
|
407
|
+
def __iter__(self):
|
|
408
|
+
"""Iterate the min- and maxpoint of this bounding box."""
|
|
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)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Copyright 2018-2024
|
|
2
|
+
# Institute of Neuroscience and Medicine (INM-1), Forschungszentrum Jülich GmbH
|
|
3
|
+
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
"""Concepts that have primarily spatial meaning."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from ..core.structure import BrainStructure
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
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
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Location(BrainStructure):
|
|
30
|
+
"""
|
|
31
|
+
Abstract base class for locations in a given reference space.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# backend for transforming coordinates between spaces
|
|
35
|
+
SPACEWARP_SERVER = "https://hbp-spatial-backend.apps.hbp.eu/v1"
|
|
36
|
+
|
|
37
|
+
# lookup of space identifiers to be used by SPACEWARP_SERVER
|
|
38
|
+
SPACEWARP_IDS = {
|
|
39
|
+
"minds/core/referencespace/v1.0.0/dafcffc5-4826-4bf1-8ff6-46b8a31ff8e2": "MNI 152 ICBM 2009c Nonlinear Asymmetric",
|
|
40
|
+
"minds/core/referencespace/v1.0.0/7f39f7be-445b-47c0-9791-e971c0b6d992": "MNI Colin 27",
|
|
41
|
+
"minds/core/referencespace/v1.0.0/a1655b99-82f1-420f-a3c2-fe80fd4c8588": "Big Brain (Histology)",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# The id of BigBrain reference space
|
|
45
|
+
BIGBRAIN_ID = "minds/core/referencespace/v1.0.0/a1655b99-82f1-420f-a3c2-fe80fd4c8588"
|
|
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
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def warp(self, space):
|
|
66
|
+
"""Generates a new location by warping the
|
|
67
|
+
current one into another reference space."""
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def transform(self, affine: np.ndarray, space=None):
|
|
72
|
+
"""Returns a new location obtained by transforming the
|
|
73
|
+
reference coordinates of this one with the given affine matrix.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
affine : numpy 4x4 ndarray
|
|
78
|
+
affine matrix
|
|
79
|
+
space : reference space (id, name, or Space)
|
|
80
|
+
Target reference space which is reached after
|
|
81
|
+
applying the transform. Note that the consistency
|
|
82
|
+
of this cannot be checked and is up to the user.
|
|
83
|
+
"""
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def species(self):
|
|
88
|
+
return None if self.space is None else self.space.species
|
|
89
|
+
|
|
90
|
+
def __str__(self):
|
|
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}"
|
|
94
|
+
|
|
95
|
+
def __repr__(self):
|
|
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
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def union(loc0: 'Location', loc1: 'Location') -> 'Location':
|
|
109
|
+
"""
|
|
110
|
+
Reassigned at the locations module level for static typing and to avoid
|
|
111
|
+
circular imports. See siibra.locations.__init__.reassign_union()
|
|
112
|
+
"""
|
|
113
|
+
raise NotImplementedError(
|
|
114
|
+
"This method is designed to be reassigned at the module level"
|
|
115
|
+
)
|