siibra 1.0.1a0__py3-none-any.whl → 1.0.1a2__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 (82) hide show
  1. siibra/VERSION +1 -1
  2. siibra/__init__.py +11 -20
  3. siibra/commons.py +17 -14
  4. siibra/configuration/__init__.py +1 -1
  5. siibra/configuration/configuration.py +6 -6
  6. siibra/configuration/factory.py +10 -9
  7. siibra/core/__init__.py +2 -2
  8. siibra/core/assignment.py +2 -1
  9. siibra/core/atlas.py +4 -4
  10. siibra/core/concept.py +7 -5
  11. siibra/core/parcellation.py +10 -10
  12. siibra/core/region.py +82 -73
  13. siibra/core/space.py +5 -7
  14. siibra/core/structure.py +4 -4
  15. siibra/exceptions.py +6 -2
  16. siibra/explorer/__init__.py +1 -1
  17. siibra/explorer/url.py +2 -2
  18. siibra/explorer/util.py +1 -1
  19. siibra/features/__init__.py +1 -1
  20. siibra/features/anchor.py +4 -6
  21. siibra/features/connectivity/__init__.py +1 -1
  22. siibra/features/connectivity/functional_connectivity.py +1 -1
  23. siibra/features/connectivity/regional_connectivity.py +12 -15
  24. siibra/features/connectivity/streamline_counts.py +1 -1
  25. siibra/features/connectivity/streamline_lengths.py +1 -1
  26. siibra/features/connectivity/tracing_connectivity.py +1 -1
  27. siibra/features/dataset/__init__.py +1 -1
  28. siibra/features/dataset/ebrains.py +2 -2
  29. siibra/features/feature.py +31 -28
  30. siibra/features/image/__init__.py +5 -3
  31. siibra/features/image/image.py +4 -6
  32. siibra/features/image/sections.py +82 -3
  33. siibra/features/image/volume_of_interest.py +1 -9
  34. siibra/features/tabular/__init__.py +2 -2
  35. siibra/features/tabular/bigbrain_intensity_profile.py +3 -2
  36. siibra/features/tabular/cell_density_profile.py +10 -11
  37. siibra/features/tabular/cortical_profile.py +9 -9
  38. siibra/features/tabular/gene_expression.py +7 -6
  39. siibra/features/tabular/layerwise_bigbrain_intensities.py +5 -4
  40. siibra/features/tabular/layerwise_cell_density.py +5 -7
  41. siibra/features/tabular/receptor_density_fingerprint.py +47 -19
  42. siibra/features/tabular/receptor_density_profile.py +2 -3
  43. siibra/features/tabular/regional_timeseries_activity.py +9 -9
  44. siibra/features/tabular/tabular.py +10 -9
  45. siibra/livequeries/__init__.py +1 -1
  46. siibra/livequeries/allen.py +23 -25
  47. siibra/livequeries/bigbrain.py +252 -55
  48. siibra/livequeries/ebrains.py +14 -11
  49. siibra/livequeries/query.py +5 -5
  50. siibra/locations/__init__.py +19 -10
  51. siibra/locations/boundingbox.py +10 -13
  52. siibra/{experimental/plane3d.py → locations/experimental.py} +117 -17
  53. siibra/locations/location.py +11 -13
  54. siibra/locations/point.py +10 -19
  55. siibra/locations/pointcloud.py +59 -23
  56. siibra/retrieval/__init__.py +1 -1
  57. siibra/retrieval/cache.py +2 -1
  58. siibra/retrieval/datasets.py +23 -17
  59. siibra/retrieval/exceptions/__init__.py +1 -1
  60. siibra/retrieval/repositories.py +14 -15
  61. siibra/retrieval/requests.py +32 -30
  62. siibra/vocabularies/__init__.py +2 -3
  63. siibra/volumes/__init__.py +5 -4
  64. siibra/volumes/parcellationmap.py +55 -20
  65. siibra/volumes/providers/__init__.py +1 -1
  66. siibra/volumes/providers/freesurfer.py +7 -7
  67. siibra/volumes/providers/gifti.py +5 -5
  68. siibra/volumes/providers/neuroglancer.py +25 -28
  69. siibra/volumes/providers/nifti.py +7 -7
  70. siibra/volumes/providers/provider.py +4 -3
  71. siibra/volumes/sparsemap.py +8 -7
  72. siibra/volumes/volume.py +33 -40
  73. {siibra-1.0.1a0.dist-info → siibra-1.0.1a2.dist-info}/METADATA +21 -8
  74. siibra-1.0.1a2.dist-info/RECORD +80 -0
  75. {siibra-1.0.1a0.dist-info → siibra-1.0.1a2.dist-info}/WHEEL +1 -1
  76. siibra/experimental/__init__.py +0 -19
  77. siibra/experimental/contour.py +0 -61
  78. siibra/experimental/cortical_profile_sampler.py +0 -57
  79. siibra/experimental/patch.py +0 -98
  80. siibra-1.0.1a0.dist-info/RECORD +0 -84
  81. {siibra-1.0.1a0.dist-info → siibra-1.0.1a2.dist-info}/LICENSE +0 -0
  82. {siibra-1.0.1a0.dist-info → siibra-1.0.1a2.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,4 @@
1
- # Copyright 2018-2024
1
+ # Copyright 2018-2025
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,13 +14,13 @@
14
14
  # limitations under the License.
15
15
  """Handles feature queries that rely on live or on-the-fly calculations."""
16
16
 
17
+ from abc import ABC, abstractmethod
18
+ from typing import List
19
+
17
20
  from ..commons import logger
18
21
  from ..features.feature import Feature
19
22
  from ..core.concept import AtlasConcept
20
23
 
21
- from abc import ABC, abstractmethod
22
- from typing import List
23
-
24
24
 
25
25
  class LiveQuery(ABC):
26
26
 
@@ -46,4 +46,4 @@ class LiveQuery(ABC):
46
46
 
47
47
  @abstractmethod
48
48
  def query(self, concept: AtlasConcept, **kwargs) -> List[Feature]:
49
- raise NotImplementedError(f"Dervied class {self.__class__} needs to implement query()")
49
+ raise NotImplementedError(f"Derived class {self.__class__} needs to implement query()")
@@ -1,4 +1,4 @@
1
- # Copyright 2018-2024
1
+ # Copyright 2018-2025
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");
@@ -13,26 +13,34 @@
13
13
  # See the License for the specific language governing permissions and
14
14
  # limitations under the License.
15
15
  """Handles spatial concepts and spatial operation like warping between spaces."""
16
+ from typing import Iterable
17
+ from functools import reduce
16
18
 
17
19
  from .location import Location
18
20
  from .point import Point
19
- from .pointcloud import PointCloud, from_points
21
+ from .pointcloud import PointCloud, Contour, from_points
22
+ from .experimental import AxisAlignedPatch, Plane
20
23
  from .boundingbox import BoundingBox
21
24
 
22
25
 
23
- def reassign_union(loc0: 'Location', loc1: 'Location') -> 'Location':
26
+ def reassign_union(*args: Iterable["Location"]) -> "Location":
27
+ return reduce(pairwise_union, args)
28
+
29
+
30
+ def pairwise_union(loc0: "Location", loc1: "Location") -> "Location":
24
31
  """
25
- Add two locations of same or diffrent type to find their union as a
32
+ Add two locations of same or different type to find their union as a
26
33
  Location object.
34
+
27
35
  Note
28
36
  ----
29
37
  `loc1` will be warped to `loc0` they are not in the same space.
38
+
30
39
  Parameters
31
40
  ----------
32
41
  loc0 : Location
33
- _description_
34
42
  loc1 : Location
35
- _description_
43
+
36
44
  Returns
37
45
  -------
38
46
  Location
@@ -58,12 +66,13 @@ def reassign_union(loc0: 'Location', loc1: 'Location') -> 'Location':
58
66
  try:
59
67
  return loc1.union(loc0)
60
68
  except Exception:
61
- raise NotImplementedError(f"There are no union method for {(loc0.__class__.__name__, loc1.__class__.__name__)}")
69
+ raise NotImplementedError(
70
+ f"There are no union method for {(loc0.__class__.__name__, loc1.__class__.__name__)}"
71
+ )
62
72
 
63
73
  # convert Points to PointClouds
64
74
  loc0, loc1 = [
65
- from_points([loc]) if isinstance(loc, Point) else loc
66
- for loc in [loc0, loc1]
75
+ from_points([loc]) if isinstance(loc, Point) else loc for loc in [loc0, loc1]
67
76
  ]
68
77
 
69
78
  # adopt the space of the first location
@@ -82,7 +91,7 @@ def reassign_union(loc0: 'Location', loc1: 'Location') -> 'Location':
82
91
  point1=[min(p[i] for p in coordinates) for i in range(3)],
83
92
  point2=[max(p[i] for p in coordinates) for i in range(3)],
84
93
  space=loc0.space,
85
- sigma_mm=[loc0.minpoint.sigma, loc0.maxpoint.sigma]
94
+ sigma_mm=[loc0.minpoint.sigma, loc0.maxpoint.sigma],
86
95
  )
87
96
 
88
97
  return reassign_union(loc1_w, loc0)
@@ -1,4 +1,4 @@
1
- # Copyright 2018-2024
1
+ # Copyright 2018-2025
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,15 +14,16 @@
14
14
  # limitations under the License.
15
15
  """A box defined by two farthest corner coordinates on a specific space."""
16
16
 
17
- from . import point, pointcloud, location
17
+ from itertools import product
18
+ import hashlib
19
+ from typing import TYPE_CHECKING, Union
20
+
21
+ import numpy as np
18
22
 
23
+ from . import location, point, pointcloud
19
24
  from ..commons import logger
20
25
  from ..exceptions import SpaceWarpingFailedError
21
26
 
22
- from itertools import product
23
- import hashlib
24
- import numpy as np
25
- from typing import TYPE_CHECKING, Union
26
27
  if TYPE_CHECKING:
27
28
  from ..core.structure import BrainStructure
28
29
  from nibabel import Nifti1Image
@@ -85,7 +86,7 @@ class BoundingBox(location.Location):
85
86
  self.maxpoint[d] = self.minpoint[d] + minsize
86
87
 
87
88
  if self.volume == 0:
88
- logger.warning(f"Zero-volume bounding box from points {point1} and {point2} in {self.space} space.")
89
+ logger.debug(f"Zero-volume bounding box from points {point1} and {point2} in {self.space} space.")
89
90
 
90
91
  @property
91
92
  def id(self) -> str:
@@ -148,11 +149,7 @@ class BoundingBox(location.Location):
148
149
  points_inside = [p for p in other if self.intersects(p)]
149
150
  if len(points_inside) == 0:
150
151
  return None
151
- result = pointcloud.PointCloud(
152
- points_inside,
153
- space=other.space,
154
- sigma_mm=[p.sigma for p in points_inside]
155
- )
152
+ result = pointcloud.from_points(points_inside)
156
153
  return result[0] if len(result) == 1 else result # if PointCloud has single point return as a Point
157
154
 
158
155
  return other.intersection(self)
@@ -213,7 +210,7 @@ class BoundingBox(location.Location):
213
210
  X, Y, Z = np.where(mask.get_fdata() > threshold)
214
211
  h = np.ones(len(X))
215
212
 
216
- # array of homogenous physical nonzero voxel coordinates
213
+ # array of homogeneous physical nonzero voxel coordinates
217
214
  coords = np.dot(mask.affine, np.vstack((X, Y, Z, h)))[:3, :].T
218
215
  minpoint = [min(self.minpoint[i], self.maxpoint[i]) for i in range(3)]
219
216
  maxpoint = [max(self.minpoint[i], self.maxpoint[i]) for i in range(3)]
@@ -1,4 +1,4 @@
1
- # Copyright 2018-2024
1
+ # Copyright 2018-2025
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");
@@ -13,18 +13,113 @@
13
13
  # See the License for the specific language governing permissions and
14
14
  # limitations under the License.
15
15
 
16
- from . import contour
17
- from . import patch
18
- from ..locations import point, pointcloud
19
- from ..volumes import volume
16
+ from typing import List
17
+ from math import atan2
20
18
 
21
19
  import numpy as np
20
+ from nilearn import image
21
+
22
+ from . import point, pointcloud, boundingbox
23
+ from ..volumes import volume
24
+ from ..commons import translation_matrix, y_rotation_matrix
25
+
26
+
27
+ class AxisAlignedPatch(pointcloud.PointCloud):
28
+
29
+ def __init__(self, corners: pointcloud.PointCloud):
30
+ """Construct a patch in physical coordinates.
31
+ As of now, only patches aligned in the y plane of the physical space
32
+ are supported."""
33
+ # TODO: need to ensure that the points are planar, if more than 3
34
+ assert len(corners) == 4
35
+ assert len(np.unique(corners.coordinates[:, 1])) == 1
36
+ pointcloud.PointCloud.__init__(
37
+ self,
38
+ coordinates=corners.coordinates,
39
+ space=corners.space,
40
+ sigma_mm=corners.sigma_mm,
41
+ labels=corners.labels
42
+ )
43
+ # self.corners = corners
44
+
45
+ def __str__(self):
46
+ return f"Patch with boundingbox {self.boundingbox}"
47
+
48
+ def flip(self):
49
+ """Returns a flipped version of the patch."""
50
+ new_corners = self.coordinates.copy()[[2, 3, 0, 1]]
51
+ return AxisAlignedPatch(pointcloud.PointCloud(new_corners, self.space))
52
+
53
+ def extract_volume(
54
+ self,
55
+ image_volume: volume.Volume,
56
+ resolution_mm: float,
57
+ ):
58
+ """
59
+ fetches image data in a planar patch.
60
+ TODO The current implementation only covers patches which are strictly
61
+ define in the y plane. A future implementation should accept arbitrary
62
+ oriented patches.accept arbitrary oriented patches.
63
+ """
64
+ assert image_volume.space == self.space
65
+
66
+ # Extend the 2D patch into a 3D structure
67
+ # this is only valid if the patch plane lies within the image canvas.
68
+ canvas = image_volume.get_boundingbox()
69
+ assert canvas.minpoint[1] <= self.coordinates[0, 1]
70
+ assert canvas.maxpoint[1] >= self.coordinates[0, 1]
71
+ XYZ = self.coordinates
72
+ voi = boundingbox.BoundingBox(
73
+ XYZ.min(0)[:3], XYZ.max(0)[:3], space=image_volume.space
74
+ )
75
+ # enforce the patch to have the same y dimensions
76
+ voi.minpoint[1] = canvas.minpoint[1]
77
+ voi.maxpoint[1] = canvas.maxpoint[1]
78
+ patch = image_volume.fetch(voi=voi, resolution_mm=resolution_mm)
79
+ assert patch is not None
22
80
 
81
+ # patch rotation defined in physical space
82
+ vx, vy, vz = XYZ[1] - XYZ[0]
83
+ alpha = -atan2(-vz, -vx)
84
+ cx, cy, cz = XYZ.mean(0)
85
+ rot_phys = np.linalg.multi_dot(
86
+ [
87
+ translation_matrix(cx, cy, cz),
88
+ y_rotation_matrix(alpha),
89
+ translation_matrix(-cx, -cy, -cz),
90
+ ]
91
+ )
23
92
 
24
- class Plane3D:
93
+ # rotate the patch in physical space
94
+ affine_rot = np.dot(rot_phys, patch.affine)
95
+
96
+ # crop in the rotated space
97
+ pixels = (
98
+ np.dot(np.linalg.inv(affine_rot), self.homogeneous.T)
99
+ .astype("int")
100
+ .T
101
+ )
102
+ # keep a pixel distance to avoid black border pixels
103
+ xmin, ymin, zmin = pixels.min(0)[:3] + 1
104
+ xmax, ymax, zmax = pixels.max(0)[:3] - 1
105
+ h, w = xmax - xmin, zmax - zmin
106
+ affine = np.dot(affine_rot, translation_matrix(xmin, 0, zmin))
107
+ return volume.from_nifti(
108
+ image.resample_img(
109
+ patch,
110
+ target_affine=affine,
111
+ target_shape=[h, 1, w],
112
+ force_resample=True
113
+ ),
114
+ space=image_volume.space,
115
+ name=f"Rotated patch with corner points {self.coordinates} sampled from {image_volume.name}",
116
+ )
117
+
118
+
119
+ class Plane:
25
120
  """
26
121
  A 3D plane in reference space.
27
- This shall eventually be derived from siibra.Location
122
+ TODO This shall eventually be derived from siibra.Location
28
123
  """
29
124
 
30
125
  def __init__(self, point1: point.Point, point2: point.Point, point3: point.Point):
@@ -33,7 +128,7 @@ class Plane3D:
33
128
  The plane's reference space is defined by the first point.
34
129
  """
35
130
  self.space = point1.space
36
- # normal is the cross product of two arbitray in-plane vectors
131
+ # normal is the cross product of two arbitrary in-plane vectors
37
132
  n = np.cross(
38
133
  (point2.warp(self.space) - point1).coordinate,
39
134
  (point3.warp(self.space) - point1).coordinate,
@@ -64,8 +159,6 @@ class Plane3D:
64
159
  Returns the set of intersection points.
65
160
  The line segments are given by two Nx3 arrays of their start- and endpoints.
66
161
  The result is an Nx3 list of intersection coordinates.
67
- TODO This returns an intersection even if the line segment intersects the plane,
68
-
69
162
  """
70
163
  directions = endpoints - startpoints
71
164
  lengths = np.linalg.norm(directions, axis=1)
@@ -83,7 +176,7 @@ class Plane3D:
83
176
  )
84
177
  return result
85
178
 
86
- def intersect_mesh(self, mesh: dict):
179
+ def intersect_mesh(self, mesh: dict) -> List[pointcloud.Contour]:
87
180
  """
88
181
  Intersects a 3D surface mesh with the plane.
89
182
  Returns a set of split 2D contours, represented by ordered coordinate lists.
@@ -107,7 +200,7 @@ class Plane3D:
107
200
  )[0]
108
201
  faces = mesh["faces"][face_indices]
109
202
 
110
- # for each of N selected faces, indicate wether we cross the plane
203
+ # for each of N selected faces, indicate whether we cross the plane
111
204
  # as we go from vertex 2->0, 0->1, 1->2, respectively.
112
205
  # This gives us an Nx3 array, where forward crossings are identified by 1,
113
206
  # and backward crossings by -1.
@@ -148,7 +241,7 @@ class Plane3D:
148
241
  # should include the exact same set of points. Verify this now.
149
242
  sortrows = lambda A: A[np.lexsort(A.T[::-1]), :]
150
243
  err = (sortrows(fwd_intersections) - sortrows(bwd_intersections)).sum()
151
- assert err == 0
244
+ assert err == 0, f"intersection inconsistency: {err}"
152
245
 
153
246
  # Due to the above property, we can construct closed contours in the
154
247
  # intersection plane by following the interleaved fwd/bwd roles of intersection
@@ -160,7 +253,7 @@ class Plane3D:
160
253
  face_id = 0 # index of the mesh face to consider
161
254
  while len(face_indices) > 0:
162
255
 
163
- # continue the contour with the next foward edge intersection
256
+ # continue the contour with the next forward edge intersection
164
257
  p = fwd_intersections[face_id]
165
258
  points.append(p)
166
259
  # Remember the ids of the face and start-/end vertices for the point
@@ -175,7 +268,7 @@ class Plane3D:
175
268
 
176
269
  # finish the current contour.
177
270
  result.append(
178
- contour.Contour(np.array(points), labels=labels, space=self.space)
271
+ pointcloud.Contour(np.array(points), labels=labels, space=self.space)
179
272
  )
180
273
  if len(face_indices) > 0:
181
274
  # prepare to process another contour segment
@@ -236,8 +329,15 @@ class Plane3D:
236
329
  )
