From 5bafe5287dd27ef6e07a64609615326110536578 Mon Sep 17 00:00:00 2001 From: Martin Schulz Date: Wed, 3 Nov 2021 04:27:15 +0100 Subject: [PATCH] [ENH]: New pyqtgraph-backend for 2D-Data-Browser (#9687) * start abstraction of Browser-Classes * outline of data-management-class * wip refactoring MNEBrowserFigure * refactored Browser-Initialization into MNEDataBrowser * fix some style-issues and remove remnants * start with integration of pyqtgraph-prototype * make pyqtgraph optional (just for development, can be removed again for final PR) * more refactoring of mpl-methods into BrowserBase * fixes for failing tests * add docstrings in _browser.py * move _update_projector-call into BrowserBase * add use/set/get for browser * rename 2D to Browser to avoid confusion * move inheritance of BrowserBaser from MNEFigure to MNEBrowseFigure * refactored _annotation_helper from test_raw.py * fix flake * move base-classes/funcs from _browser.py back to _figure.py to facilitate review * update from upstream * remove set/get/use-browser from __init__.py again to prevent make docstring from failing * add show_browser and other adjustments for pyqtgraph * refactoring _close into BrowserBase * add block for pyqtgraph * [revert for PR] adjust plot-function to accept kwargs from benchmark * fix annotation-key still working when plotting epochs * add docstring to show_browser * refactor midpoints into BrowserBase for epochs * add show to show_broser * add pyqtgraph to browse_backend-fixture * fix block not supported in Figure.show() * WIP adapt test_raw.test_plot_raw_traces * adapt test_plot_raw_traces to make it work for matplotlib and pyqtgraph * refactor _redraw * refactor _update_data * change bad-color from rgb to hex * pyqtgraph always blocks execution * reinsert block for pyqtgraph * make _redraw not abstract anymore (not needed for pyqtgraph) * add pyqtgraph-backend * fix butterfly-bug showing channels still in old y-position [ci skip] * rebase on main [ci skip] * Set usage of OpenGL to false by default * organize keyboard-shortcuts [ci skip] * clarify index-system for traces and set z-values for traces and annotations [ci skip] * add exception-hook from pytest-qt [ci skip] * actually raise exceptions from qt [ci skip] * fix bugs annotations (removing decription/select visible) [ci skip] * reorganize imports [ci skip] * fix bug _update_regions_colors [ci skip] * add '=' to keyboard-shortcuts and make scale-steps smaller [ci skip] * import pg-backend from separate repo[ci skip] * remove pg-backend from PR[ci skip] * update repo-link to mne-tools[ci skip] * Update mne/viz/_figure.py Co-authored-by: Alexandre Gramfort * remove _pg_figure from _backends[ci skip] * add browser-backend-functions to documentation[ci skip] * avoid codespell-failure [ci skip] * remove pyqtgraph from tests (tests will be run in mne-qt-browser for now) * update parameters for Raw.plot() * fix flake * fix pip install link * fix table in set_browser_backend * fix butterfly showing always all channels despite of selection * adjust test_scale_bar for pyqtgraph [ci skip] * refactor channel context figs [ci skip] * change to relative imports [ci skip] * refactor _new_child_figure [ci skip] * update some key-presses [ci skip] * adapt test_plot_raw_ssp_interaction to pyqtgraph [ci skip] * adapt test_plot_raw_child_figures to pyqtgraph [ci skip] * change default of event_lines to list [ci skip] * add drag to _fake_click [ci skip] * adapt test_annotations to pyqtgraph [ci skip] * adapt test_clock_xticks to pyqtgraph [ci skip] * remove install-question [ci skip] * adapt pyqtgraph-backend to test_plot_raw_selection [ci skip] * adapt pyqtgraph-backend to test_min_window_size [ci skip] * adapt pyqtgraph-backend to test_plot_raw_groupby [ci skip] * fix multiple tests [ci skip] * adapt annotation-test for pyqtgraph [ci skip] * fix more test-issues [ci skip] * add pyqtgraph to test_raw-suite [ci skip] * fix flake * update feature-grid * update from main branch * fix checkbox-click issue (inconsistent across OS) * fix test_plot_raw_ssp_interaction for linux * update from upstream2 * fix flake * simplify ssp_interaction * fix test_min_window_size for Windows-CI * fix flake * implement review-feedback #1 * fix _proj_click_all for inconsistent fake-click-behaviour * clarify docs for preload * fix flake * [Refactor]: browser_backend-fixture for consistency * DOC: Add doc comments for block * add speed test * rename preload to precompute * fix unused import * remove unnecessary parameters * remove speed-test * remove automatic installation of mne-qt-browser * revert removal of automatic installation until mne-qt-browser is uploaded to PyPi * FIX: Route through call * add mne-qt-browser to requirements.txt * update latest.inc * fix flake * fix latest.inc * update test-dependencies * specify docs regarding block-behaviour * fix typo in github_actions_dependencies.sh * fix docstring for plot_raw * fix docstring for plot_raw again * fix docstring for plot_raw * add mne-qt-browser to azure_dependencies.sh * add mne-qt-browser to environment.yml * specify latest.inc * specifiy use_opengl documentation * Update mne/viz/_figure.py Co-authored-by: Eric Larson * FIX: Fix test [skip azp] [skip circle] * FIX: Path [skip azp] [skip circle] * revert color-name-changes * MAINT: Test only on one run * FIX: Remove * MNT: Add to mne sys_info * DOC: Fix doc build * FIX: Better * DOC: Add link to mne-qt-browser issues in docs * Update mne/utils/docs.py Co-authored-by: Daniel McCloy * Update mne/utils/docs.py Co-authored-by: Daniel McCloy * Update mne/viz/raw.py Co-authored-by: Daniel McCloy * FIX: fix flake * FIX: Test * FIX: Correct check * STY: Flake * FIX: One more mark Co-authored-by: Alexandre Gramfort Co-authored-by: Eric Larson Co-authored-by: Daniel McCloy --- .github/workflows/compat_minimal.yml | 2 +- azure-pipelines.yml | 12 +- doc/changes/latest.inc | 6 +- doc/cited.rst | 6 +- doc/visualization.rst | 3 + environment.yml | 1 + mne/commands/mne_browse_raw.py | 5 +- mne/conftest.py | 54 ++- mne/io/base.py | 9 +- mne/utils/config.py | 4 +- mne/utils/docs.py | 23 ++ mne/utils/tests/test_config.py | 4 +- mne/viz/__init__.py | 5 +- mne/viz/_figure.py | 361 ++++++++++++++--- mne/viz/_mpl_figure.py | 240 +++--------- mne/viz/epochs.py | 4 +- mne/viz/ica.py | 2 +- mne/viz/raw.py | 35 +- mne/viz/tests/test_epochs.py | 32 +- mne/viz/tests/test_ica.py | 8 +- mne/viz/tests/test_raw.py | 553 ++++++++++++++++----------- mne/viz/utils.py | 33 +- requirements.txt | 1 + tools/github_actions_infos.sh | 1 + tools/github_actions_test.sh | 4 +- 25 files changed, 876 insertions(+), 532 deletions(-) diff --git a/.github/workflows/compat_minimal.yml b/.github/workflows/compat_minimal.yml index 2dbdfbcb98a..c6f9154d723 100644 --- a/.github/workflows/compat_minimal.yml +++ b/.github/workflows/compat_minimal.yml @@ -55,7 +55,7 @@ jobs: run: ./tools/get_testing_version.sh name: 'Get testing version' - shell: bash -el {0} - run: MNE_SKIP_TESTING_DATASET_TESTS=true pytest -m "not ultraslowtest" --tb=short --cov=mne --cov-report xml -vv -rfE mne/ + run: MNE_SKIP_TESTING_DATASET_TESTS=true pytest -m "not (ultraslowtest or pgtest)" --tb=short --cov=mne --cov-report xml -vv -rfE mne/ name: Run tests with no testing data - uses: actions/cache@v2 with: diff --git a/azure-pipelines.yml b/azure-pipelines.yml index c1dee24aef8..6d37c1ee396 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -81,7 +81,7 @@ stages: variables: AZURE_CI: 'true' jobs: - - job: Ultraslow + - job: Ultraslow+PG pool: vmImage: 'ubuntu-20.04' variables: @@ -111,6 +111,8 @@ stages: - bash: | set -e python -m pip install --progress-bar off --upgrade pip setuptools wheel codecov + python -m pip install --progress-bar off mne-qt-browser + python -m pip uninstall -yq mne python -m pip install --progress-bar off --upgrade -e .[test] displayName: 'Install dependencies with pip' - script: mne sys_info -pd @@ -124,8 +126,8 @@ stages: displayName: 'Cache testing data' - script: python -c "import mne; mne.datasets.testing.data_path(verbose=True)" displayName: 'Get test data' - - script: pytest -m "ultraslowtest" --tb=short --cov=mne --cov-report=xml --cov-report=html -vv mne - displayName: 'Run ultraslow tests' + - script: pytest -m "ultraslowtest or pgtest" --tb=short --cov=mne --cov-report=xml --cov-report=html -vv mne + displayName: 'Run ultraslow and PyQtGraph mne-qt-browser tests' - bash: bash <(curl -s https://codecov.io/bash) displayName: 'Codecov' condition: succeededOrFailed() @@ -169,7 +171,7 @@ stages: displayName: 'Cache testing data' - script: python -c "import mne; mne.datasets.testing.data_path(verbose=True)" displayName: 'Get test data' - - script: pytest --tb=short --cov=mne --cov-report=xml --cov-report=html -vv mne/viz + - script: pytest --tb=short -m "not pgtest" --cov=mne --cov-report=xml --cov-report=html -vv mne/viz displayName: 'Run viz tests' - bash: bash <(curl -s https://codecov.io/bash) displayName: 'Codecov' @@ -264,7 +266,7 @@ stages: displayName: 'Cache testing data' - script: python -c "import mne; mne.datasets.testing.data_path(verbose=True)" displayName: 'Get test data' - - script: pytest -m "not slowtest" --tb=short --cov=mne --cov-report=xml --cov-report=html -vv mne + - script: pytest -m "not (slowtest or pgtest)" --tb=short --cov=mne --cov-report=xml --cov-report=html -vv mne displayName: 'Run tests' - bash: bash <(curl -s https://codecov.io/bash) displayName: 'Codecov' diff --git a/doc/changes/latest.inc b/doc/changes/latest.inc index 0567c2ef6e4..ba3a0fe7eb0 100644 --- a/doc/changes/latest.inc +++ b/doc/changes/latest.inc @@ -211,10 +211,14 @@ Enhancements - Add support for colormap normalization in :meth:`mne.time_frequency.AverageTFR.plot` (:gh:`9851` by `Clemens Brunner`_) -- Add support for BIDS-compatible filenames when splitting big epochs files via the new ``split_naming`` parameter in :meth:`mne.Epochs.save` (:gh:9869 by `Denis Engemann`_) +- Add support for BIDS-compatible filenames when splitting big epochs files via the new ``split_naming`` parameter in :meth:`mne.Epochs.save` (:gh:`9869` by `Denis Engemann`_) - Add ``by_event_type`` parameter to :meth:`mne.Epochs.average` to create a list containing an :class:`mne.Evoked` object for each event type (:gh:`9859` by `Marijn van Vliet`_) +- Add pyqtgraph as a new backend for :meth:`mne.io.Raw.plot` (:gh:`9687` by `Martin Schulz`_) + +- Add :func:`mne.viz.set_browser_backend`, :func:`mne.viz.use_browser_backend` and :func:`mne.viz.get_browser_backend` to set matplotlib or pyqtgraph as backend for :meth:`mne.io.Raw.plot` (:gh:`9687` by `Martin Schulz`_) + Bugs ~~~~ - Fix bug in :meth:`mne.io.Raw.pick` and related functions when parameter list contains channels which are not in info instance (:gh:`9708` **by new contributor** |Evgeny Goldstein|_) diff --git a/doc/cited.rst b/doc/cited.rst index 782bb34ef87..2bc8c12c7e4 100644 --- a/doc/cited.rst +++ b/doc/cited.rst @@ -3,7 +3,7 @@ Papers citing MNE-Python ======================== -Estimates provided by Google Scholar as of 27 January 2021: +Estimates provided by Google Scholar as of 02 November 2021: -- `MNE (908) `_ -- `MNE-Python (771) `_ +- `MNE (1100) `_ +- `MNE-Python (1060) `_ diff --git a/doc/visualization.rst b/doc/visualization.rst index 86fa5c3813c..43a7b0fce4f 100644 --- a/doc/visualization.rst +++ b/doc/visualization.rst @@ -79,3 +79,6 @@ Visualization close_3d_figure close_all_3d_figures get_brain_class + set_browser_backend + get_browser_backend + use_browser_backend diff --git a/environment.yml b/environment.yml index b406d5a3390..532d87ca34c 100644 --- a/environment.yml +++ b/environment.yml @@ -42,3 +42,4 @@ dependencies: - pooch - pip: - ipyvtklink + - mne-qt-browser diff --git a/mne/commands/mne_browse_raw.py b/mne/commands/mne_browse_raw.py index 2d1a443fd3a..79f96657d7f 100644 --- a/mne/commands/mne_browse_raw.py +++ b/mne/commands/mne_browse_raw.py @@ -21,7 +21,6 @@ def run(): """Run command.""" - import matplotlib.pyplot as plt from mne.commands.utils import get_optparser, _add_verbose_flag from mne.viz import _RAW_CLIP_DEF @@ -135,8 +134,8 @@ def run(): raw.plot(duration=duration, start=start, n_channels=n_channels, group_by=group_by, show_options=show_options, events=events, highpass=highpass, lowpass=lowpass, filtorder=filtorder, - clipping=clipping, proj=not proj_off, verbose=verbose) - plt.show(block=True) + clipping=clipping, proj=not proj_off, verbose=verbose, + show=True, block=True) mne.utils.run_command_if_main() diff --git a/mne/conftest.py b/mne/conftest.py index 8ab5d4643c0..6db364e9c6d 100644 --- a/mne/conftest.py +++ b/mne/conftest.py @@ -25,7 +25,8 @@ from mne.fixes import has_numba from mne.io import read_raw_fif, read_raw_ctf from mne.stats import cluster_level -from mne.utils import _pl, _assert_no_instances, numerics, Bunch +from mne.utils import (_pl, _assert_no_instances, numerics, Bunch, + _check_pyqt5_version) # data from sample dataset from mne.viz._figure import use_browser_backend @@ -61,7 +62,7 @@ def pytest_configure(config): """Configure pytest options.""" # Markers - for marker in ('slowtest', 'ultraslowtest'): + for marker in ('slowtest', 'ultraslowtest', 'pgtest'): config.addinivalue_line('markers', marker) # Fixtures @@ -397,11 +398,52 @@ def garbage_collect(): gc.collect() -@pytest.fixture(params=['matplotlib']) -def browse_backend(request, garbage_collect): +@pytest.fixture +def mpl_backend(garbage_collect): + """Use for epochs/ica when not implemented with pyqtgraph yet.""" + with use_browser_backend('matplotlib') as backend: + yield backend + backend._close_all() + + +def _check_pyqtgraph(): + try: + import PyQt5 # noqa: F401 + except ModuleNotFoundError: + pytest.skip('PyQt5 is not installed but needed for pyqtgraph!') + try: + assert LooseVersion(_check_pyqt5_version()) >= LooseVersion('5.12') + except AssertionError: + pytest.skip(f'PyQt5 has version {_check_pyqt5_version()}' + f'but pyqtgraph needs >= 5.12!') + try: + import mne_qt_browser # noqa: F401 + except Exception: + pytest.skip('Requires mne_qt_browser') + + +@pytest.mark.pgtest +@pytest.fixture +def pg_backend(garbage_collect): + """Use for pyqtgraph-specific test-functions.""" + _check_pyqtgraph() + with use_browser_backend('pyqtgraph') as backend: + yield backend + backend._close_all() + + +@pytest.fixture(params=[ + 'matplotlib', + pytest.param('pyqtgraph', marks=pytest.mark.pgtest), +]) +def browser_backend(request, garbage_collect): """Parametrizes the name of the browser backend.""" - with use_browser_backend(request.param) as backend: + backend_name = request.param + if backend_name == 'pyqtgraph': + _check_pyqtgraph() + with use_browser_backend(backend_name) as backend: yield backend + backend._close_all() @pytest.fixture(params=["mayavi", "pyvistaqt"]) @@ -446,7 +488,7 @@ def renderer_interactive(request): if renderer._get_3d_backend() == 'mayavi': with warnings.catch_warnings(record=True): try: - from surfer import Brain # noqa: 401 analysis:ignore + from surfer import Brain # noqa: F401 analysis:ignore except Exception: pytest.skip('Requires PySurfer') yield renderer diff --git a/mne/io/base.py b/mne/io/base.py index 4cbeffbd1c6..b5e40095938 100644 --- a/mne/io/base.py +++ b/mne/io/base.py @@ -1517,22 +1517,23 @@ def _tmin_tmax_to_start_stop(self, tmin, tmax): @copy_function_doc_to_method_doc(plot_raw) def plot(self, events=None, duration=10.0, start=0.0, n_channels=20, - bgcolor='w', color=None, bad_color=(0.8, 0.8, 0.8), + bgcolor='w', color=None, bad_color='lightgray', event_color='cyan', scalings=None, remove_dc=True, order=None, show_options=False, title=None, show=True, block=False, highpass=None, lowpass=None, filtorder=4, clipping=_RAW_CLIP_DEF, show_first_samp=False, proj=True, group_by='type', butterfly=False, decim='auto', noise_cov=None, event_id=None, show_scrollbars=True, show_scalebars=True, time_format='float', - verbose=None): + precompute='auto', use_opengl=True, verbose=None): return plot_raw(self, events, duration, start, n_channels, bgcolor, color, bad_color, event_color, scalings, remove_dc, order, show_options, title, show, block, highpass, lowpass, filtorder, clipping, show_first_samp, proj, group_by, butterfly, decim, noise_cov=noise_cov, event_id=event_id, show_scrollbars=show_scrollbars, - show_scalebars=show_scalebars, - time_format=time_format, verbose=verbose) + show_scalebars=show_scalebars, time_format=time_format, + precompute=precompute, use_opengl=use_opengl, + verbose=verbose) @verbose @copy_function_doc_to_method_doc(plot_raw_psd) diff --git a/mne/utils/config.py b/mne/utils/config.py index e27a3a8d252..0cf31607688 100644 --- a/mne/utils/config.py +++ b/mne/utils/config.py @@ -512,7 +512,7 @@ def sys_info(fid=None, show_paths=False, *, dependencies='user'): """ # noqa: E501 _validate_type(dependencies, str) _check_option('dependencies', dependencies, ('user', 'developer')) - ljust = 21 if dependencies == 'developer' else 15 + ljust = 21 if dependencies == 'developer' else 16 platform_str = platform.platform() if platform.system() == 'Darwin' and sys.version_info[:2] < (3, 8): # platform.platform() in Python < 3.8 doesn't call @@ -548,7 +548,7 @@ def sys_info(fid=None, show_paths=False, *, dependencies='user'): use_mod_names = ('mne', 'numpy', 'scipy', 'matplotlib', '', 'sklearn', 'numba', 'nibabel', 'nilearn', 'dipy', 'cupy', 'pandas', 'mayavi', 'pyvista', 'pyvistaqt', 'ipyvtklink', 'vtk', - 'PyQt5', 'ipympl') + 'PyQt5', 'ipympl', 'mne_qt_browser') if dependencies == 'developer': use_mod_names += ( '', 'sphinx', 'sphinx_gallery', 'numpydoc', 'pydata_sphinx_theme', diff --git a/mne/utils/docs.py b/mne/utils/docs.py index 241221bb2dd..778e9d291e7 100644 --- a/mne/utils/docs.py +++ b/mne/utils/docs.py @@ -1515,6 +1515,29 @@ .. versionadded:: 0.24 """ +# Visualization with pyqtgraph +docdict['precompute'] = """ +precompute : bool | str + Whether to load all data (not just the visible portion) into RAM and + apply preprocessing (e.g., projectors) to the full data array in a separate + processor thread, instead of window-by-window during scrolling. The default + ``'auto'`` compares available RAM space to the expected size of the + precomputed data, and precomputes only if enough RAM is available. ``True`` + and ``'auto'`` only work if using the pyQtGraph backend. + + .. versionadded:: 0.24 +""" + +docdict['use_opengl'] = """ +use_opengl : bool + Whether to use OpenGL when rendering the plot (requires ``pyopengl``). + May increase performance, but effect is dependent on system CPU and + graphics hardware. Only works if using the pyQtGraph backend. Default is + ``True``. + + .. versionadded:: 0.24 +""" + # PSD plotting docdict["plot_psd_doc"] = """ Plot the power spectral density across channels. diff --git a/mne/utils/tests/test_config.py b/mne/utils/tests/test_config.py index 1c33468fcb9..6c0ccef947e 100644 --- a/mne/utils/tests/test_config.py +++ b/mne/utils/tests/test_config.py @@ -86,7 +86,9 @@ def test_sys_info(): assert ('numpy:' in out) if platform.system() == 'Darwin': - assert 'Platform: macOS-' in out + assert 'Platform: macOS-' in out + elif platform.system() == 'Linux': + assert 'Platform: Linux' in out def test_get_subjects_dir(tmp_path, monkeypatch): diff --git a/mne/viz/__init__.py b/mne/viz/__init__.py index d4802264082..3b155a54e73 100644 --- a/mne/viz/__init__.py +++ b/mne/viz/__init__.py @@ -30,6 +30,9 @@ from .montage import plot_montage from .backends.renderer import (set_3d_backend, get_3d_backend, use_3d_backend, set_3d_view, set_3d_title, create_3d_figure, - close_3d_figure, close_all_3d_figures, get_brain_class) + close_3d_figure, close_all_3d_figures, + get_brain_class) from . import backends from ._brain import Brain +from ._figure import (get_browser_backend, set_browser_backend, + use_browser_backend) diff --git a/mne/viz/_figure.py b/mne/viz/_figure.py index f33685e684b..5185defee3a 100644 --- a/mne/viz/_figure.py +++ b/mne/viz/_figure.py @@ -7,23 +7,22 @@ # License: Simplified BSD import importlib from abc import ABC, abstractmethod +from collections import OrderedDict from contextlib import contextmanager from copy import deepcopy from itertools import cycle import numpy as np -from .. import verbose, get_config +from .. import verbose, get_config, set_config from ..annotations import _sync_onset +from ..defaults import _handle_default from ..utils import logger, _validate_type, _check_option +from ..io.pick import _DATA_CH_TYPES_SPLIT from .backends._utils import VALID_BROWSE_BACKENDS from .utils import _get_color_list, _setup_plot_projector MNE_BROWSE_BACKEND = None -_backends = dict( - matplotlib='._mpl_figure', - pyqtgraph='._pyqtgraph' -) backend = None @@ -47,6 +46,8 @@ def __init__(self, **kwargs): from ..io import BaseRaw from ..preprocessing import ICA + self.backend_name = None + self._data = None self._times = None @@ -102,9 +103,12 @@ def __init__(self, **kwargs): self.mne.annotation_hover_line = None self.mne.draggable_annotations = False # lines - self.mne.event_lines = None + self.mne.event_lines = list() self.mne.event_texts = list() self.mne.vline_visible = False + # decim + self.mne.decim_times = None + self.mne.decim_data = None # scalings if hasattr(self.mne, 'butterfly'): self.mne.scale_factor = 0.5 if self.mne.butterfly else 1. @@ -117,6 +121,11 @@ def __init__(self, **kwargs): self.mne.fig_histogram = None self.mne.fig_selection = None self.mne.fig_annotation = None + # extra attributes for epochs + if self.mne.is_epochs: + # add epoch boundaries & center epoch numbers between boundaries + self.mne.midpoints = np.convolve(self.mne.boundary_times, + np.ones(2), mode='valid') / 2 # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # ANNOTATIONS @@ -195,7 +204,11 @@ def _toggle_bad_channel(self, idx): else: while ch_name in bads: # to make sure duplicates are removed bads.remove(ch_name) - color = self.mne.ch_colors[idx] + # Only mpl-backend has ch_colors + if hasattr(self.mne, 'ch_colors'): + color = self.mne.ch_colors[idx] + else: + color = None self.mne.info['bads'] = bads self._update_projector() @@ -215,8 +228,15 @@ def _toggle_bad_epoch(self, xtime): return epoch_ix, color + def _toggle_whitening(self): + if self.mne.noise_cov is not None: + self.mne.use_noise_cov = not self.mne.use_noise_cov + self._update_projector() + self._update_yaxis_labels() # add/remove italics + self._redraw() + # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # - # DATA TRACES + # MANAGE TRACES # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # def _update_picks(self): @@ -225,23 +245,55 @@ def _update_picks(self): selections_dict = self._make_butterfly_selections_dict() self.mne.picks = np.concatenate(tuple(selections_dict.values())) elif self.mne.butterfly: - self.mne.picks = np.arange(self.mne.ch_names.shape[0]) + self.mne.picks = self.mne.ch_order else: _slice = slice(self.mne.ch_start, self.mne.ch_start + self.mne.n_channels) self.mne.picks = self.mne.ch_order[_slice] self.mne.n_channels = len(self.mne.picks) + def _make_butterfly_selections_dict(self): + """Make an altered copy of the selections dict for butterfly mode.""" + from ..utils import _get_stim_channel + selections_dict = deepcopy(self.mne.ch_selections) + # remove potential duplicates + for selection_group in ('Vertex', 'Custom'): + selections_dict.pop(selection_group, None) + # if present, remove stim channel from non-misc selection groups + stim_ch = _get_stim_channel(None, self.mne.info, raise_error=False) + if len(stim_ch): + stim_pick = self.mne.ch_names.tolist().index(stim_ch[0]) + for _sel, _picks in selections_dict.items(): + if _sel != 'Misc': + stim_mask = np.in1d(_picks, [stim_pick], invert=True) + selections_dict[_sel] = np.array(_picks)[stim_mask] + return selections_dict + # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # MANAGE DATA # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + def _get_start_stop(self): + # update time + start_sec = self.mne.t_start - self.mne.first_time + stop_sec = start_sec + self.mne.duration + if self.mne.is_epochs: + start, stop = np.round(np.array([start_sec, stop_sec]) + * self.mne.info['sfreq']).astype(int) + else: + start, stop = self.mne.inst.time_as_index((start_sec, stop_sec)) + + return start, stop + def _load_data(self, start=None, stop=None): """Retrieve the bit of data we need for plotting.""" if 'raw' in (self.mne.instance_type, self.mne.ica_type): # Add additional sample to cover the case sfreq!=1000 # when the shown time-range wouldn't correspond to duration anymore - return self.mne.inst[:, start:stop + 2] + if stop is None: + return self.mne.inst[:, start:] + else: + return self.mne.inst[:, start:stop + 2] else: # subtract one sample from tstart before searchsorted, to make sure # we land on the left side of the boundary time (avoid precision @@ -254,47 +306,49 @@ def _load_data(self, start=None, stop=None): )[start:stop] / self.mne.info['sfreq'] return data, times - def _update_data(self): - """Update self.mne.data after user interaction.""" + def _apply_filter(self, data, start, stop, picks): + """Filter (with same defaults as raw.filter()).""" from ..filter import _overlap_add_filter, _filtfilt - # update time - start_sec = self.mne.t_start - self.mne.first_time - stop_sec = start_sec + self.mne.duration - if self.mne.is_epochs: - start, stop = np.round(np.array([start_sec, stop_sec]) - * self.mne.info['sfreq']).astype(int) - else: - start, stop = self.mne.inst.time_as_index((start_sec, stop_sec)) - # get the data - data, times = self._load_data(start, stop) + starts, stops = self.mne.filter_bounds + mask = (starts < stop) & (stops > start) + starts = np.maximum(starts[mask], start) - start + stops = np.minimum(stops[mask], stop) - start + for _start, _stop in zip(starts, stops): + _picks = np.where(np.in1d(picks, self.mne.picks_data))[0] + if len(_picks) == 0: + break + this_data = data[_picks, _start:_stop] + if isinstance(self.mne.filter_coefs, np.ndarray): # FIR + this_data = _overlap_add_filter( + this_data, self.mne.filter_coefs, copy=False) + else: # IIR + this_data = _filtfilt( + this_data, self.mne.filter_coefs, None, 1, False) + data[_picks, _start:_stop] = this_data + + def _process_data(self, data, start, stop, picks, + signals=None): + """Update self.mne.data after user interaction.""" # apply projectors if self.mne.projector is not None: + if signals: + signals.processText.emit('Applying Projectors...') data = self.mne.projector @ data # get only the channels we're displaying - picks = self.mne.picks data = data[picks] # remove DC if self.mne.remove_dc: + if signals: + signals.processText.emit('Removing DC...') data -= data.mean(axis=1, keepdims=True) - # filter (with same defaults as raw.filter()) + # apply filter if self.mne.filter_coefs is not None: - starts, stops = self.mne.filter_bounds - mask = (starts < stop) & (stops > start) - starts = np.maximum(starts[mask], start) - start - stops = np.minimum(stops[mask], stop) - start - for _start, _stop in zip(starts, stops): - _picks = np.where(np.in1d(picks, self.mne.picks_data))[0] - if len(_picks) == 0: - break - this_data = data[_picks, _start:_stop] - if isinstance(self.mne.filter_coefs, np.ndarray): # FIR - this_data = _overlap_add_filter( - this_data, self.mne.filter_coefs, copy=False) - else: # IIR - this_data = _filtfilt( - this_data, self.mne.filter_coefs, None, 1, False) - data[_picks, _start:_stop] = this_data + if signals: + signals.processText.emit('Apply Filter...') + self._apply_filter(data, start, stop, picks) # scale the data for display in a 1-vertical-axis-unit slot + if signals: + signals.processText.emit('Scale Data...') this_names = self.mne.ch_names[picks] this_types = self.mne.ch_types[picks] stims = this_types == 'stim' @@ -306,6 +360,16 @@ def _update_data(self): norms[white] = self.mne.scalings['whitened'] norms[norms == 0] = 1 data /= 2 * norms[:, np.newaxis] + + return data + + def _update_data(self): + start, stop = self._get_start_stop() + # get the data + data, times = self._load_data(start, stop) + # process the data + data = self._process_data(data, start, stop, self.mne.picks) + # set the data as attributes self.mne.data = data self.mne.times = times @@ -313,16 +377,189 @@ def _get_epoch_num_from_time(self, time): epoch_nums = self.mne.inst.selection return epoch_nums[np.searchsorted(self.mne.boundary_times[1:], time)] - @abstractmethod - def _redraw(self, **kwargs): + def _redraw(self, update_data=True, annotations=False): """Redraws backend if necessary.""" - pass + if update_data: + self._update_data() + + self._draw_traces() + + if annotations and not self.mne.is_epochs: + self._draw_annotations() + + def _close(self, event): + """Handle close events (via keypress or window [x]).""" + # write out bad epochs (after converting epoch numbers to indices) + if self.mne.instance_type == 'epochs': + bad_ixs = np.in1d(self.mne.inst.selection, + self.mne.bad_epochs).nonzero()[0] + self.mne.inst.drop(bad_ixs) + # write bad channels back to instance (don't do this for proj; + # proj checkboxes are for viz only and shouldn't modify the instance) + if self.mne.instance_type in ('raw', 'epochs'): + self.mne.inst.info['bads'] = self.mne.info['bads'] + logger.info( + f"Channels marked as bad: {self.mne.info['bads'] or 'none'}") + # ICA excludes + elif self.mne.instance_type == 'ica': + self.mne.ica.exclude = [self.mne.ica._ica_names.index(ch) + for ch in self.mne.info['bads']] + # write window size to config + str_size = ','.join([str(i) for i in self._get_size()]) + set_config('MNE_BROWSE_RAW_SIZE', str_size, set_env=False) + # Clean up child figures (don't pop(), child figs remove themselves) + while len(self.mne.child_figs): + fig = self.mne.child_figs[-1] + self._close_event(fig) # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # - # INTERACTION + # CHILD FIGURES # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @abstractmethod + def _new_child_figure(self, fig_name, **kwargs): + pass + + def _create_ch_context_fig(self, idx): + """Show context figure; idx is index of **visible** channels.""" + inst = self.mne.instance_type + pick = self.mne.picks[idx] + if inst == 'raw': + fig = self._create_ch_location_fig(pick) + elif inst == 'ica': + fig = self._create_ica_properties_fig(pick) + else: + fig = self._create_epoch_image_fig(pick) + + return fig + + def _create_ch_location_fig(self, pick): + """Show channel location figure.""" + from .utils import _channel_type_prettyprint, plot_sensors + ch_name = self.mne.ch_names[pick] + ch_type = self.mne.ch_types[pick] + if ch_type not in _DATA_CH_TYPES_SPLIT: + return + # create figure and axes + title = f'Location of {ch_name}' + fig = self._new_child_figure(figsize=(4, 4), fig_name=None, + window_title=title) + fig.suptitle(title) + ax = fig.add_subplot(111) + title = f'{ch_name} position ({_channel_type_prettyprint[ch_type]})' + _ = plot_sensors(self.mne.info, ch_type=ch_type, axes=ax, + title=title, kind='select', show=False) + # highlight desired channel & disable interactivity + inds = np.in1d(fig.lasso.ch_names, [ch_name]) + fig.lasso.disconnect() + fig.lasso.alpha_other = 0.3 + fig.lasso.linewidth_selected = 3 + fig.lasso.style_sensors(inds) + + return fig + + def _create_ica_properties_fig(self, idx): + """Show ICA properties for the selected component.""" + from mne.viz.ica import (_create_properties_layout, + _prepare_data_ica_properties, + _fast_plot_ica_properties) + + ch_name = self.mne.ch_names[idx] + if ch_name not in self.mne.ica._ica_names: # for EOG chans: do nothing + return + pick = self.mne.ica._ica_names.index(ch_name) + title = f'{ch_name} properties' + fig = self._new_child_figure(figsize=(7, 6), fig_name=None, + window_title=title) + fig.suptitle(title) + fig, axes = _create_properties_layout(fig=fig) + if not hasattr(self.mne, 'data_ica_properties'): + # Precompute epoch sources only once + self.mne.data_ica_properties = _prepare_data_ica_properties( + self.mne.ica_inst, self.mne.ica) + _fast_plot_ica_properties( + self.mne.ica, self.mne.ica_inst, picks=pick, axes=axes, + precomputed_data=self.mne.data_ica_properties, show=False) + + return fig + + def _create_epoch_image_fig(self, pick): + """Show epochs image for the selected channel.""" + from matplotlib.gridspec import GridSpec + from mne.viz import plot_epochs_image + ch_name = self.mne.ch_names[pick] + title = f'Epochs image ({ch_name})' + fig = self._new_child_figure(figsize=(6, 4), fig_name=None, + window_title=title) + fig.suptitle = title + gs = GridSpec(nrows=3, ncols=10) + fig.add_subplot(gs[:2, :9]) + fig.add_subplot(gs[2, :9]) + fig.add_subplot(gs[:2, 9]) + plot_epochs_image(self.mne.inst, picks=pick, fig=fig, show=False) + + return fig + + def _toggle_epoch_histogram(self): + """Show or hide peak-to-peak histogram of channel amplitudes.""" + if self.mne.instance_type == 'epochs': + if self.mne.fig_histogram is None: + self._create_epoch_histogram() + else: + from matplotlib.pyplot import close + close(self.mne.fig_histogram) + + def _create_epoch_histogram(self): + """Create peak-to-peak histogram of channel amplitudes.""" + epochs = self.mne.inst + data = OrderedDict() + ptp = np.ptp(epochs.get_data(), axis=2) + for ch_type in ('eeg', 'mag', 'grad'): + if ch_type in epochs: + data[ch_type] = ptp.T[self.mne.ch_types == ch_type].ravel() + units = _handle_default('units') + titles = _handle_default('titles') + colors = _handle_default('color') + scalings = _handle_default('scalings') + title = 'Histogram of peak-to-peak amplitudes' + figsize = (4, 1 + 1.5 * len(data)) + fig = self._new_child_figure(figsize=figsize, fig_name='fig_histogram', + window_title=title) + for ix, (_ch_type, _data) in enumerate(data.items()): + ax = fig.add_subplot(len(data), 1, ix + 1) + ax.set(title=titles[_ch_type], xlabel=units[_ch_type], + ylabel='Count') + # set histogram bin range based on rejection thresholds + reject = None + _range = None + if epochs.reject is not None and _ch_type in epochs.reject: + reject = epochs.reject[_ch_type] * scalings[_ch_type] + _range = (0., reject * 1.1) + # plot it + ax.hist(_data * scalings[_ch_type], bins=100, + color=colors[_ch_type], range=_range) + if reject is not None: + ax.plot((reject, reject), (0, ax.get_ylim()[1]), color='r') + # finalize + fig.suptitle(title, y=0.99) + kwargs = dict(bottom=fig._inch_to_rel(0.5, horiz=False), + top=1 - fig._inch_to_rel(0.5, horiz=False), + left=fig._inch_to_rel(0.75), + right=1 - fig._inch_to_rel(0.25)) + fig.subplots_adjust(hspace=0.7, **kwargs) + self.mne.fig_histogram = fig + + return fig + def _close_event(self, fig): + # This method is a fix for mpl issue #18609, which still seems to + # be a problem with matplotlib==3.4. + pass + # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + # TEST METHODS + # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + + @abstractmethod + def _get_size(self): pass @abstractmethod @@ -341,11 +578,23 @@ def _click_ch_name(self, ch_index, button): def _resize_by_factor(self, factor): pass + @abstractmethod + def _get_ticklabels(self, orientation): + pass + + @abstractmethod + def _update_yaxis_labels(self): + pass + def _load_backend(backend_name): global backend - backend = importlib.import_module(name=_backends[backend_name], - package='mne.viz') + if backend_name == 'matplotlib': + backend = importlib.import_module(name='._mpl_figure', + package='mne.viz') + else: + from mne_qt_browser import _pg_figure as backend + logger.info(f'Using {backend_name} as 2D backend.') return backend @@ -372,8 +621,6 @@ def _check_browser_backend_name(backend_name): return backend_name -# ToDo: This won't appear in documentation, has to be adjusted to the current -# state of the pyqtgraph backend. @verbose def set_browser_backend(backend_name, verbose=None): """Set the 2D browser backend for MNE. @@ -411,22 +658,24 @@ def set_browser_backend(backend_name, verbose=None): | :func:`plot_ica_sources` | ✓ | | +--------------------------------------+------------+-----------+ +--------------------------------------+------------+-----------+ - | **Feature:** + | **Feature:** | +--------------------------------------+------------+-----------+ - | Annotations | ✓ | ✓ | + | Show Events | ✓ | ✓ | +--------------------------------------+------------+-----------+ - | Toggle Projections | ✓ | | + | Add/Edit/Remove Annotations | ✓ | ✓ | +--------------------------------------+------------+-----------+ - | Butterfly Mode | ✓ | | + | Toggle Projections | ✓ | ✓ | +--------------------------------------+------------+-----------+ - | Smooth Scrolling | | ✓ | + | Butterfly Mode | ✓ | ✓ | +--------------------------------------+------------+-----------+ - | OpenGL Acceleration | | ✓ | + | Selection Mode | ✓ | ✓ | +--------------------------------------+------------+-----------+ - | Toolbar | | ✓ | + | Smooth Scrolling | | ✓ | +--------------------------------------+------------+-----------+ - | Dark Mode | | | + | Overview-Bar (with Z-Score-Mode) | | ✓ | +--------------------------------------+------------+-----------+ + + .. versionadded:: 0.24 """ global MNE_BROWSE_BACKEND old_backend_name = MNE_BROWSE_BACKEND diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index 730e7414eab..8a721449a1e 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -38,9 +38,8 @@ from collections import OrderedDict from contextlib import contextmanager -from copy import deepcopy -import datetime from functools import partial +import datetime import platform import warnings @@ -48,23 +47,22 @@ from matplotlib import pyplot as plt from matplotlib.figure import Figure -from mne import set_config, channel_indices_by_type, pick_types +from mne import channel_indices_by_type, pick_types from mne.annotations import _sync_onset from mne.defaults import _handle_default from mne.io.pick import (_DATA_CH_TYPES_SPLIT, _DATA_CH_TYPES_ORDER_DEFAULT, _VALID_CHANNEL_TYPES, _picks_to_idx, _FNIRS_CH_TYPES_SPLIT) from mne.time_frequency import psd_welch, psd_multitaper -from mne.utils import logger, _check_option, _check_sphere, Bunch, \ - _click_ch_name -from mne.viz import plot_sensors, plot_epochs_image -from mne.viz._figure import BrowserBase -from mne.viz.ica import (_create_properties_layout, - _prepare_data_ica_properties, - _fast_plot_ica_properties) -from mne.viz.utils import (_events_off, DraggableLine, plt_show, _prop_kw, - _merge_annotations, _set_window_title, - _validate_if_list_of_axes, _fake_click, _plot_psd) +from mne.utils import (logger, _check_option, _check_sphere, Bunch, + _click_ch_name) +from . import plot_sensors +from ._figure import BrowserBase +from .utils import (_events_off, DraggableLine, plt_show, _prop_kw, + _merge_annotations, _set_window_title, + _validate_if_list_of_axes, _fake_click, _plot_psd) + +name = 'matplotlib' # CONSTANTS (inches) ANNOTATION_FIG_PAD = 0.1 @@ -329,6 +327,8 @@ def __init__(self, inst, figsize, ica=None, from mpl_toolkits.axes_grid1.axes_size import Fixed from mpl_toolkits.axes_grid1.axes_divider import make_axes_locatable + self.backend_name = 'matplotlib' + kwargs.update({'inst': inst, 'figsize': figsize, 'ica': ica, @@ -383,9 +383,6 @@ def __init__(self, inst, figsize, ica=None, ax_hscroll.add_patch( Rectangle((start, 0), width, 1, color='none', zorder=self.mne.zorder['patch'])) - # add epoch boundaries & center epoch numbers between boundaries - midpoints = np.convolve(self.mne.boundary_times, np.ones(2), - mode='valid') / 2 # both axes, major ticks: gridlines for _ax in (ax_main, ax_hscroll): _ax.xaxis.set_major_locator( @@ -397,11 +394,11 @@ def __init__(self, inst, figsize, ica=None, ax_hscroll.grid(alpha=0.5, linewidth=0.5, linestyle='solid', **grid_kwargs) # main axes, minor ticks: ticklabel (epoch number) for every epoch - ax_main.xaxis.set_minor_locator(FixedLocator(midpoints)) + ax_main.xaxis.set_minor_locator(FixedLocator(self.mne.midpoints)) ax_main.xaxis.set_minor_formatter(FixedFormatter(epoch_nums)) # hscroll axes, minor ticks: up to 20 ticklabels (epoch numbers) ax_hscroll.xaxis.set_minor_locator( - FixedLocator(midpoints, nbins=20)) + FixedLocator(self.mne.midpoints, nbins=20)) ax_hscroll.xaxis.set_minor_formatter( FuncFormatter(lambda x, pos: self._get_epoch_num_from_time(x))) # hide some ticks @@ -503,31 +500,8 @@ def __init__(self, inst, figsize, ica=None, vsel_patch=vsel_patch, hsel_patch=hsel_patch, vline=vline, vline_hscroll=vline_hscroll, vline_text=vline_text) - def _close(self, event): - """Handle close events (via keypress or window [x]).""" - from matplotlib.pyplot import close - # write out bad epochs (after converting epoch numbers to indices) - if self.mne.instance_type == 'epochs': - bad_ixs = np.in1d(self.mne.inst.selection, - self.mne.bad_epochs).nonzero()[0] - self.mne.inst.drop(bad_ixs) - # write bad channels back to instance (don't do this for proj; - # proj checkboxes are for viz only and shouldn't modify the instance) - if self.mne.instance_type in ('raw', 'epochs'): - self.mne.inst.info['bads'] = self.mne.info['bads'] - logger.info( - f"Channels marked as bad: {self.mne.info['bads'] or 'none'}") - # ICA excludes - elif self.mne.instance_type == 'ica': - self.mne.ica.exclude = [self.mne.ica._ica_names.index(ch) - for ch in self.mne.info['bads']] - # write window size to config - size = ','.join(self.get_size_inches().astype(str)) - set_config('MNE_BROWSE_RAW_SIZE', size, set_env=False) - # Clean up child figures (don't pop(), child figs remove themselves) - while len(self.mne.child_figs): - fig = self.mne.child_figs[-1] - close(fig) + def _get_size(self): + return self.get_size_inches() def _resize(self, event): """Handle resize event for mne_browse-style plots (Raw/Epochs/ICA).""" @@ -697,7 +671,7 @@ def _keypress(self, event): elif key == 'd': # DC shift self.mne.remove_dc = not self.mne.remove_dc self._redraw() - elif key == 'h' and self.mne.instance_type == 'epochs': # histogram + elif key == 'h': # histogram self._toggle_epoch_histogram() elif key == 'j' and len(self.mne.projs): # SSP window self._toggle_proj_fig() @@ -712,11 +686,7 @@ def _keypress(self, event): elif key == 's': # scalebars self._toggle_scalebars(event) elif key == 'w': # toggle noise cov whitening - if self.mne.noise_cov is not None: - self.mne.use_noise_cov = not self.mne.use_noise_cov - self._update_projector() - self._update_yaxis_labels() # add/remove italics - self._redraw() + self._toggle_whitening() elif key == 'z': # zen mode: hide scrollbars and buttons self._toggle_scrollbars() self._redraw(update_data=False) @@ -797,6 +767,10 @@ def _pick(self, event): elif event.mouseevent.button == 3: # right click self._create_ch_context_fig(ind) + def _create_ch_context_fig(self, idx): + fig = super()._create_ch_context_fig(idx) + plt_show(fig=fig) + def _new_child_figure(self, fig_name, **kwargs): """Instantiate a new MNE dialog figure (with event listeners).""" fig = _figure(toolbar=False, parent_fig=self, fig_name=fig_name, @@ -807,118 +781,6 @@ def _new_child_figure(self, fig_name, **kwargs): setattr(self.mne, fig_name, fig) return fig - def _create_ch_context_fig(self, idx): - """Show context figure; idx is index of **visible** channels.""" - inst = self.mne.instance_type - pick = self.mne.picks[idx] - if inst == 'raw': - self._create_ch_location_fig(pick) - elif inst == 'ica': - self._create_ica_properties_fig(pick) - else: - self._create_epoch_image_fig(pick) - - def _create_ch_location_fig(self, pick): - """Show channel location figure.""" - from .utils import _channel_type_prettyprint - ch_name = self.mne.ch_names[pick] - ch_type = self.mne.ch_types[pick] - if ch_type not in _DATA_CH_TYPES_SPLIT: - return - # create figure and axes - fig = self._new_child_figure(figsize=(4, 4), fig_name=None, - window_title=f'Location of {ch_name}') - ax = fig.add_subplot(111) - title = f'{ch_name} position ({_channel_type_prettyprint[ch_type]})' - _ = plot_sensors(self.mne.info, ch_type=ch_type, axes=ax, - title=title, kind='select') - # highlight desired channel & disable interactivity - inds = np.in1d(fig.lasso.ch_names, [ch_name]) - fig.lasso.disconnect() - fig.lasso.alpha_other = 0.3 - fig.lasso.linewidth_selected = 3 - fig.lasso.style_sensors(inds) - plt_show(fig=fig) - - def _create_ica_properties_fig(self, idx): - """Show ICA properties for the selected component.""" - ch_name = self.mne.ch_names[idx] - if ch_name not in self.mne.ica._ica_names: # for EOG chans: do nothing - return - pick = self.mne.ica._ica_names.index(ch_name) - fig = self._new_child_figure(figsize=(7, 6), fig_name=None, - window_title=f'{ch_name} properties') - fig, axes = _create_properties_layout(fig=fig) - if not hasattr(self.mne, 'data_ica_properties'): - # Precompute epoch sources only once - self.mne.data_ica_properties = _prepare_data_ica_properties( - self.mne.ica_inst, self.mne.ica) - _fast_plot_ica_properties( - self.mne.ica, self.mne.ica_inst, picks=pick, axes=axes, - precomputed_data=self.mne.data_ica_properties) - - def _create_epoch_image_fig(self, pick): - """Show epochs image for the selected channel.""" - from matplotlib.gridspec import GridSpec - ch_name = self.mne.ch_names[pick] - fig = self._new_child_figure(figsize=(6, 4), fig_name=None, - window_title=f'Epochs image ({ch_name})') - gs = GridSpec(nrows=3, ncols=10) - fig.add_subplot(gs[:2, :9]) - fig.add_subplot(gs[2, :9]) - fig.add_subplot(gs[:2, 9]) - plot_epochs_image(self.mne.inst, picks=pick, fig=fig) - - def _toggle_epoch_histogram(self): - """Show or hide peak-to-peak histogram of channel amplitudes.""" - if self.mne.fig_histogram is None: - self._create_epoch_histogram() - plt_show(fig=self.mne.fig_histogram) - else: - from matplotlib.pyplot import close - close(self.mne.fig_histogram) - - def _create_epoch_histogram(self): - """Create peak-to-peak histogram of channel amplitudes.""" - epochs = self.mne.inst - data = OrderedDict() - ptp = np.ptp(epochs.get_data(), axis=2) - for ch_type in ('eeg', 'mag', 'grad'): - if ch_type in epochs: - data[ch_type] = ptp.T[self.mne.ch_types == ch_type].ravel() - units = _handle_default('units') - titles = _handle_default('titles') - colors = _handle_default('color') - scalings = _handle_default('scalings') - title = 'Histogram of peak-to-peak amplitudes' - figsize = (4, 1 + 1.5 * len(data)) - fig = self._new_child_figure(figsize=figsize, fig_name='fig_histogram', - window_title=title) - for ix, (_ch_type, _data) in enumerate(data.items()): - ax = fig.add_subplot(len(data), 1, ix + 1) - ax.set(title=titles[_ch_type], xlabel=units[_ch_type], - ylabel='Count') - # set histogram bin range based on rejection thresholds - reject = None - _range = None - if epochs.reject is not None and _ch_type in epochs.reject: - reject = epochs.reject[_ch_type] * scalings[_ch_type] - _range = (0., reject * 1.1) - # plot it - ax.hist(_data * scalings[_ch_type], bins=100, - color=colors[_ch_type], range=_range) - if reject is not None: - ax.plot((reject, reject), (0, ax.get_ylim()[1]), color='r') - # finalize - fig.suptitle(title, y=0.99) - kwargs = dict(bottom=fig._inch_to_rel(0.5, horiz=False), - top=1 - fig._inch_to_rel(0.5, horiz=False), - left=fig._inch_to_rel(0.75), - right=1 - fig._inch_to_rel(0.25)) - fig.subplots_adjust(hspace=0.7, **kwargs) - self.mne.fig_histogram = fig - plt_show(fig=fig) - # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # HELP DIALOG # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @@ -1237,7 +1099,7 @@ def _update_annotation_fig(self): def _toggle_annotation_fig(self): """Show/hide the annotation dialog window.""" - if self.mne.fig_annotation is None: + if self.mne.fig_annotation is None and not self.mne.is_epochs: self._create_annotation_fig() else: from matplotlib.pyplot import close @@ -1469,23 +1331,6 @@ def _update_selection(self): self._update_vscroll() self._redraw(annotations=True) - def _make_butterfly_selections_dict(self): - """Make an altered copy of the selections dict for butterfly mode.""" - from ..utils import _get_stim_channel - selections_dict = deepcopy(self.mne.ch_selections) - # remove potential duplicates - for selection_group in ('Vertex', 'Custom'): - selections_dict.pop(selection_group, None) - # if present, remove stim channel from non-misc selection groups - stim_ch = _get_stim_channel(None, self.mne.info, raise_error=False) - if len(stim_ch): - stim_pick = self.mne.ch_names.tolist().index(stim_ch[0]) - for _sel, _picks in selections_dict.items(): - if _sel != 'Misc': - stim_mask = np.in1d(_picks, [stim_pick], invert=True) - selections_dict[_sel] = np.array(_picks)[stim_mask] - return selections_dict - def _update_highlighted_sensors(self): """Update the sensor plot to show what is selected.""" inds = np.in1d(self.mne.fig_selection.lasso.ch_names, @@ -2003,14 +1848,10 @@ def _draw_traces(self): def _redraw(self, update_data=True, annotations=False): """Redraw (convenience method for frequently grouped actions).""" - if update_data: - self._update_data() + super()._redraw(update_data, annotations) if self.mne.vline_visible and self.mne.is_epochs: # prevent flickering _ = self._recompute_epochs_vlines(None) - self._draw_traces() - if annotations and not self.mne.is_epochs: - self._draw_annotations() self.canvas.draw_idle() # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @@ -2105,13 +1946,22 @@ def _fake_keypress(self, key, fig=None): fig = fig or self fig.canvas.key_press_event(key) - def _fake_click(self, point, fig=None, ax=None, + def _fake_click(self, point, add_points=None, fig=None, ax=None, xform='ax', button=1, kind='press'): """Fake a click at a relative point within axes.""" fig = fig or self ax = ax or self.mne.ax_main - _fake_click(fig=fig, ax=ax, point=point, xform=xform, - button=button, kind=kind) + if kind == 'drag' and add_points is not None: + _fake_click(fig=fig, ax=ax, point=point, xform=xform, + button=button, kind='press') + for apoint in add_points: + _fake_click(fig=fig, ax=ax, point=apoint, xform=xform, + button=button, kind='motion') + _fake_click(fig=fig, ax=ax, point=add_points[-1], xform=xform, + button=button, kind='release') + else: + _fake_click(fig=fig, ax=ax, point=point, xform=xform, + button=button, kind=kind) def _fake_scroll(self, x, y, step, fig=None): fig = fig or self @@ -2129,6 +1979,22 @@ def _resize_by_factor(self, factor=None): size = [int(x * factor) for x in size] self.canvas.manager.canvas.resize(*size) + def _get_ticklabels(self, orientation): + if orientation == 'x': + labels = self.mne.ax_main.get_xticklabels() + elif orientation == 'y': + labels = self.mne.ax_main.get_yticklabels() + label_texts = [lb.get_text() for lb in labels] + + return label_texts + + def _get_scale_bar_texts(self): + texts = tuple(t.get_text().strip() for t in self.mne.ax_main.texts) + # First text is empty because of vline + texts = texts[1:] + + return texts + class MNELineFigure(MNEFigure): """Interactive figure for non-scrolling line plots.""" diff --git a/mne/viz/epochs.py b/mne/viz/epochs.py index b609b783fb6..b4490b9c54e 100644 --- a/mne/viz/epochs.py +++ b/mne/viz/epochs.py @@ -562,7 +562,7 @@ def _plot_epochs_image(image, style_axes=True, epochs=None, picks=None, def plot_drop_log(drop_log, threshold=0, n_max_plot=20, subject='Unknown subj', - color=(0.8, 0.8, 0.8), width=0.8, ignore=('IGNORED',), + color='lightgray', width=0.8, ignore=('IGNORED',), show=True): """Show the channel stats based on a drop_log from Epochs. @@ -889,7 +889,7 @@ def plot_epochs(epochs, picks=None, scalings=None, n_epochs=20, n_channels=20, units=units, unit_scalings=unit_scalings, # colors - ch_color_bad=(0.8, 0.8, 0.8), + ch_color_bad='lightgray', ch_color_dict=color, epoch_color_bad=(1, 0, 0), epoch_colors=epoch_colors, diff --git a/mne/viz/ica.py b/mne/viz/ica.py index 7c03b7fe57d..a05397aa598 100644 --- a/mne/viz/ica.py +++ b/mne/viz/ica.py @@ -1042,7 +1042,7 @@ def _plot_sources(ica, inst, picks, exclude, start, stop, show, title, block, raise RuntimeError('Stop must be larger than start.') # misc - bad_color = (0.8, 0.8, 0.8) + bad_color = 'lightgray' title = 'ICA components' if title is None else title params = dict(inst=inst_array, diff --git a/mne/viz/raw.py b/mne/viz/raw.py index ff48c0c26ce..52c1a9deb32 100644 --- a/mne/viz/raw.py +++ b/mne/viz/raw.py @@ -20,21 +20,23 @@ from .topo import _plot_topo, _plot_timeseries, _plot_timeseries_unified from .utils import (plt_show, _compute_scalings, _handle_decim, _check_cov, _shorten_path_from_middle, - _get_channel_plotting_order, _make_event_color_dict) + _get_channel_plotting_order, _make_event_color_dict, + _show_browser) _RAW_CLIP_DEF = 1.5 @verbose def plot_raw(raw, events=None, duration=10.0, start=0.0, n_channels=20, - bgcolor='w', color=None, bad_color=(0.8, 0.8, 0.8), + bgcolor='w', color=None, bad_color='lightgray', event_color='cyan', scalings=None, remove_dc=True, order=None, show_options=False, title=None, show=True, block=False, highpass=None, lowpass=None, filtorder=4, clipping=_RAW_CLIP_DEF, show_first_samp=False, proj=True, group_by='type', butterfly=False, decim='auto', noise_cov=None, event_id=None, show_scrollbars=True, - show_scalebars=True, time_format='float', verbose=None): + show_scalebars=True, time_format='float', + precompute='auto', use_opengl=True, verbose=None): """Plot raw data. Parameters @@ -103,6 +105,10 @@ def plot_raw(raw, events=None, duration=10.0, start=0.0, n_channels=20, Whether to halt program execution until the figure is closed. Useful for setting bad channels on the fly by clicking on a line. May not work on all systems / platforms. + (Only pyqtgraph) If you run from a script, this needs to + be ``True`` or a Qt-eventloop needs to be started somewhere + else in the script (e.g. if you want to implement the browser + inside another Qt-Application). highpass : float | None Highpass to apply when displaying data. lowpass : float | None @@ -168,6 +174,8 @@ def plot_raw(raw, events=None, duration=10.0, start=0.0, n_channels=20, .. versionadded:: 0.20.0 %(time_format)s + %(precompute)s + %(use_opengl)s %(verbose)s Returns @@ -200,6 +208,12 @@ def plot_raw(raw, events=None, duration=10.0, start=0.0, n_channels=20, 'b', and whitening mode (when ``noise_cov is not None``) by pressing 'w'. By default, the channel means are removed when ``remove_dc`` is set to ``True``. This flag can be toggled by pressing 'd'. + + .. note:: For the pyqtgraph backend to run in IPython with ``block=False`` + you must run the magic command ``%%gui qt5`` first. + .. note:: To report issues with the pyqtgraph-backend, please use the + `issues `_ + of ``mne-qt-browser``. """ from ..io.base import BaseRaw from ._figure import _get_browser @@ -345,7 +359,10 @@ def plot_raw(raw, events=None, duration=10.0, start=0.0, n_channels=20, clipping=clipping, scrollbars_visible=show_scrollbars, scalebars_visible=show_scalebars, - window_title=title) + window_title=title, + # pyqtgraph-specific + precompute=precompute, + use_opengl=use_opengl) fig = _get_browser(**params) @@ -358,18 +375,16 @@ def plot_raw(raw, events=None, duration=10.0, start=0.0, n_channels=20, # update projector and data, and plot fig._update_projector() fig._update_trace_offsets() - fig._update_data() - fig._draw_traces() - - # plot annotations (if any) fig._setup_annotation_colors() - fig._draw_annotations() + + # Draw Plot + fig._redraw(update_data=True, annotations=True) # start with projectors dialog open, if requested if show_options: fig._toggle_proj_fig() - plt_show(show, block=block) + _show_browser(show, block=block, fig=fig) return fig diff --git a/mne/viz/tests/test_epochs.py b/mne/viz/tests/test_epochs.py index 93a3d203d7d..21b256eb093 100644 --- a/mne/viz/tests/test_epochs.py +++ b/mne/viz/tests/test_epochs.py @@ -19,7 +19,7 @@ from mne.viz.utils import _fake_click -def test_plot_epochs_not_preloaded(epochs_unloaded, browse_backend): +def test_plot_epochs_not_preloaded(epochs_unloaded, mpl_backend): """Test plotting non-preloaded epochs.""" assert epochs_unloaded._data is None epochs_unloaded.plot() @@ -27,7 +27,7 @@ def test_plot_epochs_not_preloaded(epochs_unloaded, browse_backend): def test_plot_epochs_basic(epochs, epochs_full, noise_cov_io, capsys, - browse_backend): + mpl_backend): """Test epoch plotting.""" assert len(epochs.events) == 1 with epochs.info._unlock(): @@ -36,30 +36,30 @@ def test_plot_epochs_basic(epochs, epochs_full, noise_cov_io, capsys, # ToDo: The ticks will be fetched differently with pyqtgraph. ticks = [x.get_text() for x in fig.mne.ax_main.get_xticklabels(minor=True)] assert ticks == ['2'] - browse_backend._close_all() + mpl_backend._close_all() # covariance / whitening assert len(noise_cov_io['names']) == 366 # all channels assert noise_cov_io['bads'] == [] assert epochs.info['bads'] == [] # all good with pytest.warns(RuntimeWarning, match='projection'): epochs.plot(noise_cov=noise_cov_io) - browse_backend._close_all() + mpl_backend._close_all() # add a channel to the epochs.info['bads'] epochs.info['bads'] = [epochs.ch_names[0]] with pytest.warns(RuntimeWarning, match='projection'): epochs.plot(noise_cov=noise_cov_io) - browse_backend._close_all() + mpl_backend._close_all() # add a channel to cov['bads'] noise_cov_io['bads'] = [epochs.ch_names[1]] with pytest.warns(RuntimeWarning, match='projection'): epochs.plot(noise_cov=noise_cov_io) - browse_backend._close_all() + mpl_backend._close_all() # have a data channel missing from the covariance noise_cov_io['names'] = noise_cov_io['names'][:306] noise_cov_io['data'] = noise_cov_io['data'][:306][:306] with pytest.warns(RuntimeWarning, match='projection'): epochs.plot(noise_cov=noise_cov_io) - browse_backend._close_all() + mpl_backend._close_all() # other options fig = epochs[0].plot(picks=[0, 2, 3], scalings=None) fig._fake_keypress('escape') @@ -78,12 +78,12 @@ def test_plot_epochs_basic(epochs, epochs_full, noise_cov_io, capsys, @pytest.mark.parametrize('scalings', (dict(mag=1e-12, grad=1e-11, stim='auto'), None, 'auto')) -def test_plot_epochs_scalings(epochs, scalings, browse_backend): +def test_plot_epochs_scalings(epochs, scalings, mpl_backend): """Test the valid options for scalings.""" epochs.plot(scalings=scalings) -def test_plot_epochs_colors(epochs, browse_backend): +def test_plot_epochs_colors(epochs, mpl_backend): """Test epoch_colors, for compatibility with autoreject.""" epoch_colors = [['r'] * len(epochs.ch_names) for _ in range(len(epochs.events))] @@ -96,7 +96,7 @@ def test_plot_epochs_colors(epochs, browse_backend): epochs.plot(event_color='b') -def test_plot_epochs_scale_bar(epochs, browse_backend): +def test_plot_epochs_scale_bar(epochs, mpl_backend): """Test scale bar for epochs.""" fig = epochs.plot() ax = fig.mne.ax_main @@ -109,7 +109,7 @@ def test_plot_epochs_scale_bar(epochs, browse_backend): def test_plot_epochs_clicks(epochs, epochs_full, capsys, - browse_backend): + mpl_backend): """Test plot_epochs mouse interaction.""" fig = epochs.plot(events=epochs.events) x = fig.mne.traces[0].get_xdata()[3] @@ -160,7 +160,7 @@ def test_plot_epochs_clicks(epochs, epochs_full, capsys, fig._fake_scroll(0.5, 0.5, 0.5) # scroll up -def test_plot_epochs_keypresses(epochs_full, browse_backend): +def test_plot_epochs_keypresses(epochs_full, mpl_backend): """Test plot_epochs keypress interaction.""" # we need more than 1 epoch epochs_full.drop_bad(dict(mag=4e-12)) # for histogram plot coverage @@ -180,7 +180,7 @@ def test_plot_epochs_keypresses(epochs_full, browse_backend): fig._fake_click([x, y], xform='data', button=3) # remove vlines -def test_plot_overlapping_epochs_with_events(browse_backend): +def test_plot_overlapping_epochs_with_events(mpl_backend): """Test drawing of event lines in overlapping epochs.""" data = np.zeros(shape=(3, 2, 100)) # 3 epochs, 2 channels, 100 samples sfreq = 100 @@ -198,7 +198,7 @@ def test_epochs_plot_sensors(epochs): epochs.plot_sensors() -def test_plot_epochs_nodata(browse_backend): +def test_plot_epochs_nodata(mpl_backend): """Test plotting of epochs when no data channels are present.""" data = np.random.RandomState(0).randn(10, 2, 1000) info = create_info(2, 1000., 'stim') @@ -355,7 +355,7 @@ def test_plot_psdtopo_nirs(fnirs_epochs): @testing.requires_testing_data -def test_plot_epochs_ctf(raw_ctf, browse_backend): +def test_plot_epochs_ctf(raw_ctf, mpl_backend): """Test of basic CTF plotting.""" raw_ctf.pick_channels(['UDIO001', 'UPPT001', 'SCLK01-177', 'BG1-4304', 'MLC11-4304', 'MLC11-4304', @@ -363,7 +363,7 @@ def test_plot_epochs_ctf(raw_ctf, browse_backend): evts = make_fixed_length_events(raw_ctf) epochs = Epochs(raw_ctf, evts, preload=True) epochs.plot() - browse_backend._close_all() + mpl_backend._close_all() # test butterfly fig = epochs.plot(butterfly=True) diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py index 254140d4dd5..14e54604958 100644 --- a/mne/viz/tests/test_ica.py +++ b/mne/viz/tests/test_ica.py @@ -208,7 +208,7 @@ def test_plot_ica_properties(): @requires_sklearn -def test_plot_ica_sources(raw_orig, browse_backend): +def test_plot_ica_sources(raw_orig, mpl_backend): """Test plotting of ICA panel.""" raw = raw_orig.copy().crop(0, 1) picks = _get_picks(raw) @@ -220,7 +220,7 @@ def test_plot_ica_sources(raw_orig, browse_backend): ica.fit(raw, picks=ica_picks) ica.exclude = [1] fig = ica.plot_sources(raw) - assert browse_backend._get_n_figs() == 1 + assert mpl_backend._get_n_figs() == 1 # change which component is in ICA.exclude (click data trace to remove # current one; click name to add other one) fig._redraw() @@ -231,12 +231,12 @@ def test_plot_ica_sources(raw_orig, browse_backend): _click_ch_name(fig, ch_index=0, button=1) # exclude = [0] fig._fake_keypress(fig.mne.close_key) fig._close_event() - assert browse_backend._get_n_figs() == 0 + assert mpl_backend._get_n_figs() == 0 assert_array_equal(ica.exclude, [0]) # test when picks does not include ica.exclude. fig = ica.plot_sources(raw, picks=[1]) assert len(plt.get_fignums()) == 1 - browse_backend._close_all() + mpl_backend._close_all() # dtype can change int->np.int64 after load, test it explicitly ica.n_components_ = np.int64(ica.n_components_) diff --git a/mne/viz/tests/test_raw.py b/mne/viz/tests/test_raw.py index e128b5ea9e9..e408d0c4494 100644 --- a/mne/viz/tests/test_raw.py +++ b/mne/viz/tests/test_raw.py @@ -3,9 +3,10 @@ # License: Simplified BSD import itertools +import os import numpy as np -from numpy.testing import assert_allclose, assert_array_equal +from numpy.testing import assert_allclose import pytest import matplotlib import matplotlib.pyplot as plt @@ -22,6 +23,7 @@ def _annotation_helper(raw, browse_backend, events=False): """Test interactive annotations.""" + ismpl = browse_backend.name == 'matplotlib' # Some of our checks here require modern mpl to work properly n_anns = len(raw.annotations) browse_backend._close_all() @@ -34,111 +36,162 @@ def _annotation_helper(raw, browse_backend, events=False): events = None n_events = 0 fig = raw.plot(events=events) - assert browse_backend._get_n_figs() == 1 - data_ax = fig.mne.ax_main + if ismpl: + assert browse_backend._get_n_figs() == 1 + fig._fake_keypress('a') # annotation mode - # ToDo: This will be different in pyqtgraph because it handles annotations - # from the toolbar. - assert browse_backend._get_n_figs() == 2 - # +3 from the scale bars - n_scale = 3 - assert len(data_ax.texts) == n_anns + n_events + n_scale - # modify description to create label "BAD test" ann_fig = fig.mne.fig_annotation - # semicolon is ignored - for key in ['backspace'] + list(' test;') + ['enter']: - fig._fake_keypress(key, fig=ann_fig) + if ismpl: + assert browse_backend._get_n_figs() == 2 + # +3 from the scale bars + n_scale = 3 + assert len(fig.mne.ax_main.texts) == n_anns + n_events + n_scale + else: + assert ann_fig.isVisible() - # change annotation label - for ix in (-1, 0): - xy = ann_fig.mne.radio_ax.buttons.circles[ix].center - fig._fake_click(xy, ann_fig, ann_fig.mne.radio_ax, - xform='data') + # modify description to create label "BAD test" + # semicolon is ignored + if ismpl: + for key in ['backspace'] + list(' test;') + ['enter']: + fig._fake_keypress(key, fig=ann_fig) + # change annotation label + for ix in (-1, 0): + xy = ann_fig.mne.radio_ax.buttons.circles[ix].center + fig._fake_click(xy, fig=ann_fig, ax=ann_fig.mne.radio_ax, + xform='data') + else: + # The modal dialogs of the pyqtgraph-backend would block the test, + # thus a new description will be added programmatically. + ann_fig._add_description('BAD test') # draw annotation - fig._fake_click((1., 1.), xform='data', button=1, kind='press') - fig._fake_click((5., 1.), xform='data', button=1, kind='motion') - fig._fake_click((5., 1.), xform='data', button=1, kind='release') + fig._fake_click((1., 1.), add_points=[(5., 1.)], xform='data', button=1, + kind='drag') + if ismpl: + assert len(fig.mne.ax_main.texts) == n_anns + 1 + n_events + n_scale + # test hover event + fig._fake_keypress('p') # first turn on draggable mode + assert fig.mne.draggable_annotations + hover_kwargs = dict(xform='data', button=None, kind='motion') + fig._fake_click((4.6, 1.), **hover_kwargs) # well inside ann. + fig._fake_click((4.9, 1.), **hover_kwargs) # almost at edge + assert fig.mne.annotation_hover_line is not None + fig._fake_click((5.5, 1.), **hover_kwargs) # well outside ann. + assert fig.mne.annotation_hover_line is None + # more tests of hover line + fig._fake_click((4.6, 1.), **hover_kwargs) # well inside ann. + fig._fake_click((4.9, 1.), **hover_kwargs) # almost at edge + assert fig.mne.annotation_hover_line is not None + fig._fake_keypress('p') # turn off draggable mode, then move a bit + fig._fake_click((4.95, 1.), **hover_kwargs) + assert fig.mne.annotation_hover_line is None + fig._fake_keypress('p') # turn draggable mode back on assert len(raw.annotations.onset) == n_anns + 1 assert len(raw.annotations.duration) == n_anns + 1 assert len(raw.annotations.description) == n_anns + 1 assert raw.annotations.description[n_anns] == 'BAD test' - assert len(data_ax.texts) == n_anns + 1 + n_events + n_scale onset = raw.annotations.onset[n_anns] want_onset = _sync_onset(raw, 1., inverse=True) - assert_allclose(onset, want_onset) - assert_allclose(raw.annotations.duration[n_anns], 4.) - # test hover event - fig._fake_keypress('p') # first turn on draggable mode - assert fig.mne.draggable_annotations - hover_kwargs = dict(xform='data', button=None, kind='motion') - fig._fake_click((4.6, 1.), **hover_kwargs) # well inside ann. - fig._fake_click((4.9, 1.), **hover_kwargs) # almost at edge - assert fig.mne.annotation_hover_line is not None - fig._fake_click((5.5, 1.), **hover_kwargs) # well outside ann. - assert fig.mne.annotation_hover_line is None - # more tests of hover line - fig._fake_click((4.6, 1.), **hover_kwargs) # well inside ann. - fig._fake_click((4.9, 1.), **hover_kwargs) # almost at edge - assert fig.mne.annotation_hover_line is not None - fig._fake_keypress('p') # turn off draggable mode, then move a bit - fig._fake_click((4.95, 1.), **hover_kwargs) - assert fig.mne.annotation_hover_line is None - fig._fake_keypress('p') # turn draggable mode back on + # pyqtgraph: during the transformation from pixel-coordinates + # to scene-coordinates when the click is simulated on QGraphicsView + # with QTest, there seems to happen a rounding of pixels to integers + # internally. This deviatian also seems to change between runs + # (maybe device-dependent?). + atol = 1e-10 if ismpl else 1e-2 + assert_allclose(onset, want_onset, atol=atol) + assert_allclose(raw.annotations.duration[n_anns], 4., atol=atol) # modify annotation from end (duration 4 → 1.5) - fig._fake_click((4.9, 1.), xform='data', button=None, + fig._fake_click((4.9, 1.), xform='data', button=1, kind='motion') # ease up to it - fig._fake_click((5., 1.), xform='data', button=1, kind='press') - fig._fake_click((2.5, 1.), xform='data', button=1, kind='motion') - fig._fake_click((2.5, 1.), xform='data', button=1, - kind='release') + fig._fake_click((5., 1.), add_points=[(2.5, 1.)], xform='data', + button=1, kind='drag') assert raw.annotations.onset[n_anns] == onset - assert_allclose(raw.annotations.duration[n_anns], 1.5) # 4 → 1.5 + # 4 → 1.5 + assert_allclose(raw.annotations.duration[n_anns], 1.5, atol=atol) # modify annotation from beginning (duration 1.5 → 2.0) - fig._fake_click((1., 1.), xform='data', button=1, kind='press') - fig._fake_click((0.5, 1.), xform='data', button=1, kind='motion') - fig._fake_click((0.5, 1.), xform='data', button=1, - kind='release') - assert_allclose(raw.annotations.onset[n_anns], onset - 0.5, atol=1e-10) - assert_allclose(raw.annotations.duration[n_anns], 2.0) # 1.5 → 2.0 + fig._fake_click((1., 1.), add_points=[(0.5, 1.)], xform='data', button=1, + kind='drag') + assert_allclose(raw.annotations.onset[n_anns], onset - 0.5, atol=atol) + # 1.5 → 2.0 + assert_allclose(raw.annotations.duration[n_anns], 2.0, atol=atol) assert len(raw.annotations.onset) == n_anns + 1 assert len(raw.annotations.duration) == n_anns + 1 assert len(raw.annotations.description) == n_anns + 1 assert raw.annotations.description[n_anns] == 'BAD test' - assert len(fig.axes[0].texts) == n_anns + 1 + n_events + n_scale - fig._fake_keypress('shift+right') - assert len(fig.axes[0].texts) == n_scale - fig._fake_keypress('shift+left') - assert len(fig.axes[0].texts) == n_anns + 1 + n_events + n_scale + if ismpl: + assert len(fig.axes[0].texts) == n_anns + 1 + n_events + n_scale + fig._fake_keypress('shift+right') + assert len(fig.axes[0].texts) == n_scale + fig._fake_keypress('shift+left') + assert len(fig.axes[0].texts) == n_anns + 1 + n_events + n_scale # draw another annotation merging the two - fig._fake_click((5.5, 1.), xform='data', button=1, kind='press') - fig._fake_click((2., 1.), xform='data', button=1, kind='motion') - fig._fake_click((2., 1.), xform='data', button=1, kind='release') + fig._fake_click((5.5, 1.), add_points=[(2., 1.)], + xform='data', button=1, kind='drag') # delete the annotation assert len(raw.annotations.onset) == n_anns + 1 assert len(raw.annotations.duration) == n_anns + 1 assert len(raw.annotations.description) == n_anns + 1 - assert_allclose(raw.annotations.onset[n_anns], onset - 0.5, atol=1e-10) - assert_allclose(raw.annotations.duration[n_anns], 5.0) - assert len(fig.axes[0].texts) == n_anns + 1 + n_events + n_scale + assert_allclose(raw.annotations.onset[n_anns], onset - 0.5, atol=atol) + assert_allclose(raw.annotations.duration[n_anns], 5.0, atol=atol) + if ismpl: + assert len(fig.axes[0].texts) == n_anns + 1 + n_events + n_scale # Delete fig._fake_click((1.5, 1.), xform='data', button=3, kind='press') # exit, re-enter, then exit a different way fig._fake_keypress('a') # exit fig._fake_keypress('a') # enter - fig._fake_keypress('escape', fig=fig.mne.fig_annotation) # exit again assert len(raw.annotations.onset) == n_anns - assert len(fig.axes[0].texts) == n_anns + n_events + n_scale - fig._fake_keypress('shift+right') - assert len(fig.axes[0].texts) == n_scale - fig._fake_keypress('shift+left') - assert len(fig.axes[0].texts) == n_anns + n_events + n_scale + if ismpl: + fig._fake_keypress('escape', fig=fig.mne.fig_annotation) # exit again + assert len(fig.axes[0].texts) == n_anns + n_events + n_scale + fig._fake_keypress('shift+right') + assert len(fig.axes[0].texts) == n_scale + fig._fake_keypress('shift+left') + assert len(fig.axes[0].texts) == n_anns + n_events + n_scale + + +def _proj_status(ssp_fig, browse_backend): + if browse_backend.name == 'matplotlib': + ax = ssp_fig.mne.proj_checkboxes.ax + return [line.get_visible() for line + in ax.findobj(matplotlib.lines.Line2D)][::2] + else: + return [chkbx.isChecked() for chkbx in ssp_fig.checkboxes] -def _proj_status(ax): - return [line.get_visible() - for line in ax.findobj(matplotlib.lines.Line2D)][::2] +def _proj_label(ssp_fig, browse_backend): + if browse_backend.name == 'matplotlib': + return [lb.get_text() for lb in ssp_fig.mne.proj_checkboxes.labels] + else: + return [chkbx.text() for chkbx in ssp_fig.checkboxes] + + +def _proj_click(idx, fig, browse_backend): + ssp_fig = fig.mne.fig_proj + if browse_backend.name == 'matplotlib': + pos = np.array(ssp_fig.mne.proj_checkboxes. + labels[idx].get_position()) + 0.01 + + fig._fake_click(pos, fig=ssp_fig, ax=ssp_fig.mne.proj_checkboxes.ax, + xform='data') + else: + # _fake_click on QCheckBox is inconsistent across platforms + # (also see comment in test_plot_raw_selection). + ssp_fig._proj_changed(not fig.mne.projs_on[idx], idx) + # Update Checkbox + ssp_fig.checkboxes[idx].setChecked(fig.mne.projs_on[idx]) + + +def _proj_click_all(fig, browse_backend): + ssp_fig = fig.mne.fig_proj + if browse_backend.name == 'matplotlib': + fig._fake_click((0.5, 0.5), fig=ssp_fig, ax=ssp_fig.mne.proj_all.ax) + fig._fake_click((0.5, 0.5), fig=ssp_fig, ax=ssp_fig.mne.proj_all.ax, + kind='release') + else: + # _fake_click on QPushButton is inconsistent across platforms. + ssp_fig.toggle_all() def _child_fig_helper(fig, key, attr, browse_backend): @@ -171,8 +224,9 @@ def _child_fig_helper(fig, key, attr, browse_backend): assert getattr(fig.mne, attr) is None -def test_scale_bar(browse_backend): +def test_scale_bar(browser_backend): """Test scale bar for raw.""" + ismpl = browser_backend.name == 'matplotlib' sfreq = 1000. t = np.arange(10000) / sfreq data = np.sin(2 * np.pi * 10. * t) @@ -181,13 +235,15 @@ def test_scale_bar(browse_backend): info = create_info(3, sfreq, ('mag', 'grad', 'eeg')) raw = RawArray(data, info) fig = raw.plot() - ax = fig.mne.ax_main - assert len(ax.texts) == 4 # empty vline-text + ch_type scale-bars - # ToDo: This might be solved differently in pyqtgraph. - texts = tuple(t.get_text().strip() for t in ax.texts) - wants = ('', '800.0 fT/cm', '2000.0 fT', '40.0 µV') + texts = fig._get_scale_bar_texts() + assert len(texts) == 3 # ch_type scale-bars + wants = ('800.0 fT/cm', '2000.0 fT', '40.0 µV') assert texts == wants - assert len(ax.lines) == 7 # 1 green vline, 3 data, 3 scalebars + if ismpl: + # 1 green vline, 3 data, 3 scalebars + assert len(fig.mne.ax_main.lines) == 7 + else: + assert len(fig.mne.scalebars) == 3 for data, bar in zip(fig.mne.traces, fig.mne.scalebars.values()): y = data.get_ydata() y_lims = [y.min(), y.max()] @@ -195,17 +251,16 @@ def test_scale_bar(browse_backend): assert_allclose(y_lims, bar_lims, atol=1e-4) -def test_plot_raw_selection(raw, browse_backend): +def test_plot_raw_selection(raw, browser_backend): """Test selection mode of plot_raw().""" + ismpl = browser_backend.name == 'matplotlib' with raw.info._unlock(): raw.info['lowpass'] = 10. # allow heavy decim during plotting - browse_backend._close_all() # ensure all are closed - assert browse_backend._get_n_figs() == 0 + browser_backend._close_all() # ensure all are closed + assert browser_backend._get_n_figs() == 0 fig = raw.plot(group_by='selection', proj=False) - assert browse_backend._get_n_figs() == 2 + assert browser_backend._get_n_figs() == 2 sel_fig = fig.mne.fig_selection - # ToDo: These gui-elements might differ in pyqtgraph. - buttons = sel_fig.mne.radio_ax.buttons assert sel_fig is not None # test changing selection with arrow keys sel_dict = fig.mne.ch_selections @@ -218,46 +273,71 @@ def test_plot_raw_selection(raw, browse_backend): assert len(fig.mne.traces) == len(sel_dict['Misc']) # 1 # switch to butterfly mode fig._fake_keypress('b', fig=sel_fig) - assert len(fig.mne.traces) == len(np.concatenate(list(sel_dict.values()))) + # ToDo: For pyqtgraph-backend the framework around RawTraceItem makes + # it difficult to show the same channel multiple times which is why + # it is currently not implemented. + # This would be relevant if you wanted to plot several selections in + # butterfly-mode which have some channels in common. + sel_picks = len(np.concatenate(list(sel_dict.values()))) + if ismpl: + assert len(fig.mne.traces) == sel_picks + else: + assert len(fig.mne.traces) == sel_picks - 1 assert fig.mne.butterfly # test clicking on radio buttons → should cancel butterfly mode - xy = buttons.circles[0].center - fig._fake_click(xy, sel_fig, sel_fig.mne.radio_ax, xform='data') + if ismpl: + xy = sel_fig.mne.radio_ax.buttons.circles[0].center + fig._fake_click(xy, fig=sel_fig, ax=sel_fig.mne.radio_ax, xform='data') + else: + # For an unknown reason test-clicking on checkboxes is inconsistent + # across platforms. + # (QTest.mouseClick works isolated on all platforms but somehow + # not in this context. _fake_click isn't working on linux) + sel_fig._chkbx_changed(list(sel_fig.chkbxs.keys())[0]) assert len(fig.mne.traces) == len(sel_dict['Left-temporal']) # 6 assert not fig.mne.butterfly # test clicking on "custom" when not defined: should be no-op - before_state = buttons.value_selected - xy = buttons.circles[-1].center - fig._fake_click(xy, sel_fig, sel_fig.mne.radio_ax, xform='data') + if ismpl: + before_state = sel_fig.mne.radio_ax.buttons.value_selected + xy = sel_fig.mne.radio_ax.buttons.circles[-1].center + fig._fake_click(xy, fig=sel_fig, ax=sel_fig.mne.radio_ax, xform='data') + lasso = sel_fig.lasso + sensor_ax = sel_fig.mne.sensor_ax + assert sel_fig.mne.radio_ax.buttons.value_selected == before_state + else: + before_state = sel_fig.mne.old_selection + chkbx = sel_fig.chkbxs[list(sel_fig.chkbxs.keys())[-1]] + fig._fake_click((0.5, 0.5), fig=chkbx) + lasso = sel_fig.channel_fig.lasso + sensor_ax = sel_fig.channel_widget + assert before_state == sel_fig.mne.old_selection # unchanged assert len(fig.mne.traces) == len(sel_dict['Left-temporal']) # unchanged - assert buttons.value_selected == before_state # unchanged # test marking bad channel in selection mode → should make sensor red - assert sel_fig.lasso.ec[:, 0].sum() == 0 # R of RGBA zero for all chans + assert lasso.ec[:, 0].sum() == 0 # R of RGBA zero for all chans fig._click_ch_name(ch_index=1, button=1) # mark bad - assert sel_fig.lasso.ec[:, 0].sum() == 1 # one channel red + assert lasso.ec[:, 0].sum() == 1 # one channel red fig._click_ch_name(ch_index=1, button=1) # mark good - assert sel_fig.lasso.ec[:, 0].sum() == 0 # all channels black + assert lasso.ec[:, 0].sum() == 0 # all channels black # test lasso - sel_fig._set_custom_selection() # lasso empty → should do nothing - sensor_ax = sel_fig.mne.sensor_ax - # Lasso with 1 mag/grad sensor unit (upper left) - fig._fake_click((0, 1), sel_fig, - sensor_ax, xform='ax') - fig._fake_click((0.65, 1), sel_fig, sensor_ax, - xform='ax', kind='motion') - fig._fake_click((0.65, 0.7), sel_fig, sensor_ax, - xform='ax', kind='motion') - fig._fake_click((0, 0.7), sel_fig, sensor_ax, - xform='ax', kind='release') + # Testing lasso-interactivity of sensor-plot within pyqtgraph-backend + # with QTest doesn't seem to work. want = ['MEG 0121', 'MEG 0122', 'MEG 0123'] - assert sorted(want) == sorted(sel_fig.lasso.selection) + if ismpl: + sel_fig._set_custom_selection() # lasso empty → should do nothing + # Lasso with 1 mag/grad sensor unit (upper left) + fig._fake_click((0, 1), add_points=[(0.65, 1), (0.65, 0.7), (0, 0.7)], + fig=sel_fig, ax=sensor_ax, xform='ax', kind='drag') + else: + lasso.selection = want + sel_fig._set_custom_selection() + assert sorted(want) == sorted(fig.mne.ch_names[fig.mne.picks]) # test joint closing of selection & data windows fig._fake_keypress(sel_fig.mne.close_key, fig=sel_fig) fig._close_event(sel_fig) - assert browse_backend._get_n_figs() == 0 + assert browser_backend._get_n_figs() == 0 -def test_plot_raw_ssp_interaction(raw, browse_backend): +def test_plot_raw_ssp_interaction(raw, browser_backend): """Test SSP projector UI of plot_raw().""" with raw.info._unlock(): raw.info['lowpass'] = 10. # allow heavy decim during plotting @@ -268,79 +348,74 @@ def test_plot_raw_ssp_interaction(raw, browse_backend): raw.add_proj(projs) fig = raw.plot() # open SSP window - fig._fake_click((0.5, 0.5), ax=fig.mne.ax_proj) - assert browse_backend._get_n_figs() == 2 + fig._fake_keypress('j') + assert browser_backend._get_n_figs() == 2 ssp_fig = fig.mne.fig_proj - # ToDo: These gui-elements might differ in pyqtgraph. - t = ssp_fig.mne.proj_checkboxes.labels - ax = ssp_fig.mne.proj_checkboxes.ax - assert _proj_status(ax) == [True, True, True] + assert _proj_status(ssp_fig, browser_backend) == [True, True, True] # this should have no effect (proj 0 is already applied) - assert t[0].get_text().endswith('(already applied)') - pos = np.array(t[0].get_position()) + 0.01 - fig._fake_click(pos, ssp_fig, ax, xform='data') - assert _proj_status(ax) == [True, True, True] + assert _proj_label(ssp_fig, + browser_backend)[0].endswith('(already applied)') + _proj_click(0, fig, browser_backend) + assert _proj_status(ssp_fig, browser_backend) == [True, True, True] # this should work (proj 1 not applied) - pos = np.array(t[1].get_position()) + 0.01 - fig._fake_click(pos, ssp_fig, ax, xform='data') - assert _proj_status(ax) == [True, False, True] + _proj_click(1, fig, browser_backend) + assert _proj_status(ssp_fig, browser_backend) == [True, False, True] # turn it back on - fig._fake_click(pos, ssp_fig, ax, xform='data') - assert _proj_status(ax) == [True, True, True] + _proj_click(1, fig, browser_backend) + assert _proj_status(ssp_fig, browser_backend) == [True, True, True] # toggle all off (button axes need both press and release) - fig._fake_click((0.5, 0.5), ssp_fig, ssp_fig.mne.proj_all.ax) - fig._fake_click((0.5, 0.5), ssp_fig, - ssp_fig.mne.proj_all.ax, kind='release') - assert _proj_status(ax) == [True, False, False] + _proj_click_all(fig, browser_backend) + assert _proj_status(ssp_fig, browser_backend) == [True, False, False] fig._fake_keypress('J') - assert _proj_status(ax) == [True, True, True] + assert _proj_status(ssp_fig, browser_backend) == [True, True, True] fig._fake_keypress('J') - assert _proj_status(ax) == [True, False, False] + assert _proj_status(ssp_fig, browser_backend) == [True, False, False] # turn all on - fig._fake_click((0.5, 0.5), ssp_fig, ssp_fig.mne.proj_all.ax) # all on - fig._fake_click((0.5, 0.5), ssp_fig, ssp_fig.mne.proj_all.ax, - kind='release') + _proj_click_all(fig, browser_backend) assert fig.mne.projector is not None # on - assert _proj_status(ax) == [True, True, True] + assert _proj_status(ssp_fig, browser_backend) == [True, True, True] -def test_plot_raw_child_figures(raw, browse_backend): +def test_plot_raw_child_figures(raw, browser_backend): """Test spawning and closing of child figures.""" + ismpl = browser_backend.name == 'matplotlib' with raw.info._unlock(): raw.info['lowpass'] = 10. # allow heavy decim during plotting - browse_backend._close_all() # make sure we start clean - assert browse_backend._get_n_figs() == 0 + browser_backend._close_all() # make sure we start clean + assert browser_backend._get_n_figs() == 0 fig = raw.plot() - assert browse_backend._get_n_figs() == 1 + assert browser_backend._get_n_figs() == 1 # test child fig toggles - _child_fig_helper(fig, '?', 'fig_help', browse_backend) - _child_fig_helper(fig, 'j', 'fig_proj', browse_backend) - # ToDo: This figure won't be there with pyqtgraph. - _child_fig_helper(fig, 'a', 'fig_annotation', browse_backend) + _child_fig_helper(fig, '?', 'fig_help', browser_backend) + _child_fig_helper(fig, 'j', 'fig_proj', browser_backend) + # In pyqtgraph, this is a dock-widget instead of a separated window. + if ismpl: + _child_fig_helper(fig, 'a', 'fig_annotation', browser_backend) assert len(fig.mne.child_figs) == 0 # make sure the helper cleaned up - assert browse_backend._get_n_figs() == 1 + assert browser_backend._get_n_figs() == 1 # test right-click → channel location popup fig._redraw() fig._click_ch_name(ch_index=2, button=3) assert len(fig.mne.child_figs) == 1 - assert browse_backend._get_n_figs() == 2 + assert browser_backend._get_n_figs() == 2 fig._fake_keypress('escape', fig=fig.mne.child_figs[0]) - fig._close_event(fig.mne.child_figs[0]) - assert browse_backend._get_n_figs() == 1 + if ismpl: + fig._close_event(fig.mne.child_figs[0]) + assert browser_backend._get_n_figs() == 1 # test right-click on non-data channel ix = raw.get_channel_types().index('ias') # find the shielding channel trace_ix = fig.mne.ch_order.tolist().index(ix) # get its plotting position assert len(fig.mne.child_figs) == 0 - assert browse_backend._get_n_figs() == 1 + assert browser_backend._get_n_figs() == 1 fig._redraw() fig._click_ch_name(ch_index=trace_ix, button=3) # should be no-op assert len(fig.mne.child_figs) == 0 - assert browse_backend._get_n_figs() == 1 + assert browser_backend._get_n_figs() == 1 # test resize of main window fig._resize_by_factor(0.5) -def test_plot_raw_keypresses(raw, browse_backend): +def test_plot_raw_keypresses(raw, browser_backend): """Test keypress interactivity of plot_raw().""" with raw.info._unlock(): raw.info['lowpass'] = 10. # allow heavy decim during plotting @@ -359,24 +434,24 @@ def test_plot_raw_keypresses(raw, browse_backend): fig._fake_keypress(key) -def test_plot_raw_traces(raw, events, browse_backend): +def test_plot_raw_traces(raw, events, browser_backend): """Test plotting of raw data.""" + ismpl = browser_backend.name == 'matplotlib' with raw.info._unlock(): raw.info['lowpass'] = 10. # allow heavy decim during plotting fig = raw.plot(events=events, order=[1, 7, 5, 2, 3], n_channels=3, group_by='original') assert hasattr(fig, 'mne') # make sure fig.mne param object is present - assert len(fig.axes) == 5 + if ismpl: + assert len(fig.axes) == 5 # setup x = fig.mne.traces[0].get_xdata()[5] y = fig.mne.traces[0].get_ydata()[5] - data_ax = fig.mne.ax_main - # ToDo: The interaction with scrollbars will be different in pyqtgraph. hscroll = fig.mne.ax_hscroll vscroll = fig.mne.ax_vscroll # test marking bad channels - label = fig.mne.ax_main.get_yticklabels()[0].get_text() + label = fig._get_ticklabels('y')[0] assert label not in fig.mne.info['bads'] # click data to mark bad fig._fake_click((x, y), xform='data') @@ -388,22 +463,28 @@ def test_plot_raw_traces(raw, events, browse_backend): fig._click_ch_name(ch_index=0, button=1) assert label in fig.mne.info['bads'] # test other kinds of clicks - fig._fake_click((0.5, 0.999)) # click elsewhere (add vline) - fig._fake_click((0.5, 0.999), button=3) # remove vline + fig._fake_click((0.5, 0.98)) # click elsewhere (add vline) + assert fig.mne.vline_visible is True + fig._fake_click((0.5, 0.98), button=3) # remove vline + assert fig.mne.vline_visible is False fig._fake_click((0.5, 0.5), ax=hscroll) # change time + t_start = fig.mne.t_start fig._fake_click((0.5, 0.5), ax=hscroll) # shouldn't change time this time + assert round(t_start, 6) == round(fig.mne.t_start, 6) # test scrolling through channels - labels = [label.get_text() for label in data_ax.get_yticklabels()] + labels = fig._get_ticklabels('y') assert labels == [raw.ch_names[1], raw.ch_names[7], raw.ch_names[5]] - fig._fake_click((0.5, 0.01), ax=vscroll) # change channels to end - labels = [label.get_text() for label in data_ax.get_yticklabels()] + fig._fake_click((0.5, 0.05), ax=vscroll) # change channels to end + labels = fig._get_ticklabels('y') assert labels == [raw.ch_names[5], raw.ch_names[2], raw.ch_names[3]] for _ in (0, 0): # first click changes channels to mid; second time shouldn't change - fig._fake_click((0.5, 0.5), ax=vscroll) - labels = [label.get_text() for label in data_ax.get_yticklabels()] + # This needs to be changed for pyqtgraph, because there scrollbars are + # drawn differently (value of slider at lower end, not at middle) + yclick = 0.5 if ismpl else 0.7 + fig._fake_click((0.5, yclick), ax=vscroll) + labels = fig._get_ticklabels('y') assert labels == [raw.ch_names[7], raw.ch_names[5], raw.ch_names[2]] - assert browse_backend._get_n_figs() == 1 # test clicking a channel name in butterfly mode bads = fig.mne.info['bads'].copy() @@ -420,7 +501,7 @@ def test_plot_raw_traces(raw, events, browse_backend): with pytest.raises(TypeError, match='title must be None or a string, got'): raw.plot(title=1) raw.plot(show_options=True) - browse_backend._close_all() + browser_backend._close_all() # annotations outside data range annot = Annotations([10, 10 + raw.first_samp / raw.info['sfreq']], @@ -433,12 +514,11 @@ def test_plot_raw_traces(raw, events, browse_backend): raw.plot(event_color={0: 'r'}) with pytest.raises(TypeError, match='event_color key must be an int, got'): raw.plot(event_color={'foo': 'r'}) - fig = plot_raw(raw, events=events, event_color={-1: 'r', 998: 'b'}) - browse_backend._close_all() + plot_raw(raw, events=events, event_color={-1: 'r', 998: 'b'}) @pytest.mark.parametrize('group_by', ('position', 'selection')) -def test_plot_raw_groupby(raw, browse_backend, group_by): +def test_plot_raw_groupby(raw, browser_backend, group_by): """Test group-by plotting of raw data.""" with raw.info._unlock(): raw.info['lowpass'] = 10. # allow heavy decim during plotting @@ -450,24 +530,25 @@ def test_plot_raw_groupby(raw, browse_backend, group_by): fig._fake_keypress('down') # change selection fig._fake_click((x, y), xform='data') # mark bad fig._fake_click((0.5, 0.5), ax=fig.mne.ax_vscroll) # change channels - sel_fig = fig.mne.fig_selection - topo_ax = sel_fig.mne.sensor_ax - fig._fake_click([-0.425, 0.20223853], sel_fig, topo_ax, xform='data') - fig._fake_keypress('down') + if browser_backend.name == 'matplotlib': + # Test lasso-selection + # (test difficult with pyqtgraph-backend, set plot_raw_selection) + sel_fig = fig.mne.fig_selection + topo_ax = sel_fig.mne.sensor_ax + fig._fake_click([-0.425, 0.20223853], fig=sel_fig, ax=topo_ax, + xform='data') + fig._fake_click((-0.5, 0.), add_points=[(0.5, 0.), + (0.5, 0.5), + (-0.5, 0.5)], + fig=sel_fig, ax=topo_ax, xform='data', kind='drag') + fig._fake_keypress('down') + fig._fake_keypress('up') fig._fake_keypress('up') fig._fake_scroll(0.5, 0.5, -1) # scroll down fig._fake_scroll(0.5, 0.5, 1) # scroll up - fig._fake_click([-0.5, 0.], sel_fig, topo_ax, xform='data') - fig._fake_click((0.5, 0.), sel_fig, topo_ax, xform='data', - kind='motion') - fig._fake_click((0.5, 0.5), sel_fig, topo_ax, xform='data', - kind='motion') - fig._fake_click([-0.5, 0.5], sel_fig, topo_ax, xform='data', - kind='release') - browse_backend._close_all() -def test_plot_raw_meas_date(raw, browse_backend): +def test_plot_raw_meas_date(raw, browser_backend): """Test effect of mismatched meas_date in raw.plot().""" raw.set_meas_date(_dt_to_stamp(raw.info['meas_date'])[0]) annot = Annotations([1 + raw.first_samp / raw.info['sfreq']], [5], ['bad']) @@ -478,107 +559,125 @@ def test_plot_raw_meas_date(raw, browse_backend): fig = raw.plot() for key in ['down', 'up', 'escape']: fig._fake_keypress(key, fig=fig.mne.fig_selection) - browse_backend._close_all() -def test_plot_raw_nan(raw, browse_backend): +def test_plot_raw_nan(raw, browser_backend): """Test plotting all NaNs.""" raw._data[:] = np.nan # this should (at least) not die, the output should pretty clearly show # that there is a problem so probably okay to just plot something blank with pytest.warns(None): raw.plot(scalings='auto') - browse_backend._close_all() @testing.requires_testing_data -def test_plot_raw_white(raw_orig, noise_cov_io, browse_backend): +def test_plot_raw_white(raw_orig, noise_cov_io, browser_backend): """Test plotting whitened raw data.""" raw_orig.crop(0, 1) fig = raw_orig.plot(noise_cov=noise_cov_io) # toggle whitening fig._fake_keypress('w') fig._fake_keypress('w') - browse_backend._close_all() @testing.requires_testing_data -def test_plot_ref_meg(raw_ctf, browse_backend): +def test_plot_ref_meg(raw_ctf, browser_backend): """Test plotting ref_meg.""" raw_ctf.crop(0, 1) raw_ctf.plot() - browse_backend._close_all() pytest.raises(ValueError, raw_ctf.plot, group_by='selection') -def test_plot_misc_auto(browse_backend): +def test_plot_misc_auto(browser_backend): """Test plotting of data with misc auto scaling.""" data = np.random.RandomState(0).randn(1, 1000) raw = RawArray(data, create_info(1, 1000., 'misc')) raw.plot() raw = RawArray(data, create_info(1, 1000., 'dipole')) raw.plot(order=[0]) # plot, even though it's not "data" - browse_backend._close_all() + browser_backend._close_all() @pytest.mark.slowtest -def test_plot_annotations(raw, browse_backend): +def test_plot_annotations(raw, browser_backend): """Test annotation mode of the plotter.""" + ismpl = browser_backend.name == 'matplotlib' with raw.info._unlock(): raw.info['lowpass'] = 10. - _annotation_helper(raw, browse_backend) - _annotation_helper(raw, browse_backend, events=True) + _annotation_helper(raw, browser_backend) + _annotation_helper(raw, browser_backend, events=True) annot = Annotations([42], [1], 'test', raw.info['meas_date']) with pytest.warns(RuntimeWarning, match='expanding outside'): raw.set_annotations(annot) - _annotation_helper(raw, browse_backend) + _annotation_helper(raw, browser_backend) # test annotation visibility toggle fig = raw.plot() - assert len(fig.mne.annotations) == 1 - assert len(fig.mne.annotation_texts) == 1 + if ismpl: + assert len(fig.mne.annotations) == 1 + assert len(fig.mne.annotation_texts) == 1 + else: + assert len(fig.mne.regions) == 1 fig._fake_keypress('a') # start annotation mode - # ToDo: This will be different in pyqtgraph (toolbar). - checkboxes = fig.mne.show_hide_annotation_checkboxes - checkboxes.set_active(0) - assert len(fig.mne.annotations) == 0 - assert len(fig.mne.annotation_texts) == 0 - checkboxes.set_active(0) - assert len(fig.mne.annotations) == 1 - assert len(fig.mne.annotation_texts) == 1 + if ismpl: + checkboxes = fig.mne.show_hide_annotation_checkboxes + checkboxes.set_active(0) + assert len(fig.mne.annotations) == 0 + assert len(fig.mne.annotation_texts) == 0 + checkboxes.set_active(0) + assert len(fig.mne.annotations) == 1 + assert len(fig.mne.annotation_texts) == 1 + else: + fig.mne.visible_annotations['test'] = False + fig._update_regions_visible() + assert not fig.mne.regions[0].isVisible() + fig.mne.visible_annotations['test'] = True + fig._update_regions_visible() + assert fig.mne.regions[0].isVisible() @pytest.mark.parametrize('hide_which', ([], [0], [1], [0, 1])) -def test_remove_annotations(raw, hide_which, browse_backend): +def test_remove_annotations(raw, hide_which, browser_backend): """Test that right-click doesn't remove hidden annotation spans.""" + descriptions = ['foo', 'bar'] ann = Annotations(onset=[2, 1], duration=[1, 3], - description=['foo', 'bar']) + description=descriptions) raw.set_annotations(ann) assert len(raw.annotations) == 2 fig = raw.plot() fig._fake_keypress('a') # start annotation mode - # ToDo: This will be different in pyqtgraph (toolbar). - checkboxes = fig.mne.show_hide_annotation_checkboxes - for which in hide_which: - checkboxes.set_active(which) + if browser_backend.name == 'matplotlib': + checkboxes = fig.mne.show_hide_annotation_checkboxes + for which in hide_which: + checkboxes.set_active(which) + else: + for hide_idx in hide_which: + hide_key = descriptions[hide_idx] + fig.mne.visible_annotations[hide_key] = False + fig._update_regions_visible() fig._fake_click((2.5, 0.1), xform='data', button=3) assert len(raw.annotations) == len(hide_which) @pytest.mark.parametrize('filtorder', (0, 2)) # FIR, IIR -def test_plot_raw_filtered(filtorder, raw, browse_backend): +def test_plot_raw_filtered(filtorder, raw, browser_backend): """Test filtering of raw plots.""" + # Opening that many plots can cause a Segmentation fault + # if multithreading is activated in pyqtgraph-backend + pg_kwargs = {'precompute': False} with pytest.raises(ValueError, match='lowpass.*Nyquist'): - raw.plot(lowpass=raw.info['sfreq'] / 2., filtorder=filtorder) + raw.plot(lowpass=raw.info['sfreq'] / 2., filtorder=filtorder, + **pg_kwargs) with pytest.raises(ValueError, match='highpass must be > 0'): - raw.plot(highpass=0, filtorder=filtorder) + raw.plot(highpass=0, filtorder=filtorder, **pg_kwargs) with pytest.raises(ValueError, match='Filter order must be'): - raw.plot(lowpass=1, filtorder=-1) + raw.plot(lowpass=1, filtorder=-1, **pg_kwargs) with pytest.raises(ValueError, match="Invalid value for the 'clipping'"): - raw.plot(clipping='foo') - raw.plot(lowpass=40, clipping='transparent', filtorder=filtorder) - raw.plot(highpass=1, clipping='clamp', filtorder=filtorder) - raw.plot(lowpass=40, butterfly=True, filtorder=filtorder) + raw.plot(clipping='foo', **pg_kwargs) + raw.plot(lowpass=40, clipping='transparent', filtorder=filtorder, + **pg_kwargs) + raw.plot(highpass=1, clipping='clamp', filtorder=filtorder, **pg_kwargs) + raw.plot(lowpass=40, butterfly=True, filtorder=filtorder, **pg_kwargs) # shouldn't break if all shown are non-data RawArray(np.zeros((1, 100)), create_info(1, 20., 'stim')).plot(lowpass=5) @@ -733,29 +832,31 @@ def test_plot_sensors(raw): @pytest.mark.parametrize('cfg_value', (None, '0.1,0.1')) -def test_min_window_size(raw, cfg_value, browse_backend): +def test_min_window_size(raw, cfg_value, browser_backend): """Test minimum window plot size.""" old_cfg = get_config('MNE_BROWSE_RAW_SIZE') set_config('MNE_BROWSE_RAW_SIZE', cfg_value) fig = raw.plot() - # 8 × 8 inches is default minimum size - assert_array_equal(fig.get_size_inches(), (8, 8)) + # For an unknown reason, the Windows-CI is a bit off + # (on local Windows 10 the size is exactly as expected). + atol = 0 if not os.name == 'nt' else 0.2 + # 8 × 8 inches is default minimum size. + assert_allclose(fig._get_size(), (8, 8), atol=atol) set_config('MNE_BROWSE_RAW_SIZE', old_cfg) -def test_scalings_int(browse_backend): +def test_scalings_int(browser_backend): """Test that auto scalings access samples using integers.""" raw = RawArray(np.zeros((1, 500)), create_info(1, 1000., 'eeg')) raw.plot(scalings='auto') -@pytest.mark.parametrize('dur, n_dec', [(20, 1), (4.2, 2), (0.01, 4)]) -def test_clock_xticks(raw, dur, n_dec, browse_backend): +@pytest.mark.parametrize('dur, n_dec', [(20, 1), (1.8, 2), (0.01, 4)]) +def test_clock_xticks(raw, dur, n_dec, browser_backend): """Test if decimal seconds of xticks have appropriate length.""" fig = raw.plot(duration=dur, time_format='clock') - fig.canvas.draw() - ticklabels = fig.mne.ax_main.get_xticklabels() - tick_texts = [tl.get_text() for tl in ticklabels] + fig._redraw() + tick_texts = fig._get_ticklabels('x') assert tick_texts[0].startswith('19:01:53') if len(tick_texts[0].split('.')) > 1: assert len(tick_texts[0].split('.')[1]) == n_dec diff --git a/mne/viz/utils.py b/mne/viz/utils.py index cf4844642ac..c2f04917b2c 100644 --- a/mne/viz/utils.py +++ b/mne/viz/utils.py @@ -11,7 +11,7 @@ # Daniel McCloy # # License: Simplified BSD - +import sys from collections import defaultdict from contextlib import contextmanager from functools import partial @@ -109,6 +109,37 @@ def plt_show(show=True, fig=None, **kwargs): (fig or plt).show(**kwargs) +def _show_browser(show=True, block=True, fig=None, **kwargs): + """Show the browser considering different backends. + + Parameters + ---------- + show : bool + Show the figure. + block : bool + If to block execution on showing. + fig : instance of Figure | None + Needs to be passed for pyqtgraph backend, + optional for matplotlib. + **kwargs : dict + Extra arguments for :func:`matplotlib.pyplot.show`. + """ + from ._figure import get_browser_backend + backend = get_browser_backend() + if backend == 'matplotlib': + plt_show(show, block=block, **kwargs) + else: + from qtpy.QtWidgets import QApplication + app = QApplication.instance() or QApplication(sys.argv) + app.setApplicationName('MNE-Python') + if show: + fig.show() + # If block=False, a Qt-Event-Loop has to be started + # somewhere else in the calling code. + if block: + app.exec() + + def tight_layout(pad=1.2, h_pad=None, w_pad=None, fig=None): """Adjust subplot parameters to give specified padding. diff --git a/requirements.txt b/requirements.txt index 5b308ff407d..28e94f2f069 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,3 +37,4 @@ mffpy>=0.5.7 ipywidgets ipyvtklink pooch +mne-qt-browser diff --git a/tools/github_actions_infos.sh b/tools/github_actions_infos.sh index 87c21245db8..95d18cdd2ba 100755 --- a/tools/github_actions_infos.sh +++ b/tools/github_actions_infos.sh @@ -1,4 +1,5 @@ #!/bin/bash -ef +which mne mne sys_info -pd python -c "import numpy; numpy.show_config()" diff --git a/tools/github_actions_test.sh b/tools/github_actions_test.sh index a1d102ee510..a6b9a94e096 100755 --- a/tools/github_actions_test.sh +++ b/tools/github_actions_test.sh @@ -2,9 +2,9 @@ USE_DIRS="mne/" if [ "${CI_OS_NAME}" != "osx" ]; then - CONDITION="not ultraslowtest" + CONDITION="not (ultraslowtest or pgtest)" else - CONDITION="not slowtest" + CONDITION="not (slowtest or pgtest)" fi echo 'pytest -m "${CONDITION}" --tb=short --cov=mne --cov-report xml -vv ${USE_DIRS}' pytest -m "${CONDITION}" --tb=short --cov=mne --cov-report xml -vv ${USE_DIRS}