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
siibra/locations/point.py CHANGED
@@ -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,19 +14,19 @@
14
14
  # limitations under the License.
15
15
  """Singular coordinate defined on a space, possibly with an uncertainty."""
16
16
 
17
- from . import location, boundingbox, pointset
17
+ from . import location, boundingbox, pointcloud
18
+
18
19
  from ..commons import logger
19
20
  from ..retrieval.requests import HttpRequest
21
+ from ..exceptions import SpaceWarpingFailedError, NoneCoordinateSuppliedError
20
22
 
21
- from nibabel.affines import apply_affine
22
- from nibabel import Nifti1Image
23
23
  from urllib.parse import quote
24
24
  import re
25
25
  import numpy as np
26
26
  import json
27
27
  import numbers
28
28
  import hashlib
29
- from typing import Union, Tuple
29
+ from typing import Tuple, Union
30
30
 
31
31
 
32
32
  class Point(location.Location):
@@ -56,11 +56,15 @@ class Point(location.Location):
56
56
  if len(digits) == 3:
57
57
  return tuple(float(d) for d in digits)
58
58
  elif isinstance(spec, (tuple, list)) and len(spec) in [3, 4]:
59
+ if any(v is None for v in spec):
60
+ raise NoneCoordinateSuppliedError("Cannot parse cooridantes containing None values.")
59
61
  if len(spec) == 4:
60
62
  assert spec[3] == 1
61
- return tuple(float(v) for v in spec[:3])
63
+ return tuple(float(v.item()) if isinstance(v, np.ndarray) else float(v) for v in spec[:3])
62
64
  elif isinstance(spec, np.ndarray) and spec.size == 3:
63
- return tuple(float(v) for v in spec[:3])
65
+ if any(np.isnan(v) for v in spec):
66
+ raise NoneCoordinateSuppliedError("Cannot parse cooridantes containing NaN values.")
67
+ return tuple(float(v.item()) if isinstance(v, np.ndarray) else float(v) for v in spec[:3])
64
68
  elif isinstance(spec, Point):
65
69
  return spec.coordinate
66
70
 
@@ -68,10 +72,13 @@ class Point(location.Location):
68
72
  f"Cannot decode the specification {spec} (type {type(spec)}) to create a point."
69
73
  )
70
74
 
71
- def __hash__(self):
72
- return sum(map(hash, (self.coordinate, self.sigma, self.space.key)))
73
-
74
- def __init__(self, coordinatespec, space=None, sigma_mm: float = 0.0):
75
+ def __init__(
76
+ self,
77
+ coordinatespec,
78
+ space=None,
79
+ sigma_mm: float = 0.0,
80
+ label: Union[int, float, tuple] = None
81
+ ):
75
82
  """
76
83
  Construct a new 3D point set in the given reference space.
77
84
 
@@ -83,6 +90,8 @@ class Point(location.Location):
83
90
  The reference space (id, object, or name)
84
91
  sigma_mm : float, optional
85
92
  Location uncertainty of the point
93
+ label: optional
94
+ Any object attached as an attribute to the point
86
95
 
87
96
  Note
88
97
  ----
@@ -91,82 +100,37 @@ class Point(location.Location):
91
100
  location.Location.__init__(self, space)
92
101
  self.coordinate = Point.parse(coordinatespec)
93
102
  self.sigma = sigma_mm
103
+ self.label = label
94
104
  if isinstance(coordinatespec, Point):
95
105
  assert coordinatespec.sigma == sigma_mm
96
106
  assert coordinatespec.space == space
107
+ self.label = label
97
108
 
98
109
  @property
99
110
  def homogeneous(self):
100
111
  """The homogenous coordinate of this point as a 4-tuple,