237
330
  err = (self.project_points(corners).coordinates - corners.coordinates).sum()
238
331
  if err > 1e-5:
239
- print(f"WARNING: patch coordinates were not exactly in-plane (error={err}).")
240
- return patch.Patch(self.project_points(corners))
332
+ print(
333
+ f"WARNING: patch coordinates were not exactly in-plane (error={err})."
334
+ )
335
+
336
+ try:
337
+ patch = AxisAlignedPatch(self.project_points(corners))
338
+ except AssertionError:
339
+ patch = None
340
+ return patch
241
341
 
242
342
  @classmethod
243
343
  def from_image(cls, image: volume.Volume):
@@ -1,4 +1,4 @@
1
- # Copyright 2018-2024
1
+ # Copyright 2018-2025
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");
@@ -16,14 +16,13 @@
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- from ..core.structure import BrainStructure
19
+ from typing import Union, Dict
20
+ from abc import abstractmethod
20
21
 
21
22
  import numpy as np
22
- from abc import abstractmethod
23
23
 
24
- from typing import TYPE_CHECKING, Union, Dict
25
- if TYPE_CHECKING:
26
- from siibra.core.space import Space
24
+ from ..core.structure import BrainStructure
25
+ from ..core import space as _space
27
26
 
28
27
 
29
28
  class Location(BrainStructure):
