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.

Files changed (83) hide show
  1. siibra/VERSION +1 -1
  2. siibra/__init__.py +20 -12
  3. siibra/commons.py +145 -90
  4. siibra/configuration/__init__.py +1 -1
  5. siibra/configuration/configuration.py +22 -17
  6. siibra/configuration/factory.py +177 -128
  7. siibra/core/__init__.py +1 -8
  8. siibra/core/{relation_qualification.py → assignment.py} +17 -14
  9. siibra/core/atlas.py +66 -35
  10. siibra/core/concept.py +81 -39
  11. siibra/core/parcellation.py +83 -67
  12. siibra/core/region.py +569 -263
  13. siibra/core/space.py +7 -39
  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 +16 -0
  22. siibra/explorer/url.py +112 -52
  23. siibra/explorer/util.py +31 -9
  24. siibra/features/__init__.py +73 -8
  25. siibra/features/anchor.py +75 -196
  26. siibra/features/connectivity/__init__.py +1 -1
  27. siibra/features/connectivity/functional_connectivity.py +2 -2
  28. siibra/features/connectivity/regional_connectivity.py +99 -10
  29. siibra/features/connectivity/streamline_counts.py +1 -1
  30. siibra/features/connectivity/streamline_lengths.py +1 -1
  31. siibra/features/connectivity/tracing_connectivity.py +1 -1
  32. siibra/features/dataset/__init__.py +1 -1
  33. siibra/features/dataset/ebrains.py +3 -3
  34. siibra/features/feature.py +219 -110
  35. siibra/features/image/__init__.py +1 -1
  36. siibra/features/image/image.py +21 -13
  37. siibra/features/image/sections.py +1 -1
  38. siibra/features/image/volume_of_interest.py +1 -1
  39. siibra/features/tabular/__init__.py +1 -1
  40. siibra/features/tabular/bigbrain_intensity_profile.py +24 -13
  41. siibra/features/tabular/cell_density_profile.py +111 -69
  42. siibra/features/tabular/cortical_profile.py +82 -16
  43. siibra/features/tabular/gene_expression.py +117 -6
  44. siibra/features/tabular/layerwise_bigbrain_intensities.py +7 -9
  45. siibra/features/tabular/layerwise_cell_density.py +9 -24
  46. siibra/features/tabular/receptor_density_fingerprint.py +11 -6
  47. siibra/features/tabular/receptor_density_profile.py +12 -15
  48. siibra/features/tabular/regional_timeseries_activity.py +74 -18
  49. siibra/features/tabular/tabular.py +17 -8
  50. siibra/livequeries/__init__.py +1 -7
  51. siibra/livequeries/allen.py +139 -77
  52. siibra/livequeries/bigbrain.py +104 -128
  53. siibra/livequeries/ebrains.py +7 -4
  54. siibra/livequeries/query.py +1 -2
  55. siibra/locations/__init__.py +32 -25
  56. siibra/locations/boundingbox.py +153 -127
  57. siibra/locations/location.py +45 -80
  58. siibra/locations/point.py +97 -83
  59. siibra/locations/pointcloud.py +349 -0
  60. siibra/retrieval/__init__.py +1 -1
  61. siibra/retrieval/cache.py +107 -13
  62. siibra/retrieval/datasets.py +9 -14
  63. siibra/retrieval/exceptions/__init__.py +2 -1
  64. siibra/retrieval/repositories.py +147 -53
  65. siibra/retrieval/requests.py +64 -29
  66. siibra/vocabularies/__init__.py +2 -2
  67. siibra/volumes/__init__.py +7 -9
  68. siibra/volumes/parcellationmap.py +396 -253
  69. siibra/volumes/providers/__init__.py +20 -0
  70. siibra/volumes/providers/freesurfer.py +113 -0
  71. siibra/volumes/{gifti.py → providers/gifti.py} +29 -18
  72. siibra/volumes/{neuroglancer.py → providers/neuroglancer.py} +204 -92
  73. siibra/volumes/{nifti.py → providers/nifti.py} +64 -44
  74. siibra/volumes/providers/provider.py +107 -0
  75. siibra/volumes/sparsemap.py +159 -260
  76. siibra/volumes/volume.py +720 -152
  77. {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/METADATA +25 -28
  78. siibra-1.0.0a1.dist-info/RECORD +84 -0
  79. {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/WHEEL +1 -1
  80. siibra/locations/pointset.py +0 -198
  81. siibra-0.5a2.dist-info/RECORD +0 -74
  82. {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/LICENSE +0 -0
  83. {siibra-0.5a2.dist-info → siibra-1.0.0a1.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,4 @@
1
- # Copyright 2018-2021
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,29 +14,30 @@
14
14
  # limitations under the License.
15
15
  """Provides spatial representations for parcellations and regions."""
16
16
 
17
- from . import volume as _volume, nifti
18
- from .. import logger, QUIET
17
+ from . import volume as _volume
18
+ from .providers import provider
19
+ from .. import logger, QUIET, exceptions
19
20
  from ..commons import (
20
21
  MapIndex,
21
22
  MapType,
22
- compare_maps,
23
- iterate_connected_components,
23
+ compare_arrays,
24
+ resample_img_to_img,
25
+ connected_components,
24
26
  clear_name,
25
27
  create_key,
26
28
  create_gaussian_kernel,
27
29
  siibra_tqdm,
28
30
  Species,
29
- CompareMapsResult
31
+ CompareMapsResult,
32
+ generate_uuid
30
33
  )
31
34
  from ..core import concept, space, parcellation, region as _region
32
- from ..locations import point, pointset
33
- from ..retrieval import requests
35
+ from ..locations import location, point, pointcloud
34
36
 
35
37
  import numpy as np
36
38
  from typing import Union, Dict, List, TYPE_CHECKING, Iterable, Tuple
37
39
  from scipy.ndimage import distance_transform_edt
38
40
  from collections import defaultdict
39
- from nibabel import Nifti1Image
40
41
  from nilearn import image
41
42
  import pandas as pd
42
43
  from dataclasses import dataclass, asdict
@@ -45,31 +46,18 @@ if TYPE_CHECKING:
45
46
  from ..core.region import Region
46
47
 
47
48
 
48
- class ExcessiveArgumentException(ValueError): pass
49
-
50
-
51
- class InsufficientArgumentException(ValueError): pass
52
-
53
-
54
- class ConflictingArgumentException(ValueError): pass
55
-
56
-
57
- class NonUniqueIndexError(RuntimeError): pass
58
-
59
-
60
- class NoVolumeFound(RuntimeError): pass
61
-
62
49
  @dataclass
63
- class Assignment:
50
+ class MapAssignment:
64
51
  input_structure: int
65
- centroid: Union[Tuple[np.ndarray], point.Point]
52
+ centroid: Union[Tuple[np.ndarray], point.Point]
66
53
  volume: int
67
54
  fragment: str
68
55
  map_value: np.ndarray
69
56
 
70
57
 
71
58
  @dataclass
72
- class AssignImageResult(CompareMapsResult, Assignment): pass
59
+ class AssignImageResult(CompareMapsResult, MapAssignment):
60
+ pass
73
61
 
74
62
 
75
63
  class Map(concept.AtlasConcept, configuration_folder="maps"):
@@ -80,13 +68,14 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
80
68
  name: str,
81
69
  space_spec: dict,
82
70
  parcellation_spec: dict,
83
- indices: Dict[str, Dict],
71
+ indices: Dict[str, List[Dict]],
84
72
  volumes: list = [],
85
73
  shortname: str = "",
86
74
  description: str = "",
87
75
  modality: str = None,
88
76
  publications: list = [],
89
77
  datasets: list = [],
78
+ prerelease: bool = False,
90
79
  ):
91
80
  """
92
81
  Constructs a new parcellation object.
@@ -131,8 +120,11 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
131
120
  description=description,
132
121
  publications=publications,
133
122
  datasets=datasets,
134
- modality=modality
123
+ modality=modality,
124
+ prerelease=prerelease,
135
125
  )
126
+ self._space_spec = space_spec
127
+ self._parcellation_spec = parcellation_spec
136
128
 
137
129
  # Since the volumes might include 4D arrays, where the actual
138
130
  # volume index points to a z coordinate, we create subvolume
@@ -165,15 +157,12 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
165
157
  duplicates = {x for x in all_indices if x in seen or seen.add(x)}
166
158
  if len(duplicates) > 0:
167
159
  logger.warning(f"Non unique indices encountered in {self}: {duplicates}")
168
-
169
- self._space_spec = space_spec
170
- self._parcellation_spec = parcellation_spec
171
160
  self._affine_cached = None
172
- for v in self.volumes:
173
- # allow the providers to query their parcellation map if needed
174
- for p in v._providers.values():
175
- p.parcellation_map = self
176
- v._space_spec = space_spec
161
+
162
+ @property
163
+ def key(self):
164
+ _id = self.id
165
+ return create_key(_id[len("siibra-map-v0.0.1"):])
177
166
 
178
167
  @property
179
168
  def species(self) -> Species:
@@ -205,12 +194,18 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
205
194
  """
206
195
  matches = self.find_indices(region)
207
196
  if len(matches) > 1:
208
- raise NonUniqueIndexError(
197
+ # if there is an exact match, we still use it. If not, we cannot proceed.
198
+ regionname = region.name if isinstance(region, _region.Region) \
199
+ else region
200
+ for index, matched_name in matches.items():
201
+ if matched_name == regionname:
202
+ return index
203
+ raise exceptions.NonUniqueIndexError(
209
204
  f"The specification '{region}' matches multiple mapped "
210
205
  f"structures in {str(self)}: {list(matches.values())}"
211
206
  )
212
207
  elif len(matches) == 0:
213
- raise NonUniqueIndexError(
208
+ raise exceptions.NonUniqueIndexError(
214
209
  f"The specification '{region}' does not match to any structure mapped in {self}"
215
210
  )
216
211
  else:
@@ -328,69 +323,27 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
328
323
  def regions(self):
329
324
  return list(self._indices)
330
325
 
331
- def fetch(
326
+ def get_volume(
332
327
  self,
333
- region_or_index: Union[str, "Region", MapIndex] = None,
328
+ region: Union[str, "Region"] = None,
334
329
  *,
335
330
  index: MapIndex = None,
336
- region: Union[str, "Region"] = None,
337
- **kwargs
338
- ):
339
- """
340
- Fetches one particular volume of this parcellation map.
341
-
342
- If there's only one volume, this is the default, otherwise further
343
- specification is requested:
344
- - the volume index,
345
- - the MapIndex (which results in a regional map being returned)
346
-
347
- You might also consider fetch_iter() to iterate the volumes, or
348
- compress() to produce a single-volume parcellation map.
349
-
350
- Parameters
351
- ----------
352
- region_or_index: str, Region, MapIndex
353
- Lazy match the specification.
354
- index: MapIndex
355
- Explicit specification of the map index, typically resulting
356
- in a regional map (mask or statistical map) to be returned.
357
- Note that supplying 'region' will result in retrieving the map index of that region
358
- automatically.
359
- region: str, Region
360
- Specification of a region name, resulting in a regional map
361
- (mask or statistical map) to be returned.
362
- **kwargs
363
- - resolution_mm: resolution in millimeters
364
- - format: the format of the volume, like "mesh" or "nii"
365
- - voi: a BoundingBox of interest
366
-
367
-
368
- Note
369
- ----
370
- Not all keyword arguments are supported for volume formats. Format
371
- is restricted by available formats (check formats property).
372
-
373
- Returns
374
- -------
375
- An image or mesh
376
- """
331
+ **kwargs,
332
+ ) -> Union[_volume.Volume, _volume.FilteredVolume, _volume.Subvolume]:
377
333
  try:
378
- length = len([arg for arg in [region_or_index, region, index] if arg is not None])
334
+ length = len([arg for arg in [region, index] if arg is not None])
379
335
  assert length == 1
380
336
  except AssertionError:
381
337
  if length > 1:
382
- raise ExcessiveArgumentException("One and only one of region_or_index, region, index can be defined for fetch")
383
- # user can provide no arguments, which assumes one and only one volume present
384
-
385
- if isinstance(region_or_index, MapIndex):
386
- index = region_or_index
387
-
388
- if isinstance(region_or_index, (str, _region.Region)):
389
- region = region_or_index
390
-
338
+ raise exceptions.ExcessiveArgumentException(
339
+ "One and only one of region or index can be defined for `get_volume`."
340
+ )
391
341
  mapindex = None
392
342
  if region is not None:
393
- assert isinstance(region, (str, _region.Region))
343
+ try:
344
+ assert isinstance(region, (str, _region.Region))
345
+ except AssertionError:
346
+ raise TypeError(f"Please provide a region name or region instance, not a {type(region)}")
394
347
  mapindex = self.get_index(region)
395
348
  if index is not None:
396
349
  assert isinstance(index, MapIndex)
@@ -399,34 +352,21 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
399
352
  if len(self) == 1:
400
353
  mapindex = MapIndex(volume=0, label=None)
401
354
  elif len(self) > 1:
355
+ assert self.maptype == MapType.LABELLED, f"Cannot merge multiple volumes of map type {self.maptype}. Please specify a region or index."
402
356
  logger.info(
403
357
  "Map provides multiple volumes and no specification is"
404
- "provided. Resampling all volumes to the space."
405
- )
406
- resolution = kwargs.get("resolution_mm")
407
- template = self.space.get_template().fetch(
408
- resolution_mm=resolution
358
+ " provided. Resampling all volumes to the space."
409
359
  )
410
- aggregated_volume = np.zeros(template.shape, dtype='uint8')
411
- for i, region in siibra_tqdm(
412
- enumerate(self.regions),
413
- unit=" volume", desc="Fetching", total=len(self)
414
- ):
415
- regionlabel = i + 1
416
- regionmap = image.resample_to_img(
417
- self.fetch(region=region, resolution_mm=resolution),
418
- template,
419
- interpolation='nearest'
420
- )
421
- aggregated_volume[regionmap.get_fdata() > 0] = regionlabel
422
- return Nifti1Image(aggregated_volume, affine=template.affine)
360
+ labels = list(range(len(self.volumes)))
361
+ merged_volume = _volume.merge(self.volumes, labels, **kwargs)
362
+ return merged_volume
423
363
  else:
424
- raise NoVolumeFound("Map provides no volumes.")
364
+ raise exceptions.NoVolumeFound("Map provides no volumes.")
425
365
 
426
366
  kwargs_fragment = kwargs.pop("fragment", None)
427
367
  if kwargs_fragment is not None:
428
368
  if (mapindex.fragment is not None) and (kwargs_fragment != mapindex.fragment):
429
- raise ConflictingArgumentException(
369
+ raise exceptions.ConflictingArgumentException(
430
370
  "Conflicting specifications for fetching volume fragment: "
431
371
  f"{mapindex.fragment} / {kwargs_fragment}"
432
372
  )
@@ -438,28 +378,74 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
438
378
  raise IndexError(
439
379
  f"{self} provides {len(self)} mapped volumes, but #{mapindex.volume} was requested."
440
380
  )
441
- try:
442
- result = self.volumes[mapindex.volume or 0].fetch(
443
- fragment=mapindex.fragment, label=mapindex.label, **kwargs
444
- )
445
- except requests.SiibraHttpRequestError as e:
446
- print(str(e))
381
+ if mapindex.label is None and mapindex.fragment is None:
382
+ return self.volumes[mapindex.volume]
383
+
384
+ return _volume.FilteredVolume(
385
+ parent_volume=self.volumes[mapindex.volume],
386
+ label=mapindex.label,
387
+ fragment=mapindex.fragment,
388
+ )
389
+
390
+ def fetch(
391
+ self,
392
+ region: Union[str, "Region"] = None,
393
+ *,
394
+ index: MapIndex = None,
395
+ **fetch_kwargs
396
+ ):
397
+ """
398
+ Fetches one particular volume of this parcellation map.
399
+
400
+ If there's only one volume, this is the default, otherwise further
401
+ specification is requested:
402
+ - the volume index,
403
+ - the MapIndex (which results in a regional map being returned)
404
+
405
+ You might also consider fetch_iter() to iterate the volumes, or
406
+ compress() to produce a single-volume parcellation map.
407
+
408
+ Parameters
409
+ ----------
410
+ region: str, Region
411
+ Specification of a region name, resulting in a regional map
412
+ (mask or statistical map) to be returned.
413
+ index: MapIndex
414
+ Explicit specification of the map index, typically resulting
415
+ in a regional map (mask or statistical map) to be returned.
416
+ Note that supplying 'region' will result in retrieving the map index of that region
417
+ automatically.
418
+ **fetch_kwargs
419
+ - resolution_mm: resolution in millimeters
420
+ - format: the format of the volume, like "mesh" or "nii"
421
+ - voi: a BoundingBox of interest
447
422
 
448
- if result is None:
449
- raise RuntimeError(f"Error fetching {mapindex} from {self} as {kwargs.get('format', f'{self.formats}')}.")
450
- return result
423
+
424
+ Note
425
+ ----
426
+ Not all keyword arguments are supported for volume formats. Format
427
+ is restricted by available formats (check formats property).
428
+
429
+ Returns
430
+ -------
431
+ An image or mesh
432
+ """
433
+ vol = self.get_volume(region=region, index=index, **fetch_kwargs)
434
+ return vol.fetch(**fetch_kwargs)
451
435
 
452
436
  def fetch_iter(self, **kwargs):
453
437
  """
454
438
  Returns an iterator to fetch all mapped volumes sequentially.
455
439
 
456
- All arguments are passed on to function Map.fetch().
440
+ All arguments are passed on to function Map.fetch(). By default, it
441
+ will go through all fragments as well.
457
442
  """
458
- fragment = kwargs.pop('fragment') if 'fragment' in kwargs else None
443
+ fragments = {kwargs.pop('fragment', None)} or self.fragments or {None}
459
444
  return (
460
445
  self.fetch(
461
- index=MapIndex(volume=i, label=None, fragment=fragment), **kwargs
446
+ index=MapIndex(volume=i, label=None, fragment=frag), **kwargs
462
447
  )
448
+ for frag in fragments
463
449
  for i in range(len(self))
464
450
  )
465
451
 
@@ -529,10 +515,10 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
529
515
  raise RuntimeError("The map cannot be merged since there are no multiple volumes or fragments.")
530
516
 
531
517
  # initialize empty volume according to the template
532
- template = self.space.get_template().fetch(**kwargs)
533
- result_data = np.zeros_like(np.asanyarray(template.dataobj))
534
- voxelwise_max = np.zeros_like(result_data)
535
- result_nii = Nifti1Image(result_data, template.affine)
518
+ template_img = self.space.get_template().fetch(**kwargs)
519
+ result_arr = np.zeros_like(np.asanyarray(template_img.dataobj))
520
+ result_affine = template_img.affine
521
+ voxelwise_max = np.zeros_like(result_arr)
536
522
  interpolation = 'nearest' if self.is_labelled else 'linear'
537
523
  next_labelindex = 1
538
524
  region_indices = defaultdict(list)
@@ -548,11 +534,14 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
548
534
  disable=(len(self.fragments) == 1 or self.fragments is None)
549
535
  ):
550
536
  mapindex = MapIndex(volume=volidx, fragment=frag)
551
- img = self.fetch(mapindex)
552
- if np.linalg.norm(result_nii.affine - img.affine) > 1e-14:
537
+ img = self.fetch(index=mapindex)
538
+ if np.allclose(img.affine, result_affine):
539
+ img_data = np.asanyarray(img.dataobj)
540
+ else:
553
541
  logger.debug(f"Compression requires to resample volume {volidx} ({interpolation})")
554
- img = image.resample_to_img(img, result_nii, interpolation)
555
- img_data = np.asanyarray(img.dataobj)
542
+ img_data = np.asanyarray(
543
+ resample_img_to_img(img, template_img).dataobj
544
+ )
556
545
 
557
546
  if self.is_labelled:
558
547
  labels = set(np.unique(img_data)) - {0}
@@ -571,7 +560,7 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
571
560
  update_voxels = (img_data > voxelwise_max)
572
561
  else:
573
562
  update_voxels = (img_data == label)
574
- result_data[update_voxels] = next_labelindex
563
+ result_arr[update_voxels] = next_labelindex
575
564
  voxelwise_max[update_voxels] = img_data[update_voxels]
576
565
  next_labelindex += 1
577
566
 
@@ -581,43 +570,64 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
581
570
  space_spec=self._space_spec,
582
571
  parcellation_spec=self._parcellation_spec,
583
572
  indices=region_indices,
584
- volumes=[
585
- _volume.Volume(
586
- space_spec=self._space_spec,
587
- providers=[nifti.NiftiProvider(result_nii)]
588
- )
589
- ]
573
+ volumes=[_volume.from_array(
574
+ result_arr, result_affine, self._space_spec, name=self.name + " compressed"
575
+ )]
590
576
  )
591
577
 
592
- def compute_centroids(self) -> Dict[str, point.Point]:
578
+ def compute_centroids(self, split_components: bool = True, **fetch_kwargs) -> Dict[str, pointcloud.PointCloud]:
593
579
  """
594
- Compute a dictionary of the centroids of all regions in this map.
580
+ Compute a dictionary of all regions in this map to their centroids.
581
+ By default, the regional masks will be split to connected components
582
+ and each point in the PointCloud corresponds to a region component.
583
+
584
+ Parameters
585
+ ----------
586
+ split_components: bool, default: True
587
+ If True, finds the spatial properties for each connected component
588
+ found by skimage.measure.label.
595
589
 
596
590
  Returns
597
591
  -------
598
592
  Dict[str, point.Point]
599
593
  Region names as keys and computed centroids as items.
600
594
  """
601
- centroids = {}
602
- maparr = None
595
+ assert self.provides_image, "Centroid computation for meshes is not supported yet."
596
+ centroids = dict()
603
597
  for regionname, indexlist in siibra_tqdm(
604
598
  self._indices.items(), unit="regions", desc="Computing centroids"
605
599
  ):
606
- assert len(indexlist) == 1
607
- index = indexlist[0]
608
- if index.label == 0:
609
- continue
610
- with QUIET:
611
- mapimg = self.fetch(index=index) # returns a mask of the region
612
- maparr = np.asanyarray(mapimg.dataobj)
613
- centroid_vox = np.mean(np.where(maparr == 1), axis=1)
614
600
  assert regionname not in centroids
615
- centroids[regionname] = point.Point(
616
- np.dot(mapimg.affine, np.r_[centroid_vox, 1])[:3], space=self.space
601
+ # get the mask of the region in this map
602
+ with QUIET:
603
+ if len(indexlist) >= 1:
604
+ merged_volume = _volume.merge(
605
+ [
606
+ _volume.from_nifti(
607
+ self.fetch(index=index, **fetch_kwargs),
608
+ self.space,
609
+ f"{self.name} - {index}"
610
+ )
611
+ for index in indexlist
612
+ ],
613
+ labels=[1] * len(indexlist)
614
+ )
615
+ mapimg = merged_volume.fetch()
616
+ elif len(indexlist) == 1:
617
+ index = indexlist[0]
618
+ mapimg = self.fetch(index=index, **fetch_kwargs) # returns a mask of the region
619
+ props = _volume.ComponentSpatialProperties.compute_from_image(
620
+ img=mapimg,
621
+ space=self.space,
622
+ split_components=split_components,
617
623
  )
624
+ try:
625
+ centroids[regionname] = pointcloud.from_points([c.centroid for c in props])
626
+ except exceptions.EmptyPointCloudError:
627
+ centroids[regionname] = None
618
628
  return centroids
619
629
 
620
- def get_resampled_template(self, **fetch_kwargs) -> Nifti1Image:
630
+ def get_resampled_template(self, **fetch_kwargs) -> _volume.Volume:
621
631
  """
622
632
  Resample the reference space template to fetched map image. Uses
623
633
  nilearn.image.resample_to_img to resample the template.
@@ -628,17 +638,19 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
628
638
 
629
639
  Returns
630
640
  -------
631
- Nifti1Image
641
+ Volume
632
642
  """
633
643
  source_template = self.space.get_template().fetch()
634
644
  map_image = self.fetch(**fetch_kwargs)
635
- return image.resample_to_img(
636
- source_template,
637
- map_image,
638
- interpolation='continuous'
645
+ img = image.resample_to_img(source_template, map_image, interpolation='continuous')
646
+ return _volume.from_array(
647
+ data=img.dataobj,
648
+ affine=img.affine,
649
+ space=self.space,
650
+ name=f"{source_template} resampled to coordinate system of {self}"
639
651
  )
640
652
 
641
- def colorize(self, values: dict, **kwargs) -> Nifti1Image:
653
+ def colorize(self, values: dict, **kwargs) -> _volume.Volume:
642
654
  """Colorize the map with the provided regional values.
643
655
 
644
656
  Parameters
@@ -671,7 +683,12 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
671
683
  else:
672
684
  result[img == index.label] = value
673
685
 
674
- return Nifti1Image(result, affine)
686
+ return _volume.from_array(
687
+ data=result,
688
+ affine=affine,
689
+ space=self.space,
690
+ name=f"Custom colorization of {self}"
691
+ )
675
692
 
676
693
  def get_colormap(self, region_specs: Iterable = None):
677
694
  """
@@ -732,7 +749,7 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
732
749
 
733
750
  Returns
734
751
  -------
735
- PointSet
752
+ PointCloud
736
753
  Sample points in physcial coordinates corresponding to this
737
754
  parcellationmap
738
755
  """
@@ -750,7 +767,7 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
750
767
  np.unravel_index(np.random.choice(len(p), numpoints, p=p), W.shape)
751
768
  ).T
752
769
  XYZ = np.dot(mask.affine, np.c_[XYZ_, np.ones(numpoints)].T)[:3, :].T
753
- return pointset.PointSet(XYZ, space=self.space)
770
+ return pointcloud.PointCloud(XYZ, space=self.space)
754
771
 
755
772
  def to_sparse(self):
756
773
  """
@@ -810,44 +827,52 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
810
827
 
811
828
  def _assign(
812
829
  self,
813
- item: Union[point.Point, pointset.PointSet, Nifti1Image],
830
+ item: location.Location,
814
831
  minsize_voxel=1,
815
832
  lower_threshold=0.0,
816
833
  **kwargs
817
- ) -> List[Union[Assignment,AssignImageResult]]:
834
+ ) -> List[Union[MapAssignment, AssignImageResult]]:
818
835
  """
819
836
  For internal use only. Returns a dataclass, which provides better static type checking.
820
837
  """
821
838
 
822
839
  if isinstance(item, point.Point):
823
- return self._assign_points(pointset.PointSet([item], item.space, sigma_mm=item.sigma), lower_threshold)
824
- if isinstance(item, pointset.PointSet):
840
+ return self._assign_points(
841
+ pointcloud.PointCloud([item], item.space, sigma_mm=item.sigma),
842
+ lower_threshold
843
+ )
844
+ if isinstance(item, pointcloud.PointCloud):
825
845
  return self._assign_points(item, lower_threshold)
826
- if isinstance(item, Nifti1Image):
827
- return self._assign_image(item, minsize_voxel, lower_threshold, **kwargs)
828
-
846
+ if isinstance(item, _volume.Volume):
847
+ return self._assign_volume(
848
+ queryvolume=item,
849
+ lower_threshold=lower_threshold,
850
+ minsize_voxel=minsize_voxel,
851
+ **kwargs
852
+ )
853
+
829
854
  raise RuntimeError(
830
855
  f"Items of type {item.__class__.__name__} cannot be used for region assignment."
831
856
  )
832
857
 
833
858
  def assign(
834
859
  self,
835
- item: Union[point.Point, pointset.PointSet, Nifti1Image],
860
+ item: location.Location,
836
861
  minsize_voxel=1,
837
862
  lower_threshold=0.0,
838
863
  **kwargs
839
- ):
840
- """Assign an input image to brain regions.
864
+ ) -> "pd.DataFrame":
865
+ """Assign an input Location to brain regions.
841
866
 
842
- The input image is assumed to be defined in the same coordinate space
867
+ The input is assumed to be defined in the same coordinate space
843
868
  as this parcellation map.
844
869
 
845
870
  Parameters
846
871
  ----------
847
- item: Point, PointSet, Nifti1Image
872
+ item: Location
848
873
  A spatial object defined in the same physical reference space as
849
874
  this parcellation map, which could be a point, set of points, or
850
- image. If it is an image, it will be resampled to the same voxel
875
+ image volume. If it is an image, it will be resampled to the same voxel
851
876
  space if its affine transformation differs from that of the
852
877
  parcellation map. Resampling will use linear interpolation for float
853
878
  image types, otherwise nearest neighbor.
@@ -859,7 +884,7 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
859
884
 
860
885
  Returns
861
886
  -------
862
- assignments: pandas.DataFrame
887
+ pandas.DataFrame
863
888
  A table of associated regions and their scores per component found
864
889
  in the input image, or per coordinate provided. The scores are:
865
890
 
@@ -876,11 +901,6 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
876
901
  masks as the ratio between the volume of their intersection and
877
902
  the volume of the input image signal component (NaN for exact
878
903
  coordinates)
879
- components: Nifti1Image or None
880
- If the input was an image, this is a labelled volume mapping the
881
- detected components in the input image, where pixel values correspond
882
- to the "component" column of the assignment table. If the input was
883
- a Point or PointSet, returns None.
884
904
  """
885
905
 
886
906
  assignments = self._assign(item, minsize_voxel, lower_threshold, **kwargs)
@@ -952,7 +972,7 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
952
972
  "input containedness": a.intersection_over_second,
953
973
  }
954
974
  }
955
- elif isinstance(a, Assignment):
975
+ elif isinstance(a, MapAssignment):
956
976
  item_to_append = {
957
977
  **item_to_append,
958
978
  **{
@@ -969,18 +989,18 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
969
989
  raise RuntimeError("assignments must be of type Assignment or AssignImageResult!")
970
990
 
971
991
  dataframe_list.append(item_to_append)
972
- df = pd.DataFrame(dataframe_list)
973
992
  return (
974
- df
993
+ pd.DataFrame(dataframe_list)
975
994
  .convert_dtypes() # convert will guess numeric column types
976
995
  .reindex(columns=columns)
996
+ .dropna(axis='columns', how='all')
977
997
  )
978
998
 
979
- def _assign_points(self, points: pointset.PointSet, lower_threshold: float) -> List[Assignment]:
999
+ def _assign_points(self, points: pointcloud.PointCloud, lower_threshold: float) -> List[MapAssignment]:
980
1000
  """
981
- assign a PointSet to this parcellation map.
1001
+ assign a PointCloud to this parcellation map.
982
1002
 
983
- Parameters:
1003
+ Parameters
984
1004
  -----------
985
1005
  lower_threshold: float, default: 0
986
1006
  Lower threshold on values in the statistical map. Values smaller than
@@ -1010,7 +1030,7 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
1010
1030
  if value > lower_threshold:
1011
1031
  position = pts_warped[pointindex].coordinate
1012
1032
  assignments.append(
1013
- Assignment(
1033
+ MapAssignment(
1014
1034
  input_structure=pointindex,
1015
1035
  centroid=tuple(np.array(position).round(2)),
1016
1036
  volume=vol,
@@ -1025,7 +1045,7 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
1025
1045
  # of the coordinates.
1026
1046
  for pointindex, pt in siibra_tqdm(
1027
1047
  enumerate(points.warp(self.space.id)),
1028
- total=len(points), desc="Warping points",
1048
+ total=len(points), desc="Assigning points",
1029
1049
  ):
1030
1050
  sigma_vox = pt.sigma / scaling
1031
1051
  if sigma_vox < 3:
@@ -1037,7 +1057,7 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
1037
1057
  for _, vol, frag, value in values:
1038
1058
  if value > lower_threshold:
1039
1059
  assignments.append(
1040
- Assignment(
1060
+ MapAssignment(
1041
1061
  input_structure=pointindex,
1042
1062
  centroid=tuple(pt),
1043
1063
  volume=vol,
@@ -1046,91 +1066,214 @@ class Map(concept.AtlasConcept, configuration_folder="maps"):
1046
1066
  )
1047
1067
  )
1048
1068
  else:
1049
- logger.info(
1069
+ logger.debug(
1050
1070
  f"Assigning uncertain coordinate {tuple(pt)} to {len(self)} maps."
1051
1071
  )
1052
1072
  kernel = create_gaussian_kernel(sigma_vox, 3)
1053
1073
  r = int(kernel.shape[0] / 2) # effective radius
1054
- xyz_vox = (np.dot(phys2vox, pt.homogeneous) + 0.5).astype("int")
1074
+ assert pt.homogeneous.shape[0] == 1
1075
+ xyz_vox = (np.dot(phys2vox, pt.homogeneous.T) + 0.5).astype("int")
1055
1076
  shift = np.identity(4)
1056
- shift[:3, -1] = xyz_vox[:3] - r
1077
+ shift[:3, -1] = xyz_vox[:3, 0] - r
1057
1078
  # build niftiimage with the Gaussian blob,
1058
1079
  # then recurse into this method with the image input
1059
- W = Nifti1Image(dataobj=kernel, affine=np.dot(self.affine, shift))
1060
- for entry in self._assign(W, lower_threshold=lower_threshold):
1080
+ gaussian_kernel = _volume.from_array(
1081
+ data=kernel,
1082
+ affine=np.dot(self.affine, shift),
1083
+ space=self.space,
1084
+ name=f"Gaussian kernel of {pt}"
1085
+ )
1086
+ for entry in self._assign(
1087
+ item=gaussian_kernel,
1088
+ lower_threshold=lower_threshold,
1089
+ split_components=False
1090
+ ):
1061
1091
  entry.input_structure = pointindex
1062
1092
  entry.centroid = tuple(pt)
1063
1093
  assignments.append(entry)
1064
1094
  return assignments
1065
1095
 
1066
- def _assign_image(self, queryimg: Nifti1Image, minsize_voxel: int, lower_threshold: float, split_components: bool = True) -> List[AssignImageResult]:
1096
+ def _assign_volume(
1097
+ self,
1098
+ queryvolume: "_volume.Volume",
1099
+ lower_threshold: float,
1100
+ split_components: bool = True,
1101
+ **kwargs
1102
+ ) -> List[AssignImageResult]:
1067
1103
  """
1068
1104
  Assign an image volume to this parcellation map.
1069
1105
 
1070
- Parameters:
1106
+ Parameters
1071
1107
  -----------
1108
+ queryvolume: Volume
1109
+ the volume to be compared with maps
1072
1110
  minsize_voxel: int, default: 1
1073
1111
  Minimum voxel size of image components to be taken into account.
1074
1112
  lower_threshold: float, default: 0
1075
1113
  Lower threshold on values in the statistical map. Values smaller than
1076
1114
  this threshold will be excluded from the assignment computation.
1115
+ split_components: bool, default: True
1116
+ Whether to split the query volume into disjoint components.
1077
1117
  """
1078
- assignments = []
1118
+ # TODO: split_components is not known to `assign`
1119
+ # TODO: `minsize_voxel` is not used here. Consider the implementation of `assign` again.
1120
+ if kwargs:
1121
+ logger.info(f"The keywords {[k for k in kwargs]} are not passed on during volume assignment.")
1079
1122
 
1080
- def resample(img: Nifti1Image, affine: np.ndarray, shape: tuple):
1081
- # resample query image into this image's voxel space, if required
1082
- if (img.affine - affine).sum() == 0:
1083
- return img
1084
- else:
1085
- interp = "nearest" \
1086
- if issubclass(np.asanyarray(img.dataobj).dtype.type, np.integer) \
1087
- else "linear"
1088
- return image.resample_img(
1089
- img,
1090
- target_affine=affine,
1091
- target_shape=shape,
1092
- interpolation=interp,
1093
- )
1123
+ assert queryvolume.space == self.space, ValueError("Assigned volume must be in the same space as the map.")
1094
1124
 
1095
- def progress(it, N: int = None, desc: str = "", min_elements=5):
1096
- # wraps a progress indicator around the given iterator,
1097
- # but only if the sequence is long.
1098
- seqlen = N or len(it)
1099
- return iter(it) if seqlen < min_elements \
1100
- else siibra_tqdm(it, desc=desc, total=N)
1101
-
1102
- iter_func = iterate_connected_components if split_components \
1103
- else lambda img: [(1, img)]
1104
-
1105
- with QUIET and _volume.SubvolumeProvider.UseCaching():
1106
- for frag in self.fragments or {None}:
1107
- for vol, vol_img in progress(
1108
- enumerate(self.fetch_iter(fragment=frag)),
1109
- N=len(self),
1110
- desc=f"Assigning to {len(self)} volumes"
1111
- ):
1112
- queryimg_res = resample(queryimg, vol_img.affine, vol_img.shape)
1113
- for mode, maskimg in iter_func(queryimg_res):
1114
- vol_data = np.asanyarray(vol_img.dataobj)
1115
- position = np.array(np.where(maskimg.get_fdata())).T.mean(0)
1116
- labels = {v.label for L in self._indices.values() for v in L if v.volume == vol}
1117
- for label in progress(
1118
- labels,
1119
- desc=f"Assigning to {len(labels)} labelled structures"
1120
- ):
1121
- targetimg = vol_img if label is None \
1122
- else Nifti1Image((vol_data == label).astype('uint8'), vol_img.affine)
1123
- scores = compare_maps(maskimg, targetimg)
1124
- if scores.intersection_over_union > 0:
1125
- assignments.append(
1126
- AssignImageResult(
1127
- input_structure=mode,
1128
- centroid=tuple(position.round(2)),
1129
- volume=vol,
1130
- fragment=frag,
1131
- map_value=label,
1132
- **asdict(scores)
1133
- )
1134
- )
1125
+ if split_components:
1126
+ iter_components = lambda arr: connected_components(arr)
1127
+ else:
1128
+ iter_components = lambda arr: [(0, arr)]
1129
+
1130
+ queryimg = queryvolume.fetch()
1131
+ assignments = []
1132
+ all_indices = [
1133
+ index
1134
+ for regionindices in self._indices.values()
1135
+ for index in regionindices
1136
+ ]
1137
+ with QUIET and provider.SubvolumeProvider.UseCaching():
1138
+ for index in siibra_tqdm(
1139
+ all_indices,
1140
+ desc=f"Assigning {queryvolume} to {self}",
1141
+ disable=len(all_indices) < 5,
1142
+ unit="map",
1143
+ leave=False
1144
+ ):
1145
+ region_map = self.fetch(index=index)
1146
+ region_map_arr = np.asanyarray(region_map.dataobj)
1147
+ # the shape and affine are checked by `nilearn.image.resample_to_img()`
1148
+ # and returns the original data if resampling is not necessary.
1149
+ queryimgarr_res = np.asanyarray(
1150
+ resample_img_to_img(queryimg, region_map).dataobj
1151
+ )
1152
+ for compmode, voxelmask in iter_components(queryimgarr_res):
1153
+ scores = compare_arrays(
1154
+ voxelmask,
1155
+ region_map.affine, # after resampling, both should have the same affine
1156
+ region_map_arr,
1157
+ region_map.affine
1158
+ )
1159
+ component_position = np.array(np.where(voxelmask)).T.mean(0)
1160
+ if scores.intersection_over_union > lower_threshold:
1161
+ assignments.append(
1162
+ AssignImageResult(
1163
+ input_structure=compmode,
1164
+ centroid=tuple(component_position.round(2)),
1165
+ volume=index.volume,
1166
+ fragment=index.fragment,
1167
+ map_value=index.label,
1168
+ **asdict(scores)
1169
+ )
1170
+ )
1135
1171
 
1136
1172
  return assignments
1173
+
1174
+
1175
+ def from_volume(
1176
+ name: str,
1177
+ volume: Union[_volume.Volume, List[_volume.Volume]],
1178
+ regionnames: List[str],
1179
+ regionlabels: List[int],
1180
+ parcellation_spec: Union[str, "parcellation.Parcellation"] = None
1181
+ ) -> 'Map':
1182
+ """
1183
+ Add a custom labelled parcellation map to siibra from a labelled NIfTI file.
1184
+
1185
+ Parameters
1186
+ ------------
1187
+ name: str
1188
+ Human-readable name of the parcellation.
1189
+ volume: Volume, or a list of Volumes.
1190
+ space_spec: str, Space
1191
+ Specification of the reference space (space object, name, keyword, or id - e.g. 'mni152').
1192
+ regionnames: list[str]
1193
+ List of human-readable names of the mapped regions.
1194
+ regionlabels: list[int]
1195
+ List of integer labels in the nifti file corresponding to the list of regions.
1196
+ parcellation: str or Parcellation. Optional.
1197
+ If the related parcellation already defined or preconfigured in siibra.
1198
+ """
1199
+ # providers and map indices
1200
+ providers = []
1201
+ volumes = volume if isinstance(volume, list) else [volume]
1202
+ map_space = volumes[0].space
1203
+ assert all(v.space == map_space for v in volumes), "Volumes have to be in the same space"
1204
+ for vol_idx, vol in enumerate(volumes):
1205
+ image = vol.fetch()
1206
+ arr = np.asanyarray(image.dataobj)
1207
+ labels_in_volume = np.unique(arr)[1:].astype('int')
1208
+
1209
+ # populate region indices from given name/label lists
1210
+ indices = dict()
1211
+ for label, regionname in zip(regionlabels, regionnames):
1212
+ if label not in labels_in_volume:
1213
+ logger.warning(
1214
+ f"Label {label} not mapped in the provided NIfTI volume -> "
1215
+ f"region '{regionname} will not be in the map."
1216
+ )
1217
+ elif label in [v[0]['label'] for v in indices.values() if v[0]['volume'] == vol_idx]:
1218
+ logger.warning(f"Label {label} already defined in the same volume; will not map it to '{regionname}'.")
1219
+ else:
1220
+ assert regionname not in indices, f"'{regionname}' must be unique in `regionnames`."
1221
+ indices[regionname] = [{'volume': vol_idx, 'label': label}]
1222
+
1223
+ # check for any remaining labels in the NIfTI volume
1224
+ unnamed_labels = list(set(labels_in_volume) - set(regionlabels))
1225
+ if unnamed_labels:
1226
+ logger.warning(
1227
+ f"The following labels appear in the NIfTI volume {vol_idx}, but not in "
1228
+ f"the specified regions: {', '.join(str(lb) for lb in unnamed_labels)}. "
1229
+ "They will be removed from the nifti volume."
1230
+ )
1231
+ for label in unnamed_labels:
1232
+ arr[arr == label] = 0
1233
+ providers.extend(vol._providers.values())
1234
+
1235
+ # parcellation
1236
+ if parcellation_spec is None:
1237
+ parcellation_spec = name
1238
+ try:
1239
+ parcobj = parcellation.Parcellation.registry().get(parcellation_spec)
1240
+ logger.info(f"Using '{parcellation_spec}', siibra decoded the parcellation as '{parcobj}'")
1241
+ except Exception:
1242
+ logger.info(
1243
+ f"Using '{parcellation_spec}', siibra could not decode the "
1244
+ " parcellation. Building a new parcellation."
1245
+ )
1246
+ # build a new parcellation
1247
+ parcobj = parcellation.Parcellation(
1248
+ identifier=generate_uuid(','.join(regionnames)),
1249
+ name=name,
1250
+ species=vol.space.species,
1251
+ regions=list(map(_region.Region, regionnames)),
1252
+ )
1253
+ if parcobj.key not in list(parcellation.Parcellation.registry()):
1254
+ parcellation.Parcellation.registry().add(parcobj.key, parcobj)
1255
+
1256
+ for region in siibra_tqdm(
1257
+ indices.keys(),
1258
+ desc="Checking if provided regions are defined in the parcellation."
1259
+ ):
1260
+ try:
1261
+ _ = parcobj.get_region(region)
1262
+ except Exception:
1263
+ logger.warning(f"'{region}' is missing in the parcellation.")
1264
+
1265
+ # build the parcellation map object
1266
+ parcmap = Map(
1267
+ identifier=generate_uuid(name),
1268
+ name=f"{name} map in {map_space.name}",
1269
+ space_spec={"@id": map_space.id},
1270
+ parcellation_spec={'name': parcobj.name},
1271
+ indices=indices,
1272
+ volumes=volumes
1273
+ )
1274
+
1275
+ # add it to siibra's registry
1276
+ Map.registry().add(parcmap.key, parcmap)
1277
+
1278
+ # return the map - note that it has a pointer to the parcellation
1279
+ return parcmap