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,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))
|