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.

Files changed (84) hide show
  1. siibra/VERSION +1 -0
  2. siibra/__init__.py +164 -0
  3. siibra/commons.py +823 -0
  4. siibra/configuration/__init__.py +17 -0
  5. siibra/configuration/configuration.py +189 -0
  6. siibra/configuration/factory.py +589 -0
  7. siibra/core/__init__.py +16 -0
  8. siibra/core/assignment.py +110 -0
  9. siibra/core/atlas.py +239 -0
  10. siibra/core/concept.py +308 -0
  11. siibra/core/parcellation.py +387 -0
  12. siibra/core/region.py +1223 -0
  13. siibra/core/space.py +131 -0
  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 +17 -0
  22. siibra/explorer/url.py +222 -0
  23. siibra/explorer/util.py +87 -0
  24. siibra/features/__init__.py +117 -0
  25. siibra/features/anchor.py +224 -0
  26. siibra/features/connectivity/__init__.py +33 -0
  27. siibra/features/connectivity/functional_connectivity.py +57 -0
  28. siibra/features/connectivity/regional_connectivity.py +494 -0
  29. siibra/features/connectivity/streamline_counts.py +27 -0
  30. siibra/features/connectivity/streamline_lengths.py +27 -0
  31. siibra/features/connectivity/tracing_connectivity.py +30 -0
  32. siibra/features/dataset/__init__.py +17 -0
  33. siibra/features/dataset/ebrains.py +90 -0
  34. siibra/features/feature.py +970 -0
  35. siibra/features/image/__init__.py +27 -0
  36. siibra/features/image/image.py +115 -0
  37. siibra/features/image/sections.py +26 -0
  38. siibra/features/image/volume_of_interest.py +88 -0
  39. siibra/features/tabular/__init__.py +24 -0
  40. siibra/features/tabular/bigbrain_intensity_profile.py +77 -0
  41. siibra/features/tabular/cell_density_profile.py +298 -0
  42. siibra/features/tabular/cortical_profile.py +322 -0
  43. siibra/features/tabular/gene_expression.py +257 -0
  44. siibra/features/tabular/layerwise_bigbrain_intensities.py +62 -0
  45. siibra/features/tabular/layerwise_cell_density.py +95 -0
  46. siibra/features/tabular/receptor_density_fingerprint.py +192 -0
  47. siibra/features/tabular/receptor_density_profile.py +110 -0
  48. siibra/features/tabular/regional_timeseries_activity.py +294 -0
  49. siibra/features/tabular/tabular.py +139 -0
  50. siibra/livequeries/__init__.py +19 -0
  51. siibra/livequeries/allen.py +352 -0
  52. siibra/livequeries/bigbrain.py +197 -0
  53. siibra/livequeries/ebrains.py +145 -0
  54. siibra/livequeries/query.py +49 -0
  55. siibra/locations/__init__.py +91 -0
  56. siibra/locations/boundingbox.py +454 -0
  57. siibra/locations/location.py +115 -0
  58. siibra/locations/point.py +344 -0
  59. siibra/locations/pointcloud.py +349 -0
  60. siibra/retrieval/__init__.py +27 -0
  61. siibra/retrieval/cache.py +233 -0
  62. siibra/retrieval/datasets.py +389 -0
  63. siibra/retrieval/exceptions/__init__.py +27 -0
  64. siibra/retrieval/repositories.py +769 -0
  65. siibra/retrieval/requests.py +659 -0
  66. siibra/vocabularies/__init__.py +45 -0
  67. siibra/vocabularies/gene_names.json +29176 -0
  68. siibra/vocabularies/receptor_symbols.json +210 -0
  69. siibra/vocabularies/region_aliases.json +460 -0
  70. siibra/volumes/__init__.py +23 -0
  71. siibra/volumes/parcellationmap.py +1279 -0
  72. siibra/volumes/providers/__init__.py +20 -0
  73. siibra/volumes/providers/freesurfer.py +113 -0
  74. siibra/volumes/providers/gifti.py +165 -0
  75. siibra/volumes/providers/neuroglancer.py +736 -0
  76. siibra/volumes/providers/nifti.py +266 -0
  77. siibra/volumes/providers/provider.py +107 -0
  78. siibra/volumes/sparsemap.py +468 -0
  79. siibra/volumes/volume.py +892 -0
  80. siibra-1.0.0a1.dist-info/LICENSE +201 -0
  81. siibra-1.0.0a1.dist-info/METADATA +160 -0
  82. siibra-1.0.0a1.dist-info/RECORD +84 -0
  83. siibra-1.0.0a1.dist-info/WHEEL +5 -0
  84. siibra-1.0.0a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,344 @@
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
+ """Singular coordinate defined on a space, possibly with an uncertainty."""
16
+
17
+ from . import location, boundingbox, pointcloud
18
+
19
+ from ..commons import logger
20
+ from ..retrieval.requests import HttpRequest
21
+ from ..exceptions import SpaceWarpingFailedError, NoneCoordinateSuppliedError
22
+
23
+ from urllib.parse import quote
24
+ import re
25
+ import numpy as np
26
+ import json
27
+ import numbers
28
+ import hashlib
29
+ from typing import Tuple, Union
30
+
31
+
32
+ class Point(location.Location):
33
+ """A single 3D point in reference space."""
34
+
35
+ @staticmethod
36
+ def parse(spec, unit="mm") -> Tuple[float, float, float]:
37
+ """Converts a 3D coordinate specification into a 3D tuple of floats.
38
+
39
+ Parameters
40
+ ----------
41
+ spec: Any of str, tuple(float,float,float)
42
+ For string specifications, comma separation with decimal points are expected.
43
+ unit: str
44
+ specification of the unit (only 'mm' supported so far)
45
+ Returns
46
+ -------
47
+ tuple(float, float, float)
48
+ """
49
+ if unit != "mm":
50
+ raise NotImplementedError(
51
+ "Coordinate parsing from strings is only supported for mm specifications so far."
52
+ )
53
+ if isinstance(spec, str):
54
+ pat = r"([-\d\.]*)" + unit
55
+ digits = re.findall(pat, spec)
56
+ if len(digits) == 3:
57
+ return tuple(float(d) for d in digits)
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.")
61
+ if len(spec) == 4:
62
+ assert spec[3] == 1
63
+ return tuple(float(v.item()) if isinstance(v, np.ndarray) else float(v) for v in spec[:3])
64
+ elif isinstance(spec, np.ndarray) and spec.size == 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])
68
+ elif isinstance(spec, Point):
69
+ return spec.coordinate
70
+
71
+ raise ValueError(
72
+ f"Cannot decode the specification {spec} (type {type(spec)}) to create a point."
73
+ )
74
+
75
+ def __init__(
76
+ self,
77
+ coordinatespec,
78
+ space=None,
79
+ sigma_mm: float = 0.0,
80
+ label: Union[int, float, tuple] = None
81
+ ):
82
+ """
83
+ Construct a new 3D point set in the given reference space.
84
+
85
+ Parameters
86
+ ----------
87
+ coordinatespec: 3-tuple of int/float, or string specification
88
+ Coordinate in mm of the given space
89
+ space: Space or str
90
+ The reference space (id, object, or name)
91
+ sigma_mm : float, optional
92
+ Location uncertainty of the point
93
+ label: optional
94
+ Any object attached as an attribute to the point
95
+
96
+ Note
97
+ ----
98
+ Interpreted as the isotropic standard deviation of location.
99
+ """
100
+ location.Location.__init__(self, space)
101
+ self.coordinate = Point.parse(coordinatespec)
102
+ self.sigma = sigma_mm
103
+ self.label = label
104
+ if isinstance(coordinatespec, Point):
105
+ assert coordinatespec.sigma == sigma_mm
106
+ assert coordinatespec.space == space
107
+ self.label = label
108
+
109
+ @property
110
+ def homogeneous(self):
111
+ """The homogenous coordinate of this point as a 4-tuple,
112
+ obtained by appending '1' to the original 3-tuple."""
113
+ return np.atleast_2d(self.coordinate + (1,))
114
+
115
+ def intersection(self, other: location.Location) -> "Point":
116
+ if isinstance(other, Point):
117
+ return self if self == other else None
118
+ elif isinstance(other, pointcloud.PointCloud):
119
+ return self if self in other else None
120
+ else:
121
+ return self if other.intersection(self) else None
122
+
123
+ def warp(self, space):
124
+ """
125
+ Creates a new point by warping this point to another space
126
+ TODO this needs to maintain the sigma parameter!
127
+ """
128
+ from ..core.space import Space
129
+ spaceobj = Space.get_instance(space)
130
+ if spaceobj == self.space:
131
+ return self
132
+ if any(_ not in location.Location.SPACEWARP_IDS for _ in [self.space.id, spaceobj.id]):
133
+ raise SpaceWarpingFailedError(
134
+ f"Cannot convert coordinates between {self.space.id} and {spaceobj.id}"
135
+ )
136
+ url = "{server}/transform-point?source_space={src}&target_space={tgt}&x={x}&y={y}&z={z}".format(
137
+ server=location.Location.SPACEWARP_SERVER,
138
+ src=quote(location.Location.SPACEWARP_IDS[self.space.id]),
139
+ tgt=quote(location.Location.SPACEWARP_IDS[spaceobj.id]),
140
+ x=self.coordinate[0],
141
+ y=self.coordinate[1],
142
+ z=self.coordinate[2],
143
+ )
144
+ response = HttpRequest(url, lambda b: json.loads(b.decode())).get()
145
+ if np.any(np.isnan(response['target_point'])):
146
+ raise SpaceWarpingFailedError(f'Warping {str(self)} to {spaceobj.name} resulted in NaN')
147
+
148
+ return self.__class__(
149
+ coordinatespec=tuple(response["target_point"]),
150
+ space=spaceobj.id,
151
+ label=self.label
152
+ )
153
+
154
+ @property
155
+ def volume(self):
156
+ """ The volume of a point can be nonzero if it has a location uncertainty. """
157
+ return self.sigma**3 * np.pi * 4. / 3.
158
+
159
+ def __sub__(self, other):
160
+ """Substract the coordinates of two points to get
161
+ a new point representing the offset vector. Alternatively,
162
+ subtract an integer from the all coordinates of this point
163
+ to create a new one.
164
+ TODO this needs to maintain sigma
165
+ """
166
+ if isinstance(other, numbers.Number):
167
+ return Point([c - other for c in self.coordinate], self.space)
168
+
169
+ assert self.space == other.space
170
+ return Point(
171
+ [self.coordinate[i] - other.coordinate[i] for i in range(3)],
172
+ self.space,
173
+ label=self.label
174
+ )
175
+
176
+ def __lt__(self, other):
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
180
+ return all(self[i] < o[i] for i in range(3))
181
+
182
+ def __gt__(self, other):
183
+ assert other is not None
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
187
+ return all(self[i] > o[i] for i in range(3))
188
+
189
+ def __hash__(self):
190
+ return super().__hash__()
191
+
192
+ def __eq__(self, other: 'Point'):
193
+ if isinstance(other, pointcloud.PointCloud):
194
+ return other == self # implemented at pointcloud
195
+ if not isinstance(other, Point):
196
+ return False
197
+ o = other if self.space is None else other.warp(self.space)
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
201
+
202
+ def __le__(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))
207
+
208
+ def __ge__(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))
214
+
215
+ def __add__(self, other):
216
+ """Add the coordinates of two points to get
217
+ a new point representing."""
218
+ if isinstance(other, numbers.Number):
219
+ return Point([c + other for c in self.coordinate], self.space)
220
+ if isinstance(other, Point):
221
+ assert self.space == other.space
222
+ return Point(
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)
227
+ )
228
+
229
+ def __truediv__(self, number: float):
230
+ """Return a new point with divided
231
+ coordinates in the same space."""
232
+ return Point(
233
+ np.array(self.coordinate) / number,
234
+ self.space,
235
+ sigma_mm=self.sigma / number,
236
+ label=self.label
237
+ )
238
+
239
+ def __mul__(self, number: float):
240
+ """Return a new point with multiplied
241
+ coordinates in the same space."""
242
+ return Point(
243
+ np.array(self.coordinate) * number,
244
+ self.space,
245
+ sigma_mm=self.sigma * number,
246
+ label=self.label
247
+ )
248
+
249
+ def transform(self, affine: np.ndarray, space=None):
250
+ """Returns a new Point obtained by transforming the
251
+ coordinate of this one with the given affine matrix.
252
+ TODO this needs to maintain sigma
253
+
254
+ Parameters
255
+ ----------
256
+ affine: numpy 4x4 ndarray
257
+ affine matrix
258
+ space: str, Space, or None
259
+ Target reference space which is reached after applying the transform
260
+
261
+ Note
262
+ ----
263
+ The consistency of this cannot be checked and is up to the user.
264
+ """
265
+ x, y, z, h = np.dot(affine, self.homogeneous.T)
266
+ if h != 1:
267
+ logger.warning(f"Homogeneous coordinate is not one: {h}")
268
+ return self.__class__(
269
+ (x / h, y / h, z / h),
270
+ space,
271
+ sigma_mm=self.sigma,
272
+ label=self.label
273
+ )
274
+
275
+ def get_enclosing_cube(self, width_mm):
276
+ """
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)
279
+ """
280
+ offset = width_mm / 2
281
+ from .boundingbox import BoundingBox
282
+ return BoundingBox(
283
+ point1=self - offset,
284
+ point2=self + offset,
285
+ space=self.space,
286
+ )
287
+
288
+ def __len__(self):
289
+ return 1
290
+
291
+ def __iter__(self):
292
+ """Return an iterator over the location,
293
+ so the Point can be easily cast to list or tuple."""
294
+ return iter(self.coordinate)
295
+
296
+ def __setitem__(self, index, value):
297
+ """Write access to the coefficients of this point."""
298
+ assert 0 <= index < 3
299
+ values = list(self.coordinate)
300
+ values[index] = value
301
+ self.coordinate = tuple(values)
302
+
303
+ def __getitem__(self, index):
304
+ """Index access to the coefficients of this point."""
305
+ assert 0 <= index < 3
306
+ return self.coordinate[index]
307
+
308
+ @property
309
+ def boundingbox(self):
310
+ w = max(self.sigma or 0, 1e-6) # at least a micrometer
311
+ return boundingbox.BoundingBox(
312
+ self - w, self + w, self.space, self.sigma
313
+ )
314
+
315
+ def bigbrain_section(self):
316
+ """
317
+ Estimate the histological section number of BigBrain
318
+ which corresponds to this point. If the point is given
319
+ in another space, a warping to BigBrain space will be tried.
320
+ """
321
+ if self.space.id == location.Location.BIGBRAIN_ID:
322
+ coronal_position = self[1]
323
+ else:
324
+ try:
325
+ bigbrain_point = self.warp("bigbrain")
326
+ coronal_position = bigbrain_point[1]
327
+ except Exception:
328
+ raise RuntimeError(
329
+ "BigBrain section numbers can only be determined "
330
+ "for points in BigBrain space, but the given point "
331
+ f"is given in '{self.space.name}' and could not "
332
+ "be converted."
333
+ )
334
+ return int((coronal_position + 70.0) / 0.02 + 1.5)
335
+
336
+ @property
337
+ def id(self) -> str:
338
+ return hashlib.md5(
339
+ f"{self.space.id}{','.join(str(val) for val in self)}".encode("utf-8")
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))