Source code for atlannot.region_meta

# Copyright 2021, Blue Brain Project, EPFL
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Implementation of the RegionMeta class."""
from __future__ import annotations

import json
import logging
import numbers
import re

logger = logging.getLogger(__name__)


[docs]class RegionMeta: """Class holding the hierarchical region metadata. Typically, such information would be parsed from a `brain_regions.json` file. Parameters ---------- background_id : int, optional Override the default ID for the background. background_color : str, optional Override the default color for the background. Should be a string of length 6 with RGB values in hexadecimal form. """ def __init__(self, background_id=0, background_color="000000"): self.background_id = background_id self.root_id = None self.atlas_id = {self.background_id: None} self.ontology_id = {self.background_id: None} self.acronym_ = {self.background_id: "bg"} self.name_ = {self.background_id: "background"} self.color_hex_triplet = {self.background_id: background_color} self.graph_order = {self.background_id: None} self.st_level = {self.background_id: None} self.hemisphere_id = {self.background_id: None} self.parent_id = {self.background_id: None} self.children_ids: dict[int, list[int]] = {self.background_id: []} self.level = {self.background_id: 0} def __repr__(self): """Create the repr of the instance.""" return f"<{str(self)}>" def __str__(self): """Create a string representation of the instance.""" return f"{self.__class__.__qualname__}, {self.size} regions, depth {self.depth}" @property def color_map(self): """Map region IDs to RGB colors. Returns ------- color_map : dict The color map. Keys are regions IDs, and values are tuples with three integers between 0 and 255 representing the RGB colors. """ color_map = {} for region_id, color in self.color_hex_triplet.items(): red, green, blue = color[:2], color[2:4], color[4:] color_map[region_id] = tuple(int(c, base=16) for c in (red, green, blue)) return color_map
[docs] def ids_at_level(self, level): """Region IDs at a given hierarchy level. Finds all region IDs at the given level and yields them one by one. Yields ------ region_id : int A region ID at the given level """ for region_id, region_level in self.level.items(): if region_level == level: yield region_id
[docs] def is_valid_id(self, id_): """Check whether the given region ID is part of the structure graph. Parameters ---------- id_ : int The region ID in question. Returns ------- bool Whether the given region ID is part of the structure graph """ # The parent_id dictionary should have all region IDs as keys return id_ in self.parent_id
[docs] def is_leaf(self, region_id): """Check if the given region is a leaf region. Parameters ---------- region_id : int The region ID in question. Returns ------- bool Whether or not the given region is a leaf region. """ return len(self.children_ids[region_id]) == 0
[docs] def parent(self, region_id): """Get the parent region ID of a region. Parameters ---------- region_id The region ID in question. Returns ------- int or None The region ID of the parent. If there's no parent then None is returned. """ return self.parent_id.get(region_id)
[docs] def children(self, region_id): """Get all child region IDs of a given region. Note that by children we mean only the direct children, much like by parent we only mean the direct parent. The cumulative quantities that span all generations are called ancestors and descendants. Parameters ---------- region_id : int The region ID in question. Yields ------ int The region ID of a child region. """ return tuple(self.children_ids[region_id])
[docs] def name(self, id_): """Get the name of a region. Parameters ---------- id_ A region ID. Returns ------- str The name of the given region. """ if not self.is_valid_id(id_): logger.warning(f"Unknown region ID: {id_!r}; no name available.") return "" else: return self.name_[id_]
[docs] def acronym(self, id_): """Get the acronym of a region. Parameters ---------- id_ A region ID. Returns ------- The acronym a the given region. """ if not self.is_valid_id(id_): logger.warning(f"Unknown region ID: {id_!r}; no acronym available.") return "" else: return self.acronym_[id_]
[docs] def find_by_name(self, name): """Find the region ID given its name. Parameters ---------- name : str The name of a region ID Returns ------- int or None The region ID if a region is found, otherwise None """ for id_, region_name in self.name_.items(): if name == region_name: return id_ return None
[docs] def find_by_acronym(self, acronym): """Find the region ID given its acronym. Parameters ---------- acronym : str The acronym of a region ID Returns ------- int or None The region ID if a region is found, otherwise None """ for id_, region_acronym in self.acronym_.items(): if acronym == region_acronym: return id_ return None
[docs] def ancestors(self, ids, include_background=False): """Find all ancestors of given regions. The result is inclusive, i.e. the input region IDs will be included in the result. Parameters ---------- ids : int or iterable of int A region ID or a collection of region IDs to collect ancestors for. include_background : bool If True the background region ID will be included in the result. Returns ------- set All ancestor region IDs of the given regions, including the input regions themselves. """ if isinstance(ids, numbers.Integral): unique_ids = {ids} else: unique_ids = set(ids) ancestors = set() for id_ in unique_ids: while id_ is not None: ancestors.add(id_) id_ = self.parent(id_) if not include_background: ancestors.remove(self.background_id) return ancestors
[docs] def descendants(self, ids): """Find all descendants of given regions. The result is inclusive, i.e. the input region IDs will be included in the result. Parameters ---------- ids : int or iterable of int A region ID or a collection of region IDs to collect descendants for. Returns ------- set All descendant region IDs of the given regions, including the input regions themselves. """ if isinstance(ids, numbers.Integral): unique_ids = {ids} else: unique_ids = set(ids) def iter_descendants(region_id): """Iterate over all descendants of a given region ID.""" yield region_id for child in self.children(region_id): yield child yield from iter_descendants(child) descendants = set() for id_ in unique_ids: descendants |= set(iter_descendants(id_)) return descendants
@property def depth(self): """Find the depth of the region hierarchy. The background region is not taken into account. Returns ------- int The depth of the region hierarchy. """ return max(self.level.values()) @property def size(self): """Find the number of regions in the structure graph. Returns ------- int The number of regions in the structure graph """ # parent_id should have all region IDs as keys. Subtract one to remove # the background from the count return len(self.parent_id) - 1
[docs] def in_region_like(self, region_name_regex, region_id): """Check if region belongs to a region with a given name pattern. Note that providing a simple string without any special characters is equivalent to a substring test. Parameters ---------- region_name_regex : str A regex to match the region name. region_id : int A region ID. Returns ------- bool Whether or not the region with the given ID is in a region with the given name part. All parent regions are also checked. """ if not self.is_valid_id(region_id): logger.warning("Invalid region ID: %d", region_id) return False while region_id != self.background_id: if re.search(region_name_regex, self.name(region_id)): return True region_id = self.parent(region_id) return False
[docs] def print_regions(self, root_id=None, max_depth=-1, acronym=False): """Print the region hierarchy tree to stdout. Parameters ---------- root_id : int, optional The ID that shall be at the top of the printed hierarchy tree. Defaults to the true hierarchy root region if none is provided. max_depth : int, default -1 If a non-negative integer then the hierarchy will be printed only up to the given depth. Otherwise the whole hierarchy will be printed. acronym : bool, default False If true then instead of the full region name its acronym will be used. """ if root_id is None: root_id = self.root_id # Tracks which parent region guide lines should be printed. guides: list[bool] = [] def handle_region(id_, level=0, is_last=False): # Print the region name line name = self.acronym(id_) if acronym else self.name(id_) indents = ["| " if is_active else " " for is_active in guides] if indents: indents[-1] = "└── " if is_last else "├── " print(f'{"".join(indents)}{name} ({id_})') # Stop recursion at max depth if level == max_depth: return # Recurse to the children children = self.children(id_) if not children: return guides.append(True) for child in children[:-1]: handle_region(child, level + 1) guides[-1] = False handle_region(children[-1], level + 1, is_last=True) guides.pop() handle_region(root_id)
def _parse_region_hierarchy(self, region, is_root=False): """Parse and save a region and its children. This helper method is usually used to initialize the class instance. Parameters ---------- region : dict Metadata for a region and its children. is_root : bool, default False If True then it will be assumed that this region is the root region. As a consequence it will be attached as the child of the background. """ region_id = region["id"] if is_root: self.root_id = region_id parent_id = self.background_id else: parent_id = region["parent_structure_id"] self.children_ids[parent_id].append(region_id) self.atlas_id[region_id] = region["atlas_id"] self.ontology_id[region_id] = region["ontology_id"] self.acronym_[region_id] = region["acronym"] self.name_[region_id] = region["name"] self.color_hex_triplet[region_id] = region["color_hex_triplet"] self.graph_order[region_id] = region["graph_order"] self.st_level[region_id] = region["st_level"] self.hemisphere_id[region_id] = region["hemisphere_id"] self.parent_id[region_id] = parent_id self.level[region_id] = self.level[parent_id] + 1 self.children_ids[region_id] = [] for child in region["children"]: self._parse_region_hierarchy(child)
[docs] @classmethod def from_dict(cls, region_hierarchy, warn_raw_response=True): """Construct an instance from the region hierarchy. Parameters ---------- region_hierarchy : dict The dictionary of the region hierarchy. Should have the format as usually provided by the AIBS. warn_raw_response: bool If True and a raw AIBS response (containing the "msg" key) is used, then a warning will be logged. Returns ------- region_meta : RegionMeta The initialized instance of this class. """ if "msg" in region_hierarchy: if warn_raw_response: logger.warning( "Seems like you're trying to use the raw AIBS response as " 'input, I gotcha. Next time please use response["msg"][0].' ) region_hierarchy = region_hierarchy["msg"][0] self = cls() self._parse_region_hierarchy(region_hierarchy, is_root=True) return self
[docs] def to_dict(self, root_id=None): """Serialise the region structure data to a dictionary. This is exactly the inverse of the ``from_dict`` method. Parameters ---------- root_id : int or None, optional Which region ID to start with. This will be the new top of the serialised structure graph. If none is provided then to real root region is used and as a consequence the complete structure graph is serialised. Returns ------- dict The serialised region structure data. """ def region_to_dict(id_): region_dict = { "id": id_, "atlas_id": self.atlas_id[id_], "ontology_id": self.ontology_id[id_], "acronym": self.acronym_[id_], "name": self.name_[id_], "color_hex_triplet": self.color_hex_triplet[id_], "graph_order": self.graph_order[id_], "st_level": self.st_level[id_], "hemisphere_id": self.hemisphere_id[id_], "parent_structure_id": self.parent_id[id_], "children": [], } for child_id in self.children_ids[id_]: region_dict["children"].append(region_to_dict(child_id)) return region_dict if root_id is None: root_id = self.root_id result = region_to_dict(root_id) # Detach the root region rest of the rest of structure graph result["parent_structure_id"] = None return result
[docs] @classmethod def load_json(cls, json_path): """Load the structure graph from a JSON file and create an instance. Parameters ---------- json_path : str or pathlib.Path Returns ------- RegionMeta The initialized instance of this class. """ with open(json_path) as fh: structure_graph = json.load(fh) # The JSON file could be either a raw response with the AIBS headers # or just the bare structure graph. We don't make any assumptions and # support both. No need to warn the user at this point if it's the # raw response. return cls.from_dict(structure_graph, warn_raw_response=False)