Source code for brainrender.scene

"""Scene class for adding, removing, and slicing actors and brain regions in brainrender."""

import sys
from pathlib import Path
from typing import Any

import pyinspect as pi
from loguru import logger
from myterial import amber, orange, orange_darker, salmon
from rich import print
from vedo import Assembly, Mesh, Plane, Plotter, Text2D

from brainrender import settings
from brainrender._io import load_mesh_from_file
from brainrender._jupyter import JupyterMixIn, not_on_jupyter
from brainrender._utils import listify, return_list_smart
from brainrender.actor import Actor
from brainrender.actors import Volume
from brainrender.atlas import Atlas
from brainrender.render import Render


[docs] class Scene(JupyterMixIn, Render): """ Main scene in brainrender. Coordinates the actors and the overall appearance. """ def __init__( self, root: bool = True, atlas_name: str | None = None, check_latest: bool = True, inset: bool = True, title: str | None = None, screenshots_folder: str | Path | None = None, plotter: Plotter | None = None, title_color: str = "k", ) -> None: """ Parameters ---------- root If true the brain root mesh is added. atlas_name Name of the brainglobe atlas to be used. check_latest If True checks that the atlas is the latest version. inset If true an inset is shown with the brain's outline. title If given, a title is added to the top of the window. screenshots_folder Where the screenshots will be saved. Defaults to cwd. plotter Existing vedo Plotter to use. A new one is created if None. title_color Colour of the title text. """ logger.debug( f"Creating scene with parameters: root: {root}, atlas_name: '{atlas_name}'', inset: {inset}, screenshots_folder: {screenshots_folder}" ) JupyterMixIn.__init__(self) self.actors = [] # stores all actors in the scene self.labels = [] # stores all `labels` actors in scene self.atlas = Atlas(atlas_name=atlas_name, check_latest=check_latest) self.screenshots_folder = ( Path(screenshots_folder) if screenshots_folder is not None else Path().cwd() ) self.screenshots_folder.mkdir(exist_ok=True) # Initialise render class Render.__init__(self, plotter) root_name = self.atlas.structures[self.atlas.hierarchy.root]["acronym"] # Get root mesh self.root = self.add_brain_region( root_name, alpha=settings.ROOT_ALPHA, color=settings.ROOT_COLOR, silhouette=bool(root and settings.SHADER_STYLE == "cartoon"), ) self.atlas.root = self.root # give atlas access to root self._root_mesh = self.root.mesh.clone() if not root: self.remove(self.root) # keep track if we need to make an inset self.inset = inset # add title if title: self.add( Text2D(title, pos="top-center", s=2.5, c=title_color, alpha=1), names="title", classes="title", ) def __str__(self) -> str: return f"A `brainrender.scene.Scene` with {len(self.actors)} actors." def __repr__(self) -> str: # pragma: no cover return f"A `brainrender.scene.Scene` with {len(self.actors)} actors." def __repr_html__(self) -> str: # pragma: no cover return f"A `brainrender.scene.Scene` with {len(self.actors)} actors." def __del__(self) -> None: self.close() @not_on_jupyter def _get_inset(self) -> None: """ Create a small inset showing the brain's orientation. """ if settings.OFFSCREEN: return inset = self._root_mesh.clone() inset.alpha(1) # scale(0.5) self.plotter.add_inset(inset, pos=(0.95, 0.1), draggable=False) if settings.SHADER_STYLE == "cartoon": inset.lighting("off")
[docs] def add( self, *items: Mesh | Assembly | Text2D | Actor | str | Path, names: str | list[str | None] | None = None, classes: str | list[str | None] | None = None, transform: bool = True, **kwargs: Any, ) -> Actor | list[Actor]: """ General method to add Actors to the scene. Whatever the input, it's turned into an instance of Actor before being added to the scene. Parameters ---------- *items vedo.Mesh, Actor, or (str, Path). If str/path it should be a path to a .obj or .stl file. names Names to be assigned to the Actors. classes br_classes to be assigned to the Actors. transform If True, apply the axes-orientation transform to new actors. **kwargs Parameters to be passed to the individual loading functions (e.g. to load from file and specify the color). Returns ------- Actor or list of Actor The actor(s) added to the scene. """ names = names or [None for a in items] classes = classes or [None for a in items] # turn items into Actors actors = [] for item, name, _class in zip(items, listify(names), listify(classes)): if item is None: continue if isinstance(item, (Mesh, Assembly)): actors.append(Actor(item, name=name, br_class=_class)) elif isinstance(item, Text2D): # Mark text actors differently because they don't behave like # other 3d actors actors.append( Actor( item, name=name, br_class=_class, is_text=True, **kwargs, ) ) elif pi.utils._class_name(item) == "Volume" and not isinstance( item, Volume ): actors.append( Volume(item, name=name, br_class=_class, **kwargs) ) elif isinstance(item, Actor): actors.append(item) elif isinstance(item, (str, Path)): mesh = load_mesh_from_file(item, **kwargs) name = name or Path(item).name _class = _class or "from file" actors.append(Actor(mesh, name=name, br_class=_class)) else: raise ValueError( f"Unrecognized argument: {item} [{pi.utils._class_name(item)}]" ) # transform actors if transform: for actor in actors: self._prepare_actor(actor) # add actors to plotter for actor in actors: try: self.plotter.add(actor._mesh) except AttributeError: # e.g. for titles self.plotter.add(actor.mesh) # Add to the lists actors self.actors.extend(actors) return return_list_smart(actors)
[docs] def remove(self, *actors: Actor) -> None: """ Remove actors from the scene. Parameters ---------- *actors Actors to remove. """ logger.debug(f"Removing {len(actors)} actors from scene") for act in actors: try: self.actors.pop(self.actors.index(act)) except Exception: print( f"Could not remove ({act}, {pi.utils._class_name(act)}) from actors" ) else: # remove from plotter try: self.plotter.remove(act._mesh) except AttributeError: pass if act.silhouette is not None: self.plotter.remove(act.silhouette.mesh) for label in act.labels: self.plotter.remove(label.mesh)
[docs] def get_actors( self, name: str | int | list[str | int] | None = None, br_class: str | list[str] | None = None, ) -> list[Actor]: """ Return the scene's actors that match some search criteria. Parameters ---------- name Actor name(s) to match. br_class Actor br_class(es) to match. Returns ------- list of Actor The actors in the scene that match the specified search criteria. """ matches = self.actors if name is not None: name = listify(name) matches = [m for m in matches if m.name in name] if br_class is not None: br_class = listify(br_class) matches = [m for m in matches if m.br_class in br_class] return matches
[docs] def add_brain_region( self, *regions: str | int, alpha: float = 1, color: str | None = None, silhouette: bool | None = None, hemisphere: str = "both", force: bool = False, ) -> Actor | list[Actor] | None: """ Dedicated method to add brain regions to render. Parameters ---------- *regions Region names or IDs. alpha How opaque the regions are rendered. color Uses the atlas default colour if None. silhouette If true, region Actors will have a silhouette. hemisphere ``"both"`` returns the complete mesh; ``"left"``/``"right"`` return only the corresponding half of the mesh. force If true, force adding of region even if already rendered. Returns ------- Actor or list of Actor or None The actors added to the scene. None if no actors added. """ if silhouette is None: silhouette = ( silhouette or True if settings.SHADER_STYLE == "cartoon" else False ) # avoid adding regions already rendered if not force: already_in = [ r.name for r in self.get_actors(br_class="brain region") ] regions = [r for r in regions if r not in already_in] if not regions: # they were all already rendered logger.debug( "Not adding any region because they are all already in the scene" ) return None logger.debug( f"SCENE: Adding {len(regions)} brain regions to scene: {regions}" ) # get regions actors from atlas regions = self.atlas.get_region(*regions, alpha=alpha, color=color) regions = listify(regions) or [] # add actors actors = self.add(*regions) # slice to keep only one hemisphere if hemisphere in ("left", "right"): if self.atlas.metadata["symmetric"]: mesh_center = ( self.root._mesh.bounds().reshape((3, 2)).mean(axis=1) ) else: mesh_center = self.root._mesh.center_of_mass() normal = (0, 0, 1) if hemisphere == "right" else (0, 0, -1) plane = self.atlas.get_plane(pos=mesh_center, norm=normal) if not isinstance(actors, list): actors._mesh.cut_with_plane( origin=plane.center, normal=plane.normal, ) actors.cap() else: for actor in actors: actor._mesh.cut_with_plane( origin=plane.center, normal=plane.normal, ) actor.cap() # make silhouettes if silhouette and regions and alpha: self.add_silhouette(*regions, lw=2) return actors
@not_on_jupyter def add_silhouette( self, *actors: Actor, lw: float = 1, color: str = "k" ) -> None: """ Dedicated method to add silhouette to actors. Parameters ---------- *actors Actors to silhouette. lw Line weight. color Silhouette colour. """ for actor in actors: if actor is None: continue actor._needs_silhouette = True actor._silhouette_kwargs = dict( lw=lw or settings.LW, color=color, ) @not_on_jupyter def add_label(self, actor: Actor, label: str, **kwargs: Any) -> None: """ Dedicated method to add labels to actors. Parameters ---------- actor Actor to label. label Text of the label. **kwargs See ``brainrender._actor.make_actor_label`` for kwargs. """ actor._needs_label = True actor._label_str = label actor._label_kwargs = kwargs
[docs] def slice( self, plane: str | Plane, actors: Actor | list[Actor] | None = None, close_actors: bool = False, invert: bool = False, ) -> None: """ Slice actors with a plane. Parameters ---------- plane If a string it needs to be a supported plane from brainglobe's atlas api (e.g. ``"frontal"``), otherwise a vedo.Plane mesh. actors Actors to be sliced. If None, all actors will be sliced. close_actors If true, the openings in the actors' meshes caused by the cut will be closed. invert Invert the slice direction. """ if isinstance(plane, str): if invert is False: norm = self.atlas.space.plane_normals[plane] elif invert is True: norm = tuple( x * -1 for x in self.atlas.space.plane_normals[plane] ) plane = self.atlas.get_plane(plane=plane, norm=norm) if not actors or actors is None: actors = self.clean_actors.copy() for actor in listify(actors): actor._mesh = actor._mesh.cut_with_plane( origin=plane.center, normal=plane.normal, ) if close_actors: actor.cap() if actor.silhouette is not None: self.plotter.remove(actor.silhouette.mesh) self.plotter.add(actor.make_silhouette().mesh)
@property def content(self) -> None: """ Print an overview of the Actors in the scene. """ actors = pi.Report( "Scene actors", accent=salmon, dim=orange, color=orange ) for act in self.actors: actors.add( f"[bold][{amber}]- {act.name}[/bold][{orange_darker}] (type: [{orange}]{act.br_class}[/{orange}])" ) if sys.platform != "win32": actors.print() else: print(pi.utils.stringify(actors, maxlen=-1)) @property def renderables(self) -> list[Mesh]: """ Return the meshes for all actors. """ if not self.backend: return [a.mesh for a in self.actors + self.labels] else: return [a.mesh for a in self.actors if not a.is_text] @property def clean_actors(self) -> list[Actor]: """ Return only actors that are not Text objects and similar. """ return [a for a in self.actors if not a.is_text] @property def clean_renderables(self) -> list[Mesh]: """ Return meshes only for 'clean actors' (i.e. not text). ``_mesh`` is returned to account for internal rotations. """ return [a._mesh for a in self.actors if not a.is_text]