@@ -46,29 +45,28 @@ class Location(BrainStructure):
46
45
  _MASK_MEMO = {} # cache region masks for Location._assign_region()
47
46
  _ASSIGNMENT_CACHE = {} # caches assignment results, see Region.assign()
48
47
 
49
- def __init__(self, spacespec: Union[str, Dict[str, str], "Space"]):
48
+ def __init__(self, spacespec: Union[str, Dict[str, str], "_space.Space"]):
50
49
  self._space_spec = spacespec
51
50
  self._space_cached = None
52
51
 
53
52
  @property
54
- def space(self):
53
+ def space(self) -> "_space.Space":
55
54
  if self._space_cached is None:
56
- from ..core.space import Space
57
55
  if isinstance(self._space_spec, dict):
58
56
  spec = self._space_spec.get("@id") or self._space_spec.get("name")
59
- self._space_cached = Space.get_instance(spec)
57
+ self._space_cached = _space.Space.get_instance(spec)
60
58
  else:
61
- self._space_cached = Space.get_instance(self._space_spec)
59
+ self._space_cached = _space.Space.get_instance(self._space_spec)
62
60
  return self._space_cached
63
61
 
64
62
  @abstractmethod
65
- def warp(self, space):
63
+ def warp(self, space: Union[str, "_space.Space"]):
66
64
  """Generates a new location by warping the
67
65
  current one into another reference space."""
