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.
- siibra/VERSION +1 -1
- siibra/__init__.py +20 -12
- siibra/commons.py +145 -90
- siibra/configuration/__init__.py +1 -1
- siibra/configuration/configuration.py +22 -17
- siibra/configuration/factory.py +177 -128
- siibra/core/__init__.py +1 -8
- siibra/core/{relation_qualification.py → assignment.py} +17 -14
- siibra/core/atlas.py +66 -35
- siibra/core/concept.py +81 -39
- siibra/core/parcellation.py +83 -67
- siibra/core/region.py +569 -263
- siibra/core/space.py +7 -39
- 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 +16 -0
- siibra/explorer/url.py +112 -52
- siibra/explorer/util.py +31 -9
- siibra/features/__init__.py +73 -8
- siibra/features/anchor.py +75 -196
- siibra/features/connectivity/__init__.py +1 -1
- siibra/features/connectivity/functional_connectivity.py +2 -2
- siibra/features/connectivity/regional_connectivity.py +99 -10
- siibra/features/connectivity/streamline_counts.py +1 -1
- siibra/features/connectivity/streamline_lengths.py +1 -1
- siibra/features/connectivity/tracing_connectivity.py +1 -1
- siibra/features/dataset/__init__.py +1 -1
- siibra/features/dataset/ebrains.py +3 -3
- siibra/features/feature.py +219 -110
- siibra/features/image/__init__.py +1 -1
- siibra/features/image/image.py +21 -13
- siibra/features/image/sections.py +1 -1
- siibra/features/image/volume_of_interest.py +1 -1
- siibra/features/tabular/__init__.py +1 -1
- siibra/features/tabular/bigbrain_intensity_profile.py +24 -13
- siibra/features/tabular/cell_density_profile.py +111 -69
- siibra/features/tabular/cortical_profile.py +82 -16
- siibra/features/tabular/gene_expression.py +117 -6
- siibra/features/tabular/layerwise_bigbrain_intensities.py +7 -9
- siibra/features/tabular/layerwise_cell_density.py +9 -24
- siibra/features/tabular/receptor_density_fingerprint.py +11 -6
- siibra/features/tabular/receptor_density_profile.py +12 -15
- siibra/features/tabular/regional_timeseries_activity.py +74 -18
- siibra/features/tabular/tabular.py +17 -8
- siibra/livequeries/__init__.py +1 -7
- siibra/livequeries/allen.py +139 -77
- siibra/livequeries/bigbrain.py +104 -128
- siibra/livequeries/ebrains.py +7 -4
- siibra/livequeries/query.py +1 -2
- siibra/locations/__init__.py +32 -25
- siibra/locations/boundingbox.py +153 -127
- siibra/locations/location.py +45 -80
- siibra/locations/point.py +97 -83
- siibra/locations/pointcloud.py +349 -0
- siibra/retrieval/__init__.py +1 -1
- siibra/retrieval/cache.py +107 -13
- siibra/retrieval/datasets.py +9 -14
- siibra/retrieval/exceptions/__init__.py +2 -1
- siibra/retrieval/repositories.py +147 -53
- siibra/retrieval/requests.py +64 -29
- siibra/vocabularies/__init__.py +2 -2
- siibra/volumes/__init__.py +7 -9
- siibra/volumes/parcellationmap.py +396 -253
- siibra/volumes/providers/__init__.py +20 -0
- siibra/volumes/providers/freesurfer.py +113 -0
- siibra/volumes/{gifti.py → providers/gifti.py} +29 -18
- siibra/volumes/{neuroglancer.py → providers/neuroglancer.py} +204 -92
- siibra/volumes/{nifti.py → providers/nifti.py} +64 -44
- siibra/volumes/providers/provider.py +107 -0
- siibra/volumes/sparsemap.py +159 -260
- siibra/volumes/volume.py +720 -152
- {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/METADATA +25 -28
- siibra-1.0.0a1.dist-info/RECORD +84 -0
- {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/WHEEL +1 -1
- siibra/locations/pointset.py +0 -198
- siibra-0.5a2.dist-info/RECORD +0 -74
- {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/LICENSE +0 -0
- {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-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
72
|
-
|
|
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
|
+
):
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
|
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(
|
|
182
|
-
|
|
183
|
-
|
|
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"]),
|
|
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)],
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)],
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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__(
|
|
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))
|
siibra/retrieval/__init__.py
CHANGED