101
112
  obtained by appending '1' to the original 3-tuple."""
102
- return self.coordinate + (1,)
113
+ return np.atleast_2d(self.coordinate + (1,))
103
114
 
104
- def intersection(self, other: Union[location.Location, Nifti1Image]) -> "Point":
115
+ def intersection(self, other: location.Location) -> "Point":
105
116
  if isinstance(other, Point):
106
117
  return self if self == other else None
107
- elif isinstance(other, pointset.PointSet):
118
+ elif isinstance(other, pointcloud.PointCloud):
108
119
  return self if self in other else None
109
- elif isinstance(other, boundingbox.BoundingBox):
110
- return self if other.contains(self) else None
111
- elif isinstance(other, Nifti1Image):
112
- return self if self.intersects(other) else None
113
- else:
114
- raise NotImplementedError(
115
- f"Intersection of {self.__class__.__name__} with "
116
- f"{other.__class__.__name__} not implemented"
117
- )
118
-
119
- def intersects(self, other: Union[location.Location, Nifti1Image]) -> bool:
120
- """Returns true if this point lies in the given mask.
121
-
122
- NOTE: The affine matrix of the image must be set to warp voxels
123
- coordinates into the reference space of this Bounding Box.
124
- """
125
- # transform physical coordinates to voxel coordinates for the query
126
- def coordinate_inside_mask(mask, c):
127
- voxel = (apply_affine(np.linalg.inv(mask.affine), c) + 0.5).astype(int)
128
- if np.any(voxel >= mask.dataobj.shape):
129
- return False
130
- elif np.any(voxel < 0):
131
- return False
132
- elif mask.dataobj[voxel[0], voxel[1], voxel[2]] == 0:
133
- return False
134
- else:
135
- return True
136
-
137
- if isinstance(other, location.Location):
138
- return (self.intersection(other) is not None)
139
- elif isinstance(other, Nifti1Image):
140
- if other.ndim == 4:
141
- return any(
142
- coordinate_inside_mask(other.slicer[:, :, :, i], self.coordinate)
143
- for i in range(other.shape[3])
144
- )
145
- else:
146
- return coordinate_inside_mask(other, self.coordinate)
147
120
  else:
148
- raise NotImplementedError(
149
- f"Intersection test of {self.__class__.__name__} with "
150
- f"{other.__class__.__name__} not implemented"
151
- )
152
-
153
- def contained_in(self, other: Union[location.Location, Nifti1Image]):
154
- return self.intersects(other)
155
-
156
- def contains(self, other: Union[location.Location, Nifti1Image]):
157
- if isinstance(other, Point):
158
- return self == other
159
- else:
160
- return False
121
+ return self if other.intersection(self) else None
161
122
 
162
123
  def warp(self, space):
163
- """Creates a new point by warping this point to another space"""
124
+ """
125
+ Creates a new point by warping this point to another space
126
+ TODO this needs to maintain the sigma parameter!
127
+ """
164
128
  from ..core.space import Space
165
129
  spaceobj = Space.get_instance(space)
166
130
  if spaceobj == self.space:
167
131
  return self
168
132
  if any(_ not in location.Location.SPACEWARP_IDS for _ in [self.space.id, spaceobj.id]):
169
- raise ValueError(
133
+ raise SpaceWarpingFailedError(
170
134
  f"Cannot convert coordinates between {self.space.id} and {spaceobj.id}"
171
135
  )
172
136
  url = "{server}/transform-point?source_space={src}&target_space={tgt}&x={x}&y={y}&z={z}".format(
@@ -178,11 +142,13 @@ class Point(location.Location):
178
142
  z=self.coordinate[2],
179
143
  )
180
144
  response = HttpRequest(url, lambda b: json.loads(b.decode())).get()
181
- if any(map(np.isnan, response['target_point'])):
182
- logger.debug(f'Warping {str(self)} to {spaceobj.name} resulted in NaN')
183
- return None
145
+ if np.any(np.isnan(response['target_point'])):
146
+ raise SpaceWarpingFailedError(f'Warping {str(self)} to {spaceobj.name} resulted in NaN')
147
+
184
148
  return self.__class__(
185
- coordinatespec=tuple(response["target_point"]), space=spaceobj.id
149
+ coordinatespec=tuple(response["target_point"]),
150
+ space=spaceobj.id,
151
+ label=self.label
186
152
  )
187
153
 
188
154
  @property
@@ -194,34 +160,57 @@ class Point(location.Location):
194
160
  """Substract the coordinates of two points to get
