From 8c13eaf24e8b5827be12be67e0c97e1e5a00836f Mon Sep 17 00:00:00 2001 From: payam Date: Sat, 17 Oct 2026 18:10:00 +0200 Subject: [PATCH] move --- mne/viz/_brain/_brain.py | 1026 +------------------------------------- mne/viz/_brain/_movie.py | 269 ++++++++++ mne/viz/_brain/_scene.py | 767 ++++++++++++++++++++++++++++ 3 files changed, 1044 insertions(+), 1018 deletions(-) create mode 100644 mne/viz/_brain/_movie.py create mode 100644 mne/viz/_brain/_scene.py diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 760066ab1bf..1f6ee0855c0 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -5,36 +5,16 @@ import os import os.path as op import time -import traceback import weakref from io import BytesIO import numpy as np from scipy.interpolate import interp1d -from ..._fiff.meas_info import Info -from ..._fiff.pick import pick_types -from ..._freesurfer import ( - _estimate_talxfm_rigid, - _get_aseg, - _get_head_surface, - _get_skull_surface, - read_freesurfer_lut, -) -from ...defaults import DEFAULTS, _handle_default -from ...surface import ( - _decimate_surface_ico_oct, - _marching_cubes, - _mesh_borders, - mesh_edges, -) -from ...transforms import ( - Transform, - _frame_to_str, - _get_trans, - _get_transforms_to_coord_frame, - apply_trans, -) +from ..._freesurfer import _estimate_talxfm_rigid +from ...defaults import _handle_default +from ...surface import _decimate_surface_ico_oct, _mesh_borders, mesh_edges +from ...transforms import apply_trans from ...utils import ( Bunch, _auto_weakref, @@ -50,15 +30,7 @@ verbose_static, warn, ) -from .._3d import ( - _check_views, - _handle_sensor_types, - _handle_time, - _plot_forward, - _plot_helmet, - _plot_sensors_3d, - _process_clim, -) +from .._3d import _check_views, _handle_time, _process_clim from .._3d_overlay import LayeredMesh from ..ui_events import ( ColormapRange, @@ -79,6 +51,8 @@ concatenate_images, safe_event, ) +from ._movie import _MovieMixin +from ._scene import _SceneMixin from ._time_viewer import _TimeViewerMixin from .colormap import _CORTEX_PRESETS, calculate_lut from .surface import _Surface @@ -92,7 +66,7 @@ def _resolve_offset(offset, surf, hemi): @fill_doc_static("views", "view_layout", "theme_3d") -class Brain(_TimeViewerMixin): +class Brain(_SceneMixin, _MovieMixin, _TimeViewerMixin): """Class for visualizing a brain. .. warning:: @@ -1440,735 +1414,6 @@ def add_label( self._labels[hemi].append(label) self._renderer._update() - @fill_doc_static("fwd", "trans_not_none", "alpha") - def add_forward(self, fwd, trans, alpha=1, scale=None): - """Add a quiver to render positions of dipoles. - - Parameters - ---------- - fwd : instance of Forward - The forward solution. If present, the orientations of the dipoles - present in the forward solution are displayed. - trans : str | dict | instance of Transform - If str, the path to the head<->MRI transform ``*-trans.fif`` file produced - during coregistration. Can also be ``'fsaverage'`` to use the built-in - fsaverage transformation. - alpha : float in [0, 1] - Alpha level to control opacity. - Default 1. - scale : None | float - The size of the arrow representing the dipoles in - :class:`mne.viz.Brain` units. Default 1.5mm. - - Notes - ----- - .. versionadded:: 1.0 - """ - head_mri_t = _get_trans(trans, "head", "mri", allow_none=False)[0] - del trans - if scale is None: - scale = 1.5 if self._units == "mm" else 1.5e-3 - error_msg = ( - 'Unexpected forward model coordinate frame {}, must be "head" or "mri"' - ) - if fwd["coord_frame"] in _frame_to_str: - fwd_frame = _frame_to_str[fwd["coord_frame"]] - if fwd_frame == "mri": - fwd_trans = Transform("mri", "mri") - elif fwd_frame == "head": - fwd_trans = head_mri_t - else: - raise RuntimeError(error_msg.format(fwd_frame)) - else: - raise RuntimeError(error_msg.format(fwd["coord_frame"])) - for actor in _plot_forward( - self._renderer, - fwd, - fwd_trans, - fwd_scale=1e3 if self._units == "mm" else 1, - scale=scale, - alpha=alpha, - ): - self._add_actor("forward", actor) - - self._renderer._update() - - def remove_forward(self): - """Remove forward sources from the rendered scene.""" - self._remove("forward", render=True) - - @fill_doc_static("trans_not_none", "alpha") - def add_dipole( - self, dipole, trans, colors="red", alpha=1, scales=None, *, mode="arrow" - ): - """Add a quiver to render positions of dipoles. - - Parameters - ---------- - dipole : instance of Dipole - Dipole object containing position, orientation and amplitude of - one or more dipoles or in the forward solution. - trans : str | dict | instance of Transform - If str, the path to the head<->MRI transform ``*-trans.fif`` file produced - during coregistration. Can also be ``'fsaverage'`` to use the built-in - fsaverage transformation. - colors : list | matplotlib-style color | None - A single color or list of anything matplotlib accepts: - string, RGB, hex, etc. Default red. - alpha : float in [0, 1] - Alpha level to control opacity. - Default 1. - scales : list | float | None - The size of the arrow representing the dipole in - :class:`mne.viz.Brain` units. Default 5mm. - mode : "2darrow" | "arrow" | "cone" | "cylinder" | "sphere" | "oct" - The drawing mode for the dipole to render. - Defaults to ``"arrow"``. - - Notes - ----- - .. versionadded:: 1.0 - """ - head_mri_t = _get_trans(trans, "head", "mri", allow_none=False)[0] - del trans - n_dipoles = len(dipole) - if not isinstance(colors, list | tuple): - colors = [colors] * n_dipoles # make into list - if len(colors) != n_dipoles: - raise ValueError( - f"The number of colors ({len(colors)}) " - f"and dipoles ({n_dipoles}) must match" - ) - colors = [ - _to_rgb(color, name=f"colors[{ci}]") for ci, color in enumerate(colors) - ] - if scales is None: - scales = 5 if self._units == "mm" else 5e-3 - if not isinstance(scales, list | tuple): - scales = [scales] * n_dipoles # make into list - if len(scales) != n_dipoles: - raise ValueError( - f"The number of scales ({len(scales)}) " - f"and dipoles ({n_dipoles}) must match" - ) - pos = apply_trans(head_mri_t, dipole.pos) - pos *= 1e3 if self._units == "mm" else 1 - for _ in self._iter_views("vol"): - for this_pos, this_ori, color, scale in zip( - pos, dipole.ori, colors, scales - ): - actor, _ = self._renderer.quiver3d( - *this_pos, - *this_ori, - color=color, - opacity=alpha, - mode=mode, - scale=scale, - ) - self._add_actor("dipole", actor) - - self._renderer._update() - - def remove_dipole(self): - """Remove dipole objects from the rendered scene.""" - self._remove("dipole", render=True) - - @fill_doc_static("color_matplotlib", "alpha") - def add_head(self, dense=True, color="gray", alpha=0.5): - """Add a mesh to render the outer head surface. - - Parameters - ---------- - dense : bool - Whether to plot the dense head (``seghead``) or the less dense head - (``head``). - color : color - A list of anything matplotlib accepts: string, RGB, hex, etc. - alpha : float in [0, 1] - Alpha level to control opacity. - - Notes - ----- - .. versionadded:: 0.24 - """ - # load head - surf = _get_head_surface( - "seghead" if dense else "head", self._subject, self._subjects_dir - ) - verts, triangles = surf["rr"], surf["tris"] - verts *= 1e3 if self._units == "mm" else 1 - color = _to_rgb(color) - - for _ in self._iter_views("vol"): - actor, _ = self._renderer.mesh( - *verts.T, - triangles=triangles, - color=color, - opacity=alpha, - render=False, - ) - self._add_actor("head", actor) - - self._renderer._update() - - def remove_head(self): - """Remove head objects from the rendered scene.""" - self._remove("head", render=True) - - @fill_doc_static("color_matplotlib", "alpha") - def add_skull(self, outer=True, color="gray", alpha=0.5): - """Add a mesh to render the skull surface. - - Parameters - ---------- - outer : bool - Adds the outer skull if ``True``, otherwise adds the inner skull. - color : color - A list of anything matplotlib accepts: string, RGB, hex, etc. - alpha : float in [0, 1] - Alpha level to control opacity. - - Notes - ----- - .. versionadded:: 0.24 - """ - surf = _get_skull_surface( - "outer" if outer else "inner", self._subject, self._subjects_dir - ) - verts, triangles = surf["rr"], surf["tris"] - verts *= 1e3 if self._units == "mm" else 1 - color = _to_rgb(color) - - for _ in self._iter_views("vol"): - actor, _ = self._renderer.mesh( - *verts.T, - triangles=triangles, - color=color, - opacity=alpha, - reset_camera=False, - render=False, - ) - self._add_actor("skull", actor) - - self._renderer._update() - - def remove_skull(self): - """Remove skull objects from the rendered scene.""" - self._remove("skull", render=True) - - @fill_doc_static("aseg", "labels_aseg", "alpha", "smooth") - def add_volume_labels( - self, - aseg="auto", - labels=None, - colors=None, - alpha=0.5, - smooth=0.9, - fill_hole_size=None, - legend=None, - ): - """Add labels to the rendering from an anatomical segmentation. - - Parameters - ---------- - aseg : str - The anatomical segmentation file. Default ``auto`` uses ``aparc+aseg`` - if available and ``wmparc`` if not. This may be any anatomical - segmentation file in the mri subdirectory of the FreeSurfer subject - directory. - - .. versionchanged:: 1.8 - Added support for the new default ``'auto'``. - labels : list of str | None - Labeled regions of interest to plot. See - :func:`mne.get_montage_volume_labels` for one way to determine regions of - interest. Regions can also be chosen from the :term:`FreeSurfer LUT`. If - ``None``, all labels that are defined in the segmentation file are used. - colors : list | matplotlib-style color | None - A list of anything matplotlib accepts: string, RGB, hex, etc. - (default :term:`FreeSurfer LUT` colors). - alpha : float in [0, 1] - Alpha level to control opacity. - smooth : float in [0, 1) - The smoothing factor to be applied. Default 0 is no smoothing. - fill_hole_size : int | None - The size of holes to remove in the mesh in voxels. Default is None, - no holes are removed. Warning, this dilates the boundaries of the - surface by ``fill_hole_size`` number of voxels so use the minimal - size. - legend : bool | None | dict - Add a legend displaying the names of the ``labels``. Default (None) - is ``True`` if the number of ``labels`` is 10 or fewer. - Can also be a dict of ``kwargs`` to pass to - ``pyvista.Plotter.add_legend``. - - Notes - ----- - .. versionadded:: 0.24 - """ - aseg, aseg_data = _get_aseg(aseg, self._subject, self._subjects_dir) - - vox_mri_t = aseg.header.get_vox2ras_tkr() - mult = 1e-3 if self._units == "m" else 1 - vox_mri_t[:3] *= mult - del aseg - - # read freesurfer lookup table - lut, fs_colors = read_freesurfer_lut() - if labels is None: # assign default ROI labels based on indices - lut_r = {v: k for k, v in lut.items()} - labels = [lut_r[idx] for idx in DEFAULTS["volume_label_indices"]] - - _validate_type(fill_hole_size, (int, None), "fill_hole_size") - _validate_type(legend, (bool, None, dict), "legend") - if legend is None: - legend = len(labels) < 11 - - if colors is None: - colors = [fs_colors[label] / 255 for label in labels] - elif not isinstance(colors, list | tuple): - colors = [colors] * len(labels) # make into list - colors = [ - _to_rgb(color, name=f"colors[{ci}]") for ci, color in enumerate(colors) - ] - surfs = _marching_cubes( - aseg_data, - [lut[label] for label in labels], - smooth=smooth, - fill_hole_size=fill_hole_size, - ) - for label, color, (verts, triangles) in zip(labels, colors, surfs): - if len(verts) == 0: # not in aseg vals - warn( - f"Value {lut[label]} not found for label " - f"{repr(label)} in anatomical segmentation file " - ) - continue - verts = apply_trans(vox_mri_t, verts) - for _ in self._iter_views("vol"): - actor, _ = self._renderer.mesh( - *verts.T, - triangles=triangles, - color=color, - opacity=alpha, - reset_camera=False, - render=False, - ) - self._add_actor("volume_labels", actor) - - if legend or isinstance(legend, dict): - # use empty kwargs for legend = True - legend = legend if isinstance(legend, dict) else dict() - self._renderer.plotter.add_legend(list(zip(labels, colors)), **legend) - - self._renderer._update() - - def remove_volume_labels(self): - """Remove the volume labels from the rendered scene.""" - self._remove("volume_labels", render=True) - self._renderer.plotter.remove_legend() - - @fill_doc_static("color_matplotlib", "alpha") - def add_foci( - self, - coords, - coords_as_verts=False, - map_surface=None, - scale_factor=1, - color="white", - alpha=1, - name=None, - hemi=None, - resolution=50, - ): - """Add spherical foci, possibly mapping to displayed surf. - - The foci spheres can be displayed at the coordinates given, or - mapped through a surface geometry. In other words, coordinates - from a volume-based analysis in MNI space can be displayed on an - inflated average surface by finding the closest vertex on the - white surface and mapping to that vertex on the inflated mesh. - - Parameters - ---------- - coords : ndarray, shape (n_coords, 3) - Coordinates in stereotaxic space (default) or array of - vertex ids (with ``coord_as_verts=True``). - coords_as_verts : bool - Whether the coords parameter should be interpreted as vertex ids. - map_surface : str | None - Surface to project the coordinates to, or None to use raw coords. - When set to a surface, each foci is positioned at the closest - vertex in the mesh. - scale_factor : float - Controls the size of the foci spheres (relative to 1cm). - color : color - A list of anything matplotlib accepts: string, RGB, hex, etc. - alpha : float in [0, 1] - Alpha level to control opacity. - Default is 1. - name : str - Internal name to use. - hemi : str | None - If None, it is assumed to belong to the hemisphere being - shown. If two hemispheres are being shown, an error will - be thrown. - resolution : int - The resolution of the spheres. - """ - from scipy.spatial.distance import cdist - - hemi = self._check_hemi(hemi, extras=["vol"]) - - # Figure out how to interpret the first parameter - if coords_as_verts: - coords = self.geo[hemi].coords[coords] - map_surface = None - - # Possibly map the foci coords through a surface - if map_surface is not None: - foci_surf = _Surface( - self._subject, - hemi, - map_surface, - self._subjects_dir, - offset=0, - units=self._units, - x_dir=self._rigid[0, :3], - ) - foci_surf.load_geometry() - foci_vtxs = np.argmin(cdist(foci_surf.coords, coords), axis=0) - coords = self.geo[hemi].coords[foci_vtxs] - - # Convert the color code - color = _to_rgb(color) - - if self._units == "m": - scale_factor = scale_factor / 1000.0 - - for _, _, v in self._iter_views(hemi): - self._renderer.sphere( - center=coords, - color=color, - scale=(10.0 * scale_factor), - opacity=alpha, - resolution=resolution, - ) - self._set_camera(**views_dicts[hemi][v]) - self._renderer._update() - - # Store the foci separately from overlay data - data_foci = coords - if "foci" in self._foci_data.get(hemi, {}): - data_foci = np.vstack((self._foci_data[hemi]["foci"], data_foci)) - self._foci_data.setdefault(hemi, {})["foci"] = data_foci - - @verbose_static( - "info_not_none", - "trans_not_none", - "meg", - "eeg", - "fnirs", - "ecog", - "seeg", - "dbs", - "max_dist_ieeg", - "sensor_colors", - "sensor_scales", - ) - def add_sensors( - self, - info, - trans, - meg=None, - eeg="original", - fnirs=True, - ecog=True, - seeg=True, - dbs=True, - max_dist=0.004, - *, - sensor_colors=None, - sensor_scales=None, - verbose=None, - ): - """Add mesh objects to represent sensor positions. - - Parameters - ---------- - info : mne.Info - The :class:`mne.Info` object with information about the - sensors and methods of measurement. - trans : str | dict | instance of Transform - If str, the path to the head<->MRI transform ``*-trans.fif`` file produced - during coregistration. Can also be ``'fsaverage'`` to use the built-in - fsaverage transformation. - meg : str | list | dict | bool | None - Can be "helmet", "sensors" or "ref" to show the MEG helmet, sensors or - reference sensors respectively, or a combination like ``('helmet', - 'sensors')`` (same as None, default). True translates to ``('helmet', - 'sensors', 'ref')``. Can also be a dict to specify alpha values, e.g. - ``{"helmet": 0.1, "sensors": 0.8}``. - - .. versionchanged:: 1.6 - Added support for specifying alpha values as a dict. - eeg : bool | str | list | dict - String options are: - - - "original" (default; equivalent to ``True``) - Shows EEG sensors using their digitized locations (after - transformation to the chosen ``coord_frame``) - - "projected" - The EEG locations projected onto the scalp, as is done in - forward modeling - - Can also be a list of these options, or a dict to specify the alpha values - to use, e.g. ``dict(original=0.2, projected=0.8)``. - - .. versionchanged:: 1.6 - Added support for specifying alpha values as a dict. - fnirs : str | list | dict | bool | None - Can be "channels", "pairs", "detectors", and/or "sources" to show the - fNIRS channel locations, optode locations, or line between - source-detector pairs, or a combination like ``('pairs', 'channels')``. - True translates to ``('pairs',)``. A dict can also be used to specify - alpha values (but only "channels" and "pairs" will be used), e.g. - ``dict(channels=0.2, pairs=0.7)``. - - .. versionchanged:: 1.6 - Added support for specifying alpha values as a dict. - ecog : bool - If True (default), show ECoG sensors. - seeg : bool - If True (default), show sEEG electrodes. - dbs : bool - If True (default), show DBS (deep brain stimulation) electrodes. - max_dist : float - The maximum distance to project a sensor to the pial surface in meters. - Sensors that are greater than this distance from the pial surface will - not be assigned locations. Projections can be done to the inflated or - flat brain. - sensor_colors : array-like of color | dict | None - Colors to use for the sensor glyphs. Can be None (default) to use default - colors. A dict should provide the colors (values) for each channel type - (keys), e.g.:: - - dict(eeg=eeg_colors) - - Where the value (``eeg_colors`` above) can be broadcast to an array of - colors with length that matches the number of channels of that type, i.e., - is compatible with :func:`matplotlib.colors.to_rgba_array`. A few examples - of this for the case above are the string ``"k"``, a list of ``n_eeg`` color - strings, or an NumPy ndarray of shape ``(n_eeg, 3)`` or ``(n_eeg, 4)``. - - .. versionadded:: 1.6 - sensor_scales : int | float | array-like | dict | None - Scale to use for the sensor glyphs. Can be None (default) to use default - scale. A dict should provide the Scale (values) for each channel type - (keys), e.g.:: - - dict(eeg=eeg_scales) - - Where the value (``eeg_scales`` above) can be broadcast to an array of - values with length that matches the number of channels of that type. A few - examples of this for the case above are the value ``10e-3``, a list of - ``n_eeg`` values, or an NumPy ndarray of shape ``(n_eeg,)``. - - .. versionadded:: 1.9 - verbose : bool | str | int | None - Control verbosity of the logging output. If ``None``, use the default - verbosity level. See the :ref:`logging documentation ` and - :func:`mne.verbose` for details. Should only be passed as a keyword - argument. - - Notes - ----- - .. versionadded:: 0.24 - """ - from ...preprocessing.ieeg._projection import _project_sensors_onto_inflated - - _validate_type(info, Info, "info") - meg, eeg, fnirs, warn_meg, sensor_alpha = _handle_sensor_types(meg, eeg, fnirs) - picks = pick_types( - info, - meg=("sensors" in meg), - ref_meg=("ref" in meg), - eeg=(len(eeg) > 0), - ecog=ecog, - seeg=seeg, - dbs=dbs, - fnirs=(len(fnirs) > 0), - ) - head_mri_t = _get_trans(trans, "head", "mri", allow_none=False)[0] - if self._surf in ("inflated", "flat"): - for modality, check in dict(seeg=seeg, ecog=ecog).items(): - if pick_types(info, **{modality: check}).size > 0: - info = _project_sensors_onto_inflated( - info.copy(), - head_mri_t, - subject=self._subject, - subjects_dir=self._subjects_dir, - picks=modality, - max_dist=max_dist, - flat=self._surf == "flat", - ) - del trans - # get transforms to "mri" window - to_cf_t = _get_transforms_to_coord_frame(info, head_mri_t, coord_frame="mri") - if pick_types(info, eeg=True, exclude=()).size > 0 and "projected" in eeg: - head_surf = _get_head_surface("seghead", self._subject, self._subjects_dir) - else: - head_surf = None - # Do the main plotting - for _ in self._iter_views("vol"): - if picks.size > 0: - sensors_actors = _plot_sensors_3d( - self._renderer, - info, - to_cf_t, - picks, - meg, - eeg, - fnirs, - warn_meg, - head_surf, - self._units, - sensor_alpha=sensor_alpha, - sensor_colors=sensor_colors, - sensor_scales=sensor_scales, - ) - # sensors_actors can still be None - for item, actors in (sensors_actors or {}).items(): - for actor in actors: - self._add_actor(item, actor) - - if "helmet" in meg and pick_types(info, meg=True).size > 0: - actor, _, _ = _plot_helmet( - self._renderer, - info, - to_cf_t, - head_mri_t, - "mri", - alpha=sensor_alpha["meg_helmet"], - scale=1 if self._units == "m" else 1e3, - ) - self._add_actor("helmet", actor) - - self._renderer._update() - - def remove_sensors(self, kind=None): - """Remove sensors from the rendered scene. - - Parameters - ---------- - kind : str | list | None - If None, removes all sensor-related data including the helmet. - Can be "meg", "eeg", "fnirs", "ecog", "seeg", "dbs" or "helmet" - to remove that item. - """ - all_kinds = ("meg", "eeg", "fnirs", "ecog", "seeg", "dbs", "helmet") - if kind is None: - for item in all_kinds: - self._remove(item, render=False) - else: - if isinstance(kind, str): - kind = [kind] - for this_kind in kind: - _check_option("kind", this_kind, all_kinds) - self._remove(this_kind, render=False) - self._renderer._update() - - def add_text( - self, - x, - y, - text, - name=None, - color=None, - opacity=1.0, - row=0, - col=0, - font_size=None, - justification=None, - font_file=None, - ): - """Add a text to the visualization. - - Parameters - ---------- - x : float - X coordinate. - y : float - Y coordinate. - text : str - Text to add. - name : str - Name of the text (text label can be updated using update_text()). - color : tuple - Color of the text. Default is the foreground color set during - initialization (default is black or white depending on the - background color). - opacity : float - Opacity of the text (default 1.0). - row : int | None - Row index of which brain to use. Default is the top row. - col : int | None - Column index of which brain to use. Default is the left-most - column. - font_size : float | None - The font size to use. - justification : str | None - The text justification. - font_file : str | None - Path to an absolute path of a font file to use for rendering - the text. See https://freetype.org/freetype2/docs/index.html for a list of - supported font file formats. - """ - _validate_type(name, (str, None), "name") - name = text if name is None else name - if "text" in self._actors and name in self._actors["text"]: - raise ValueError(f"Text with the name {name} already exists") - if color is None: - color = self._fg_color - for ri, ci, _ in self._iter_views("vol"): - if (row is None or row == ri) and (col is None or col == ci): - actor = self._renderer.text2d( - x_window=x, - y_window=y, - text=text, - color=color, - size=font_size, - justification=justification, - font_file=font_file, - ) - if "text" not in self._actors: - self._actors["text"] = dict() - self._actors["text"][name] = actor - - def remove_text(self, name=None): - """Remove text from the rendered scene. - - Parameters - ---------- - name : str | None - Remove specific text by name. If None, all text will be removed. - """ - _validate_type(name, (str, None), "name") - if name is None: - for actor in self._actors["text"].values(): - self._renderer.plotter.remove_actor(actor, render=False) - self._actors.pop("text") - else: - names = [None] - if "text" in self._actors: - names += list(self._actors["text"].keys()) - _check_option("name", name, names) - self._renderer.plotter.remove_actor( - self._actors["text"][name], render=False - ) - self._actors["text"].pop(name) - self._renderer._update() - @fill_doc_static("alpha") def add_annotation( self, @@ -3349,261 +2594,6 @@ def views(self): def hemis(self): return self._hemis - def _save_movie( - self, - filename, - time_dilation=4.0, - tmin=None, - tmax=None, - framerate=24, - interpolation=None, - codec=None, - bitrate=None, - callback=None, - time_viewer=False, - **kwargs, - ): - import imageio - - with self._renderer._disabled_interaction(): - images = self._make_movie_frames( - time_dilation, - tmin, - tmax, - framerate, - interpolation, - callback, - time_viewer, - ) - # find imageio FFMPEG parameters - if "fps" not in kwargs: - kwargs["fps"] = framerate - if codec is not None: - kwargs["codec"] = codec - if bitrate is not None: - kwargs["bitrate"] = bitrate - # when using GIF we need to convert FPS to duration in milliseconds for Pillow - if str(filename).endswith(".gif"): - kwargs["duration"] = 1000 * len(images) / kwargs.pop("fps") - imageio.mimwrite(filename, images, **kwargs) - - def _save_movie_tv( - self, - filename, - time_dilation=4.0, - tmin=None, - tmax=None, - framerate=24, - interpolation=None, - codec=None, - bitrate=None, - callback=None, - time_viewer=False, - **kwargs, - ): - def frame_callback(frame, n_frames): - if frame == n_frames: - # On the ImageIO step - self.status_msg.set_value(f"Saving with ImageIO: {filename}") - self.status_msg.show() - self.status_progress.hide() - self._renderer._status_bar_update() - else: - self.status_msg.set_value( - f"Rendering images (frame {frame + 1} / {n_frames}) ..." - ) - self.status_msg.show() - self.status_progress.show() - self.status_progress.set_range([0, n_frames - 1]) - self.status_progress.set_value(frame) - self.status_progress.update() - self.status_msg.update() - self._renderer._status_bar_update() - - # set cursor to busy - default_cursor = self._renderer._window_get_cursor() - self._renderer._window_set_cursor( - self._renderer._window_new_cursor("WaitCursor") - ) - - try: - self._save_movie( - filename, - time_dilation, - tmin, - tmax, - framerate, - interpolation, - codec, - bitrate, - frame_callback, - time_viewer, - **kwargs, - ) - except (Exception, KeyboardInterrupt): - warn("Movie saving aborted:\n" + traceback.format_exc()) - finally: - self._renderer._window_set_cursor(default_cursor) - - @fill_doc_static("interpolation_brain_time", "time_viewer_brain_screenshot") - def save_movie( - self, - filename=None, - time_dilation=4.0, - tmin=None, - tmax=None, - framerate=24, - interpolation=None, - codec=None, - bitrate=None, - callback=None, - time_viewer=False, - **kwargs, - ): - """Save a movie (for data with a time axis). - - The movie is created through the :mod:`imageio` module. The format is - determined by the extension, and additional options can be specified - through keyword arguments that depend on the format, see - :doc:`imageio's format page `. - - .. Warning:: - This method assumes that time is specified in seconds when adding - data. If time is specified in milliseconds this will result in - movies 1000 times longer than expected. - - Parameters - ---------- - filename : str - Path at which to save the movie. The extension determines the - format (e.g., ``'*.mov'``, ``'*.gif'``, ...; see the :mod:`imageio` - documentation for available formats). - time_dilation : float - Factor by which to stretch time (default 4). For example, an epoch - from -100 to 600 ms lasts 700 ms. With ``time_dilation=4`` this - would result in a 2.8 s long movie. - tmin : float - First time point to include (default: all data). - tmax : float - Last time point to include (default: all data). - framerate : float - Framerate of the movie (frames per second, default 24). - interpolation : str | None - Interpolation method (:class:`scipy.interpolate.interp1d` parameter). - Must be one of ``'linear'``, ``'nearest'``, ``'zero'``, ``'slinear'``, - ``'quadratic'`` or ``'cubic'``. - If None, it uses the current ``brain.interpolation``, - which defaults to ``'nearest'``. Defaults to None. - codec : str | None - The codec to use. - bitrate : float | None - The bitrate to use. - callback : callable | None - A function to call on each iteration. Useful for status message - updates. It will be passed keyword arguments ``frame`` and - ``n_frames``. - time_viewer : bool - If True, include time viewer traces. Only used if - ``time_viewer=True`` and ``separate_canvas=False``. - **kwargs : dict - Specify additional options for :mod:`imageio`. - """ - if filename is None: - filename = _generate_default_filename(".mp4") - func = self._save_movie_tv if self.time_viewer else self._save_movie - func( - filename, - time_dilation, - tmin, - tmax, - framerate, - interpolation, - codec, - bitrate, - callback, - time_viewer, - **kwargs, - ) - - def _make_movie_frames( - self, time_dilation, tmin, tmax, framerate, interpolation, callback, time_viewer - ): - from math import floor - - # find tmin - if tmin is None: - tmin = self._times[0] - elif tmin < self._times[0]: - raise ValueError( - f"tmin={repr(tmin)} is smaller than the first time point " - f"({repr(self._times[0])})" - ) - - # find indexes at which to create frames - if tmax is None: - tmax = self._times[-1] - elif tmax > self._times[-1]: - raise ValueError( - f"tmax={repr(tmax)} is greater than the latest time point " - f"({repr(self._times[-1])})" - ) - n_frames = floor((tmax - tmin) * time_dilation * framerate) - times = np.arange(n_frames, dtype=float) - times /= framerate * time_dilation - times += tmin - time_idx = np.interp(times, self._times, np.arange(self._n_times)) - - n_times = len(time_idx) - if n_times == 0: - raise ValueError("No time points selected") - - logger.debug(f"Save movie for time points/samples\n{times}\n{time_idx}") - # Sometimes the first screenshot is rendered with a different - # resolution on OS X - self.screenshot(time_viewer=time_viewer) - old_mode = self.time_interpolation - if interpolation is not None: - self.set_time_interpolation(interpolation) - try: - images = [ - self.screenshot(time_viewer=time_viewer) - for _ in self._iter_time(time_idx, callback) - ] - finally: - self.set_time_interpolation(old_mode) - if callback is not None: - callback(frame=len(time_idx), n_frames=len(time_idx)) - return images - - def _iter_time(self, time_idx, callback): - """Iterate through time points, then reset to current time. - - Parameters - ---------- - time_idx : array_like - Time point indexes through which to iterate. - callback : callable | None - Callback to call before yielding each frame. - - Yields - ------ - idx : int | float - Current index. - - Notes - ----- - Used by movie and image sequence saving functions. - """ - current_time_idx = self._data["time_idx"] - for ii, idx in enumerate(time_idx): - self.set_time_point(idx) - if callback is not None: - callback(frame=ii, n_frames=len(time_idx)) - yield idx - - # Restore original time index - self.set_time_point(current_time_idx) - def _check_stc(self, hemi, array, vertices): from ...source_estimate import ( _BaseMixedSourceEstimate, diff --git a/mne/viz/_brain/_movie.py b/mne/viz/_brain/_movie.py new file mode 100644 index 00000000000..930d7445766 --- /dev/null +++ b/mne/viz/_brain/_movie.py @@ -0,0 +1,269 @@ +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import traceback + +import numpy as np + +from ...utils import fill_doc_static, logger, warn +from ..utils import _generate_default_filename + + +class _MovieMixin: + """Mixin providing movie export for Brain.""" + + def _save_movie( + self, + filename, + time_dilation=4.0, + tmin=None, + tmax=None, + framerate=24, + interpolation=None, + codec=None, + bitrate=None, + callback=None, + time_viewer=False, + **kwargs, + ): + import imageio + + with self._renderer._disabled_interaction(): + images = self._make_movie_frames( + time_dilation, + tmin, + tmax, + framerate, + interpolation, + callback, + time_viewer, + ) + # find imageio FFMPEG parameters + if "fps" not in kwargs: + kwargs["fps"] = framerate + if codec is not None: + kwargs["codec"] = codec + if bitrate is not None: + kwargs["bitrate"] = bitrate + # when using GIF we need to convert FPS to duration in milliseconds for Pillow + if str(filename).endswith(".gif"): + kwargs["duration"] = 1000 * len(images) / kwargs.pop("fps") + imageio.mimwrite(filename, images, **kwargs) + + def _save_movie_tv( + self, + filename, + time_dilation=4.0, + tmin=None, + tmax=None, + framerate=24, + interpolation=None, + codec=None, + bitrate=None, + callback=None, + time_viewer=False, + **kwargs, + ): + def frame_callback(frame, n_frames): + if frame == n_frames: + # On the ImageIO step + self.status_msg.set_value(f"Saving with ImageIO: {filename}") + self.status_msg.show() + self.status_progress.hide() + self._renderer._status_bar_update() + else: + self.status_msg.set_value( + f"Rendering images (frame {frame + 1} / {n_frames}) ..." + ) + self.status_msg.show() + self.status_progress.show() + self.status_progress.set_range([0, n_frames - 1]) + self.status_progress.set_value(frame) + self.status_progress.update() + self.status_msg.update() + self._renderer._status_bar_update() + + # set cursor to busy + default_cursor = self._renderer._window_get_cursor() + self._renderer._window_set_cursor( + self._renderer._window_new_cursor("WaitCursor") + ) + + try: + self._save_movie( + filename, + time_dilation, + tmin, + tmax, + framerate, + interpolation, + codec, + bitrate, + frame_callback, + time_viewer, + **kwargs, + ) + except (Exception, KeyboardInterrupt): + warn("Movie saving aborted:\n" + traceback.format_exc()) + finally: + self._renderer._window_set_cursor(default_cursor) + + @fill_doc_static("interpolation_brain_time", "time_viewer_brain_screenshot") + def save_movie( + self, + filename=None, + time_dilation=4.0, + tmin=None, + tmax=None, + framerate=24, + interpolation=None, + codec=None, + bitrate=None, + callback=None, + time_viewer=False, + **kwargs, + ): + """Save a movie (for data with a time axis). + + The movie is created through the :mod:`imageio` module. The format is + determined by the extension, and additional options can be specified + through keyword arguments that depend on the format, see + :doc:`imageio's format page `. + + .. Warning:: + This method assumes that time is specified in seconds when adding + data. If time is specified in milliseconds this will result in + movies 1000 times longer than expected. + + Parameters + ---------- + filename : str + Path at which to save the movie. The extension determines the + format (e.g., ``'*.mov'``, ``'*.gif'``, ...; see the :mod:`imageio` + documentation for available formats). + time_dilation : float + Factor by which to stretch time (default 4). For example, an epoch + from -100 to 600 ms lasts 700 ms. With ``time_dilation=4`` this + would result in a 2.8 s long movie. + tmin : float + First time point to include (default: all data). + tmax : float + Last time point to include (default: all data). + framerate : float + Framerate of the movie (frames per second, default 24). + interpolation : str | None + Interpolation method (:class:`scipy.interpolate.interp1d` parameter). + Must be one of ``'linear'``, ``'nearest'``, ``'zero'``, ``'slinear'``, + ``'quadratic'`` or ``'cubic'``. + If None, it uses the current ``brain.interpolation``, + which defaults to ``'nearest'``. Defaults to None. + codec : str | None + The codec to use. + bitrate : float | None + The bitrate to use. + callback : callable | None + A function to call on each iteration. Useful for status message + updates. It will be passed keyword arguments ``frame`` and + ``n_frames``. + time_viewer : bool + If True, include time viewer traces. Only used if + ``time_viewer=True`` and ``separate_canvas=False``. + **kwargs : dict + Specify additional options for :mod:`imageio`. + """ + if filename is None: + filename = _generate_default_filename(".mp4") + func = self._save_movie_tv if self.time_viewer else self._save_movie + func( + filename, + time_dilation, + tmin, + tmax, + framerate, + interpolation, + codec, + bitrate, + callback, + time_viewer, + **kwargs, + ) + + def _make_movie_frames( + self, time_dilation, tmin, tmax, framerate, interpolation, callback, time_viewer + ): + from math import floor + + # find tmin + if tmin is None: + tmin = self._times[0] + elif tmin < self._times[0]: + raise ValueError( + f"tmin={repr(tmin)} is smaller than the first time point " + f"({repr(self._times[0])})" + ) + + # find indexes at which to create frames + if tmax is None: + tmax = self._times[-1] + elif tmax > self._times[-1]: + raise ValueError( + f"tmax={repr(tmax)} is greater than the latest time point " + f"({repr(self._times[-1])})" + ) + n_frames = floor((tmax - tmin) * time_dilation * framerate) + times = np.arange(n_frames, dtype=float) + times /= framerate * time_dilation + times += tmin + time_idx = np.interp(times, self._times, np.arange(self._n_times)) + + n_times = len(time_idx) + if n_times == 0: + raise ValueError("No time points selected") + + logger.debug(f"Save movie for time points/samples\n{times}\n{time_idx}") + # Sometimes the first screenshot is rendered with a different + # resolution on OS X + self.screenshot(time_viewer=time_viewer) + old_mode = self.time_interpolation + if interpolation is not None: + self.set_time_interpolation(interpolation) + try: + images = [ + self.screenshot(time_viewer=time_viewer) + for _ in self._iter_time(time_idx, callback) + ] + finally: + self.set_time_interpolation(old_mode) + if callback is not None: + callback(frame=len(time_idx), n_frames=len(time_idx)) + return images + + def _iter_time(self, time_idx, callback): + """Iterate through time points, then reset to current time. + + Parameters + ---------- + time_idx : array_like + Time point indexes through which to iterate. + callback : callable | None + Callback to call before yielding each frame. + + Yields + ------ + idx : int | float + Current index. + + Notes + ----- + Used by movie and image sequence saving functions. + """ + current_time_idx = self._data["time_idx"] + for ii, idx in enumerate(time_idx): + self.set_time_point(idx) + if callback is not None: + callback(frame=ii, n_frames=len(time_idx)) + yield idx + + # Restore original time index + self.set_time_point(current_time_idx) diff --git a/mne/viz/_brain/_scene.py b/mne/viz/_brain/_scene.py new file mode 100644 index 00000000000..75ca73799b8 --- /dev/null +++ b/mne/viz/_brain/_scene.py @@ -0,0 +1,767 @@ +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import numpy as np + +from ..._fiff.meas_info import Info +from ..._fiff.pick import pick_types +from ..._freesurfer import ( + _get_aseg, + _get_head_surface, + _get_skull_surface, + read_freesurfer_lut, +) +from ...defaults import DEFAULTS +from ...surface import _marching_cubes +from ...transforms import ( + Transform, + _frame_to_str, + _get_trans, + _get_transforms_to_coord_frame, + apply_trans, +) +from ...utils import ( + _check_option, + _to_rgb, + _validate_type, + fill_doc_static, + verbose_static, + warn, +) +from .._3d import _handle_sensor_types, _plot_forward, _plot_helmet, _plot_sensors_3d +from .surface import _Surface +from .view import views_dicts + + +class _SceneMixin: + """Mixin providing the scene objects (sensors, head, foci, ...) of Brain.""" + + @fill_doc_static("fwd", "trans_not_none", "alpha") + def add_forward(self, fwd, trans, alpha=1, scale=None): + """Add a quiver to render positions of dipoles. + + Parameters + ---------- + fwd : instance of Forward + The forward solution. If present, the orientations of the dipoles + present in the forward solution are displayed. + trans : str | dict | instance of Transform + If str, the path to the head<->MRI transform ``*-trans.fif`` file produced + during coregistration. Can also be ``'fsaverage'`` to use the built-in + fsaverage transformation. + alpha : float in [0, 1] + Alpha level to control opacity. + Default 1. + scale : None | float + The size of the arrow representing the dipoles in + :class:`mne.viz.Brain` units. Default 1.5mm. + + Notes + ----- + .. versionadded:: 1.0 + """ + head_mri_t = _get_trans(trans, "head", "mri", allow_none=False)[0] + del trans + if scale is None: + scale = 1.5 if self._units == "mm" else 1.5e-3 + error_msg = ( + 'Unexpected forward model coordinate frame {}, must be "head" or "mri"' + ) + if fwd["coord_frame"] in _frame_to_str: + fwd_frame = _frame_to_str[fwd["coord_frame"]] + if fwd_frame == "mri": + fwd_trans = Transform("mri", "mri") + elif fwd_frame == "head": + fwd_trans = head_mri_t + else: + raise RuntimeError(error_msg.format(fwd_frame)) + else: + raise RuntimeError(error_msg.format(fwd["coord_frame"])) + for actor in _plot_forward( + self._renderer, + fwd, + fwd_trans, + fwd_scale=1e3 if self._units == "mm" else 1, + scale=scale, + alpha=alpha, + ): + self._add_actor("forward", actor) + + self._renderer._update() + + def remove_forward(self): + """Remove forward sources from the rendered scene.""" + self._remove("forward", render=True) + + @fill_doc_static("trans_not_none", "alpha") + def add_dipole( + self, dipole, trans, colors="red", alpha=1, scales=None, *, mode="arrow" + ): + """Add a quiver to render positions of dipoles. + + Parameters + ---------- + dipole : instance of Dipole + Dipole object containing position, orientation and amplitude of + one or more dipoles or in the forward solution. + trans : str | dict | instance of Transform + If str, the path to the head<->MRI transform ``*-trans.fif`` file produced + during coregistration. Can also be ``'fsaverage'`` to use the built-in + fsaverage transformation. + colors : list | matplotlib-style color | None + A single color or list of anything matplotlib accepts: + string, RGB, hex, etc. Default red. + alpha : float in [0, 1] + Alpha level to control opacity. + Default 1. + scales : list | float | None + The size of the arrow representing the dipole in + :class:`mne.viz.Brain` units. Default 5mm. + mode : "2darrow" | "arrow" | "cone" | "cylinder" | "sphere" | "oct" + The drawing mode for the dipole to render. + Defaults to ``"arrow"``. + + Notes + ----- + .. versionadded:: 1.0 + """ + head_mri_t = _get_trans(trans, "head", "mri", allow_none=False)[0] + del trans + n_dipoles = len(dipole) + if not isinstance(colors, list | tuple): + colors = [colors] * n_dipoles # make into list + if len(colors) != n_dipoles: + raise ValueError( + f"The number of colors ({len(colors)}) " + f"and dipoles ({n_dipoles}) must match" + ) + colors = [ + _to_rgb(color, name=f"colors[{ci}]") for ci, color in enumerate(colors) + ] + if scales is None: + scales = 5 if self._units == "mm" else 5e-3 + if not isinstance(scales, list | tuple): + scales = [scales] * n_dipoles # make into list + if len(scales) != n_dipoles: + raise ValueError( + f"The number of scales ({len(scales)}) " + f"and dipoles ({n_dipoles}) must match" + ) + pos = apply_trans(head_mri_t, dipole.pos) + pos *= 1e3 if self._units == "mm" else 1 + for _ in self._iter_views("vol"): + for this_pos, this_ori, color, scale in zip( + pos, dipole.ori, colors, scales + ): + actor, _ = self._renderer.quiver3d( + *this_pos, + *this_ori, + color=color, + opacity=alpha, + mode=mode, + scale=scale, + ) + self._add_actor("dipole", actor) + + self._renderer._update() + + def remove_dipole(self): + """Remove dipole objects from the rendered scene.""" + self._remove("dipole", render=True) + + @fill_doc_static("color_matplotlib", "alpha") + def add_head(self, dense=True, color="gray", alpha=0.5): + """Add a mesh to render the outer head surface. + + Parameters + ---------- + dense : bool + Whether to plot the dense head (``seghead``) or the less dense head + (``head``). + color : color + A list of anything matplotlib accepts: string, RGB, hex, etc. + alpha : float in [0, 1] + Alpha level to control opacity. + + Notes + ----- + .. versionadded:: 0.24 + """ + # load head + surf = _get_head_surface( + "seghead" if dense else "head", self._subject, self._subjects_dir + ) + verts, triangles = surf["rr"], surf["tris"] + verts *= 1e3 if self._units == "mm" else 1 + color = _to_rgb(color) + + for _ in self._iter_views("vol"): + actor, _ = self._renderer.mesh( + *verts.T, + triangles=triangles, + color=color, + opacity=alpha, + render=False, + ) + self._add_actor("head", actor) + + self._renderer._update() + + def remove_head(self): + """Remove head objects from the rendered scene.""" + self._remove("head", render=True) + + @fill_doc_static("color_matplotlib", "alpha") + def add_skull(self, outer=True, color="gray", alpha=0.5): + """Add a mesh to render the skull surface. + + Parameters + ---------- + outer : bool + Adds the outer skull if ``True``, otherwise adds the inner skull. + color : color + A list of anything matplotlib accepts: string, RGB, hex, etc. + alpha : float in [0, 1] + Alpha level to control opacity. + + Notes + ----- + .. versionadded:: 0.24 + """ + surf = _get_skull_surface( + "outer" if outer else "inner", self._subject, self._subjects_dir + ) + verts, triangles = surf["rr"], surf["tris"] + verts *= 1e3 if self._units == "mm" else 1 + color = _to_rgb(color) + + for _ in self._iter_views("vol"): + actor, _ = self._renderer.mesh( + *verts.T, + triangles=triangles, + color=color, + opacity=alpha, + reset_camera=False, + render=False, + ) + self._add_actor("skull", actor) + + self._renderer._update() + + def remove_skull(self): + """Remove skull objects from the rendered scene.""" + self._remove("skull", render=True) + + @fill_doc_static("aseg", "labels_aseg", "alpha", "smooth") + def add_volume_labels( + self, + aseg="auto", + labels=None, + colors=None, + alpha=0.5, + smooth=0.9, + fill_hole_size=None, + legend=None, + ): + """Add labels to the rendering from an anatomical segmentation. + + Parameters + ---------- + aseg : str + The anatomical segmentation file. Default ``auto`` uses ``aparc+aseg`` + if available and ``wmparc`` if not. This may be any anatomical + segmentation file in the mri subdirectory of the FreeSurfer subject + directory. + + .. versionchanged:: 1.8 + Added support for the new default ``'auto'``. + labels : list of str | None + Labeled regions of interest to plot. See + :func:`mne.get_montage_volume_labels` for one way to determine regions of + interest. Regions can also be chosen from the :term:`FreeSurfer LUT`. If + ``None``, all labels that are defined in the segmentation file are used. + colors : list | matplotlib-style color | None + A list of anything matplotlib accepts: string, RGB, hex, etc. + (default :term:`FreeSurfer LUT` colors). + alpha : float in [0, 1] + Alpha level to control opacity. + smooth : float in [0, 1) + The smoothing factor to be applied. Default 0 is no smoothing. + fill_hole_size : int | None + The size of holes to remove in the mesh in voxels. Default is None, + no holes are removed. Warning, this dilates the boundaries of the + surface by ``fill_hole_size`` number of voxels so use the minimal + size. + legend : bool | None | dict + Add a legend displaying the names of the ``labels``. Default (None) + is ``True`` if the number of ``labels`` is 10 or fewer. + Can also be a dict of ``kwargs`` to pass to + ``pyvista.Plotter.add_legend``. + + Notes + ----- + .. versionadded:: 0.24 + """ + aseg, aseg_data = _get_aseg(aseg, self._subject, self._subjects_dir) + + vox_mri_t = aseg.header.get_vox2ras_tkr() + mult = 1e-3 if self._units == "m" else 1 + vox_mri_t[:3] *= mult + del aseg + + # read freesurfer lookup table + lut, fs_colors = read_freesurfer_lut() + if labels is None: # assign default ROI labels based on indices + lut_r = {v: k for k, v in lut.items()} + labels = [lut_r[idx] for idx in DEFAULTS["volume_label_indices"]] + + _validate_type(fill_hole_size, (int, None), "fill_hole_size") + _validate_type(legend, (bool, None, dict), "legend") + if legend is None: + legend = len(labels) < 11 + + if colors is None: + colors = [fs_colors[label] / 255 for label in labels] + elif not isinstance(colors, list | tuple): + colors = [colors] * len(labels) # make into list + colors = [ + _to_rgb(color, name=f"colors[{ci}]") for ci, color in enumerate(colors) + ] + surfs = _marching_cubes( + aseg_data, + [lut[label] for label in labels], + smooth=smooth, + fill_hole_size=fill_hole_size, + ) + for label, color, (verts, triangles) in zip(labels, colors, surfs): + if len(verts) == 0: # not in aseg vals + warn( + f"Value {lut[label]} not found for label " + f"{repr(label)} in anatomical segmentation file " + ) + continue + verts = apply_trans(vox_mri_t, verts) + for _ in self._iter_views("vol"): + actor, _ = self._renderer.mesh( + *verts.T, + triangles=triangles, + color=color, + opacity=alpha, + reset_camera=False, + render=False, + ) + self._add_actor("volume_labels", actor) + + if legend or isinstance(legend, dict): + # use empty kwargs for legend = True + legend = legend if isinstance(legend, dict) else dict() + self._renderer.plotter.add_legend(list(zip(labels, colors)), **legend) + + self._renderer._update() + + def remove_volume_labels(self): + """Remove the volume labels from the rendered scene.""" + self._remove("volume_labels", render=True) + self._renderer.plotter.remove_legend() + + @fill_doc_static("color_matplotlib", "alpha") + def add_foci( + self, + coords, + coords_as_verts=False, + map_surface=None, + scale_factor=1, + color="white", + alpha=1, + name=None, + hemi=None, + resolution=50, + ): + """Add spherical foci, possibly mapping to displayed surf. + + The foci spheres can be displayed at the coordinates given, or + mapped through a surface geometry. In other words, coordinates + from a volume-based analysis in MNI space can be displayed on an + inflated average surface by finding the closest vertex on the + white surface and mapping to that vertex on the inflated mesh. + + Parameters + ---------- + coords : ndarray, shape (n_coords, 3) + Coordinates in stereotaxic space (default) or array of + vertex ids (with ``coord_as_verts=True``). + coords_as_verts : bool + Whether the coords parameter should be interpreted as vertex ids. + map_surface : str | None + Surface to project the coordinates to, or None to use raw coords. + When set to a surface, each foci is positioned at the closest + vertex in the mesh. + scale_factor : float + Controls the size of the foci spheres (relative to 1cm). + color : color + A list of anything matplotlib accepts: string, RGB, hex, etc. + alpha : float in [0, 1] + Alpha level to control opacity. + Default is 1. + name : str + Internal name to use. + hemi : str | None + If None, it is assumed to belong to the hemisphere being + shown. If two hemispheres are being shown, an error will + be thrown. + resolution : int + The resolution of the spheres. + """ + from scipy.spatial.distance import cdist + + hemi = self._check_hemi(hemi, extras=["vol"]) + + # Figure out how to interpret the first parameter + if coords_as_verts: + coords = self.geo[hemi].coords[coords] + map_surface = None + + # Possibly map the foci coords through a surface + if map_surface is not None: + foci_surf = _Surface( + self._subject, + hemi, + map_surface, + self._subjects_dir, + offset=0, + units=self._units, + x_dir=self._rigid[0, :3], + ) + foci_surf.load_geometry() + foci_vtxs = np.argmin(cdist(foci_surf.coords, coords), axis=0) + coords = self.geo[hemi].coords[foci_vtxs] + + # Convert the color code + color = _to_rgb(color) + + if self._units == "m": + scale_factor = scale_factor / 1000.0 + + for _, _, v in self._iter_views(hemi): + self._renderer.sphere( + center=coords, + color=color, + scale=(10.0 * scale_factor), + opacity=alpha, + resolution=resolution, + ) + self._set_camera(**views_dicts[hemi][v]) + self._renderer._update() + + # Store the foci separately from overlay data + data_foci = coords + if "foci" in self._foci_data.get(hemi, {}): + data_foci = np.vstack((self._foci_data[hemi]["foci"], data_foci)) + self._foci_data.setdefault(hemi, {})["foci"] = data_foci + + @verbose_static( + "info_not_none", + "trans_not_none", + "meg", + "eeg", + "fnirs", + "ecog", + "seeg", + "dbs", + "max_dist_ieeg", + "sensor_colors", + "sensor_scales", + ) + def add_sensors( + self, + info, + trans, + meg=None, + eeg="original", + fnirs=True, + ecog=True, + seeg=True, + dbs=True, + max_dist=0.004, + *, + sensor_colors=None, + sensor_scales=None, + verbose=None, + ): + """Add mesh objects to represent sensor positions. + + Parameters + ---------- + info : mne.Info + The :class:`mne.Info` object with information about the + sensors and methods of measurement. + trans : str | dict | instance of Transform + If str, the path to the head<->MRI transform ``*-trans.fif`` file produced + during coregistration. Can also be ``'fsaverage'`` to use the built-in + fsaverage transformation. + meg : str | list | dict | bool | None + Can be "helmet", "sensors" or "ref" to show the MEG helmet, sensors or + reference sensors respectively, or a combination like ``('helmet', + 'sensors')`` (same as None, default). True translates to ``('helmet', + 'sensors', 'ref')``. Can also be a dict to specify alpha values, e.g. + ``{"helmet": 0.1, "sensors": 0.8}``. + + .. versionchanged:: 1.6 + Added support for specifying alpha values as a dict. + eeg : bool | str | list | dict + String options are: + + - "original" (default; equivalent to ``True``) + Shows EEG sensors using their digitized locations (after + transformation to the chosen ``coord_frame``) + - "projected" + The EEG locations projected onto the scalp, as is done in + forward modeling + + Can also be a list of these options, or a dict to specify the alpha values + to use, e.g. ``dict(original=0.2, projected=0.8)``. + + .. versionchanged:: 1.6 + Added support for specifying alpha values as a dict. + fnirs : str | list | dict | bool | None + Can be "channels", "pairs", "detectors", and/or "sources" to show the + fNIRS channel locations, optode locations, or line between + source-detector pairs, or a combination like ``('pairs', 'channels')``. + True translates to ``('pairs',)``. A dict can also be used to specify + alpha values (but only "channels" and "pairs" will be used), e.g. + ``dict(channels=0.2, pairs=0.7)``. + + .. versionchanged:: 1.6 + Added support for specifying alpha values as a dict. + ecog : bool + If True (default), show ECoG sensors. + seeg : bool + If True (default), show sEEG electrodes. + dbs : bool + If True (default), show DBS (deep brain stimulation) electrodes. + max_dist : float + The maximum distance to project a sensor to the pial surface in meters. + Sensors that are greater than this distance from the pial surface will + not be assigned locations. Projections can be done to the inflated or + flat brain. + sensor_colors : array-like of color | dict | None + Colors to use for the sensor glyphs. Can be None (default) to use default + colors. A dict should provide the colors (values) for each channel type + (keys), e.g.:: + + dict(eeg=eeg_colors) + + Where the value (``eeg_colors`` above) can be broadcast to an array of + colors with length that matches the number of channels of that type, i.e., + is compatible with :func:`matplotlib.colors.to_rgba_array`. A few examples + of this for the case above are the string ``"k"``, a list of ``n_eeg`` color + strings, or an NumPy ndarray of shape ``(n_eeg, 3)`` or ``(n_eeg, 4)``. + + .. versionadded:: 1.6 + sensor_scales : int | float | array-like | dict | None + Scale to use for the sensor glyphs. Can be None (default) to use default + scale. A dict should provide the Scale (values) for each channel type + (keys), e.g.:: + + dict(eeg=eeg_scales) + + Where the value (``eeg_scales`` above) can be broadcast to an array of + values with length that matches the number of channels of that type. A few + examples of this for the case above are the value ``10e-3``, a list of + ``n_eeg`` values, or an NumPy ndarray of shape ``(n_eeg,)``. + + .. versionadded:: 1.9 + verbose : bool | str | int | None + Control verbosity of the logging output. If ``None``, use the default + verbosity level. See the :ref:`logging documentation ` and + :func:`mne.verbose` for details. Should only be passed as a keyword + argument. + + Notes + ----- + .. versionadded:: 0.24 + """ + from ...preprocessing.ieeg._projection import _project_sensors_onto_inflated + + _validate_type(info, Info, "info") + meg, eeg, fnirs, warn_meg, sensor_alpha = _handle_sensor_types(meg, eeg, fnirs) + picks = pick_types( + info, + meg=("sensors" in meg), + ref_meg=("ref" in meg), + eeg=(len(eeg) > 0), + ecog=ecog, + seeg=seeg, + dbs=dbs, + fnirs=(len(fnirs) > 0), + ) + head_mri_t = _get_trans(trans, "head", "mri", allow_none=False)[0] + if self._surf in ("inflated", "flat"): + for modality, check in dict(seeg=seeg, ecog=ecog).items(): + if pick_types(info, **{modality: check}).size > 0: + info = _project_sensors_onto_inflated( + info.copy(), + head_mri_t, + subject=self._subject, + subjects_dir=self._subjects_dir, + picks=modality, + max_dist=max_dist, + flat=self._surf == "flat", + ) + del trans + # get transforms to "mri" window + to_cf_t = _get_transforms_to_coord_frame(info, head_mri_t, coord_frame="mri") + if pick_types(info, eeg=True, exclude=()).size > 0 and "projected" in eeg: + head_surf = _get_head_surface("seghead", self._subject, self._subjects_dir) + else: + head_surf = None + # Do the main plotting + for _ in self._iter_views("vol"): + if picks.size > 0: + sensors_actors = _plot_sensors_3d( + self._renderer, + info, + to_cf_t, + picks, + meg, + eeg, + fnirs, + warn_meg, + head_surf, + self._units, + sensor_alpha=sensor_alpha, + sensor_colors=sensor_colors, + sensor_scales=sensor_scales, + ) + # sensors_actors can still be None + for item, actors in (sensors_actors or {}).items(): + for actor in actors: + self._add_actor(item, actor) + + if "helmet" in meg and pick_types(info, meg=True).size > 0: + actor, _, _ = _plot_helmet( + self._renderer, + info, + to_cf_t, + head_mri_t, + "mri", + alpha=sensor_alpha["meg_helmet"], + scale=1 if self._units == "m" else 1e3, + ) + self._add_actor("helmet", actor) + + self._renderer._update() + + def remove_sensors(self, kind=None): + """Remove sensors from the rendered scene. + + Parameters + ---------- + kind : str | list | None + If None, removes all sensor-related data including the helmet. + Can be "meg", "eeg", "fnirs", "ecog", "seeg", "dbs" or "helmet" + to remove that item. + """ + all_kinds = ("meg", "eeg", "fnirs", "ecog", "seeg", "dbs", "helmet") + if kind is None: + for item in all_kinds: + self._remove(item, render=False) + else: + if isinstance(kind, str): + kind = [kind] + for this_kind in kind: + _check_option("kind", this_kind, all_kinds) + self._remove(this_kind, render=False) + self._renderer._update() + + def add_text( + self, + x, + y, + text, + name=None, + color=None, + opacity=1.0, + row=0, + col=0, + font_size=None, + justification=None, + font_file=None, + ): + """Add a text to the visualization. + + Parameters + ---------- + x : float + X coordinate. + y : float + Y coordinate. + text : str + Text to add. + name : str + Name of the text (text label can be updated using update_text()). + color : tuple + Color of the text. Default is the foreground color set during + initialization (default is black or white depending on the + background color). + opacity : float + Opacity of the text (default 1.0). + row : int | None + Row index of which brain to use. Default is the top row. + col : int | None + Column index of which brain to use. Default is the left-most + column. + font_size : float | None + The font size to use. + justification : str | None + The text justification. + font_file : str | None + Path to an absolute path of a font file to use for rendering + the text. See https://freetype.org/freetype2/docs/index.html for a list of + supported font file formats. + """ + _validate_type(name, (str, None), "name") + name = text if name is None else name + if "text" in self._actors and name in self._actors["text"]: + raise ValueError(f"Text with the name {name} already exists") + if color is None: + color = self._fg_color + for ri, ci, _ in self._iter_views("vol"): + if (row is None or row == ri) and (col is None or col == ci): + actor = self._renderer.text2d( + x_window=x, + y_window=y, + text=text, + color=color, + size=font_size, + justification=justification, + font_file=font_file, + ) + if "text" not in self._actors: + self._actors["text"] = dict() + self._actors["text"][name] = actor + + def remove_text(self, name=None): + """Remove text from the rendered scene. + + Parameters + ---------- + name : str | None + Remove specific text by name. If None, all text will be removed. + """ + _validate_type(name, (str, None), "name") + if name is None: + for actor in self._actors["text"].values(): + self._renderer.plotter.remove_actor(actor, render=False) + self._actors.pop("text") + else: + names = [None] + if "text" in self._actors: + names += list(self._actors["text"].keys()) + _check_option("name", name, names) + self._renderer.plotter.remove_actor( + self._actors["text"][name], render=False + ) + self._actors["text"].pop(name) + self._renderer._update()