siibra 1.0a1__1-py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of siibra might be problematic. Click here for more details.

Files changed (84) hide show
  1. siibra/VERSION +1 -0
  2. siibra/__init__.py +164 -0
  3. siibra/commons.py +823 -0
  4. siibra/configuration/__init__.py +17 -0
  5. siibra/configuration/configuration.py +189 -0
  6. siibra/configuration/factory.py +589 -0
  7. siibra/core/__init__.py +16 -0
  8. siibra/core/assignment.py +110 -0
  9. siibra/core/atlas.py +239 -0
  10. siibra/core/concept.py +308 -0
  11. siibra/core/parcellation.py +387 -0
  12. siibra/core/region.py +1223 -0
  13. siibra/core/space.py +131 -0
  14. siibra/core/structure.py +111 -0
  15. siibra/exceptions.py +63 -0
  16. siibra/experimental/__init__.py +19 -0
  17. siibra/experimental/contour.py +61 -0
  18. siibra/experimental/cortical_profile_sampler.py +57 -0
  19. siibra/experimental/patch.py +98 -0
  20. siibra/experimental/plane3d.py +256 -0
  21. siibra/explorer/__init__.py +17 -0
  22. siibra/explorer/url.py +222 -0
  23. siibra/explorer/util.py +87 -0
  24. siibra/features/__init__.py +117 -0
  25. siibra/features/anchor.py +224 -0
  26. siibra/features/connectivity/__init__.py +33 -0
  27. siibra/features/connectivity/functional_connectivity.py +57 -0
  28. siibra/features/connectivity/regional_connectivity.py +494 -0
  29. siibra/features/connectivity/streamline_counts.py +27 -0
  30. siibra/features/connectivity/streamline_lengths.py +27 -0
  31. siibra/features/connectivity/tracing_connectivity.py +30 -0
  32. siibra/features/dataset/__init__.py +17 -0
  33. siibra/features/dataset/ebrains.py +90 -0
  34. siibra/features/feature.py +970 -0
  35. siibra/features/image/__init__.py +27 -0
  36. siibra/features/image/image.py +115 -0
  37. siibra/features/image/sections.py +26 -0
  38. siibra/features/image/volume_of_interest.py +88 -0
  39. siibra/features/tabular/__init__.py +24 -0
  40. siibra/features/tabular/bigbrain_intensity_profile.py +77 -0
  41. siibra/features/tabular/cell_density_profile.py +298 -0
  42. siibra/features/tabular/cortical_profile.py +322 -0
  43. siibra/features/tabular/gene_expression.py +257 -0
  44. siibra/features/tabular/layerwise_bigbrain_intensities.py +62 -0
  45. siibra/features/tabular/layerwise_cell_density.py +95 -0
  46. siibra/features/tabular/receptor_density_fingerprint.py +192 -0
  47. siibra/features/tabular/receptor_density_profile.py +110 -0
  48. siibra/features/tabular/regional_timeseries_activity.py +294 -0
  49. siibra/features/tabular/tabular.py +139 -0
  50. siibra/livequeries/__init__.py +19 -0
  51. siibra/livequeries/allen.py +352 -0
  52. siibra/livequeries/bigbrain.py +197 -0
  53. siibra/livequeries/ebrains.py +145 -0
  54. siibra/livequeries/query.py +49 -0
  55. siibra/locations/__init__.py +91 -0
  56. siibra/locations/boundingbox.py +454 -0
  57. siibra/locations/location.py +115 -0
  58. siibra/locations/point.py +344 -0
  59. siibra/locations/pointcloud.py +349 -0
  60. siibra/retrieval/__init__.py +27 -0
  61. siibra/retrieval/cache.py +233 -0
  62. siibra/retrieval/datasets.py +389 -0
  63. siibra/retrieval/exceptions/__init__.py +27 -0
  64. siibra/retrieval/repositories.py +769 -0
  65. siibra/retrieval/requests.py +659 -0
  66. siibra/vocabularies/__init__.py +45 -0
  67. siibra/vocabularies/gene_names.json +29176 -0
  68. siibra/vocabularies/receptor_symbols.json +210 -0
  69. siibra/vocabularies/region_aliases.json +460 -0
  70. siibra/volumes/__init__.py +23 -0
  71. siibra/volumes/parcellationmap.py +1279 -0
  72. siibra/volumes/providers/__init__.py +20 -0
  73. siibra/volumes/providers/freesurfer.py +113 -0
  74. siibra/volumes/providers/gifti.py +165 -0
  75. siibra/volumes/providers/neuroglancer.py +736 -0
  76. siibra/volumes/providers/nifti.py +266 -0
  77. siibra/volumes/providers/provider.py +107 -0
  78. siibra/volumes/sparsemap.py +468 -0
  79. siibra/volumes/volume.py +892 -0
  80. siibra-1.0.0a1.dist-info/LICENSE +201 -0
  81. siibra-1.0.0a1.dist-info/METADATA +160 -0
  82. siibra-1.0.0a1.dist-info/RECORD +84 -0
  83. siibra-1.0.0a1.dist-info/WHEEL +5 -0
  84. siibra-1.0.0a1.dist-info/top_level.txt +1 -0