195
161
  a new point representing the offset vector. Alternatively,
196
162
  subtract an integer from the all coordinates of this point
197
- to create a new one."""
163
+ to create a new one.
164
+ TODO this needs to maintain sigma
165
+ """
198
166
  if isinstance(other, numbers.Number):
199
167
  return Point([c - other for c in self.coordinate], self.space)
200
168
 
201
169
  assert self.space == other.space
202
170
  return Point(
203
- [self.coordinate[i] - other.coordinate[i] for i in range(3)], self.space
171
+ [self.coordinate[i] - other.coordinate[i] for i in range(3)],
172
+ self.space,
173
+ label=self.label
204
174
  )
205
175
 
206
176
  def __lt__(self, other):
207
177
  o = other if self.space is None else other.warp(self.space)
178
+ if o is None:
179
+ return True # 'other' was warped outside reference space bounds
208
180
  return all(self[i] < o[i] for i in range(3))
209
181
 
210
182
  def __gt__(self, other):
183
+ assert other is not None
211
184
  o = other if self.space is None else other.warp(self.space)
185
+ if o is None:
186
+ return False # 'other' was warped outside reference space bounds
212
187
  return all(self[i] > o[i] for i in range(3))
213
188
 
189
+ def __hash__(self):
190
+ return super().__hash__()
191
+
214
192
  def __eq__(self, other: 'Point'):
193
+ if isinstance(other, pointcloud.PointCloud):
194
+ return other == self # implemented at pointcloud
215
195
  if not isinstance(other, Point):
216
196
  return False
217
197
  o = other if self.space is None else other.warp(self.space)
218
- return all(self[i] == o[i] for i in range(3))
198
+ if o is None:
199
+ return False # 'other' was warped outside reference space bounds
200
+ return all(self[i] == o[i] for i in range(3)) and self.sigma == other.sigma
219
201
 
220
202
  def __le__(self, other):
221
- return (self < other) or (self == other)
203
+ o = other if self.space is None else other.warp(self.space)
204
+ if o is None:
205
+ return True # 'other' was warped outside reference space bounds
206
+ return all(self[i] <= o[i] for i in range(3))
222
207
 
223
208
  def __ge__(self, other):
224
- return (self > other) or (self == other)
209
+ assert other is not None
210
+ o = other if self.space is None else other.warp(self.space)
211
+ if o is None:
212
+ return False # 'other' was warped outside reference space bounds
213
+ return all(self[i] >= o[i] for i in range(3))
225
214
 
226
215
  def __add__(self, other):
227
216
  """Add the coordinates of two points to get
@@ -231,22 +220,36 @@ class Point(location.Location):
231
220
  if isinstance(other, Point):
232
221
  assert self.space == other.space
233
222
  return Point(
234
- [self.coordinate[i] + other.coordinate[i] for i in range(3)], self.space
223
+ [self.coordinate[i] + other.coordinate[i] for i in range(3)],
224
+ self.space,
225
+ sigma_mm=self.sigma + other.sigma,
226
+ label=(self.label, other.label)
235
227
  )
236
228
 
237
229
  def __truediv__(self, number: float):
238
230
  """Return a new point with divided
239
231
  coordinates in the same space."""
240
- return Point(np.array(self.coordinate) / number, self.space, self.sigma / number)
232
+ return Point(
233
+ np.array(self.coordinate) / number,
234
+ self.space,
235
+ sigma_mm=self.sigma / number,
236
+ label=self.label
237
+ )
241
238
 
242
239
  def __mul__(self, number: float):
243
240
  """Return a new point with multiplied