68
66
  pass
69
67
 
70
68
  @abstractmethod
71
- def transform(self, affine: np.ndarray, space=None):
69
+ def transform(self, affine: np.ndarray, space: Union[str, "_space.Space", None] = None):
72
70
  """Returns a new location obtained by transforming the
73
71
  reference coordinates of this one with the given affine matrix.
74
72
 
siibra/locations/point.py CHANGED
@@ -1,4 +1,4 @@
1
- # Copyright 2018-2024
1
+ # Copyright 2018-2025
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,20 +14,20 @@
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, pointcloud
18
-
19
- from ..commons import logger
20
- from ..retrieval.requests import HttpRequest
21
- from ..exceptions import SpaceWarpingFailedError, NoneCoordinateSuppliedError
22
-
23
17
  from urllib.parse import quote
24
18
  import re
25
- import numpy as np
26
19
  import json
27
20
  import numbers
28
21
  import hashlib
29
22
  from typing import Tuple, Union
30
23
 
24
+ import numpy as np
25
+
26
+ from . import location, pointcloud
27
+ from ..commons import logger
28
+ from ..retrieval.requests import HttpRequest
29
+ from ..exceptions import SpaceWarpingFailedError, NoneCoordinateSuppliedError
30
+
31
31
 
32
32
  class Point(location.Location):
33
33
  """A single 3D point in reference space."""
@@ -108,15 +108,13 @@ class Point(location.Location):
108
108
 
109
109
  @property
110
110
  def homogeneous(self):
111
- """The homogenous coordinate of this point as a 4-tuple,
111
+ """The homogeneous coordinate of this point as a 4-tuple,
112
112
  obtained by appending '1' to the original 3-tuple."""