siibra/core/space.py ADDED
@@ -0,0 +1,131 @@
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 particular brain reference space."""
16
+
17
+
18
+ from .concept import AtlasConcept
19
+
20
+ from ..commons import logger, Species
21
+
22
+ from typing import List, TYPE_CHECKING, Union
23
+
24
+ if TYPE_CHECKING:
25
+ from ..volumes import volume
26
+
27
+
28
+ class Space(AtlasConcept, configuration_folder="spaces"):
29
+
30
+ def __init__(
31
+ self,
32
+ identifier: str,
33
+ name: str,
34
+ species: Union[str, Species],
35
+ volumes: List['volume.Volume'] = [],
36
+ shortname: str = "",
37
+ description: str = "",
38
+ modality: str = "",
39
+ publications: list = [],
40
+ datasets: list = [],
41
+ prerelease: bool = False,
42
+ ):
43
+ """
44
+ Constructs a new parcellation object.
45
+
46
+ Parameters
47
+ ----------
48
+ identifier : str
49
+ Unique identifier of the space
50
+ name : str
51
+ Human-readable name of the space
52
+ species: str or Species
53
+ Specification of the species
54
+ volumes: list[Volume]
55
+ list of template volumes
56
+ shortname: str, optional
57
+ Shortform of human-readable name
58
+ description: str, optional
59
+ Textual description of the parcellation
60
+ modality : str or None
61
+ Specification of the modality representing this reference space
62
+ publications: list
63
+ List of associated publications, each a dictionary with "doi" and/or "citation" fields
64
+ ebrains_ids : dict
65
+ Identifiers of EBRAINS entities corresponding to this Parcellation.
66
+ Key: EBRAINS KG schema, value: EBRAINS KG @id
67
+ """
68
+
69
+ AtlasConcept.__init__(
70
+ self,
71
+ identifier=identifier,
72
+ name=name,
73
+ species=species,
74
+ shortname=shortname,
75
+ description=description,
76
+ modality=modality,
77
+ publications=publications,
78
+ datasets=datasets,
79
+ prerelease=prerelease,
80
+ )
81
+ self.volumes = volumes
82
+ for v in self.volumes:
83
+ v.space_info = {"@id": self.id}
84
+
85
+ def get_template(self, variant: str = None):
86
+ """
87
+ Get the volumetric reference template for this space.
88
+
89
+ Parameters
90
+ ----------
91
+ variant: str, optional
92
+ Some templates are provided in different variants, e.g.
93
+ freesurfer is available as either white matter, pial or
94
+ inflated surface for left and right hemispheres (6 variants).
95
+ This field could be used to request a specific variant.
96
+ Per default, the first found variant is returned.
97
+
98
+ Returns
99
+ -------
100
+ Volume
101
+ representing the reference template, or None if not available.
102
+ """
103
+ tests = []
104
+ if variant is not None:
105
+ tests.append(lambda v: hasattr(v, 'variant') and variant.lower() in v.variant.lower())
106
+ candidates = [v for v in self.volumes if all(t(v) for t in tests)]
107
+
108
+ if len(candidates) == 0:
109
+ msg = f"Volume variant {variant} not available for '{self.name}'. " \
110
+ if variant else f"No volumes available for '{self.name}'. "
111
+ raise RuntimeError(msg)
112
+
113
+ if len(candidates) > 1:
114
+ logger.info(
115
+ f"Multiple template variants available for '{self.name}': "
116
+ f"{', '.join(c.variant for c in candidates)}. "
117
+ f"'{candidates[0].variant}' is chosen, but you might specify another with the 'variant' parameter."
118
+ )
119
+
120
+ template = candidates[0]
121
+ if len(template.datasets) == 0:
122
+ template.datasets = self.datasets
123
+ return template
124
+
125
+ @property
126
+ def provides_mesh(self):
127
+ return any(v.provides_mesh for v in self.volumes)
128
+
129
+ @property
130
+ def provides_image(self):
131
+ return any(v.provides_image for v in self.volumes)
@@ -0,0 +1,111 @@
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
+ """
16
+ Abstract base class for any kind of brain structure.
17
+ A brain structure is more general than a brain region. It refers to any object
18
+ defining a spatial extent in one or more reference spaces, and can thus be
19
+ used to compute intersections with other structures in space. For example,
20
+ a brain region is a structure which is at the same time an AtlasConcept. A
21
+ bounding box in MNI space is a structure, but not an AtlasConcept.
22
+ """
23
+
24
+ from . import assignment, region as _region
25
+
26
+ from abc import ABC, abstractmethod
27
+ from typing import Tuple, Dict
28
+
29
+
30
+ class BrainStructure(ABC):
31
+ """Abstract base class for types who can act as a location filter."""
32
+
33
+ # cache assignment results at class level
34
+ _ASSIGNMENT_CACHE: Dict[
35
+ Tuple["BrainStructure", "BrainStructure"],
36
+ "assignment.AnatomicalAssignment"
37
+ ] = {}
38
+
39
+ def intersects(self, other: "BrainStructure") -> bool:
40
+ """
41
+ Whether or not two BrainStructures have any intersection.
42
+ """
43
+ return self.intersection(other) is not None
44
+
45
+ def __contains__(self, other: "BrainStructure") -> bool:
46
+ return self.intersection(other) == other
47
+
48
+ def __hash__(self) -> int:
49
+ return hash(self.__repr__())
50
+
51
+ @abstractmethod
52
+ def __repr__(self) -> str:
53
+ raise NotImplementedError
54
+
55
+ @abstractmethod
56
+ def intersection(self, other: "BrainStructure") -> "BrainStructure":
57
+ """
58
+ Return the intersection of two BrainStructures,
59
+ ie. the other BrainStructure filtered by this BrainStructure.
60
+ """
61
+ pass
62
+
63
+ def assign(self, other: "BrainStructure") -> assignment.AnatomicalAssignment:
64
+ """
65
+ Compute assignment of a BrainStructure to this filter.
66
+
67
+ Parameters
68
+ ----------
69
+ other : Location or Region
70
+
71
+ Returns
72
+ -------
73
+ assignment.AnatomicalAssignment or None
74
+ None if there is no AssignmentQualification found.
75
+ """
76
+ # Two cases:
77
+ # 1) self is location, other is location -> look at spatial intersection/relationship, do it here
78
+ # 2) self is location, other is region -> get region map, then call again. do it here
79
+ # If self is region -> Region overwrite this method, adressed there
80
+
81
+ assert not isinstance(self, _region.Region) # method is overwritten by Region!
82
+ if (self, other) in self._ASSIGNMENT_CACHE:
83
+ return self._ASSIGNMENT_CACHE[self, other]
84
+ if (other, self) in self._ASSIGNMENT_CACHE:
85
+ return self._ASSIGNMENT_CACHE[other, self].invert()
86
+
87
+ if isinstance(other, _region.Region):
88
+ inverse_assignment = other.assign(self)
89
+ if inverse_assignment is None:
90
+ self._ASSIGNMENT_CACHE[self, other] = None
91
+ else:
92
+ self._ASSIGNMENT_CACHE[self, other] = inverse_assignment.invert()
93
+ return self._ASSIGNMENT_CACHE[self, other]
94
+ else: # other is a location object, just check spatial relationships
95
+ qualification = None
96
+ if self == other:
97
+ qualification = assignment.Qualification.EXACT
98
+ else:
99
+ intersection = self.intersection(other)
100
+ if intersection is not None:
101
+ if intersection == other:
102
+ qualification = assignment.Qualification.CONTAINS
103
+ elif intersection == self:
104
+ qualification = assignment.Qualification.CONTAINED
105
+ else:
106
+ qualification = assignment.Qualification.OVERLAPS
107
+ if qualification is None:
108
+ self._ASSIGNMENT_CACHE[self, other] = None
109
+ else:
110
+ self._ASSIGNMENT_CACHE[self, other] = assignment.AnatomicalAssignment(self, other, qualification)
111
+ return self._ASSIGNMENT_CACHE[self, other]
siibra/exceptions.py ADDED
@@ -0,0 +1,63 @@
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
+ """Siibra specific exceptions"""
16
+
17
+
18
+ class ExcessiveArgumentException(ValueError):
19
+ pass
20
+
21
+
22
+ class InsufficientArgumentException(ValueError):
23
+ pass
24
+
25
+
26
+ class ConflictingArgumentException(ValueError):
27
+ pass
28
+
29
+
30
+ class NonUniqueIndexError(RuntimeError):
31
+ pass
32
+
33
+
34
+ class NoMapAvailableError(RuntimeError):
35
+ pass
36
+
37
+
38
+ class SpaceWarpingFailedError(RuntimeError):
39
+ pass
40
+
41
+
42
+ class NoVolumeFound(RuntimeError):
43
+ pass
44
+
45
+
46
+ class WarmupRegException(Exception):
47
+ pass
48
+
49
+
50
+ class ZeroVolumeBoundingBox(Exception):
51
+ pass
52
+
53
+
54
+ class NoneCoordinateSuppliedError(ValueError):
55
+ pass
56
+
57
+
58
+ class NoMapMatchingValues(ValueError):
59
+ pass
60
+
61
+
62
+ class EmptyPointCloudError(ValueError):
63
+ pass
@@ -0,0 +1,19 @@
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
+
16
+ from .plane3d import Plane3D
17
+ from .contour import Contour
18
+ from .cortical_profile_sampler import CorticalProfileSampler
19
+ from .patch import Patch
@@ -0,0 +1,61 @@
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
+
16
+ from ..locations import point, pointcloud, boundingbox
17
+
18
+ import numpy as np
19
+
20
+
21
+ class Contour(pointcloud.PointCloud):
22
+ """
23
+ A PointCloud that represents a contour line.
24
+ The only difference is that the point order is relevant,
25
+ and consecutive points are thought as being connected by an edge.
26
+
27
+ In fact, PointCloud assumes order as well, but no connections between points.
28
+ """
29
+
30
+ def __init__(self, coordinates, space=None, sigma_mm=0, labels: list = None):
31
+ pointcloud.PointCloud.__init__(self, coordinates, space, sigma_mm, labels)
32
+
33
+ def crop(self, voi: boundingbox.BoundingBox):
34
+ """
35
+ Crop the contour with a volume of interest.
36
+ Since the contour might be split from the cropping,
37
+ returns a set of contour segments.
38
+ """
39
+ segments = []
40
+
41
+ # set the contour point labels to a linear numbering
42
+ # so we can use them after the intersection to detect splits.
43
+ old_labels = self.labels
44
+ self.labels = list(range(len(self)))
45
+ cropped = self.intersection(voi)
46
+
47
+ if cropped is not None and not isinstance(cropped, point.Point):
48
+ assert isinstance(cropped, pointcloud.PointCloud)
49
+ # Identifiy contour splits are by discontinuouities ("jumps")
50
+ # of their labels, which denote positions in the original contour
51
+ jumps = np.diff([self.labels.index(lb) for lb in cropped.labels])
52
+ splits = [0] + list(np.where(jumps > 1)[0] + 1) + [len(cropped)]
53
+ for i, j in zip(splits[:-1], splits[1:]):
54
+ segments.append(
55
+ self.__class__(cropped.coordinates[i:j, :], space=cropped.space)
56
+ )
57
+
58
+ # reset labels of the input contour points.
59
+ self.labels = old_labels
60
+
61
+ return segments
@@ -0,0 +1,57 @@
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
+
16
+ from . import contour
17
+ from ..locations import point
18
+ from ..core import parcellation
19
+
20
+ import numpy as np
21
+
22
+
23
+ class CorticalProfileSampler:
24
+ """Samples cortical profiles from the cortical layer maps."""
25
+
26
+ def __init__(self):
27
+ self.layermap = parcellation.Parcellation.get_instance(
28
+ "cortical layers"
29
+ ).get_map(space="bigbrain", maptype="labelled")
30
+
31
+ def query(self, query_point: point.Point):
32
+ q = query_point.warp(self.layermap.space)
33
+ smallest_dist = np.inf
34
+ best_match = None
35
+ for layername in self.layermap.regions:
36
+ vertices = self.layermap.fetch(region=layername, format="mesh")["verts"]
37
+ dists = np.sqrt(((vertices - q.coordinate) ** 2).sum(1))
38
+ best = np.argmin(dists)
39
+ if dists[best] < smallest_dist:
40
+ best_match = (layername, best)
41
+ smallest_dist = dists[best]
42
+
43
+ best_vertex = best_match[1]
44
+ hemisphere = "left" if "left" in best_match[0] else "right"
45
+ print(f"Best match is vertex #{best_match[1]} in {best_match[0]}.")
46
+
47
+ profile = [
48
+ (_, self.layermap.fetch(region=_, format="mesh")["verts"][best_vertex])
49
+ for _ in self.layermap.regions
50
+ if hemisphere in _
51
+ ]
52
+
53
+ return contour.Contour(
54
+ [p[1] for p in profile],
55
+ space=self.layermap.space,
56
+ labels=[p[0] for p in profile],
57
+ )
@@ -0,0 +1,98 @@
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
+
16
+ from ..volumes import volume
17
+ from ..locations import pointcloud, boundingbox
18
+ from ..commons import translation_matrix, y_rotation_matrix
19
+
20
+ import numpy as np
21
+ import math
22
+ from nilearn import image
23
+
24
+
25
+ class Patch:
26
+
27
+ def __init__(self, corners: pointcloud.PointCloud):
28
+ """Construct a patch in physical coordinates.
29
+ As of now, only patches aligned in the y plane of the physical space
30
+ are supported."""
31
+ # TODO: need to ensure that the points are planar, if more than 3
32
+ assert len(corners) == 4
33
+ assert len(np.unique(corners.coordinates[:, 1])) == 1
34
+ self.corners = corners
35
+
36
+ @property
37
+ def space(self):
38
+ return self.corners.space
39
+
40
+ def flip(self):
41
+ """Flips the patch. """
42
+ self.corners._coordinates = self.corners.coordinates[[2, 3, 0, 1]]
43
+
44
+ def extract_volume(self, image_volume: volume.Volume, resolution_mm: float):
45
+ """
46
+ fetches image data in a planar patch.
47
+ TODO The current implementation only covers patches which are strictly
48
+ define in the y plane. A future implementation should accept arbitrary
49
+ oriented patches.accept arbitrary oriented patches.
50
+ """
51
+ assert image_volume.space == self.space
52
+
53
+ # Extend the 2D patch into a 3D structure
54
+ # this is only valid if the patch plane lies within the image canvas.
55
+ canvas = image_volume.get_boundingbox()
56
+ assert canvas.minpoint[1] <= self.corners.coordinates[0, 1]
57
+ assert canvas.maxpoint[1] >= self.corners.coordinates[0, 1]
58
+ XYZ = self.corners.coordinates
59
+ voi = boundingbox.BoundingBox(
60
+ XYZ.min(0)[:3], XYZ.max(0)[:3], space=image_volume.space
61
+ )
62
+ # enforce the patch to have the same y dimensions
63
+ voi.minpoint[1] = canvas.minpoint[1]
64
+ voi.maxpoint[1] = canvas.maxpoint[1]
65
+ patch = image_volume.fetch(voi=voi, resolution_mm=resolution_mm)
66
+ assert patch is not None
67
+
68
+ # patch rotation defined in physical space
69
+ vx, vy, vz = XYZ[1] - XYZ[0]
70
+ alpha = -math.atan2(-vz, -vx)
71
+ cx, cy, cz = XYZ.mean(0)
72
+ rot_phys = np.linalg.multi_dot(
73
+ [
74
+ translation_matrix(cx, cy, cz),
75
+ y_rotation_matrix(alpha),
76
+ translation_matrix(-cx, -cy, -cz),
77
+ ]
78
+ )
79
+
80
+ # rotate the patch in physical space
81
+ affine_rot = np.dot(rot_phys, patch.affine)
82
+
83
+ # crop in the rotated space
84
+ pixels = (
85
+ np.dot(np.linalg.inv(affine_rot), self.corners.homogeneous.T)
86
+ .astype("int")
87
+ .T
88
+ )
89
+ # keep a pixel distance to avoid black border pixels
90
+ xmin, ymin, zmin = pixels.min(0)[:3] + 1
91
+ xmax, ymax, zmax = pixels.max(0)[:3] - 1
92
+ h, w = xmax - xmin, zmax - zmin
93
+ affine = np.dot(affine_rot, translation_matrix(xmin, 0, zmin))
94
+ return volume.from_nifti(
95
+ image.resample_img(patch, target_affine=affine, target_shape=[h, 1, w]),
96
+ space=image_volume.space,
97
+ name=f"Rotated patch with corner points {self.corners} sampled from {image_volume.name}",
98
+ )