244
241
  coordinates in the same space."""
245
- return Point(np.array(self.coordinate) * number, self.space, self.sigma * number)
242
+ return Point(
243
+ np.array(self.coordinate) * number,
244
+ self.space,
245
+ sigma_mm=self.sigma * number,
246
+ label=self.label
247
+ )
246
248
 
247
249
  def transform(self, affine: np.ndarray, space=None):
248
250
  """Returns a new Point obtained by transforming the
249
251
  coordinate of this one with the given affine matrix.
252
+ TODO this needs to maintain sigma
250
253
 
251
254
  Parameters
252
255
  ----------
@@ -259,16 +262,20 @@ class Point(location.Location):
259
262
  ----
260
263
  The consistency of this cannot be checked and is up to the user.
261
264
  """
262
- from ..core.space import Space
263
- spaceobj = Space.get_instance(space)
264
- x, y, z, h = np.dot(affine, self.homogeneous)
265
+ x, y, z, h = np.dot(affine, self.homogeneous.T)
265
266
  if h != 1:
266
267
  logger.warning(f"Homogeneous coordinate is not one: {h}")
267
- return self.__class__((x / h, y / h, z / h), spaceobj)
268
+ return self.__class__(
269
+ (x / h, y / h, z / h),
270
+ space,
271
+ sigma_mm=self.sigma,
272
+ label=self.label
273
+ )
268
274
 
269
275
  def get_enclosing_cube(self, width_mm):
270
276
  """
271
277
  Create a bounding box centered around this point with the given width.
278
+ TODO this should respect sigma (in addition or instead of the offset)
272
279
  """
273
280
  offset = width_mm / 2
274
281
  from .boundingbox import BoundingBox
@@ -278,6 +285,9 @@ class Point(location.Location):
278
285
  space=self.space,
279
286
  )
280
287
 
288
+ def __len__(self):
289
+ return 1
290
+
281
291
  def __iter__(self):
282
292
  """Return an iterator over the location,