113
113
  return np.atleast_2d(self.coordinate + (1,))
114
114
 
115
115
  def intersection(self, other: location.Location) -> "Point":
116
116
  if isinstance(other, Point):
117
117
  return self if self == other else None
118
- elif isinstance(other, pointcloud.PointCloud):
119
- return self if self in other else None
120
118
  else:
121
119
  return self if other.intersection(self) else None
122
120
 
@@ -157,7 +155,7 @@ class Point(location.Location):
157
155
  return self.sigma**3 * np.pi * 4. / 3.
158
156
 
159
157
  def __sub__(self, other):
160
- """Substract the coordinates of two points to get
158
+ """Subtract the coordinates of two points to get
161
159
  a new point representing the offset vector. Alternatively,
162
160
  subtract an integer from the all coordinates of this point
163
161
  to create a new one.
@@ -305,13 +303,6 @@ class Point(location.Location):
305
303
  assert 0 <= index < 3
306
304
  return self.coordinate[index]
307
305
 
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
306
  def bigbrain_section(self):
316
307
  """
317
308
  Estimate the histological section number of BigBrain
@@ -1,4 +1,4 @@
1
- # Copyright 2018-2024
1
+ # Copyright 2018-2025
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,15 +14,10 @@
14
14
  # limitations under the License.
15
15
  """A set of coordinates on a reference space."""
16
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
17
  from typing import List, Union, Tuple
24
18
  import numbers
25
19
  import json
20
+
26
21
  import numpy as np
27
22
  try:
28
23
  from sklearn.cluster import HDBSCAN
@@ -30,10 +25,11 @@ try:
30
25
  except ImportError:
31
26
  import sklearn
32
27
  _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
- )
28
+
29
+ from . import location, point, boundingbox as _boundingbox
30
+ from ..retrieval.requests import HttpRequest
31
+ from ..commons import logger
32
+ from ..exceptions import SpaceWarpingFailedError, EmptyPointCloudError
37
33
 
38
34
 
39
35
  def from_points(points: List["point.Point"], newlabels: List[Union[int, float, tuple]] = None) -> "PointCloud":
@@ -120,18 +116,15 @@ class PointCloud(location.Location):
120
116
  if not isinstance(other, (point.Point, PointCloud, _boundingbox.BoundingBox)):
121
117
  return other.intersection(self)
122
118
 
123
- intersections = [(i, p) for i, p in enumerate(self) if p.intersects(other)]
124
- if len(intersections) == 0:
119
+ if isinstance(other, PointCloud):
120
+ intersecting_points = [p for p in self if p.coordinate in other.coordinates]
121
+ elif isinstance(other, point.Point):
122
+ return other if other in self else None
123
+ else:
124
+ intersecting_points = [p for p in self if p.intersects(other)]
125
+ if len(intersecting_points) == 0:
125
126
  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
- )
127
+ intersection = from_points(intersecting_points)
135
128
  return intersection[0] if len(intersection) == 1 else intersection
136
129
 
137
130
  @property
@@ -320,7 +313,7 @@ class PointCloud(location.Location):
320
313
  if not _HAS_HDBSCAN:
321
314
  raise RuntimeError(
322
315
  f"HDBSCAN is not available with your version {sklearn.__version__} "
323
- "of sckit-learn. `PointCloud.find_clusters()` will not be avaiable."
316
+ "of sckit-learn. `PointCloud.find_clusters()` will not be available."
324
317
  )
325
318
  points = np.array(self.as_list())
326
319
  N = points.shape[0]
@@ -347,3 +340,46 @@ class PointCloud(location.Location):
347
340
  logger.error("Matplotlib is not available. Label colors is disabled.")
348
341
  return None
349
342
  return colormaps.rainbow(np.linspace(0, 1, max(self.labels) + 1))
343
+
344
+
345
+ class Contour(PointCloud):
346
+ """
347
+ A PointCloud that represents a contour line.
348
+ The only difference is that the point order is relevant,
349
+ and consecutive points are thought as being connected by an edge.
350
+
351
+ In fact, PointCloud assumes order as well, but no connections between points.
352
+ """
353
+
354
+ def __init__(self, coordinates, space=None, sigma_mm=0, labels: list = None):
355
+ PointCloud.__init__(self, coordinates, space, sigma_mm, labels)
356
+
357
+ def crop(self, voi: "_boundingbox.BoundingBox"):
358
+ """
359
+ Crop the contour with a volume of interest.
360
+ Since the contour might be split from the cropping,
361
+ returns a set of contour segments.
362
+ """
363
+ segments = []
364
+
365
+ # set the contour point labels to a linear numbering
366
+ # so we can use them after the intersection to detect splits.
367
+ old_labels = self.labels
368
+ self.labels = list(range(len(self)))
369
+ cropped = self.intersection(voi)
370
+
371
+ if cropped is not None and not isinstance(cropped, point.Point):
372
+ assert isinstance(cropped, PointCloud)
373
+ # Identify contour splits are by discontinuouities ("jumps")
374
+ # of their labels, which denote positions in the original contour
375
+ jumps = np.diff([self.labels.index(lb) for lb in cropped.labels])
376
+ splits = [0] + list(np.where(jumps > 1)[0] + 1) + [len(cropped)]
377
+ for i, j in zip(splits[:-1], splits[1:]):
378
+ segments.append(
379
+ self.__class__(cropped.coordinates[i:j, :], space=cropped.space)
380
+ )
381
+
382
+ # reset labels of the input contour points.
383
+ self.labels = old_labels
384
+
385
+ return segments
@@ -1,4 +1,4 @@
1
- # Copyright 2018-2024
1
+ # Copyright 2018-2025
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");
siibra/retrieval/cache.py CHANGED
@@ -1,4 +1,4 @@
1
- # Copyright 2018-2024
1
+ # Copyright 2018-2025
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");
@@ -23,6 +23,7 @@ from enum import Enum
23
23
  from typing import Callable, List, NamedTuple, Union
24
24
  from concurrent.futures import ThreadPoolExecutor
25
25
  from pathlib import Path
26
+
26
27
  from filelock import FileLock as Lock
27
28
 
28
29
  from ..commons import logger, SIIBRA_CACHEDIR, SKIP_CACHEINIT_MAINTENANCE, siibra_tqdm