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.

Files changed (83) hide show
  1. siibra/VERSION +1 -1
  2. siibra/__init__.py +20 -12
  3. siibra/commons.py +145 -90
  4. siibra/configuration/__init__.py +1 -1
  5. siibra/configuration/configuration.py +22 -17
  6. siibra/configuration/factory.py +177 -128
  7. siibra/core/__init__.py +1 -8
  8. siibra/core/{relation_qualification.py → assignment.py} +17 -14
  9. siibra/core/atlas.py +66 -35
  10. siibra/core/concept.py +81 -39
  11. siibra/core/parcellation.py +83 -67
  12. siibra/core/region.py +569 -263
  13. siibra/core/space.py +7 -39
  14. siibra/core/structure.py +111 -0
  15. siibra/exceptions.py +63 -0
  16. siibra/experimental/__init__.py +19 -0
  17. siibra/experimental/contour.py +61 -0
  18. siibra/experimental/cortical_profile_sampler.py +57 -0
  19. siibra/experimental/patch.py +98 -0
  20. siibra/experimental/plane3d.py +256 -0
  21. siibra/explorer/__init__.py +16 -0
  22. siibra/explorer/url.py +112 -52
  23. siibra/explorer/util.py +31 -9
  24. siibra/features/__init__.py +73 -8
  25. siibra/features/anchor.py +75 -196
  26. siibra/features/connectivity/__init__.py +1 -1
  27. siibra/features/connectivity/functional_connectivity.py +2 -2
  28. siibra/features/connectivity/regional_connectivity.py +99 -10
  29. siibra/features/connectivity/streamline_counts.py +1 -1
  30. siibra/features/connectivity/streamline_lengths.py +1 -1
  31. siibra/features/connectivity/tracing_connectivity.py +1 -1
  32. siibra/features/dataset/__init__.py +1 -1
  33. siibra/features/dataset/ebrains.py +3 -3
  34. siibra/features/feature.py +219 -110
  35. siibra/features/image/__init__.py +1 -1
  36. siibra/features/image/image.py +21 -13
  37. siibra/features/image/sections.py +1 -1
  38. siibra/features/image/volume_of_interest.py +1 -1
  39. siibra/features/tabular/__init__.py +1 -1
  40. siibra/features/tabular/bigbrain_intensity_profile.py +24 -13
  41. siibra/features/tabular/cell_density_profile.py +111 -69
  42. siibra/features/tabular/cortical_profile.py +82 -16
  43. siibra/features/tabular/gene_expression.py +117 -6
  44. siibra/features/tabular/layerwise_bigbrain_intensities.py +7 -9
  45. siibra/features/tabular/layerwise_cell_density.py +9 -24
  46. siibra/features/tabular/receptor_density_fingerprint.py +11 -6
  47. siibra/features/tabular/receptor_density_profile.py +12 -15
  48. siibra/features/tabular/regional_timeseries_activity.py +74 -18
  49. siibra/features/tabular/tabular.py +17 -8
  50. siibra/livequeries/__init__.py +1 -7
  51. siibra/livequeries/allen.py +139 -77
  52. siibra/livequeries/bigbrain.py +104 -128
  53. siibra/livequeries/ebrains.py +7 -4
  54. siibra/livequeries/query.py +1 -2
  55. siibra/locations/__init__.py +32 -25
  56. siibra/locations/boundingbox.py +153 -127
  57. siibra/locations/location.py +45 -80
  58. siibra/locations/point.py +97 -83
  59. siibra/locations/pointcloud.py +349 -0
  60. siibra/retrieval/__init__.py +1 -1
  61. siibra/retrieval/cache.py +107 -13
  62. siibra/retrieval/datasets.py +9 -14
  63. siibra/retrieval/exceptions/__init__.py +2 -1
  64. siibra/retrieval/repositories.py +147 -53
  65. siibra/retrieval/requests.py +64 -29
  66. siibra/vocabularies/__init__.py +2 -2
  67. siibra/volumes/__init__.py +7 -9
  68. siibra/volumes/parcellationmap.py +396 -253
  69. siibra/volumes/providers/__init__.py +20 -0
  70. siibra/volumes/providers/freesurfer.py +113 -0
  71. siibra/volumes/{gifti.py → providers/gifti.py} +29 -18
  72. siibra/volumes/{neuroglancer.py → providers/neuroglancer.py} +204 -92
  73. siibra/volumes/{nifti.py → providers/nifti.py} +64 -44
  74. siibra/volumes/providers/provider.py +107 -0
  75. siibra/volumes/sparsemap.py +159 -260
  76. siibra/volumes/volume.py +720 -152
  77. {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/METADATA +25 -28
  78. siibra-1.0.0a1.dist-info/RECORD +84 -0
  79. {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/WHEEL +1 -1
  80. siibra/locations/pointset.py +0 -198
  81. siibra-0.5a2.dist-info/RECORD +0 -74
  82. {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/LICENSE +0 -0
  83. {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,4 @@
1
- # Copyright 2018-2021
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 WholeBrain, Location
17
+ from .location import Location
18
18
  from .point import Point
19
- from .pointset import PointSet
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 = PointSet
40
- - Point U PointSet = PointSet
41
- - PointSet U PointSet = PointSet
39
+ - Point U Point = PointCloud
40
+ - Point U PointCloud = PointCloud
41
+ - PointCloud U PointCloud = PointCloud
42
42
  - BoundingBox U BoundingBox = BoundingBox
43
- - BoundingBox U PointSet = BoundingBox
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
- if isinstance(loc0, WholeBrain) or isinstance(loc1, WholeBrain):
52
- raise NotImplementedError("Union of WholeBrains is not yet implemented.")
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
- loc1_w = loc1.warp(loc0.space) # adopt the space of the first location
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
- if isinstance(loc0, Point): # turn Points to PointSets
57
- return reassign_union(
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, PointSet):
62
- if isinstance(loc1_w, PointSet):
63
- points = set(loc0.points + loc1_w.points)
64
- return PointSet(
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
- points = [loc0.minpoint, loc0.maxpoint, loc1_w.minpoint, loc1_w.maxpoint]
80
+ coordinates = [loc0.minpoint, loc0.maxpoint, loc1_w.minpoint, loc1_w.maxpoint]
74
81
  return BoundingBox(
75
- point1=[min(p[i] for p in points) for i in range(3)],
76
- point2=[max(p[i] for p in points) for i in range(3)],
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
  )
@@ -1,4 +1,4 @@
1
- # Copyright 2018-2021
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, pointset, location, boundingbox
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
- from nibabel import Nifti1Image
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__(self, point1, point2, space=None, minsize: float = None, sigma_mm=None):
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)}) mm "
133
- f"to ({','.join(f'{v:.2f}' for v in self.maxpoint)}) mm"
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)}) mm "
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 contains(self, other: location.Location):
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
- TODO process the sigma values o the points
178
-
179
- Args:
180
- other (BoundingBox): Another bounding box
181
- dims (list of int): Dimensions where the intersection should be computed (applies only to bounding boxes)
182
- Default: all three. Along dimensions not listed, the union is applied instead.
183
- threshold: optional intensity threshold for intersecting with image mask
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
- if isinstance(other, Nifti1Image):
186
- return self._intersect_mask(other, threshold=threshold)
187
- elif isinstance(other, BoundingBox):
188
- return self._intersect_bbox(other, dims)
189
- else:
190
- raise NotImplementedError(
191
- f"Intersection of bounding box with {type(other)} not implemented."
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 bounding box by sorting
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
- def _intersect_mask(self, mask, threshold=0):
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
- pointset
272
- .PointSet(XYZ, space=self.space, sigma_mm=voxel_size.max())
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
- return self.__class__(
300
- point1=self.minpoint.warp(spaceobj),
301
- point2=self.maxpoint.warp(spaceobj),
302
- space=spaceobj,
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
- return self.__class__(
339
- point1=self.minpoint.transform(affine, spaceobj),
340
- point2=self.maxpoint.transform(affine, spaceobj),
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 = pointset.PointSet(
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)
@@ -1,4 +1,4 @@
1
- # Copyright 2018-2021
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
- """Concpets that have primarily spatial meaning."""
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 ABC, abstractmethod
19
- from nibabel import Nifti1Image
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(ABC):
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
- def __init__(self, space):
42
- from ..core.space import Space
43
- self.space = Space.get_instance(space)
44
-
45
- @abstractmethod
46
- def intersection(self, mask: Nifti1Image) -> bool:
47
- """All subclasses of Location must implement intersection, as it is required by SpatialFeature._test_mask()
48
- """
49
- pass
50
-
51
- @abstractmethod
52
- def intersects(self, other: Union[Nifti1Image, 'Location']) -> bool:
53
- """
54
- Verifies wether this 3D location intersects the given mask.
55
-
56
- NOTE: The affine matrix of the image must be set to warp voxels
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
- @abstractmethod
84
- def __iter__(self):
85
- """To be implemented in derived classes to return an iterator
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
- return (
92
- f"{self.__class__.__name__} "
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
- return f"{self.__class__.__name__}: {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
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}"