283
293
  so the Point can be easily cast to list or tuple."""
@@ -328,3 +338,7 @@ class Point(location.Location):
328
338
  return hashlib.md5(
329
339
  f"{self.space.id}{','.join(str(val) for val in self)}".encode("utf-8")
330
340
  ).hexdigest()
341
+
342
+ def __repr__(self):
343
+ spacespec = f"'{self.space.id}'" if self.space else None
344
+ return f"<Point({self.coordinate}, space={spacespec}, sigma_mm={self.sigma})>"
@@ -0,0 +1,349 @@
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 set of coordinates on a reference space."""
16
+
17
+ from . import location, point, boundingbox as _boundingbox
18
+
19
+ from ..retrieval.requests import HttpRequest
20
+ from ..commons import logger
21
+ from ..exceptions import SpaceWarpingFailedError, EmptyPointCloudError
22
+
23
+ from typing import List, Union, Tuple
24
+ import numbers
25
+ import json
26
+ import numpy as np
27
+ try:
28
+ from sklearn.cluster import HDBSCAN
29
+ _HAS_HDBSCAN = True
30
+ except ImportError:
31
+ import sklearn
32
+ _HAS_HDBSCAN = False
33
+ logger.warning(
34
+ f"HDBSCAN is not available with your version {sklearn.__version__} of sckit-learn."
35
+ "`PointCloud.find_clusters()` will not be avaiable."
36
+ )
37
+
38
+
39
+ def from_points(points: List["point.Point"], newlabels: List[Union[int, float, tuple]] = None) -> "PointCloud":
40
+ """
41
+ Create a PointCloud from an iterable of Points.
42
+
43
+ Parameters
44
+ ----------
45
+ points : Iterable[point.Point]
46
+ newlabels: List[int], optional
47
+ Use these labels instead of the original labels of the points.
48
+
49
+ Returns
50
+ -------
51
+ PointCloud
52
+ """
53
+ if len(points) == 0:
54
+ raise EmptyPointCloudError("Cannot create a PointCloud without any points.")
55
+
56
+ spaces = {p.space for p in points}
57
+ assert len(spaces) == 1, f"PointCloud can only be constructed with points from the same space.\n{spaces}"
58
+ coords, sigmas, labels = zip(*((p.coordinate, p.sigma, p.label) for p in points))
59
+ if all(lb is None for lb in set(labels)):
60
+ labels = None
61
+ return PointCloud(
62
+ coordinates=coords,
63
+ space=next(iter(spaces)),
64
+ sigma_mm=sigmas,
65
+ labels=newlabels or labels
66
+ )
67
+
68
+
69
+ class PointCloud(location.Location):
70
+ """A set of 3D points in the same reference space,
71
+ defined by a list of coordinates."""
72
+
73
+ def __init__(
74
+ self,
75
+ coordinates: Union[List[Tuple], np.ndarray],
76
+ space=None,
77
+ sigma_mm: Union[int, float, List[Union[int, float]]] = 0,
78
+ labels: List[Union[int, float, tuple]] = None
79
+ ):
80
+ """
81
+ Construct a 3D point set in the given reference space.
82
+
83
+ Parameters
84
+ ----------
85
+ coordinates : array-like, Nx3
86
+ Coordinates in mm of the given space
87
+ space : reference space (id, name, or Space object)
88
+ The reference space
89
+ sigma_mm : float, or list of float
90
+ Optional standard deviation of point locations.
91
+ labels: list of point labels (optional)
92
+ """
93
+ location.Location.__init__(self, space)
94
+
95
+ if len(coordinates) == 0:
96
+ raise EmptyPointCloudError(f"Cannot create a {self.__class__.__name__} without any coordinates.")
97
+
98
+ self._coordinates = coordinates
99
+ if not isinstance(coordinates, np.ndarray):
100
+ self._coordinates = np.array(self._coordinates).reshape((-1, 3))
101
+ assert len(self._coordinates.shape) == 2
102
+ assert self._coordinates.shape[1] == 3
103
+
104
+ if isinstance(sigma_mm, numbers.Number):
105
+ self.sigma_mm = [sigma_mm for _ in range(len(self))]
106
+ else:
107
+ assert len(sigma_mm) == len(self), "The number of coordinate must be equal to the number of sigmas."
108
+ self.sigma_mm = sigma_mm
109
+
110
+ if labels is not None:
111
+ assert len(labels) == self._coordinates.shape[0]
112
+ self.labels = labels
113
+
114
+ def intersection(self, other: location.Location):
115
+ """Return the subset of points that are inside the given mask.
116
+
117
+ NOTE: The affine matrix of the image must be set to warp voxels
118
+ coordinates into the reference space of this Bounding Box.
119
+ """
120
+ if not isinstance(other, (point.Point, PointCloud, _boundingbox.BoundingBox)):
121
+ return other.intersection(self)
122
+
123
+ intersections = [(i, p) for i, p in enumerate(self) if p.intersects(other)]
124
+ if len(intersections) == 0:
125
+ return None
126
+ ids, points = zip(*intersections)
127
+ labels = None if self.labels is None else [self.labels[i] for i in ids]
128
+ sigma = [p.sigma for p in points]
129
+ intersection = PointCloud(
130
+ points,
131
+ space=self.space,
132
+ sigma_mm=sigma,
133
+ labels=labels
134
+ )
135
+ return intersection[0] if len(intersection) == 1 else intersection
136
+
137
+ @property
138
+ def coordinates(self) -> np.ndarray:
139
+ return self._coordinates
140
+
141
+ @property
142
+ def sigma(self) -> List[Union[int, float]]:
143
+ """The list of sigmas corresponding to the points."""
144
+ return self.sigma_mm
145
+
146
+ @property
147
+ def has_constant_sigma(self) -> bool:
148
+ return len(set(self.sigma)) == 1
149
+
150
+ def warp(self, space, chunksize=1000):
151
+ """Creates a new point set by warping its points to another space"""
152
+ from ..core.space import Space
153
+ spaceobj = space if isinstance(space, Space) else Space.get_instance(space)
154
+ if spaceobj == self.space:
155
+ return self
156
+ if any(_ not in location.Location.SPACEWARP_IDS for _ in [self.space.id, spaceobj.id]):
157
+ raise SpaceWarpingFailedError(
158
+ f"Cannot convert coordinates between {self.space.id} and {spaceobj.id}"
159
+ )
160
+
161
+ src_points = self.as_list()
162
+ tgt_points = []
163
+ N = len(src_points)
164
+ if N > 10e5:
165
+ logger.info(f"Warping {N} points from {self.space.name} to {spaceobj.name} space")
166
+ for i0 in range(0, N, chunksize):
167
+
168
+ i1 = min(i0 + chunksize, N)
169
+ data = json.dumps({
170
+ "source_space": location.Location.SPACEWARP_IDS[self.space.id],
171
+ "target_space": location.Location.SPACEWARP_IDS[spaceobj.id],
172
+ "source_points": src_points[i0:i1]
173
+ })
174
+ response = HttpRequest(
175
+ url=f"{location.Location.SPACEWARP_SERVER}/transform-points",
176
+ post=True,
177
+ headers={
178
+ "accept": "application/json",
179
+ "Content-Type": "application/json",
180
+ },
181
+ data=data,
182
+ func=lambda b: json.loads(b.decode()),
183
+ ).data
184
+ tgt_points.extend(list(response["target_points"]))
185
+
186
+ # TODO: consider using np.isnan(np.dot(arr, arr)). see https://stackoverflow.com/a/45011547
187
+ if np.any(np.isnan(response['target_points'])):
188
+ raise SpaceWarpingFailedError(f'Warping {str(self)} to {spaceobj.name} resulted in NaN')
189
+
190
+ return self.__class__(coordinates=tuple(tgt_points), space=spaceobj, labels=self.labels)
191
+
192
+ def transform(self, affine: np.ndarray, space=None):
193
+ """Returns a new PointCloud obtained by transforming the
194
+ coordinates of this one with the given affine matrix.
195
+
196
+ Parameters
197
+ ----------
198
+ affine : numpy 4x4 ndarray
199
+ affine matrix
200
+ space : reference space (id, name, or Space)
201
+ Target reference space which is reached after
202
+ applying the transform. Note that the consistency
203
+ of this cannot be checked and is up to the user.
204
+ """
205
+ return self.__class__(
206
+ np.dot(affine, self.homogeneous.T)[:3, :].T,
207
+ space,
208
+ labels=self.labels
209
+ )
210
+
211
+ def __getitem__(self, index: int):
212
+ if (abs(index) >= self.__len__()):
213
+ raise IndexError(
214
+ f"pointcloud with {self.__len__()} points "
215
+ f"cannot be accessed with index {index}."
216
+ )
217
+ return point.Point(
218
+ self.coordinates[index, :],
219
+ space=self.space,
220
+ sigma_mm=self.sigma_mm[index],
221
+ label=None if self.labels is None else self.labels[index]
222
+ )
223
+
224
+ def __iter__(self):
225
+ """Return an iterator over the coordinate locations."""
226
+ return (
227
+ point.Point(
228
+ self.coordinates[i, :],
229
+ space=self.space,
230
+ sigma_mm=self.sigma_mm[i],
231
+ label=None if self.labels is None else self.labels[i]
232
+ )
233
+ for i in range(len(self))
234
+ )
235
+
236
+ def __eq__(self, other: 'PointCloud'):
237
+ if isinstance(other, point.Point):
238
+ return len(self) == 1 and self[0] == other
239
+ if not isinstance(other, PointCloud):
240
+ return False
241
+ return list(self) == list(other)
242
+
243
+ def __hash__(self):
244
+ return super().__hash__()
245
+
246
+ def __len__(self):
247
+ """The number of points in this PointCloud."""
248
+ return self.coordinates.shape[0]
249
+
250
+ def __str__(self):
251
+ return f"Set of {len(self)} points in the {self.boundingbox}"
252
+
253
+ @property
254
+ def boundingbox(self):
255
+ """
256
+ Return the bounding box of these points, or None in the
257
+ special case of an empty PointCloud.
258
+ """
259
+ if len(self.coordinates) == 0:
260
+ return None
261
+ coords = self.coordinates
262
+ # TODO this needs a more precise treatment of the sigmas
263
+ sigma_min = max(self.sigma[i] for i in coords.argmin(0))
264
+ sigma_max = max(self.sigma[i] for i in coords.argmax(0))
265
+ return _boundingbox.BoundingBox(
266
+ point1=coords.min(0),
267
+ point2=coords.max(0),
268
+ space=self.space,
269
+ sigma_mm=[sigma_min, sigma_max]
270
+ )
271
+
272
+ @property
273
+ def centroid(self):
274
+ return point.Point(self.coordinates.mean(0), space=self.space)
275
+
276
+ @property
277
+ def volume(self):
278
+ if len(self) < 2:
279
+ return 0
280
+ else:
281
+ return self.boundingbox.volume
282
+
283
+ def as_list(self):
284
+ """Return the point set as a list of 3D tuples."""
285
+ return list(zip(*self.coordinates.T.tolist()))
286
+
287
+ @property
288
+ def homogeneous(self):
289
+ """Access the list of 3D point as an Nx4 array of homogeneous coordinates."""
290
+ return np.c_[self.coordinates, np.ones(len(self))]
291
+
292
+ def find_clusters(
293
+ self,
294
+ min_fraction: float = 1 / 200,
295
+ max_fraction: float = 1 / 8
296
+ ) -> List[int]:
297
+ """
298
+ Find clusters using HDBSCAN (https://dl.acm.org/doi/10.1145/2733381)
299
+ implementation of scikit-learn (https://dl.acm.org/doi/10.5555/1953048.2078195).
300
+
301
+ Parameters
302
+ ----------
303
+ min_fraction: min cluster size as a fraction of total points in the PointCloud
304
+ max_fraction: max cluster size as a fraction of total points in the PointCloud
305
+
306
+ Returns
307
+ -------
308
+ List[int]
309
+ Returns the cluster labels found by skilearn.cluster.HDBSCAN.
310
+
311
+ Note
312
+ ----
313
+ Replaces the labels of the PointCloud instance with these labels.
314
+
315
+ Raises
316
+ ------
317
+ RuntimeError
318
+ If a sklearn version without HDBSCAN is installed.
319
+ """
320
+ if not _HAS_HDBSCAN:
321
+ raise RuntimeError(
322
+ f"HDBSCAN is not available with your version {sklearn.__version__} "
323
+ "of sckit-learn. `PointCloud.find_clusters()` will not be avaiable."
324
+ )
325
+ points = np.array(self.as_list())
326
+ N = points.shape[0]
327
+ clustering = HDBSCAN(
328
+ min_cluster_size=int(N * min_fraction),
329
+ max_cluster_size=int(N * max_fraction),
330
+ )
331
+ if self.labels is not None:
332
+ logger.warning(
333
+ "Existing labels of PointCloud will be overwritten with cluster labels."
334
+ )
335
+ self.labels = clustering.fit_predict(points)
336
+ return self.labels
337
+
338
+ @property
339
+ def label_colors(self):
340
+ """ return a color for the given label. """
341
+ if self.labels is None:
342
+ return None
343
+ else:
344
+ try:
345
+ from matplotlib.pyplot import cm as colormaps
346
+ except Exception:
347
+ logger.error("Matplotlib is not available. Label colors is disabled.")
348
+ return None
349
+ return colormaps.rainbow(np.linspace(0, 1, max(self.labels) + 1))
@@ -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");