diff --git a/.circleci/config.yml b/.circleci/config.yml index 942013cd848..46b55f2ea3c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -16,6 +16,7 @@ jobs: echo "export OPENBLAS_NUM_THREADS=4" >> $BASH_ENV export MNE_ROOT=${PWD}/minimal_cmds echo "export MNE_ROOT=${PWD}/minimal_cmds" >> $BASH_ENV + echo "export MNE_3D_BACKEND=pyvista" >> $BASH_ENV echo "export PATH=~/.local/bin/:${MNE_ROOT}/bin:$PATH" >> $BASH_ENV curl https://staff.washington.edu/larsoner/minimal_cmds.tar.gz | tar xz echo "export LD_LIBRARY_PATH=${MNE_ROOT}/lib:$LD_LIBRARY_PATH" >> $BASH_ENV diff --git a/environment.yml b/environment.yml index 906c93f2c9f..27d62344d10 100644 --- a/environment.yml +++ b/environment.yml @@ -34,7 +34,7 @@ dependencies: - pip: - mne - vtk - - pyvista>=0.21.3 + - https://github.com/pyvista/pyvista/zipball/master - mayavi - PySurfer[save_movie] - dipy --only-binary dipy diff --git a/examples/visualization/plot_xhemi.py b/examples/visualization/plot_xhemi.py index 6d6ce1bec52..4f1668b733e 100644 --- a/examples/visualization/plot_xhemi.py +++ b/examples/visualization/plot_xhemi.py @@ -18,7 +18,6 @@ data_dir = mne.datasets.sample.data_path() subjects_dir = data_dir + '/subjects' stc_path = data_dir + '/MEG/sample/sample_audvis-meg-eeg' - stc = mne.read_source_estimate(stc_path, 'sample') # First, morph the data to fsaverage_sym, for which we have left_right diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index 8739003bb54..dad80dda4c8 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -390,7 +390,7 @@ def plot_evoked_field(evoked, surf_maps, time=None, time_label='t = %0.0f ms', if '%' in time_label: time_label %= (1e3 * evoked.times[time_idx]) - renderer.text2d(x=0.01, y=0.01, text=time_label, width=0.4) + renderer.text2d(x=0.01, y=0.01, text=time_label) renderer.set_camera(azimuth=10, elevation=60) renderer.show() return renderer.scene() @@ -1639,6 +1639,7 @@ def plot_source_estimates(stc, subject=None, surface='inflated', hemi='lh', An instance of :class:`surfer.Brain` from PySurfer or matplotlib figure. """ # noqa: E501 + from .backends.renderer import get_3d_backend # import here to avoid circular import problem from ..source_estimate import SourceEstimate _validate_type(stc, SourceEstimate, "stc", "Surface Source Estimate") @@ -1666,7 +1667,10 @@ def plot_source_estimates(stc, subject=None, surface='inflated', hemi='lh', time_unit=time_unit, background=background, spacing=spacing, time_viewer=time_viewer, colorbar=colorbar, transparent=transparent) - from surfer import Brain, TimeViewer + if get_3d_backend() == "mayavi": + from surfer import Brain, TimeViewer + else: + from ._brain import _Brain as Brain _check_option('hemi', hemi, ['lh', 'rh', 'split', 'both']) time_label, times = _handle_time(time_label, time_unit, stc.times) @@ -1687,24 +1691,32 @@ def plot_source_estimates(stc, subject=None, surface='inflated', hemi='lh', background=background, foreground=foreground, figure=figure, subjects_dir=subjects_dir, views=views) - - ad_kwargs, sd_kwargs = _get_ps_kwargs( - initial_time, diverging, scale_pts[1], transparent) - del initial_time, transparent + center = 0. if diverging else None for hemi in hemis: hemi_idx = 0 if hemi == 'lh' else 1 data = getattr(stc, hemi + '_data') vertices = stc.vertices[hemi_idx] if len(data) > 0: + kwargs = { + "array": data, "colormap": colormap, + "vertices": vertices, + "smoothing_steps": smoothing_steps, + "time": times, "time_label": time_label, + "alpha": alpha, "hemi": hemi, + "colorbar": colorbar, "initial_time": initial_time, + "transparent": transparent, "center": center, + "verbose": False + } + if get_3d_backend() == "mayavi": + kwargs["min"] = scale_pts[0] + kwargs["mid"] = scale_pts[1] + kwargs["max"] = scale_pts[2] + else: + kwargs["fmin"] = scale_pts[0] + kwargs["fmid"] = scale_pts[1] + kwargs["fmax"] = scale_pts[2] with warnings.catch_warnings(record=True): # traits warnings - brain.add_data(data, colormap=colormap, vertices=vertices, - smoothing_steps=smoothing_steps, time=times, - time_label=time_label, alpha=alpha, hemi=hemi, - colorbar=colorbar, - min=scale_pts[0], max=scale_pts[2], **ad_kwargs) - if 'mid' not in ad_kwargs: # PySurfer < 0.9 - brain.scale_data_colormap(fmin=scale_pts[0], fmid=scale_pts[1], - fmax=scale_pts[2], **sd_kwargs) + brain.add_data(**kwargs) if time_viewer: TimeViewer(brain) return brain diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 76840137ccb..10bb181c0c5 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -8,11 +8,14 @@ # License: Simplified BSD import numpy as np - -from .colormap import _calculate_lut -from .view import views_dict +import os +from os.path import join as pjoin +from ...label import read_label +from .colormap import calculate_lut +from .view import lh_views_dict, rh_views_dict, View from .surface import Surface -from ..utils import _check_option, logger +from .utils import mesh_edges, smoothing_matrix +from ..utils import _check_option, logger, verbose class _Brain(object): @@ -36,6 +39,12 @@ class _Brain(object): freesurfer surface mesh name (ie 'white', 'inflated', etc.). title : str Title for the window. + cortex : str or None + Specifies how the cortical surface is rendered. + The name of one of the preset cortex styles can be: + ``'classic'`` (default), ``'high_contrast'``, + ``'low_contrast'``, or ``'bone'`` or a valid color name. + Setting this to ``None`` is equivalent to ``(0.5, 0.5, 0.5)``. alpha : float in [0, 1] Alpha level to control opacity of the cortical surface. size : float | tuple(float, float) @@ -48,7 +57,6 @@ class _Brain(object): None (default) will use black or white depending on the value of ``background``. figure : list of Figure | None | int - Not supported yet. If None (default), a new window will be created with the appropriate views. For single view plots, the figure can be specified as int to retrieve the corresponding Mayavi window. @@ -92,146 +100,102 @@ class _Brain(object): +---------------------------+--------------+-----------------------+ | 3D function: | surfer.Brain | mne.viz._brain._Brain | +===========================+==============+=======================+ - | add_annotation | ✓ | | - +---------------------------+--------------+-----------------------+ - | add_contour_overlay | ✓ | | - +---------------------------+--------------+-----------------------+ | add_data | ✓ | - | +---------------------------+--------------+-----------------------+ - | add_foci | ✓ | | - +---------------------------+--------------+-----------------------+ - | add_label | ✓ | | - +---------------------------+--------------+-----------------------+ - | add_morphometry | ✓ | | - +---------------------------+--------------+-----------------------+ - | add_overlay | ✓ | | - +---------------------------+--------------+-----------------------+ - | add_text | ✓ | | - +---------------------------+--------------+-----------------------+ - | animate | ✓ | | + | add_foci | ✓ | - | +---------------------------+--------------+-----------------------+ - | annot | ✓ | | + | add_label | ✓ | - | +---------------------------+--------------+-----------------------+ - | close | ✓ | | + | add_text | ✓ | - | +---------------------------+--------------+-----------------------+ - | contour | ✓ | | + | close | ✓ | ✓ | +---------------------------+--------------+-----------------------+ | data | ✓ | ✓ | +---------------------------+--------------+-----------------------+ - | data_dict | ✓ | | - +---------------------------+--------------+-----------------------+ - | data_time_index | ✓ | | - +---------------------------+--------------+-----------------------+ | foci | ✓ | | +---------------------------+--------------+-----------------------+ - | get_data_properties | ✓ | | - +---------------------------+--------------+-----------------------+ - | hide_colorbar | ✓ | | - +---------------------------+--------------+-----------------------+ - | index_for_time | ✓ | | + | index_for_time | ✓ | ✓ | +---------------------------+--------------+-----------------------+ | labels | ✓ | | +---------------------------+--------------+-----------------------+ | labels_dict | ✓ | | +---------------------------+--------------+-----------------------+ - | overlays | ✓ | | + | overlays | ✓ | - | +---------------------------+--------------+-----------------------+ | remove_data | ✓ | | +---------------------------+--------------+-----------------------+ | remove_foci | ✓ | | +---------------------------+--------------+-----------------------+ - | remove_labels | ✓ | | - +---------------------------+--------------+-----------------------+ - | reset_view | ✓ | | - +---------------------------+--------------+-----------------------+ - | save_image | ✓ | | - +---------------------------+--------------+-----------------------+ - | save_image_sequence | ✓ | | - +---------------------------+--------------+-----------------------+ - | save_imageset | ✓ | | - +---------------------------+--------------+-----------------------+ - | save_montage | ✓ | | + | remove_labels | ✓ | - | +---------------------------+--------------+-----------------------+ - | save_movie | ✓ | | + | save_image | ✓ | ✓ | +---------------------------+--------------+-----------------------+ - | save_single_image | ✓ | | + | screenshot | ✓ | ✓ | +---------------------------+--------------+-----------------------+ - | scale_data_colormap | ✓ | | - +---------------------------+--------------+-----------------------+ - | screenshot | ✓ | | - +---------------------------+--------------+-----------------------+ - | screenshot_single | ✓ | | - +---------------------------+--------------+-----------------------+ - | set_data_smoothing_steps | ✓ | | - +---------------------------+--------------+-----------------------+ - | set_data_time_index | ✓ | | - +---------------------------+--------------+-----------------------+ - | set_distance | ✓ | | - +---------------------------+--------------+-----------------------+ - | set_surf | ✓ | | - +---------------------------+--------------+-----------------------+ - | set_time | ✓ | | - +---------------------------+--------------+-----------------------+ - | show_colorbar | ✓ | | - +---------------------------+--------------+-----------------------+ - | show_view | ✓ | | - +---------------------------+--------------+-----------------------+ - | texts | ✓ | | - +---------------------------+--------------+-----------------------+ - | toggle_toolbars | ✓ | | - +---------------------------+--------------+-----------------------+ - | update_text | ✓ | | + | show_view | ✓ | - | +---------------------------+--------------+-----------------------+ """ def __init__(self, subject_id, hemi, surf, title=None, - alpha=1.0, size=800, background=(0, 0, 0), + cortex=None, alpha=1.0, size=800, background="black", foreground=None, figure=None, subjects_dir=None, views=['lateral'], offset=True, show_toolbar=False, offscreen=False, interaction=None, units='mm'): - if hemi == 'split': - raise ValueError('Option hemi="split" is not supported yet.') - - if figure is not None: - raise ValueError('figure parameter is not supported yet.') + from ..backends.renderer import _Renderer, _check_figure + from matplotlib.colors import colorConverter if interaction is not None: raise ValueError('"interaction" parameter is not supported.') - from ..backends.renderer import _Renderer + if hemi in ('both', 'split'): + self._hemis = ('lh', 'rh') + elif hemi in ('lh', 'rh'): + self._hemis = (hemi, ) + else: + raise KeyError('hemi has to be either "lh", "rh", "split", ' + 'or "both"') + + if isinstance(background, str): + background = colorConverter.to_rgb(background) + if isinstance(foreground, str): + foreground = colorConverter.to_rgb(foreground) + if isinstance(views, str): + views = [views] + n_row = len(views) + col_dict = dict(lh=1, rh=1, both=1, split=2) + n_col = col_dict[hemi] + + if isinstance(size, int): + fig_size = (size, size) + elif isinstance(size, tuple): + fig_size = size + else: + raise ValueError('"size" parameter must be int or tuple.') self._foreground = foreground self._hemi = hemi self._units = units self._title = title self._subject_id = subject_id + self._subjects_dir = subjects_dir self._views = views + self._n_times = None + self._scalarbar = False # for now only one color bar can be added # since it is the same for all figures self._colorbar_added = False # array of data used by TimeViewer self._data = {} self.geo, self._hemi_meshes, self._overlays = {}, {}, {} - self._renderers = [[] for _ in views] # load geometry for one or both hemispheres as necessary offset = None if (not offset or hemi != 'both') else 0.0 - if hemi in ('both', 'split'): - self._hemis = ('lh', 'rh') - elif hemi in ('lh', 'rh'): - self._hemis = (hemi, ) - else: - raise KeyError('hemi has to be either "lh", "rh", "split", ' - 'or "both"') - - if isinstance(size, int): - fig_size = (size, size) - elif isinstance(size, tuple): - fig_size = size - else: - raise ValueError('"size" parameter must be int or tuple.') + if figure is not None and not isinstance(figure, int): + _check_figure(figure) + self._renderer = _Renderer(size=fig_size, bgcolor=background, + shape=(n_row, n_col), fig=figure) for h in self._hemis: # Initialize a Surface object as the geometry @@ -243,31 +207,24 @@ def __init__(self, subject_id, hemi, surf, title=None, self.geo[h] = geo for ri, v in enumerate(views): - renderer = _Renderer(size=fig_size, bgcolor=background) - self._renderers[ri].append(renderer) - renderer.set_camera(azimuth=views_dict[v].azim, - elevation=views_dict[v].elev, - distance=490.0) - - for ci, h in enumerate(self._hemis): - if ci == 1 and hemi == 'split': - # create a separate figure for right hemisphere - renderer = _Renderer(size=fig_size, bgcolor=background) - self._renderers[ri].append(renderer) - renderer.set_camera(azimuth=views_dict[v].azim, - elevation=views_dict[v].elev, - distance=490.0) - - mesh = renderer.mesh(x=self.geo[h].coords[:, 0], - y=self.geo[h].coords[:, 1], - z=self.geo[h].coords[:, 2], - triangles=self.geo[h].faces, - color=self.geo[h].grey_curv) - - self._hemi_meshes[h + '_' + v] = mesh - + for hi, h in enumerate(['lh', 'rh']): + views_dict = lh_views_dict if hemi == 'lh' else rh_views_dict + if not (hemi in ['lh', 'rh'] and h != hemi): + ci = hi if hemi == 'split' else 0 + self._renderer.subplot(ri, ci) + self._renderer.mesh(x=self.geo[h].coords[:, 0], + y=self.geo[h].coords[:, 1], + z=self.geo[h].coords[:, 2], + triangles=self.geo[h].faces, + color=self.geo[h].grey_curv) + self._renderer.set_camera(azimuth=views_dict[v].azim, + elevation=views_dict[v].elev) + # Force rendering + self._renderer.show() + + @verbose def add_data(self, array, fmin=None, fmid=None, fmax=None, - thresh=None, center=None, transparent=None, colormap="auto", + thresh=None, center=None, transparent=False, colormap="auto", alpha=1, vertices=None, smoothing_steps=None, time=None, time_label="time index=%d", colorbar=True, hemi=None, remove_existing=None, time_label_size=None, @@ -309,7 +266,6 @@ def add_data(self, array, fmin=None, fmid=None, fmax=None, if not None, center of a divergent colormap, changes the meaning of fmin, fmax and fmid. transparent : bool - Not supported yet. if True: use a linear transparency between fmin and fmid and make values below fmin fully transparent (symmetrically for divergent colormaps) @@ -355,9 +311,7 @@ def add_data(self, array, fmin=None, fmid=None, fmax=None, Not supported yet. alpha level to control opacity of the arrows. Only used for vector-valued data. If None (default), ``alpha`` is used. - verbose : bool, str, int, or None - Not supported yet. - If not None, override default verbose level (see surfer.verbose). + %(verbose)s Notes ----- @@ -371,27 +325,58 @@ def add_data(self, array, fmin=None, fmid=None, fmax=None, Due to a Mayavi (or VTK) alpha rendering bug, ``vector_alpha`` is clamped to be strictly < 1. """ - if len(array.shape) == 3: - raise ValueError('Vector values in "array" are not supported.') + _check_option('transparent', type(transparent), [bool]) # those parameters are not supported yet, only None is allowed _check_option('thresh', thresh, [None]) - _check_option('transparent', transparent, [None]) _check_option('remove_existing', remove_existing, [None]) _check_option('time_label_size', time_label_size, [None]) _check_option('scale_factor', scale_factor, [None]) _check_option('vector_alpha', vector_alpha, [None]) - _check_option('verbose', verbose, [None]) - - from surfer.utils import mesh_edges, smoothing_matrix hemi = self._check_hemi(hemi) array = np.asarray(array) - if initial_time is None: + # Create time array and add label if > 1D + if array.ndim <= 1: time_idx = 0 else: - time_idx = np.argmin(abs(time - initial_time)) + # check time array + if time is None: + time = np.arange(array.shape[-1]) + else: + time = np.asarray(time) + if time.shape != (array.shape[-1],): + raise ValueError('time has shape %s, but need shape %s ' + '(array.shape[-1])' % + (time.shape, (array.shape[-1],))) + + if self._n_times is None: + self._n_times = len(time) + self._times = time + elif len(time) != self._n_times: + raise ValueError("New n_times is different from previous " + "n_times") + elif not np.array_equal(time, self._times): + raise ValueError("Not all time values are consistent with " + "previously set times.") + + # initial time + if initial_time is None: + time_idx = 0 + else: + time_idx = self.index_for_time(initial_time) + + # time label + if isinstance(time_label, str): + time_label_fmt = time_label + + def time_label(x): + return time_label_fmt % x + self._data["time_label"] = time_label + self._data["time"] = time + self._data["time_idx"] = 0 + y_txt = 0.05 + 0.1 * bool(colorbar) if time is not None and len(array.shape) == 2: # we have scalar_data with time dimension @@ -418,8 +403,6 @@ def add_data(self, array, fmin=None, fmid=None, fmax=None, self._data['fmid'] = fmid self._data['fmax'] = fmax - lut = self.update_lut() - # Create smoothing matrix if necessary if len(act_data) < self.geo[hemi].x.shape[0]: if vertices is None: @@ -433,45 +416,344 @@ def add_data(self, array, fmin=None, fmid=None, fmax=None, act_data = smooth_mat.dot(act_data) self._data[hemi + '_smooth_mat'] = smooth_mat - # data mapping into [0, 1] interval dt_max = fmax - dt_min = fmin if center is None else -1 * max - k = 1 / (dt_max - dt_min) - b = 1 - k * dt_max - act_data = k * act_data + b - act_data = np.clip(act_data, 0, 1) + dt_min = fmin if center is None else -1 * fmax + + ctable = self.update_lut(transparent=transparent) + + for ri, v in enumerate(self._views): + views_dict = lh_views_dict if hemi == 'lh' else rh_views_dict + if self._hemi != 'split': + ci = 0 + else: + ci = 0 if hemi == 'lh' else 1 + self._renderer.subplot(ri, ci) + mesh = self._renderer.mesh(x=self.geo[hemi].coords[:, 0], + y=self.geo[hemi].coords[:, 1], + z=self.geo[hemi].coords[:, 2], + triangles=self.geo[hemi].faces, + color=None, + colormap=ctable, + vmin=dt_min, + vmax=dt_max, + scalars=act_data) + if array.ndim >= 2 and callable(time_label): + self._renderer.text2d(x=0.95, y=y_txt, + size=time_label_size, + text=time_label(time[time_idx]), + justification='right') + if colorbar and not self._colorbar_added: + self._renderer.scalarbar(source=mesh, n_labels=8, + bgcolor=(0.5, 0.5, 0.5)) + self._colorbar_added = True + self._renderer.set_camera(azimuth=views_dict[v].azim, + elevation=views_dict[v].elev) + + def add_label(self, label, color=None, alpha=1, scalar_thresh=None, + borders=False, hemi=None, subdir=None): + """Add an ROI label to the image. - act_color = lut(act_data) + Parameters + ---------- + label : str | instance of Label + label filepath or name. Can also be an instance of + an object with attributes "hemi", "vertices", "name", and + optionally "color" and "values" (if scalar_thresh is not None). + color : matplotlib-style color | None + anything matplotlib accepts: string, RGB, hex, etc. (default + "crimson") + alpha : float in [0, 1] + alpha level to control opacity + scalar_thresh : None or number + threshold the label ids using this value in the label + file's scalar field (i.e. label only vertices with + scalar >= thresh) + borders : bool | int + Show only label borders. If int, specify the number of steps + (away from the true border) along the cortical mesh to include + as part of the border definition. + hemi : str | None + If None, it is assumed to belong to the hemipshere being + shown. If two hemispheres are being shown, an error will + be thrown. + subdir : None | str + If a label is specified as name, subdir can be used to indicate + that the label file is in a sub-directory of the subject's + label directory rather than in the label directory itself (e.g. + for ``$SUBJECTS_DIR/$SUBJECT/label/aparc/lh.cuneus.label`` + ``brain.add_label('cuneus', subdir='aparc')``). - self._data['k'] = k - self._data['b'] = b + Notes + ----- + To remove previously added labels, run Brain.remove_labels(). + """ + from matplotlib.colors import colorConverter + if isinstance(label, str): + hemi = self._check_hemi(hemi) + if color is None: + color = "crimson" + + if os.path.isfile(label): + filepath = label + label_name = os.path.basename(filepath).split('.')[1] + else: + label_name = label + label_fname = ".".join([hemi, label_name, 'label']) + if subdir is None: + filepath = pjoin(self._subjects_dir, self._subject_id, + 'label', label_fname) + else: + filepath = pjoin(self._subjects_dir, self._subject_id, + 'label', subdir, label_fname) + if not os.path.exists(filepath): + raise ValueError('Label file %s does not exist' + % filepath) + label = read_label(filepath) + ids = label.vertices + else: + # try to extract parameters from label instance + try: + hemi = label.hemi + ids = label.vertices + if label.name is None: + label_name = 'unnamed' + else: + label_name = str(label.name) + + if color is None: + if hasattr(label, 'color') and label.color is not None: + color = label.color + else: + color = "crimson" + + if scalar_thresh is not None: + scalars = label.values + except Exception: + raise ValueError('Label was not a filename (str), and could ' + 'not be understood as a class. The class ' + 'must have attributes "hemi", "vertices", ' + '"name", and (if scalar_thresh is not None)' + '"values"') + hemi = self._check_hemi(hemi) + + if scalar_thresh is not None: + ids = ids[scalars >= scalar_thresh] + + # XXX: add support for label_name + self._label_name = label_name + + label = np.zeros(self.geo[hemi].coords.shape[0]) + label[ids] = 1 + color = colorConverter.to_rgba(color, alpha) + cmap = np.array([(0, 0, 0, 0,), color]) + ctable = np.round(cmap * 255).astype(np.uint8) for ri, v in enumerate(self._views): if self._hemi != 'split': ci = 0 else: ci = 0 if hemi == 'lh' else 1 - renderer = self._renderers[ri][ci] - mesh = renderer.mesh(x=self.geo[hemi].coords[:, 0], - y=self.geo[hemi].coords[:, 1], - z=self.geo[hemi].coords[:, 2], - triangles=self.geo[hemi].faces, - color=act_color) - self._overlays[hemi + '_' + v] = mesh - - # How can we make this bit universal as well??? - # if colorbar and not self._colorbar_added: - # ColorBar(self) - # self._colorbar_added = True - - def show(self): - u"""Display widget.""" - try: - return self._renderers[0][0].show() - except RuntimeError: - logger.info("No active/running renderer available.") + self._renderer.subplot(ri, ci) + self._renderer.mesh(x=self.geo[hemi].coords[:, 0], + y=self.geo[hemi].coords[:, 1], + z=self.geo[hemi].coords[:, 2], + triangles=self.geo[hemi].faces, + scalars=label, + color=None, + colormap=ctable, + backface_culling=False) + self._renderer.set_camera(azimuth=0., + elevation=90.) + + def add_foci(self, coords, coords_as_verts=False, map_surface=None, + scale_factor=1, color="white", alpha=1, name=None, + hemi=None): + """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 : numpy array + x, y, z 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 : Freesurfer surf or None + surface to map coordinates through, or None to use raw coords + scale_factor : float + Controls the size of the foci spheres (relative to 1cm). + color : matplotlib color code + HTML name, RBG tuple, or hex code + alpha : float in [0, 1] + opacity of focus gylphs + name : str + internal name to use + hemi : str | None + If None, it is assumed to belong to the hemipshere being + shown. If two hemispheres are being shown, an error will + be thrown. + """ + from matplotlib.colors import colorConverter + hemi = self._check_hemi(hemi) + + # those parameters are not supported yet, only None is allowed + _check_option('map_surface', map_surface, [None]) + + # Figure out how to interpret the first parameter + if coords_as_verts: + coords = self.geo[hemi].coords[coords] + + # Convert the color code + if not isinstance(color, tuple): + color = colorConverter.to_rgb(color) + + if self._units == 'm': + scale_factor = scale_factor / 1000. + for ri, v in enumerate(self._views): + views_dict = lh_views_dict if hemi == 'lh' else rh_views_dict + if self._hemi != 'split': + ci = 0 + else: + ci = 0 if hemi == 'lh' else 1 + self._renderer.subplot(ri, ci) + self._renderer.sphere(center=coords, color=color, + scale=(10. * scale_factor), + opacity=alpha) + self._renderer.set_camera(azimuth=views_dict[v].azim, + elevation=views_dict[v].elev) + + def add_text(self, x, y, text, name=None, color=None, opacity=1.0, + row=-1, col=-1, font_size=None, justification=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 + Row index of which brain to use + col : int + Column index of which brain to use + """ + # XXX: support `name` should be added when update_text/remove_text + # are implemented + # _check_option('name', name, [None]) + + self._renderer.text2d(x=x, y=y, text=text, color=color, + size=font_size, justification=justification) + + def remove_labels(self, labels=None): + """Remove one or more previously added labels from the image. + + Parameters + ---------- + labels : None | str | list of str + Labels to remove. Can be a string naming a single label, or None to + remove all labels. Possible names can be found in the Brain.labels + attribute. + """ + pass + + def index_for_time(self, time, rounding='closest'): + """Find the data time index closest to a specific time point. + + Parameters + ---------- + time : scalar + Time. + rounding : 'closest' | 'up' | 'down' + How to round if the exact time point is not an index. + + Returns + ------- + index : int + Data time index closest to time. + """ + if self._n_times is None: + raise RuntimeError("Brain has no time axis") + times = self._times + + # Check that time is in range + tmin = np.min(times) + tmax = np.max(times) + max_diff = (tmax - tmin) / (len(times) - 1) / 2 + if time < tmin - max_diff or time > tmax + max_diff: + err = ("time = %s lies outside of the time axis " + "[%s, %s]" % (time, tmin, tmax)) + raise ValueError(err) + + if rounding == 'closest': + idx = np.argmin(np.abs(times - time)) + elif rounding == 'up': + idx = np.nonzero(times >= time)[0][0] + elif rounding == 'down': + idx = np.nonzero(times <= time)[0][-1] + else: + err = "Invalid rounding parameter: %s" % repr(rounding) + raise ValueError(err) - def update_lut(self, fmin=None, fmid=None, fmax=None): + return idx + + def close(self): + """Close all figures and cleanup data structure.""" + self._renderer.close() + + def show_view(self, view=None, roll=None, distance=None): + """Orient camera to display view.""" + views_dict = lh_views_dict if self._hemi == 'lh' else rh_views_dict + if isinstance(view, str): + view = views_dict.get(view) + elif isinstance(view, dict): + view = View(azim=view['azimuth'], + elev=view['elevation']) + self._renderer.set_camera(azimuth=view.azim, + elevation=view.elev) + + def save_image(self, filename, mode='rgb'): + """Save view from all panels to disk. + + Parameters + ---------- + filename: string + path to new image file + mode : string + Either 'rgb' or 'rgba' for values to return. + """ + self._renderer.screenshot(mode=mode, filename=filename) + + def screenshot(self, mode='rgb'): + """Generate a screenshot of current view. + + Parameters + ---------- + mode : string + Either 'rgb' or 'rgba' for values to return. + + Returns + ------- + screenshot : array + Image pixel values. + """ + return self._renderer.screenshot(mode) + + def update_lut(self, fmin=None, fmid=None, fmax=None, transparent=True): u"""Update color map. Parameters @@ -491,11 +773,11 @@ def update_lut(self, fmin=None, fmid=None, fmax=None): fmid = self._data['fmid'] if fmid is None else fmid fmax = self._data['fmax'] if fmax is None else fmax - self._data['lut'] = _calculate_lut(colormap, alpha=alpha, - fmin=fmin, fmid=fmid, - fmax=fmax, center=center) + self._data['ctable'] = \ + calculate_lut(colormap, alpha=alpha, fmin=fmin, fmid=fmid, + fmax=fmax, center=center, transparent=transparent) - return self._data['lut'] + return self._data['ctable'] @property def overlays(self): @@ -514,6 +796,13 @@ def views(self): def hemis(self): return self._hemis + def _show(self): + """Request rendering of the window.""" + try: + return self._renderer.show() + except RuntimeError: + logger.info("No active/running renderer available.") + def _check_hemi(self, hemi): u"""Check for safe single-hemi input, returns str.""" if hemi is None: diff --git a/mne/viz/_brain/colormap.py b/mne/viz/_brain/colormap.py index 9e1dcf42185..e68b683fff2 100644 --- a/mne/viz/_brain/colormap.py +++ b/mne/viz/_brain/colormap.py @@ -8,7 +8,58 @@ import numpy as np -def _calculate_lut(lim_cmap, alpha, fmin, fmid, fmax, center=None): +def create_lut(cmap, n_colors=256, center=None): + """Return a colormap suitable for setting as a LUT.""" + from matplotlib import cm + cmap = cm.get_cmap(cmap) + lut = (cmap(np.linspace(0, 1, n_colors)) * 255.0).astype(np.int) + return lut + + +def scale_sequential_lut(lut_table, fmin, fmid, fmax): + """Scale a sequential colormap.""" + lut_table_new = lut_table.copy() + n_colors = lut_table.shape[0] + n_colors2 = n_colors // 2 + + fmid_idx = int(np.round(n_colors * ((fmid - fmin) / + (fmax - fmin))) - 1) + + for i in range(4): + part1 = np.interp(np.linspace(0, n_colors2 - 1, fmid_idx + 1), + np.arange(n_colors), + lut_table[:, i]) + lut_table_new[:fmid_idx + 1, i] = part1 + part2 = np.interp(np.linspace(n_colors2, n_colors - 1, + n_colors - fmid_idx - 1), + np.arange(n_colors), + lut_table[:, i]) + lut_table_new[fmid_idx + 1:, i] = part2 + + return lut_table_new + + +def get_fill_colors(cols, n_fill): + """Get the fill colors for the middle of divergent colormaps.""" + steps = np.linalg.norm(np.diff(cols[:, :3].astype(float), axis=0), + axis=1) + + ind = np.flatnonzero(steps[1:-1] > steps[[0, -1]].mean() * 3) + if ind.size > 0: + # choose the two colors between which there is the large step + ind = ind[0] + 1 + fillcols = np.r_[np.tile(cols[ind, :], (n_fill / 2, 1)), + np.tile(cols[ind + 1, :], + (n_fill - n_fill / 2, 1))] + else: + # choose a color from the middle of the colormap + fillcols = np.tile(cols[int(cols.shape[0] / 2), :], (n_fill, 1)) + + return fillcols + + +def calculate_lut(lut_table, alpha, fmin, fmid, fmax, center=None, + transparent=True): u"""Transparent color map calculation. A colormap may be sequential or divergent. When the colormap is @@ -45,64 +96,63 @@ def _calculate_lut(lim_cmap, alpha, fmin, fmid, fmax, center=None): center : float or None If not None, center of a divergent colormap, changes the meaning of fmin, fmax and fmid. + transparent : boolean + if True: use a linear transparency between fmin and fmid and make + values below fmin fully transparent (symmetrically for divergent + colormaps) Returns ------- cmap : matplotlib.ListedColormap Color map with transparency channel. """ - from matplotlib import cm - from matplotlib.colors import ListedColormap - - if center is None: - # 'hot' or another linear color map - ctrl_pts = (fmin, fmid, fmax) - scale_pts = ctrl_pts - rgb_cmap = cm.get_cmap(lim_cmap) - # take 60% of hot color map, so it will be consistent - # with mayavi plots - cmap_size = int(rgb_cmap.N * 0.6) - cmap = rgb_cmap(np.arange(rgb_cmap.N))[rgb_cmap.N - cmap_size:, :] - alphas = np.ones(cmap_size) - step = 2 * (scale_pts[-1] - scale_pts[0]) / rgb_cmap.N - # coefficients for linear mapping - # from [ctrl_pts[0], ctrl_pts[1]) interval into [0, 1] - k = 1 / (ctrl_pts[1] - ctrl_pts[0]) - b = - ctrl_pts[0] * k - - for i in range(0, cmap_size): - curr_pos = i * step + scale_pts[0] - - if (curr_pos < ctrl_pts[0]): - alphas[i] = 0 - elif (curr_pos < ctrl_pts[1]): - alphas[i] = k * curr_pos + b + lut_table = create_lut(lut_table) + divergent = center is not None + n_colors = lut_table.shape[0] + + # Add transparency if needed + if transparent: + if divergent: + N4 = np.full(4, n_colors / 4, dtype=int) + N4[:np.mod(n_colors, 4)] += 1 + assert N4.sum() == n_colors + lut_table[:, -1] = np.r_[255 * np.ones(N4[0]), + np.linspace(255, 0, N4[2]), + np.linspace(0, 255, N4[3]), + 255 * np.ones(N4[1])] + else: + n_colors2 = int(n_colors / 2) + lut_table[:n_colors2, -1] = np.linspace(0, 255, n_colors2) + lut_table[n_colors2:, -1] = 255 * np.ones(n_colors - n_colors2) + + alpha = float(alpha) + if alpha < 1.0: + lut_table[:, -1] = lut_table[:, -1] * alpha + + if divergent: + n_colors2 = int(n_colors / 2) + n_fill = int(round(fmin * n_colors2 / (fmax - fmin))) * 2 + lut_table = np.r_[ + scale_sequential_lut(lut_table[:n_colors2, :], + center - fmax, center - fmid, + center - fmin), + get_fill_colors( + lut_table[n_colors2 - 3:n_colors2 + 3, :], n_fill), + scale_sequential_lut(lut_table[n_colors2:, :], + center + fmin, center + fmid, + center + fmax)] else: - # 'mne' or another divergent color map - ctrl_pts = (center + fmin, center + fmid, center + fmax) - scale_pts = (center - fmax, center, center + fmax) - rgb_cmap = lim_cmap - cmap = rgb_cmap(np.arange(rgb_cmap.N)) - alphas = np.ones(rgb_cmap.N) - step = (scale_pts[-1] - scale_pts[0]) / rgb_cmap.N - # coefficients for linear mapping into [0, 1] - k_pos = 1 / (ctrl_pts[1] - ctrl_pts[0]) - k_neg = -k_pos - b = - ctrl_pts[0] * k_pos - - for i in range(0, rgb_cmap.N): - curr_pos = i * step + scale_pts[0] - - if -ctrl_pts[0] < curr_pos < ctrl_pts[0]: - alphas[i] = 0 - elif ctrl_pts[0] <= curr_pos < ctrl_pts[1]: - alphas[i] = k_pos * curr_pos + b - elif -ctrl_pts[1] < curr_pos <= -ctrl_pts[0]: - alphas[i] = k_neg * curr_pos + b - - alphas *= alpha - np.clip(alphas, 0, 1) - cmap[:, -1] = alphas - cmap = ListedColormap(cmap) - - return cmap + lut_table = scale_sequential_lut(lut_table, fmin, fmid, fmax) + + n_colors = lut_table.shape[0] + if n_colors != 256: + lut = np.zeros((256, 4)) + x = np.linspace(1, n_colors, 256) + for chan in range(4): + lut[:, chan] = np.interp(x, + np.arange(1, n_colors + 1), + lut_table[:, chan]) + lut_table = lut + + lut_table = lut_table.astype(np.float) / 255.0 + return lut_table diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 22830c4d51b..152a9528440 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -14,7 +14,7 @@ from mne import read_source_estimate from mne.datasets import testing from mne.viz._brain import _Brain -from mne.viz._brain.colormap import _calculate_lut +from mne.viz._brain.colormap import calculate_lut from matplotlib import cm @@ -22,33 +22,40 @@ subject_id = 'sample' subjects_dir = path.join(data_path, 'subjects') fname_stc = path.join(data_path, 'MEG/sample/sample_audvis_trunc-meg') +fname_label = path.join(data_path, 'MEG/sample/labels/Vis-lh.label') surf = 'inflated' @testing.requires_testing_data def test_brain_init(renderer): """Test initialization of the _Brain instance.""" - backend_name = renderer.get_3d_backend() hemi = 'both' - with pytest.raises(ValueError, match='hemi'): - _Brain(subject_id=subject_id, hemi="split", surf=surf) - with pytest.raises(ValueError, match='figure'): - _Brain(subject_id=subject_id, hemi=hemi, surf=surf, figure=0) + with pytest.raises(ValueError, match='size'): + _Brain(subject_id=subject_id, hemi=hemi, surf=surf, size=0.5) + with pytest.raises(TypeError, match='figure'): + _Brain(subject_id=subject_id, hemi=hemi, surf=surf, figure='foo') with pytest.raises(ValueError, match='interaction'): _Brain(subject_id=subject_id, hemi=hemi, surf=surf, interaction=0) with pytest.raises(KeyError): - _Brain(subject_id=subject_id, hemi="foo", surf=surf) + _Brain(subject_id=subject_id, hemi='foo', surf=surf) - brain = _Brain(subject_id, hemi, surf, subjects_dir=subjects_dir) - if backend_name != 'mayavi': - brain.show() + _Brain(subject_id, hemi, surf, size=(300, 300), + subjects_dir=subjects_dir) + + +@testing.requires_testing_data +def test_brain_screenshot(renderer): + """Test screenshot of a _Brain instance.""" + brain = _Brain(subject_id, hemi='both', size=600, + surf=surf, subjects_dir=subjects_dir) + img = brain.screenshot(mode='rgb') + assert(img.shape == (600, 600, 3)) @testing.requires_testing_data def test_brain_add_data(renderer): """Test adding data in _Brain instance.""" - backend_name = renderer.get_3d_backend() stc = read_source_estimate(fname_stc) hemi = 'lh' @@ -60,6 +67,16 @@ def test_brain_add_data(renderer): brain_data = _Brain(subject_id, hemi, surf, size=300, subjects_dir=subjects_dir) + with pytest.raises(ValueError, match='thresh'): + brain_data.add_data(hemi_data, thresh=-1) + with pytest.raises(ValueError, match='remove_existing'): + brain_data.add_data(hemi_data, remove_existing=-1) + with pytest.raises(ValueError, match='time_label_size'): + brain_data.add_data(hemi_data, time_label_size=-1) + with pytest.raises(ValueError, match='scale_factor'): + brain_data.add_data(hemi_data, scale_factor=-1) + with pytest.raises(ValueError, match='vector_alpha'): + brain_data.add_data(hemi_data, vector_alpha=-1) with pytest.raises(ValueError): brain_data.add_data(array=np.array([0, 1, 2])) with pytest.raises(ValueError): @@ -68,10 +85,38 @@ def test_brain_add_data(renderer): brain_data.add_data(hemi_data, fmin=fmin, hemi=hemi, fmax=fmax, colormap='hot', vertices=hemi_vertices, - colorbar=False) + colorbar=False, time=None) + brain_data.add_data(hemi_data, fmin=fmin, hemi=hemi, fmax=fmax, + colormap='hot', vertices=hemi_vertices, + initial_time=0., colorbar=True, time=None) + + +@testing.requires_testing_data +def test_brain_add_label(renderer): + """Test adding data in _Brain instance.""" + from mne.label import read_label + brain = _Brain(subject_id, hemi='lh', size=500, + surf=surf, subjects_dir=subjects_dir) + label = read_label(fname_label) + brain.add_label(fname_label) + brain.add_label(label) - if backend_name != 'mayavi': - brain_data.show() + +@testing.requires_testing_data +def test_brain_add_foci(renderer): + """Test adding foci in _Brain instance.""" + brain = _Brain(subject_id, hemi='lh', size=500, + surf=surf, subjects_dir=subjects_dir) + brain.add_foci([0], coords_as_verts=True, + hemi='lh', color='blue') + + +@testing.requires_testing_data +def test_brain_add_text(renderer): + """Test adding text in _Brain instance.""" + brain = _Brain(subject_id, hemi='lh', size=250, + surf=surf, subjects_dir=subjects_dir) + brain.add_text(x=0, y=0, text='foo') def test_brain_colormap(): @@ -82,9 +127,9 @@ def test_brain_colormap(): fmid = 0.5 fmax = 1.0 center = None - _calculate_lut(colormap, alpha=alpha, fmin=fmin, - fmid=fmid, fmax=fmax, center=center) + calculate_lut(colormap, alpha=alpha, fmin=fmin, + fmid=fmid, fmax=fmax, center=center) center = 0.0 colormap = cm.get_cmap(colormap) - _calculate_lut(colormap, alpha=alpha, fmin=fmin, - fmid=fmid, fmax=fmax, center=center) + calculate_lut(colormap, alpha=alpha, fmin=fmin, + fmid=fmid, fmax=fmax, center=center) diff --git a/mne/viz/_brain/utils.py b/mne/viz/_brain/utils.py new file mode 100644 index 00000000000..d1f94eaa241 --- /dev/null +++ b/mne/viz/_brain/utils.py @@ -0,0 +1,100 @@ +# Authors: Guillaume Favelier +# +# License: Simplified BSD + + +import numpy as np +from scipy import sparse + +from ..utils import logger, verbose + + +def mesh_edges(faces): + """Return sparse matrix with edges as an adjacency matrix. + + Parameters + ---------- + faces : array, shape (n_triangles, 3) + The mesh faces + + Returns + ------- + edges : sparse matrix + The adjacency matrix + """ + npoints = np.max(faces) + 1 + nfaces = len(faces) + a, b, c = faces.T + edges = sparse.coo_matrix((np.ones(nfaces), (a, b)), + shape=(npoints, npoints)) + edges = edges + sparse.coo_matrix((np.ones(nfaces), (b, c)), + shape=(npoints, npoints)) + edges = edges + sparse.coo_matrix((np.ones(nfaces), (c, a)), + shape=(npoints, npoints)) + edges = edges + edges.T + edges = edges.tocoo() + return edges + + +@verbose +def smoothing_matrix(vertices, adj_mat, smoothing_steps=20, verbose=None): + """Create a smoothing matrix. + + This smoothing matrix can be used to interpolate data defined + for a subset of vertices onto mesh with an adjancency matrix given by + adj_mat. + + If smoothing_steps is None, as many smoothing steps are applied until + the whole mesh is filled with with non-zeros. Only use this option if + the vertices correspond to a subsampled version of the mesh. + + Parameters + ---------- + vertices : 1d array + vertex indices + adj_mat : sparse matrix + N x N adjacency matrix of the full mesh + smoothing_steps : int or None + number of smoothing steps (Default: 20) + %(verbose)s + + Returns + ------- + smooth_mat : sparse matrix + smoothing matrix with size N x len(vertices) + """ + from scipy import sparse + + logger.info("Updating smoothing matrix, be patient..") + + e = adj_mat.copy() + e.data[e.data == 2] = 1 + n_vertices = e.shape[0] + e = e + sparse.eye(n_vertices, n_vertices) + idx_use = vertices + smooth_mat = 1.0 + n_iter = smoothing_steps if smoothing_steps is not None else 1000 + for k in range(n_iter): + e_use = e[:, idx_use] + + data1 = e_use * np.ones(len(idx_use)) + idx_use = np.where(data1)[0] + scale_mat = sparse.dia_matrix((1 / data1[idx_use], 0), + shape=(len(idx_use), len(idx_use))) + + smooth_mat = scale_mat * e_use[idx_use, :] * smooth_mat + + logger.info("Smoothing matrix creation, step %d" % (k + 1)) + if smoothing_steps is None and len(idx_use) >= n_vertices: + break + + # Make sure the smoothing matrix has the right number of rows + # and is in COO format + smooth_mat = smooth_mat.tocoo() + smooth_mat = sparse.coo_matrix((smooth_mat.data, + (idx_use[smooth_mat.row], + smooth_mat.col)), + shape=(n_vertices, + len(vertices))) + + return smooth_mat diff --git a/mne/viz/_brain/view.py b/mne/viz/_brain/view.py index 9fefe63a51e..1be4e9ed76b 100644 --- a/mne/viz/_brain/view.py +++ b/mne/viz/_brain/view.py @@ -11,16 +11,29 @@ View = namedtuple('View', 'elev azim') -views_dict = {'lateral': View(elev=5, azim=0), - 'medial': View(elev=5, azim=180), - 'rostral': View(elev=5, azim=90), - 'caudal': View(elev=5, azim=-90), - 'dorsal': View(elev=90, azim=0), - 'ventral': View(elev=-90, azim=0), - 'frontal': View(elev=5, azim=110), - 'parietal': View(elev=5, azim=-110)} +lh_views_dict = {'lateral': View(azim=180., elev=90.), + 'medial': View(azim=0., elev=90.0), + 'rostral': View(azim=90., elev=90.), + 'caudal': View(azim=270., elev=90.), + 'dorsal': View(azim=180., elev=0.), + 'ventral': View(azim=180., elev=180.), + 'frontal': View(azim=120., elev=80.), + 'parietal': View(azim=-120., elev=60.)} +rh_views_dict = {'lateral': View(azim=180., elev=-90.), + 'medial': View(azim=0., elev=-90.0), + 'rostral': View(azim=-90., elev=-90.), + 'caudal': View(azim=90., elev=-90.), + 'dorsal': View(azim=180., elev=0.), + 'ventral': View(azim=180., elev=180.), + 'frontal': View(azim=60., elev=80.), + 'parietal': View(azim=-60., elev=60.)} # add short-size version entries into the dict -_views_dict = dict() -for k, v in views_dict.items(): - _views_dict[k[:3]] = v -views_dict.update(_views_dict) +_lh_views_dict = dict() +for k, v in lh_views_dict.items(): + _lh_views_dict[k[:3]] = v +lh_views_dict.update(_lh_views_dict) + +_rh_views_dict = dict() +for k, v in rh_views_dict.items(): + _rh_views_dict[k[:3]] = v +rh_views_dict.update(_rh_views_dict) diff --git a/mne/viz/backends/_pysurfer_mayavi.py b/mne/viz/backends/_pysurfer_mayavi.py index fb6db4620d7..d50f01c0eb7 100644 --- a/mne/viz/backends/_pysurfer_mayavi.py +++ b/mne/viz/backends/_pysurfer_mayavi.py @@ -58,14 +58,20 @@ class _Renderer(_BaseRenderer): """ def __init__(self, fig=None, size=(600, 600), bgcolor=(0., 0., 0.), - name=None, show=False): + name=None, show=False, shape=(1, 1)): self.mlab = _import_mlab() + self.window_size = size + self.shape = shape if fig is None: self.fig = _mlab_figure(figure=name, bgcolor=bgcolor, size=size) + elif isinstance(fig, int): + self.fig = _mlab_figure(figure=fig, bgcolor=bgcolor, size=size) else: self.fig = fig - if show is False: - _toggle_mlab_render(self.fig, False) + _toggle_mlab_render(self.fig, show) + + def subplot(self, x, y): + pass def scene(self): return self.fig @@ -77,8 +83,10 @@ def set_interactive(self): tvtk.InteractorStyleTerrain() def mesh(self, x, y, z, triangles, color, opacity=1.0, shading=False, - backface_culling=False, **kwargs): - if isinstance(color, np.ndarray) and color.ndim > 1: + backface_culling=False, scalars=None, colormap=None, + vmin=None, vmax=None, **kwargs): + if color is not None and isinstance(color, np.ndarray) \ + and color.ndim > 1: if color.shape[1] == 3: vertex_color = np.c_[color, np.ones(len(color))] * 255.0 else: @@ -87,7 +95,6 @@ def mesh(self, x, y, z, triangles, color, opacity=1.0, shading=False, scalars = np.arange(len(color)) color = None else: - scalars = None vertex_color = None with warnings.catch_warnings(record=True): # traits surface = self.mlab.triangular_mesh(x, y, z, triangles, @@ -95,10 +102,22 @@ def mesh(self, x, y, z, triangles, color, opacity=1.0, shading=False, scalars=scalars, opacity=opacity, figure=self.fig, + vmin=vmin, + vmax=vmax, **kwargs) if vertex_color is not None: surface.module_manager.scalar_lut_manager.lut.table = \ vertex_color + elif isinstance(colormap, np.ndarray): + l_m = surface.module_manager.scalar_lut_manager + if colormap.dtype == np.uint8: + l_m.lut.table = colormap + elif colormap.dtype == np.float: + l_m.load_lut_from_list(colormap) + else: + raise TypeError('Expected type for colormap values are' + ' np.float or np.uint8: ' + '{} was given'.format(colormap.dtype)) surface.actor.property.shading = shading surface.actor.property.backface_culling = backface_culling return surface @@ -193,19 +212,39 @@ def quiver3d(self, x, y, z, u, v, w, color, scale, mode, resolution=8, glyph_resolution quiv.actor.property.backface_culling = backface_culling - def text2d(self, x, y, text, width, color=(1.0, 1.0, 1.0)): + def text2d(self, x, y, text, size=14, color=(1.0, 1.0, 1.0), + justification=None): + size = 14 if size is None else size with warnings.catch_warnings(record=True): # traits - self.mlab.text(x, y, text, width=width, color=color, - figure=self.fig) + text = self.mlab.text(x, y, text, color=color, figure=self.fig) + text.property.font_size = size + text.actor.text_scale_mode = 'viewport' + if isinstance(justification, str): + text.property.justification = justification def text3d(self, x, y, z, text, scale, color=(1.0, 1.0, 1.0)): with warnings.catch_warnings(record=True): # traits self.mlab.text3d(x, y, z, text, scale=scale, color=color, figure=self.fig) - def scalarbar(self, source, title=None, n_labels=4): + def scalarbar(self, source, title=None, n_labels=4, bgcolor=None): with warnings.catch_warnings(record=True): # traits self.mlab.scalarbar(source, title=title, nb_labels=n_labels) + if bgcolor is not None: + from tvtk.api import tvtk + bgcolor = np.asarray(bgcolor) + bgcolor = np.append(bgcolor, 1.0) * 255. + cmap = source.module_manager.scalar_lut_manager + lut = cmap.lut + ctable = lut.table.to_array() + cbar_lut = tvtk.LookupTable() + cbar_lut.deep_copy(lut) + alphas = ctable[:, -1][:, np.newaxis] / 255. + use_lut = ctable.copy() + use_lut[:, -1] = 255. + vals = (use_lut * alphas) + bgcolor * (1 - alphas) + cbar_lut.table.from_array(vals) + cmap.scalar_bar.lookup_table = cbar_lut def show(self): if self.fig is not None: @@ -220,9 +259,17 @@ def set_camera(self, azimuth=None, elevation=None, distance=None, elevation=elevation, distance=distance, focalpoint=focalpoint) - def screenshot(self): - with warnings.catch_warnings(record=True): # traits - return self.mlab.screenshot(self.fig) + def screenshot(self, mode='rgb', filename=None): + from mne.viz.backends.renderer import MNE_3D_BACKEND_TEST_DATA + if MNE_3D_BACKEND_TEST_DATA: + ndim = 3 if mode == 'rgb' else 4 + return np.zeros(tuple(self.window_size) + (ndim,), np.uint8) + else: + with warnings.catch_warnings(record=True): # traits + img = self.mlab.screenshot(self.fig, mode=mode) + if isinstance(filename, str): + _save_figure(img, filename) + return img def project(self, xyz, ch_names): xy = _3d_to_2d(self.fig, xyz) @@ -361,3 +408,18 @@ def _set_3d_title(figure, title, size=40): text.property.vertical_justification = 'top' text.property.font_size = size mlab.draw(figure) + + +def _check_figure(figure): + from mayavi.core.scene import Scene + if not isinstance(figure, Scene): + raise TypeError('figure must be a mayavi scene') + + +def _save_figure(img, filename): + from matplotlib.backends.backend_agg import FigureCanvasAgg + from matplotlib.figure import Figure + fig = Figure(frameon=False) + FigureCanvasAgg(fig) + fig.figimage(img, resize=True) + fig.savefig(filename) diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index 3741d0eb65f..dde302481a6 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -18,6 +18,8 @@ from ._utils import _get_colormap_from_array from ...utils import copy_base_doc_to_subclass_doc +_FIGURES = dict() + class _Figure(object): def __init__(self, plotter=None, @@ -25,6 +27,7 @@ def __init__(self, plotter=None, display=None, title='PyVista Scene', size=(600, 600), + shape=(1, 1), background_color=(0., 0., 0.), smooth_shading=True, off_screen=False, @@ -39,6 +42,7 @@ def __init__(self, plotter=None, self.store = dict() self.store['title'] = title self.store['window_size'] = size + self.store['shape'] = shape self.store['off_screen'] = off_screen def build(self): @@ -53,8 +57,6 @@ def build(self): if self.plotter_class == Plotter: self.store.pop('title', None) - elif self.plotter_class == BackgroundPlotter: - self.store.pop('off_screen', None) if self.plotter is None: plotter = self.plotter_class(**self.store) @@ -62,6 +64,11 @@ def build(self): self.plotter = plotter return self.plotter + def is_active(self): + if self.plotter is None: + return False + return hasattr(self.plotter, 'ren_win') + class _Projection(object): """Class storing projection information. @@ -97,24 +104,42 @@ class _Renderer(_BaseRenderer): """ def __init__(self, fig=None, size=(600, 600), bgcolor=(0., 0., 0.), - name="PyVista Scene", show=False): + name="PyVista Scene", show=False, shape=(1, 1)): + from pyvista import OFF_SCREEN from mne.viz.backends.renderer import MNE_3D_BACKEND_TEST_DATA - if fig is None: - self.figure = _Figure(title=name, size=size, - background_color=bgcolor, - notebook=_check_notebook()) + figure = _Figure(title=name, size=size, shape=shape, + background_color=bgcolor, notebook=_check_notebook()) + self.font_family = "arial" + if isinstance(fig, int): + saved_fig = _FIGURES.get(fig) + # Restore only active plotter + if saved_fig is not None and saved_fig.is_active(): + self.figure = saved_fig + else: + self.figure = figure + _FIGURES[fig] = self.figure + elif fig is None: + self.figure = figure else: self.figure = fig - if MNE_3D_BACKEND_TEST_DATA: - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - from pyvista import Plotter - self.figure.plotter_class = Plotter + # Enable off_screen if sphinx-gallery or testing + if OFF_SCREEN or MNE_3D_BACKEND_TEST_DATA: self.figure.store['off_screen'] = True - self.plotter = self.figure.build() - self.plotter.hide_axes() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + if MNE_3D_BACKEND_TEST_DATA: + from pyvista import Plotter + self.figure.plotter_class = Plotter + + self.plotter = self.figure.build() + self.plotter.hide_axes() + + def subplot(self, x, y): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=FutureWarning) + self.plotter.subplot(x, y) def scene(self): return self.figure @@ -123,7 +148,8 @@ def set_interactive(self): self.plotter.enable_terrain_style() def mesh(self, x, y, z, triangles, color, opacity=1.0, shading=False, - backface_culling=False, **kwargs): + backface_culling=False, scalars=None, colormap=None, + vmin=None, vmax=None, **kwargs): with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=FutureWarning) from pyvista import PolyData @@ -132,7 +158,8 @@ def mesh(self, x, y, z, triangles, color, opacity=1.0, shading=False, n_vertices = len(vertices) triangles = np.c_[np.full(len(triangles), 3), triangles] pd = PolyData(vertices, triangles) - if len(color) == n_vertices: + rgba = False + if color is not None and len(color) == n_vertices: if color.shape[1] == 3: scalars = np.c_[color, np.ones(n_vertices)] else: @@ -144,13 +171,16 @@ def mesh(self, x, y, z, triangles, color, opacity=1.0, shading=False, # https://github.com/pyvista/pyvista-support/issues/15 smooth_shading = False rgba = True - else: - scalars = None - rgba = False + if isinstance(colormap, np.ndarray): + if colormap.dtype == np.uint8: + colormap = colormap.astype(np.float) / 255. + from matplotlib.colors import ListedColormap + colormap = ListedColormap(colormap) self.plotter.add_mesh(mesh=pd, color=color, scalars=scalars, - rgba=rgba, opacity=opacity, + rgba=rgba, opacity=opacity, cmap=colormap, backface_culling=backface_culling, + rng=[vmin, vmax], show_scalar_bar=False, smooth_shading=smooth_shading) def contour(self, surface, scalars, contours, line_width=1.0, opacity=1.0, @@ -295,7 +325,7 @@ def quiver3d(self, x, y, z, u, v, w, color, scale, mode, resolution=8, elif mode == "cylinder": cylinder = vtk.vtkCylinderSource() cylinder.SetHeight(glyph_height) - cylinder.SetRadius(glyph_height) + cylinder.SetRadius(0.15) cylinder.SetCenter(glyph_center) cylinder.SetResolution(glyph_resolution) cylinder.Update() @@ -319,12 +349,28 @@ def quiver3d(self, x, y, z, u, v, w, color, scale, mode, resolution=8, smooth_shading=self.figure. smooth_shading) - def text2d(self, x, y, text, width, color=(1.0, 1.0, 1.0)): + def text2d(self, x, y, text, size=14, color=(1.0, 1.0, 1.0), + justification=None): + size = 14 if size is None else size + position = (x, y) with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=FutureWarning) - self.plotter.add_text(text, position=(x, y), - font_size=int(width * 100), - color=color) + actor = self.plotter.add_text(text, position=position, + font_size=size, + font=self.font_family, + color=color, + viewport=True) + if isinstance(justification, str): + if justification == 'left': + actor.GetTextProperty().SetJustificationToLeft() + elif justification == 'center': + actor.GetTextProperty().SetJustificationToCentered() + elif justification == 'right': + actor.GetTextProperty().SetJustificationToRight() + else: + raise ValueError('Expected values for `justification`' + 'are `left`, `center` or `right` but ' + 'got {} instead.'.format(justification)) def text3d(self, x, y, z, text, scale, color=(1.0, 1.0, 1.0)): with warnings.catch_warnings(): @@ -333,14 +379,19 @@ def text3d(self, x, y, z, text, scale, color=(1.0, 1.0, 1.0)): labels=[text], point_size=scale, text_color=color, + font_family=self.font_family, name=text, shape_opacity=0) - def scalarbar(self, source, title=None, n_labels=4): + def scalarbar(self, source, title=None, n_labels=4, bgcolor=None): with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=FutureWarning) self.plotter.add_scalar_bar(title=title, n_labels=n_labels, - position_x=0.15, width=0.7) + use_opacity=False, n_colors=256, + position_x=0.15, width=0.7, + label_font_size=22, + font_family=self.font_family, + background_color=bgcolor) def show(self): self.figure.display = self.plotter.show() @@ -354,8 +405,9 @@ def set_camera(self, azimuth=None, elevation=None, distance=None, _set_3d_view(self.figure, azimuth=azimuth, elevation=elevation, distance=distance, focalpoint=focalpoint) - def screenshot(self): - return self.plotter.screenshot() + def screenshot(self, mode='rgb', filename=None): + return self.plotter.screenshot(transparent_background=(mode == 'rgba'), + filename=filename) def project(self, xyz, ch_names): xy = _3d_to_2d(self.plotter, xyz) @@ -487,3 +539,8 @@ def _set_3d_title(figure, title, size=40): with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=FutureWarning) figure.plotter.add_text(title, font_size=32, color=(1.0, 1.0, 1.0)) + + +def _check_figure(figure): + if not isinstance(figure, _Figure): + raise TypeError('figure must be an instance of _Figure') diff --git a/mne/viz/backends/base_renderer.py b/mne/viz/backends/base_renderer.py index f9ec471bdb1..ffaf424ff14 100644 --- a/mne/viz/backends/base_renderer.py +++ b/mne/viz/backends/base_renderer.py @@ -13,10 +13,15 @@ class _BaseRenderer(metaclass=ABCMeta): @abstractclassmethod def __init__(self, fig=None, size=(600, 600), bgcolor=(0., 0., 0.), - name=None, show=False): + name=None, show=False, shape=(1, 1)): """Set up the scene.""" pass + @abstractclassmethod + def subplot(self, x, y): + """Set the active subplot.""" + pass + @abstractclassmethod def scene(self): """Return scene handle.""" @@ -227,7 +232,7 @@ def quiver3d(self, x, y, z, u, v, w, color, scale, mode, resolution=8, pass @abstractclassmethod - def text2d(self, x, y, text, width, color=(1.0, 1.0, 1.0)): + def text2d(self, x, y, text, size=14, color=(1.0, 1.0, 1.0)): """Add 2d text in the scene. Parameters @@ -238,8 +243,8 @@ def text2d(self, x, y, text, width, color=(1.0, 1.0, 1.0)): The Y component to use as position of the text. text: str The content of the text. - width: float - The width of the text. + size: int + The size of the font. color: tuple The color of the text. """ @@ -310,8 +315,17 @@ def set_camera(self, azimuth=None, elevation=None, distance=None, pass @abstractclassmethod - def screenshot(self): - """Take a screenshot of the scene.""" + def screenshot(self, mode='rgb', filename=None): + """Take a screenshot of the scene. + + Parameters + ---------- + mode: str + Either 'rgb' or 'rgba' for values to return. + Default is 'rgb'. + filename: str | None + If not None, save the figure to the disk. + """ pass @abstractclassmethod diff --git a/mne/viz/backends/renderer.py b/mne/viz/backends/renderer.py index 5e146010515..c780ca16909 100644 --- a/mne/viz/backends/renderer.py +++ b/mne/viz/backends/renderer.py @@ -24,7 +24,7 @@ logger.info('Using %s 3d backend.\n' % MNE_3D_BACKEND) -_fromlist = ('_Renderer', '_Projection', '_close_all') +_fromlist = ('_Renderer', '_Projection', '_close_all', '_check_figure') _name_map = dict(mayavi='_pysurfer_mayavi', pyvista='_pyvista') if MNE_3D_BACKEND in VALID_3D_BACKENDS: # This is (hopefully) the equivalent to: @@ -139,8 +139,7 @@ def _use_test_3d_backend(backend_name): """ with use_3d_backend(backend_name): global MNE_3D_BACKEND_TEST_DATA - if backend_name == 'pyvista': - MNE_3D_BACKEND_TEST_DATA = True + MNE_3D_BACKEND_TEST_DATA = True yield @@ -181,7 +180,7 @@ def set_3d_title(figure, title, size=40): _mod._set_3d_title(figure=figure, title=title, size=size) -def create_3d_figure(size, bgcolor=(0, 0, 0)): +def create_3d_figure(size, bgcolor=(0, 0, 0), handle=None): """Return an empty figure based on the current 3d backend. Parameters @@ -190,11 +189,13 @@ def create_3d_figure(size, bgcolor=(0, 0, 0)): The dimensions of the 3d figure (width, height). bgcolor: tuple The color of the background. + handle: int | None + The figure identifier. Returns ------- figure: The requested empty scene. """ - renderer = _mod._Renderer(size=size, bgcolor=bgcolor) + renderer = _mod._Renderer(fig=handle, size=size, bgcolor=bgcolor) return renderer.scene() diff --git a/mne/viz/backends/tests/test_renderer.py b/mne/viz/backends/tests/test_renderer.py index 98b454a3731..2d09db2b11e 100644 --- a/mne/viz/backends/tests/test_renderer.py +++ b/mne/viz/backends/tests/test_renderer.py @@ -44,6 +44,15 @@ def test_backend_environment_setup(backend, backend_mocker, monkeypatch): assert get_3d_backend() == backend +def test_3d_functions(renderer): + """Test figure management functions.""" + fig = renderer.create_3d_figure((300, 300)) + renderer._check_figure(fig) + renderer.set_3d_view(figure=fig) + renderer.set_3d_title(figure=fig, title='foo') + renderer._close_all() + + def test_3d_backend(renderer): """Test default plot.""" # set data @@ -86,7 +95,7 @@ def test_3d_backend(renderer): txt_x = 0.0 txt_y = 0.0 txt_text = "renderer" - txt_width = 1.0 + txt_size = 14 cam_distance = 5 * tet_size @@ -131,7 +140,8 @@ def test_3d_backend(renderer): rend.scalarbar(source=tube, title="Scalar Bar") # use text - rend.text2d(x=txt_x, y=txt_y, text=txt_text, width=txt_width) + rend.text2d(x=txt_x, y=txt_y, text=txt_text, + size=txt_size, justification='right') rend.text3d(x=0, y=0, z=0, text=txt_text, scale=1.0) rend.set_camera(azimuth=180.0, elevation=90.0, distance=cam_distance, diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index b940810fbe6..fb573fc7b0f 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -31,7 +31,7 @@ plot_sensors_connectivity, plot_brain_colorbar) from mne.viz.utils import _fake_click from mne.utils import (requires_mayavi, requires_pysurfer, run_tests_if_main, - _import_mlab, requires_nibabel, check_version, + requires_nibabel, check_version, traits_test, requires_version, catch_logging) from mne.datasets import testing from mne.source_space import read_source_spaces @@ -335,7 +335,7 @@ def test_plot_alignment(tmpdir, renderer): @requires_pysurfer @requires_mayavi @traits_test -def test_limits_to_control_points(): +def test_limits_to_control_points(renderer): """Test functionality for determining control points.""" sample_src = read_source_spaces(src_fname) kwargs = dict(subjects_dir=subjects_dir, smoothing_steps=1) @@ -348,14 +348,12 @@ def test_limits_to_control_points(): stc = SourceEstimate(stc_data, vertices, 1, 1, 'sample') # Test for simple use cases - mlab = _import_mlab() stc.plot(**kwargs) stc.plot(clim=dict(pos_lims=(10, 50, 90)), **kwargs) stc.plot(colormap='hot', clim='auto', **kwargs) stc.plot(colormap='mne', clim='auto', **kwargs) - figs = [mlab.figure(), mlab.figure()] stc.plot(clim=dict(kind='value', lims=(10, 50, 90)), figure=99, **kwargs) - pytest.raises(ValueError, stc.plot, clim='auto', figure=figs, **kwargs) + pytest.raises(TypeError, stc.plot, clim='auto', figure=[0], **kwargs) # Test for correct clim values with pytest.raises(ValueError, match='monotonically'): @@ -378,7 +376,7 @@ def test_limits_to_control_points(): stc._data.fill(0.) with pytest.warns(RuntimeWarning, match='All data were zero'): plot_source_estimates(stc, **kwargs) - mlab.close(all=True) + renderer._close_all() @testing.requires_testing_data diff --git a/requirements.txt b/requirements.txt index 97230d8b995..42f3b7f1cf2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,5 +29,5 @@ neo xlrd pydocstyle flake8 -pyvista>=0.21.3 +https://github.com/pyvista/pyvista/zipball/master panel