Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix image query #538

Merged
merged 7 commits into from
Jan 18, 2024
26 changes: 20 additions & 6 deletions e2e/features/image/test_image.py
Original file line number Diff line number Diff line change
@@ -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
45 changes: 34 additions & 11 deletions siibra/core/region.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -708,20 +725,26 @@ 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(
other_space,
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}.")
Expand Down
16 changes: 9 additions & 7 deletions siibra/core/structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 10 additions & 3 deletions siibra/features/anchor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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]:
"""
Expand Down
17 changes: 11 additions & 6 deletions siibra/features/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -467,6 +467,7 @@ def _match(
cls,
concept: structure.BrainStructure,
feature_type: Union[str, Type['Feature'], list],
restrict_space: bool = False,
**kwargs
) -> List['Feature']:
"""
Expand All @@ -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
Expand All @@ -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
), [])
))

Expand All @@ -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)

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions siibra/volumes/volume.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down