diff --git a/e2e/features/image/test_image.py b/e2e/features/image/test_image.py index 4a5198ad7..e9c3dc415 100644 --- a/e2e/features/image/test_image.py +++ b/e2e/features/image/test_image.py @@ -1,13 +1,27 @@ import siibra import pytest +from siibra.features.image.image import Image -features = siibra.features.get( - siibra.get_template("big brain"), "CellBodyStainedVolumeOfInterest" -) +# Update this as new configs are added +results = [ + (siibra.features.get(siibra.get_template("big brain"), "CellbodyStainedSection"), 145), + (siibra.features.get(siibra.get_template("big brain"), "CellBodyStainedVolumeOfInterest"), 2), + (siibra.features.get(siibra.get_template("mni152"), "image", restrict_space=True), 4), + (siibra.features.get(siibra.get_template("mni152"), "image", restrict_space=False), 13), + (siibra.features.get(siibra.get_region('julich 3', 'hoc1 left'), "CellbodyStainedSection"), 47), + (siibra.features.get(siibra.get_region('julich 2.9', 'hoc1 left'), "CellbodyStainedSection"), 41) +] +features = [f for fts, _ in results for f in fts] @pytest.mark.parametrize("feature", features) -def test_feature_has_datasets( - feature: siibra.features.image.CellBodyStainedVolumeOfInterest, -): +def test_feature_has_datasets(feature: Image): assert len(feature.datasets) > 0 + + +@pytest.mark.parametrize("features, result_len", results) +def test_image_query_results( + features: Image, + result_len: int +): + assert len(features) == result_len diff --git a/siibra/core/region.py b/siibra/core/region.py index e87732284..602012361 100644 --- a/siibra/core/region.py +++ b/siibra/core/region.py @@ -637,18 +637,30 @@ def assign(self, other: structure.BrainStructure) -> AnatomicalAssignment: return self._ASSIGNMENT_CACHE[other, self].invert() if isinstance(other, location.Location): - for space in [other.space] + self.supported_spaces: + if self.mapped_in_space(other.space): + regionmap = self.get_regional_map(other.space) + self._ASSIGNMENT_CACHE[self, other] = regionmap.assign(other) + return self._ASSIGNMENT_CACHE[self, other] + + assignment_result = None + for space in self.supported_spaces: try: + other_warped = other.warp(space) regionmap = self.get_regional_map(space) - self._ASSIGNMENT_CACHE[self, other] = regionmap.assign( - other.warp(space) - ) + assignment_result = regionmap.assign(other_warped) + except SpaceWarpingFailedError: + try: + regionbbox_warped = self.get_boundingbox( + space, restrict_space=True + ).warp(other.space) + except SpaceWarpingFailedError: + continue + assignment_result = regionbbox_warped.assign(other) except Exception as e: logger.debug(e) continue break - if (self, other) not in self._ASSIGNMENT_CACHE: - self._ASSIGNMENT_CACHE[self, other] = None + self._ASSIGNMENT_CACHE[self, other] = assignment_result else: # other is a Region assert isinstance(other, Region) if self == other: @@ -682,6 +694,7 @@ def get_boundingbox( space: _space.Space, maptype: MapType = MapType.LABELLED, threshold_statistical=None, + restrict_space=False, **fetch_kwargs ): """Compute the bounding box of this region in the given space. @@ -697,6 +710,10 @@ def get_boundingbox( threshold_statistical: float, or None if not None, masks will be preferably constructed by thresholding statistical maps with the given value. + restrict_space: bool, default: False + If True, it will not try to fetch maps from other spaces and warp + its boundingbox to requested space. + Returns ------- BoundingBox @@ -708,6 +725,8 @@ def get_boundingbox( ) return mask.get_boundingbox(clip=True, background=0.0, **fetch_kwargs) except (RuntimeError, ValueError): + if restrict_space: + return None for other_space in self.parcellation.spaces - spaceobj: try: mask = self.get_regional_map( @@ -715,13 +734,17 @@ def get_boundingbox( maptype=maptype, threshold=threshold_statistical, ) - logger.warning( - f"No bounding box for {self.name} defined in {spaceobj.name}, " - f"will warp the bounding box from {other_space.name} instead." - ) bbox = mask.get_boundingbox(clip=True, background=0.0, **fetch_kwargs) if bbox is not None: - return bbox.warp(spaceobj) + try: + bbox_warped = bbox.warp(spaceobj) + except SpaceWarpingFailedError: + continue + logger.warning( + f"No bounding box for {self.name} defined in {spaceobj.name}, " + f"warped the bounding box from {other_space.name} instead." + ) + return bbox_warped except RuntimeError: continue logger.error(f"Could not compute bounding box for {self.name}.") diff --git a/siibra/core/structure.py b/siibra/core/structure.py index c02632f11..154ff1a5d 100644 --- a/siibra/core/structure.py +++ b/siibra/core/structure.py @@ -93,16 +93,18 @@ def assign(self, other: "BrainStructure") -> assignment.AnatomicalAssignment: self._ASSIGNMENT_CACHE[self, other] = inverse_assignment.invert() return self._ASSIGNMENT_CACHE[self, other] else: # other is a location object, just check spatial relationships + qualification = None if self == other: qualification = assignment.Qualification.EXACT - elif self.__contains__(other): - qualification = assignment.Qualification.CONTAINS - elif other.__contains__(self): - qualification = assignment.Qualification.CONTAINED - elif self.intersects(other): - qualification = assignment.Qualification.OVERLAPS else: - qualification = None + intersection = self.intersection(other) + if intersection is not None: + if intersection == other: + qualification = assignment.Qualification.CONTAINS + elif intersection == self: + qualification = assignment.Qualification.CONTAINED + else: + qualification = assignment.Qualification.OVERLAPS if qualification is None: self._ASSIGNMENT_CACHE[self, other] = None else: diff --git a/siibra/features/anchor.py b/siibra/features/anchor.py index 21e684c7a..6d8de57bf 100644 --- a/siibra/features/anchor.py +++ b/siibra/features/anchor.py @@ -156,11 +156,18 @@ def __str__(self): else: return region + separator + location - def assign(self, concept: BrainStructure) -> AnatomicalAssignment: + def assign(self, concept: BrainStructure, restrict_space: bool = False) -> AnatomicalAssignment: """ Match this anchor to a query concept. Assignments are cached at runtime, so repeated assignment with the same concept will be cheap. """ + if ( + restrict_space + and self.location is not None + and isinstance(concept, Location) + and not self.location.space.matches(concept.space) + ): + return [] if concept not in self._assignments: assignments: List[AnatomicalAssignment] = [] if self.location is not None: @@ -177,8 +184,8 @@ def assign(self, concept: BrainStructure) -> AnatomicalAssignment: else None return self._assignments[concept] - def matches(self, concept: BrainStructure) -> bool: - return len(self.assign(concept)) > 0 + def matches(self, concept: BrainStructure, restrict_space: bool = False) -> bool: + return len(self.assign(concept, restrict_space)) > 0 def represented_parcellations(self) -> List[Parcellation]: """ diff --git a/siibra/features/feature.py b/siibra/features/feature.py index 912234b4d..0e48be610 100644 --- a/siibra/features/feature.py +++ b/siibra/features/feature.py @@ -235,13 +235,13 @@ def _clean_instances(cls): """ Removes all instantiated object instances""" cls._preconfigured_instances = None - def matches(self, concept: concept.AtlasConcept) -> bool: + def matches(self, concept: structure.BrainStructure, restrict_space: bool = False) -> bool: """ Match the features anatomical anchor agains the given query concept. Record the most recently matched concept for inspection by the caller. """ # TODO: storing the last matched concept. It is not ideal, might cause problems in multithreading - if self.anchor and self.anchor.matches(concept): + if self.anchor and self.anchor.matches(concept, restrict_space): self.anchor._last_matched_concept = concept return True self.anchor._last_matched_concept = None @@ -467,6 +467,7 @@ def _match( cls, concept: structure.BrainStructure, feature_type: Union[str, Type['Feature'], list], + restrict_space: bool = False, **kwargs ) -> List['Feature']: """ @@ -483,8 +484,12 @@ def _match( ---------- concept: AtlasConcept An anatomical concept, typically a brain region or parcellation. - modality: subclass of Feature + feature_type: subclass of Feature, str specififies the type of features ("modality") + restrict_space: bool: default: False + If true, will skip features anchored at spatial locations of + different spaces than the concept. Requires concept to be a + Location. """ if isinstance(feature_type, list): # a list of feature types is given, collect match results on those @@ -494,7 +499,7 @@ def _match( ) return list(dict.fromkeys( sum(( - cls._match(concept, t, **kwargs) for t in feature_type + cls._match(concept, t, restrict_space, **kwargs) for t in feature_type ), []) )) @@ -511,7 +516,7 @@ def _match( f"'{feature_type}' decoded as feature type/s: " f"{[c.__name__ for c in ftype_candidates]}." ) - return cls._match(concept, ftype_candidates, **kwargs) + return cls._match(concept, ftype_candidates, restrict_space, **kwargs) assert issubclass(feature_type, Feature) @@ -537,7 +542,7 @@ def _match( total=len(instances), disable=(not instances) ) - if f.matches(concept) + if f.matches(concept, restrict_space) ] # Then run any registered live queries for the requested feature type diff --git a/siibra/volumes/volume.py b/siibra/volumes/volume.py index 15cee3815..53f5c9e2a 100644 --- a/siibra/volumes/volume.py +++ b/siibra/volumes/volume.py @@ -29,6 +29,7 @@ from time import sleep import json from skimage import feature as skimage_feature, filters +from functools import lru_cache if TYPE_CHECKING: from ..retrieval.datasets import EbrainsDataset @@ -115,6 +116,7 @@ def concat(url: Union[str, Dict[str, str]], concat: str): for srctype, prov in self._providers.items() } + @lru_cache(2) def get_boundingbox(self, clip: bool = True, background: float = 0.0, **fetch_kwargs) -> "boundingbox.BoundingBox": """ Obtain the bounding box in physical coordinates of this volume.