From 306b53e74e3482c12fb1af3bc6822f08aea47868 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Tue, 31 May 2022 10:04:21 -0400 Subject: [PATCH 01/55] Initial commit to show skeleton --- mne_icalabel/gui/__init__.py | 42 +++++++++ mne_icalabel/gui/_label_components.py | 126 ++++++++++++++++++++++++++ requirements_testing.txt | 1 + 3 files changed, 169 insertions(+) create mode 100644 mne_icalabel/gui/__init__.py create mode 100644 mne_icalabel/gui/_label_components.py diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py new file mode 100644 index 00000000..1fb63a1a --- /dev/null +++ b/mne_icalabel/gui/__init__.py @@ -0,0 +1,42 @@ +from mne.utils import verbose + + +@verbose +def label_ica_components(info, trans, aligned_ct, subject=None, subjects_dir=None, + groups=None, verbose=None): + """Label ICA components. + + Parameters + ---------- + %(info_not_none)s + %(trans_not_none)s + aligned_ct : path-like | nibabel.spatialimages.SpatialImage + The CT image that has been aligned to the Freesurfer T1. Path-like + inputs and nibabel image objects are supported. + %(subject)s + %(subjects_dir)s + groups : dict | None + A dictionary with channels as keys and their group index as values. + If None, the groups will be inferred by the channel names. Channel + names must have a format like ``LAMY 7`` where a string prefix + like ``LAMY`` precedes a numeric index like ``7``. If the channels + are formatted improperly, group plotting will work incorrectly. + Group assignments can be adjusted in the GUI. + %(verbose)s + + Returns + ------- + gui : instance of IntracranialElectrodeLocator + The graphical user interface (GUI) window. + """ + from ._ieeg_locate_gui import IntracranialElectrodeLocator + from qtpy.QtWidgets import QApplication + # get application + app = QApplication.instance() + if app is None: + app = QApplication(["Intracranial Electrode Locator"]) + gui = IntracranialElectrodeLocator( + info, trans, aligned_ct, subject=subject, + subjects_dir=subjects_dir, groups=groups, verbose=verbose) + gui.show() + return gui diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py new file mode 100644 index 00000000..7877bea3 --- /dev/null +++ b/mne_icalabel/gui/_label_components.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +"""ICA GUI for labeling components.""" + +# Authors: Adam Li +# +# License: BSD (3-clause) + + +import platform + +from qtpy import QtCore, QtGui +from qtpy.QtCore import Slot, Signal +from qtpy.QtWidgets import (QMainWindow, QGridLayout, + QVBoxLayout, QHBoxLayout, QLabel, + QMessageBox, QWidget, QAbstractItemView, + QListView, QSlider, QPushButton, + QComboBox, QPlainTextEdit) + + +# _IMG_LABELS = [['I', 'P'], ['I', 'L'], ['P', 'L']] +# _CH_PLOT_SIZE = 1024 +# _ZOOM_STEP_SIZE = 5 +# _RADIUS_SCALAR = 0.4 +# _TUBE_SCALAR = 0.1 +# _BOLT_SCALAR = 30 # mm +_CH_MENU_WIDTH = 30 if platform.system() == 'Windows' else 10 + + +# TODO: +#? - plot_properties plot +#? - update ICA components +#? - menu with save, load +class ICAComponentLabeler(QMainWindow): + def __init__(self, ica, raw, ) -> None: + # initialize QMainWindow class + super().__init__() + + self._ica = ica + self._raw = raw + + # GUI design + + # Main plots: make one plot for each view; sagittal, coronal, axial + plt_grid = QGridLayout() + plts = [_make_slice_plot(), _make_slice_plot(), _make_slice_plot()] + self._figs = [plts[0][1], plts[1][1], plts[2][1]] + plt_grid.addWidget(plts[0][0], 0, 0) + plt_grid.addWidget(plts[1][0], 0, 1) + plt_grid.addWidget(plts[2][0], 1, 0) + self._renderer = _get_renderer( + name='IEEG Locator', size=(400, 400), bgcolor='w') + plt_grid.addWidget(self._renderer.plotter) + + # Channel selector + self._ch_list = QListView() + self._ch_list.setSelectionMode(QAbstractItemView.SingleSelection) + max_ch_name_len = max([len(name) for name in self._chs]) + self._ch_list.setMinimumWidth(max_ch_name_len * _CH_MENU_WIDTH) + self._ch_list.setMaximumWidth(max_ch_name_len * _CH_MENU_WIDTH) + self._set_ch_names() + + # Plots + self._plot_images() + + # Menus + button_hbox = self._get_button_bar() + slider_hbox = self._get_slider_bar() + bottom_hbox = self._get_bottom_bar() + + # Add lines + self._lines = dict() + self._lines_2D = dict() + for group in set(self._groups.values()): + self._update_lines(group) + + # Put everything together + plot_ch_hbox = QHBoxLayout() + plot_ch_hbox.addLayout(plt_grid) + plot_ch_hbox.addWidget(self._ch_list) + + main_vbox = QVBoxLayout() + main_vbox.addLayout(button_hbox) + main_vbox.addLayout(slider_hbox) + main_vbox.addLayout(plot_ch_hbox) + main_vbox.addLayout(bottom_hbox) + + central_widget = QWidget() + central_widget.setLayout(main_vbox) + self.setCentralWidget(central_widget) + + # ready for user + self._move_cursors_to_pos() + self._ch_list.setFocus() # always focus on list + + def _plot_images(self): + pass + + def _save_component_labels(self): + pass + + def _update_grouop(self): + pass + + @Slot() + def _mark_component(self): + pass + + @safe_event + def closeEvent(self, event): + """Clean up upon closing the window.""" + self._renderer.plotter.close() + self.close() + + def _key_press_event(self, event): + pass + + def _show_help(self): + """Show the help menu.""" + QMessageBox.information( + self, 'Help', + "Help:\n'm': mark channel location\n" + "'r': remove channel location\n" + "'b': toggle viewing of brain in T1\n" + "'+'/'-': zoom\nleft/right arrow: left/right\n" + "up/down arrow: superior/inferior\n" + "left angle bracket/right angle bracket: anterior/posterior") diff --git a/requirements_testing.txt b/requirements_testing.txt index 0948c3eb..df01a8a1 100644 --- a/requirements_testing.txt +++ b/requirements_testing.txt @@ -23,3 +23,4 @@ python-picard joblib scikit-learn pandas +qtpy \ No newline at end of file From 3ae5dcd50d5cf990caa19a8424a6a6e71b9619d7 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 6 Jun 2022 18:26:45 -0400 Subject: [PATCH 02/55] Some more components --- mne_icalabel/gui/_label_components.py | 57 +++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 7877bea3..516e59a4 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -8,6 +8,12 @@ import platform +from mne.viz.backends.renderer import _get_renderer + +from matplotlib.figure import Figure +from matplotlib.backends.backend_qt5agg import FigureCanvas + + from qtpy import QtCore, QtGui from qtpy.QtCore import Slot, Signal from qtpy.QtWidgets import (QMainWindow, QGridLayout, @@ -26,8 +32,49 @@ _CH_MENU_WIDTH = 30 if platform.system() == 'Windows' else 10 +def _make_topo_plot(width=4, height=4, dpi=300): + """Make subplot for the topomap.""" + fig = Figure(figsize=(width, height), dpi=dpi) + canvas = FigureCanvas(fig) + ax = fig.subplots() + fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + ax.set_facecolor('k') + # clean up excess plot text, invert + ax.invert_yaxis() + ax.set_xticks([]) + ax.set_yticks([]) + return canvas, fig + + +def _make_ts_plot(width=4, height=4, dpi=300): + """Make subplot for the component time-series.""" + fig = Figure(figsize=(width, height), dpi=dpi) + canvas = FigureCanvas(fig) + ax = fig.subplots() + fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + ax.set_facecolor('k') + # clean up excess plot text, invert + ax.invert_yaxis() + ax.set_xticks([]) + ax.set_yticks([]) + return canvas, fig + +def _make_spectrum_plot(width=4, height=4, dpi=300): + """Make subplot for the spectrum.""" + fig = Figure(figsize=(width, height), dpi=dpi) + canvas = FigureCanvas(fig) + ax = fig.subplots() + fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + ax.set_facecolor('k') + # clean up excess plot text, invert + ax.invert_yaxis() + ax.set_xticks([]) + ax.set_yticks([]) + return canvas, fig + + # TODO: -#? - plot_properties plot +#? - plot_properties plot - topoplot, ICA time-series #? - update ICA components #? - menu with save, load class ICAComponentLabeler(QMainWindow): @@ -40,15 +87,16 @@ def __init__(self, ica, raw, ) -> None: # GUI design - # Main plots: make one plot for each view; sagittal, coronal, axial + # Main plots: make one plot for each view: + # topographic, time-series, power-spectrum plt_grid = QGridLayout() - plts = [_make_slice_plot(), _make_slice_plot(), _make_slice_plot()] + plts = [_make_topo_plot(), _make_ts_plot(), _make_spectrum_plot()] self._figs = [plts[0][1], plts[1][1], plts[2][1]] plt_grid.addWidget(plts[0][0], 0, 0) plt_grid.addWidget(plts[1][0], 0, 1) plt_grid.addWidget(plts[2][0], 1, 0) self._renderer = _get_renderer( - name='IEEG Locator', size=(400, 400), bgcolor='w') + name='ICA Component Labeler', size=(400, 400), bgcolor='w') plt_grid.addWidget(self._renderer.plotter) # Channel selector @@ -93,6 +141,7 @@ def __init__(self, ica, raw, ) -> None: self._ch_list.setFocus() # always focus on list def _plot_images(self): + # TODO: embed the matplotlib figure in each FigureCanvas pass def _save_component_labels(self): From 7fed4cd8d63f656c31914233049a8158e9020805 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Tue, 14 Jun 2022 20:32:20 -0400 Subject: [PATCH 03/55] Add draft gui --- mne_icalabel/gui/_label_components.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 516e59a4..6d56aecc 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -122,23 +122,23 @@ def __init__(self, ica, raw, ) -> None: self._update_lines(group) # Put everything together - plot_ch_hbox = QHBoxLayout() - plot_ch_hbox.addLayout(plt_grid) - plot_ch_hbox.addWidget(self._ch_list) + # plot_ch_hbox = QHBoxLayout() + # plot_ch_hbox.addLayout(plt_grid) + # plot_ch_hbox.addWidget(self._ch_list) - main_vbox = QVBoxLayout() - main_vbox.addLayout(button_hbox) - main_vbox.addLayout(slider_hbox) - main_vbox.addLayout(plot_ch_hbox) - main_vbox.addLayout(bottom_hbox) + # main_vbox = QVBoxLayout() + # main_vbox.addLayout(button_hbox) + # main_vbox.addLayout(slider_hbox) + # main_vbox.addLayout(plot_ch_hbox) + # main_vbox.addLayout(bottom_hbox) - central_widget = QWidget() - central_widget.setLayout(main_vbox) - self.setCentralWidget(central_widget) + # central_widget = QWidget() + # central_widget.setLayout(main_vbox) + # self.setCentralWidget(central_widget) # ready for user - self._move_cursors_to_pos() - self._ch_list.setFocus() # always focus on list + # self._move_cursors_to_pos() + # self._ch_list.setFocus() # always focus on list def _plot_images(self): # TODO: embed the matplotlib figure in each FigureCanvas From 1a6df47c197a941e117fcded14b99de9b4675da3 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Tue, 14 Jun 2022 21:01:49 -0400 Subject: [PATCH 04/55] Adding the GUI with basic Python API --- examples/label_components.py | 43 ++++++++++++++++++++ mne_icalabel/__init__.py | 1 + mne_icalabel/gui/__init__.py | 33 ++++++---------- mne_icalabel/gui/_label_components.py | 56 +++++++++++++++------------ 4 files changed, 87 insertions(+), 46 deletions(-) create mode 100644 examples/label_components.py diff --git a/examples/label_components.py b/examples/label_components.py new file mode 100644 index 00000000..b106d4da --- /dev/null +++ b/examples/label_components.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +""" +.. _tut-label-ica-components: + +Labeling ICA components with a GUI +================================== + +This tutorial covers how to label ICA components with a GUI. +""" + +# %% + +import os + +import mne +from mne.preprocessing import ICA + +import mne_icalabel + +sample_data_folder = mne.datasets.sample.data_path() +sample_data_raw_file = os.path.join( + sample_data_folder, "MEG", "sample", "sample_audvis_filt-0-40_raw.fif" +) +raw = mne.io.read_raw_fif(sample_data_raw_file) + +# Here we'll crop to 60 seconds and drop gradiometer channels for speed +raw.crop(tmax=60.0).pick_types(meg="mag", eeg=True, stim=True, eog=True) +raw.load_data() + +# high-pass filter the data and then perform ICA +filt_raw = raw.copy().filter(l_freq=1.0, h_freq=None) +ica = ICA(n_components=15, max_iter="auto", random_state=97) +ica.fit(filt_raw) + +# now label +gui = mne_icalabel.gui.label_ica_components(raw, ica) + +# The `ica` object is modified to contain the component labels +# after closing the GUI and can now be saved +# gui.close() # typically you close when done + +# Now, we can take a look at the components, which can be +# saved into the BIDs directory. diff --git a/mne_icalabel/__init__.py b/mne_icalabel/__init__.py index 198f97cf..cbbeaed2 100644 --- a/mne_icalabel/__init__.py +++ b/mne_icalabel/__init__.py @@ -7,4 +7,5 @@ __version__ = "0.2dev0" +from . import gui from .label_components import label_components # noqa: F401 diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py index 1fb63a1a..418b6db6 100644 --- a/mne_icalabel/gui/__init__.py +++ b/mne_icalabel/gui/__init__.py @@ -2,41 +2,30 @@ @verbose -def label_ica_components(info, trans, aligned_ct, subject=None, subjects_dir=None, - groups=None, verbose=None): +def label_ica_components(inst, ica, verbose=None): """Label ICA components. Parameters ---------- - %(info_not_none)s - %(trans_not_none)s - aligned_ct : path-like | nibabel.spatialimages.SpatialImage - The CT image that has been aligned to the Freesurfer T1. Path-like - inputs and nibabel image objects are supported. - %(subject)s - %(subjects_dir)s - groups : dict | None - A dictionary with channels as keys and their group index as values. - If None, the groups will be inferred by the channel names. Channel - names must have a format like ``LAMY 7`` where a string prefix - like ``LAMY`` precedes a numeric index like ``7``. If the channels - are formatted improperly, group plotting will work incorrectly. - Group assignments can be adjusted in the GUI. + inst : Raw | Epochs + The raw data instance that was used for ICA. + ica : ICA + The fitted ICA instance. %(verbose)s Returns ------- - gui : instance of IntracranialElectrodeLocator + gui : instance of ICAComponentLabeler The graphical user interface (GUI) window. """ - from ._ieeg_locate_gui import IntracranialElectrodeLocator from qtpy.QtWidgets import QApplication + + from ._label_components import ICAComponentLabeler + # get application app = QApplication.instance() if app is None: - app = QApplication(["Intracranial Electrode Locator"]) - gui = IntracranialElectrodeLocator( - info, trans, aligned_ct, subject=subject, - subjects_dir=subjects_dir, groups=groups, verbose=verbose) + app = QApplication(["ICA Component Labeler"]) + gui = ICAComponentLabeler(inst, ica, verbose=verbose) gui.show() return gui diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 6d56aecc..87ab7e6d 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -8,20 +8,26 @@ import platform -from mne.viz.backends.renderer import _get_renderer - -from matplotlib.figure import Figure from matplotlib.backends.backend_qt5agg import FigureCanvas - - +from matplotlib.figure import Figure +from mne.viz.backends.renderer import _get_renderer from qtpy import QtCore, QtGui -from qtpy.QtCore import Slot, Signal -from qtpy.QtWidgets import (QMainWindow, QGridLayout, - QVBoxLayout, QHBoxLayout, QLabel, - QMessageBox, QWidget, QAbstractItemView, - QListView, QSlider, QPushButton, - QComboBox, QPlainTextEdit) - +from qtpy.QtCore import Signal, Slot +from qtpy.QtWidgets import ( + QAbstractItemView, + QComboBox, + QGridLayout, + QHBoxLayout, + QLabel, + QListView, + QMainWindow, + QMessageBox, + QPlainTextEdit, + QPushButton, + QSlider, + QVBoxLayout, + QWidget, +) # _IMG_LABELS = [['I', 'P'], ['I', 'L'], ['P', 'L']] # _CH_PLOT_SIZE = 1024 @@ -29,7 +35,7 @@ # _RADIUS_SCALAR = 0.4 # _TUBE_SCALAR = 0.1 # _BOLT_SCALAR = 30 # mm -_CH_MENU_WIDTH = 30 if platform.system() == 'Windows' else 10 +_CH_MENU_WIDTH = 30 if platform.system() == "Windows" else 10 def _make_topo_plot(width=4, height=4, dpi=300): @@ -38,7 +44,7 @@ def _make_topo_plot(width=4, height=4, dpi=300): canvas = FigureCanvas(fig) ax = fig.subplots() fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - ax.set_facecolor('k') + ax.set_facecolor("k") # clean up excess plot text, invert ax.invert_yaxis() ax.set_xticks([]) @@ -52,20 +58,21 @@ def _make_ts_plot(width=4, height=4, dpi=300): canvas = FigureCanvas(fig) ax = fig.subplots() fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - ax.set_facecolor('k') + ax.set_facecolor("k") # clean up excess plot text, invert ax.invert_yaxis() ax.set_xticks([]) ax.set_yticks([]) return canvas, fig + def _make_spectrum_plot(width=4, height=4, dpi=300): """Make subplot for the spectrum.""" fig = Figure(figsize=(width, height), dpi=dpi) canvas = FigureCanvas(fig) ax = fig.subplots() fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - ax.set_facecolor('k') + ax.set_facecolor("k") # clean up excess plot text, invert ax.invert_yaxis() ax.set_xticks([]) @@ -74,11 +81,11 @@ def _make_spectrum_plot(width=4, height=4, dpi=300): # TODO: -#? - plot_properties plot - topoplot, ICA time-series -#? - update ICA components -#? - menu with save, load +# ? - plot_properties plot - topoplot, ICA time-series +# ? - update ICA components +# ? - menu with save, load class ICAComponentLabeler(QMainWindow): - def __init__(self, ica, raw, ) -> None: + def __init__(self, ica, raw) -> None: # initialize QMainWindow class super().__init__() @@ -95,8 +102,7 @@ def __init__(self, ica, raw, ) -> None: plt_grid.addWidget(plts[0][0], 0, 0) plt_grid.addWidget(plts[1][0], 0, 1) plt_grid.addWidget(plts[2][0], 1, 0) - self._renderer = _get_renderer( - name='ICA Component Labeler', size=(400, 400), bgcolor='w') + self._renderer = _get_renderer(name="ICA Component Labeler", size=(400, 400), bgcolor="w") plt_grid.addWidget(self._renderer.plotter) # Channel selector @@ -166,10 +172,12 @@ def _key_press_event(self, event): def _show_help(self): """Show the help menu.""" QMessageBox.information( - self, 'Help', + self, + "Help", "Help:\n'm': mark channel location\n" "'r': remove channel location\n" "'b': toggle viewing of brain in T1\n" "'+'/'-': zoom\nleft/right arrow: left/right\n" "up/down arrow: superior/inferior\n" - "left angle bracket/right angle bracket: anterior/posterior") + "left angle bracket/right angle bracket: anterior/posterior", + ) From cd0e947eca0b72d42c6a155a6f4e96795d1edd89 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Tue, 14 Jun 2022 22:15:29 -0400 Subject: [PATCH 05/55] Adding updated widget --- mne_icalabel/gui/__init__.py | 2 +- mne_icalabel/gui/_label_components.py | 107 +++++++++++++++++--------- 2 files changed, 71 insertions(+), 38 deletions(-) diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py index 418b6db6..0e2ed7bd 100644 --- a/mne_icalabel/gui/__init__.py +++ b/mne_icalabel/gui/__init__.py @@ -26,6 +26,6 @@ def label_ica_components(inst, ica, verbose=None): app = QApplication.instance() if app is None: app = QApplication(["ICA Component Labeler"]) - gui = ICAComponentLabeler(inst, ica, verbose=verbose) + gui = ICAComponentLabeler(inst=inst, ica=ica, verbose=verbose) gui.show() return gui diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 87ab7e6d..2751cd12 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -10,8 +10,9 @@ from matplotlib.backends.backend_qt5agg import FigureCanvas from matplotlib.figure import Figure +from mne.preprocessing import ICA from mne.viz.backends.renderer import _get_renderer -from qtpy import QtCore, QtGui +from qtpy import QtGui from qtpy.QtCore import Signal, Slot from qtpy.QtWidgets import ( QAbstractItemView, @@ -85,41 +86,48 @@ def _make_spectrum_plot(width=4, height=4, dpi=300): # ? - update ICA components # ? - menu with save, load class ICAComponentLabeler(QMainWindow): - def __init__(self, ica, raw) -> None: + def __init__(self, inst, ica: ICA) -> None: # initialize QMainWindow class super().__init__() + # keep an internal pointer to the ICA and Raw self._ica = ica - self._raw = raw + self._inst = inst - # GUI design - - # Main plots: make one plot for each view: - # topographic, time-series, power-spectrum + # GUI design to add widgets into a Layout + # Main plots: make one plot for each view: topographic, time-series, power-spectrum plt_grid = QGridLayout() plts = [_make_topo_plot(), _make_ts_plot(), _make_spectrum_plot()] self._figs = [plts[0][1], plts[1][1], plts[2][1]] plt_grid.addWidget(plts[0][0], 0, 0) plt_grid.addWidget(plts[1][0], 0, 1) plt_grid.addWidget(plts[2][0], 1, 0) + + # TODO: is this the correct function to use to render? or nah... since we don't have 3D? self._renderer = _get_renderer(name="ICA Component Labeler", size=(400, 400), bgcolor="w") plt_grid.addWidget(self._renderer.plotter) - # Channel selector - self._ch_list = QListView() - self._ch_list.setSelectionMode(QAbstractItemView.SingleSelection) - max_ch_name_len = max([len(name) for name in self._chs]) - self._ch_list.setMinimumWidth(max_ch_name_len * _CH_MENU_WIDTH) - self._ch_list.setMaximumWidth(max_ch_name_len * _CH_MENU_WIDTH) - self._set_ch_names() + # initialize channel data + self._component_index = 0 + + # component names are just a list of numbers from 0 to n_components + self._component_names = list(range(ica.n_components_)) + + # Component selector in a clickable selection list + self._component_list = QListView() + self._component_list.setSelectionMode(QAbstractItemView.SingleSelection) + max_comp_name_len = max([len(name) for name in self._component_list]) + self._component_list.setMinimumWidth(max_comp_name_len * _CH_MENU_WIDTH) + self._component_list.setMaximumWidth(max_comp_name_len * _CH_MENU_WIDTH) + self._set_component_names() # Plots self._plot_images() - # Menus - button_hbox = self._get_button_bar() - slider_hbox = self._get_slider_bar() - bottom_hbox = self._get_bottom_bar() + # TODO: Menus for user interface + # button_hbox = self._get_button_bar() + # slider_hbox = self._get_slider_bar() + # bottom_hbox = self._get_bottom_bar() # Add lines self._lines = dict() @@ -128,23 +136,55 @@ def __init__(self, ica, raw) -> None: self._update_lines(group) # Put everything together - # plot_ch_hbox = QHBoxLayout() - # plot_ch_hbox.addLayout(plt_grid) - # plot_ch_hbox.addWidget(self._ch_list) + plot_component_hbox = QHBoxLayout() + plot_component_hbox.addLayout(plt_grid) + plot_component_hbox.addWidget(self._component_list) - # main_vbox = QVBoxLayout() + # TODO: add the rest of the button and other widgets/menus + main_vbox = QVBoxLayout() + main_vbox.addLayout(plot_component_hbox) # main_vbox.addLayout(button_hbox) # main_vbox.addLayout(slider_hbox) - # main_vbox.addLayout(plot_ch_hbox) # main_vbox.addLayout(bottom_hbox) - # central_widget = QWidget() - # central_widget.setLayout(main_vbox) - # self.setCentralWidget(central_widget) + central_widget = QWidget() + central_widget.setLayout(main_vbox) + self.setCentralWidget(central_widget) # ready for user - # self._move_cursors_to_pos() - # self._ch_list.setFocus() # always focus on list + self._component_list.setFocus() # always focus on list + + def _set_component_names(self): + """Add the component names to the selector.""" + self._component_list_model = QtGui.QStandardItemModel(self._component_list) + for name in self._component_names: + self._component_list_model.appendRow(QtGui.QStandardItem(name)) + # TODO: can add a method to color code the list of items + # self._color_list_item(name=name) + self._component_list.setModel(self._component_list_model) + self._component_list.clicked.connect(self._go_to_component) + self._component_list.setCurrentIndex( + self._component_list_model.index(self._component_index, 0) + ) + self._component_list.keyPressEvent = self._key_press_event + + def _go_to_component(self, index): + """Change current channel to the item selected.""" + self._component_index = index.row() + self._update_component_selection() + + def _update_component_selection(self): + """Update which channel is selected.""" + name = self._component_names[self._component_index] + self._component_list.setCurrentIndex( + self._component_list_model.index(self._component_index, 0) + ) + # self._group_selector.setCurrentIndex(self._groups[name]) + # self._update_group() + # if not np.isnan(self._chs[name]).any(): + # self._set_ras(self._chs[name]) + # self._update_camera(render=True) + # self._draw() def _plot_images(self): # TODO: embed the matplotlib figure in each FigureCanvas @@ -153,9 +193,6 @@ def _plot_images(self): def _save_component_labels(self): pass - def _update_grouop(self): - pass - @Slot() def _mark_component(self): pass @@ -174,10 +211,6 @@ def _show_help(self): QMessageBox.information( self, "Help", - "Help:\n'm': mark channel location\n" - "'r': remove channel location\n" - "'b': toggle viewing of brain in T1\n" - "'+'/'-': zoom\nleft/right arrow: left/right\n" - "up/down arrow: superior/inferior\n" - "left angle bracket/right angle bracket: anterior/posterior", + "Help:\n'g': mark component as good (brain)\n" + "up/down arrow: move up/down the list of components\n", ) From 77426c1815a97fe7a828b115ff04efcb8ac9b0ae Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 15 Jun 2022 13:27:40 +0200 Subject: [PATCH 06/55] draft_2 --- mne_icalabel/gui/__init__.py | 4 +- mne_icalabel/gui/_label_components_2.py | 186 ++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 mne_icalabel/gui/_label_components_2.py diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py index 0e2ed7bd..fe53bfe1 100644 --- a/mne_icalabel/gui/__init__.py +++ b/mne_icalabel/gui/__init__.py @@ -20,12 +20,12 @@ def label_ica_components(inst, ica, verbose=None): """ from qtpy.QtWidgets import QApplication - from ._label_components import ICAComponentLabeler + from ._label_components_2 import ICAComponentLabeler # get application app = QApplication.instance() if app is None: app = QApplication(["ICA Component Labeler"]) - gui = ICAComponentLabeler(inst=inst, ica=ica, verbose=verbose) + gui = ICAComponentLabeler(inst=inst, ica=ica) gui.show() return gui diff --git a/mne_icalabel/gui/_label_components_2.py b/mne_icalabel/gui/_label_components_2.py new file mode 100644 index 00000000..dc94aa0d --- /dev/null +++ b/mne_icalabel/gui/_label_components_2.py @@ -0,0 +1,186 @@ +import platform + +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg +from matplotlib.figure import Figure +from qtpy.QtCore import Qt, Slot +from qtpy.QtWidgets import ( + QAbstractItemView, + QButtonGroup, + QGridLayout, + QListWidget, + QMainWindow, + QPushButton, + QVBoxLayout, + QWidget, +) + +_CH_MENU_WIDTH = 30 if platform.system() == "Windows" else 10 + + +class TopomapFig(FigureCanvasQTAgg): + """Topographic map figure widget.""" + + def __init__(self, width=4, height=4, dpi=300): + fig = Figure(figsize=(width, height), dpi=dpi) + ax = fig.subplots() + fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + # clean up excess plot text, invert + ax.invert_yaxis() + ax.set_xticks([]) + ax.set_yticks([]) + super().__init__(fig) + + def change_ic(self, ica, idx): + pass + + +class PowerSpectralDensityFig(FigureCanvasQTAgg): + """PSD figure widget.""" + + def __init__(self, width=4, height=4, dpi=300): + fig = Figure(figsize=(width, height), dpi=dpi) + ax = fig.subplots() + fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + # clean up excess plot text, invert + ax.set_xticks([]) + ax.set_yticks([]) + super().__init__(fig) + + def change_ic(self, ica, idx): + pass + + +class TimeSeriesFig(FigureCanvasQTAgg): + """Time-series figure widget.""" + + def __init__(self, width=4, height=4, dpi=300): + fig = Figure(figsize=(width, height), dpi=dpi) + ax = fig.subplots() + fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + # clean up excess plot text, invert + ax.set_xticks([]) + ax.set_yticks([]) + super().__init__(fig) + + def change_ic(self, ica, inst, idx): + pass + + +# TODO: Maybe that should inherit from a QGroupBox? +class Labels(QWidget): + """Widget with labels as push buttons. + + Only one of the labels can be selected at once. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + labels = [ + "Brain", + "Eye", + "Heart", + "Muscle", + "Channel Noise", + "Line Noise", + "Other", + ] + + self.buttonGroup = QButtonGroup() + self.buttonGroup.setExclusive(True) + layout = QVBoxLayout() + for k, label in enumerate(labels): + pushButton = Labels.create_pushButton(label) + self.buttonGroup.addButton(pushButton, k) + layout.addWidget(pushButton) + self.setLayout(layout) + # connect signal to slots + self.buttonGroup.buttonClicked.connect(self.pushButton_clicked) + + @staticmethod + def create_pushButton(label): + pushButton = QPushButton() + pushButton.setObjectName(f"pushButton_{label.lower().replace(' ', '_')}") + pushButton.setText(label) + pushButton.setCheckable(True) + pushButton.setChecked(False) + pushButton.setEnabled(False) + return pushButton + + @Slot() + def pushButton_clicked(self): + # retrieve label from object name + pass + + +class ICAComponentLabeler(QMainWindow): + def __init__(self, inst, ica) -> None: + super().__init__() + self.setWindowTitle("ICA Component Labeler") + self.setContextMenuPolicy(Qt.NoContextMenu) + + # keep an internal pointer to the ICA and Raw + self._ica = ica + self._inst = inst + + # create viewbox to select components + self.list_components = ICAComponentLabeler.list_components(self._ica) + # create buttons to select label + self.buttonGroup_labels = Labels() + # create figure widgets + self.widget_topo = TopomapFig() + self.widget_psd = PowerSpectralDensityFig() + self.widget_timeSeries = TimeSeriesFig() + + # add central widget and layout + self.central_widget = QWidget(self) + self.central_widget.setObjectName("central_widget") + layout = QGridLayout() + layout.addWidget(self.list_components, 0, 0, 2, 1) + layout.addWidget(self.buttonGroup_labels, 0, 1, 2, 1) + layout.addWidget(self.widget_topo, 0, 2) + layout.addWidget(self.widget_psd, 0, 3) + layout.addWidget(self.widget_timeSeries, 1, 2, 1, 2) + self.central_widget.setLayout(layout) + self.setCentralWidget(self.central_widget) + self.resize(800, 350) + + # connect signal and slots + self.connect_signals_to_slots() + + @staticmethod + def list_components(ica): + """List the components in a QListView.""" + list_components = QListWidget() + list_components.setSelectionMode(QAbstractItemView.SingleSelection) + list_components.setMinimumWidth(6 * _CH_MENU_WIDTH) + list_components.setMaximumWidth(6 * _CH_MENU_WIDTH) + list_components.addItems([f"ICA{str(k).zfill(3)}" for k in range(ica.n_components_)]) + return list_components + + def connect_signals_to_slots(self): # noqa: D102 + self.list_components.clicked.connect(self.list_component_clicked) + + @Slot() + def list_component_clicked(self): + """Jump to the selected component and draw the plots.""" + checked_idx = self.buttonGroup_labels.buttonGroup.checkedId() + # checked_idx: the value range from 0 to n_buttons-1 + # -1 is returned if no button is checked + for button in self.buttonGroup_labels.buttonGroup.buttons(): + button.setChecked(False) + button.setEnabled(True) + + idx = self.list_components.currentRow() + # idx: the value range from 0 to n_components-1 + self.widget_topo.change_ic(self._ica, idx) + self.widget_psd.change_ic(self._ica, idx) + self.widget_timeSeries.change_ic(self._ica, self._inst, idx) + + def closeEvent(self, event): + """Clean up upon closing the window. + + Check if any IC is not labelled and ask the user to confirm if this is + the case. Save all labels in BIDS format. + """ + event.accept() From eac7cc6a3c041ba104f6349993e5cf88d4986f02 Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 15 Jun 2022 16:45:01 +0200 Subject: [PATCH 07/55] update logic --- examples/label_components.py | 1 + mne_icalabel/gui/_label_components_2.py | 34 +++++++++++++------------ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/examples/label_components.py b/examples/label_components.py index b106d4da..17d7f048 100644 --- a/examples/label_components.py +++ b/examples/label_components.py @@ -33,6 +33,7 @@ ica.fit(filt_raw) # now label +mne.set_log_level("DEBUG") gui = mne_icalabel.gui.label_ica_components(raw, ica) # The `ica` object is modified to contain the component labels diff --git a/mne_icalabel/gui/_label_components_2.py b/mne_icalabel/gui/_label_components_2.py index dc94aa0d..37ef89b3 100644 --- a/mne_icalabel/gui/_label_components_2.py +++ b/mne_icalabel/gui/_label_components_2.py @@ -2,6 +2,7 @@ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from matplotlib.figure import Figure +from mne.utils import logger from qtpy.QtCore import Qt, Slot from qtpy.QtWidgets import ( QAbstractItemView, @@ -94,8 +95,6 @@ def __init__(self, *args, **kwargs): self.buttonGroup.addButton(pushButton, k) layout.addWidget(pushButton) self.setLayout(layout) - # connect signal to slots - self.buttonGroup.buttonClicked.connect(self.pushButton_clicked) @staticmethod def create_pushButton(label): @@ -107,13 +106,16 @@ def create_pushButton(label): pushButton.setEnabled(False) return pushButton - @Slot() - def pushButton_clicked(self): - # retrieve label from object name - pass - class ICAComponentLabeler(QMainWindow): + """Qt GUI to annotate components. + + Parameters + ---------- + inst : Raw + ica : ICA + """ + def __init__(self, inst, ica) -> None: super().__init__() self.setWindowTitle("ICA Component Labeler") @@ -164,18 +166,18 @@ def connect_signals_to_slots(self): # noqa: D102 @Slot() def list_component_clicked(self): """Jump to the selected component and draw the plots.""" - checked_idx = self.buttonGroup_labels.buttonGroup.checkedId() - # checked_idx: the value range from 0 to n_buttons-1 - # -1 is returned if no button is checked + # reset all buttons + self.buttonGroup_labels.buttonGroup.setExclusive(False) for button in self.buttonGroup_labels.buttonGroup.buttons(): - button.setChecked(False) button.setEnabled(True) + button.setChecked(False) + self.buttonGroup_labels.buttonGroup.setExclusive(True) - idx = self.list_components.currentRow() - # idx: the value range from 0 to n_components-1 - self.widget_topo.change_ic(self._ica, idx) - self.widget_psd.change_ic(self._ica, idx) - self.widget_timeSeries.change_ic(self._ica, self._inst, idx) + # update selectedf IC + self._current_ic = self.list_components.currentRow() + self.widget_topo.change_ic(self._ica, self._current_ic) + self.widget_psd.change_ic(self._ica, self._current_ic) + self.widget_timeSeries.change_ic(self._ica, self._inst, self._current_ic) def closeEvent(self, event): """Clean up upon closing the window. From 88ab82814849178c81c9a938c9105662d92a0fbf Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 15 Jun 2022 17:04:00 +0200 Subject: [PATCH 08/55] some typos --- mne_icalabel/gui/_label_components_2.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/mne_icalabel/gui/_label_components_2.py b/mne_icalabel/gui/_label_components_2.py index 37ef89b3..703937fd 100644 --- a/mne_icalabel/gui/_label_components_2.py +++ b/mne_icalabel/gui/_label_components_2.py @@ -2,7 +2,6 @@ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from matplotlib.figure import Figure -from mne.utils import logger from qtpy.QtCore import Qt, Slot from qtpy.QtWidgets import ( QAbstractItemView, @@ -22,16 +21,16 @@ class TopomapFig(FigureCanvasQTAgg): """Topographic map figure widget.""" def __init__(self, width=4, height=4, dpi=300): - fig = Figure(figsize=(width, height), dpi=dpi) - ax = fig.subplots() - fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - # clean up excess plot text, invert - ax.invert_yaxis() - ax.set_xticks([]) - ax.set_yticks([]) - super().__init__(fig) + self.fig = Figure(figsize=(width, height), dpi=dpi) + self.axes = self.fig.subplots() + self.fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + super().__init__(self.fig) def change_ic(self, ica, idx): + # self.axes.clear() + # ica.plot_components(picks=idx, topomap_args=dict(axes=self.axes)) + # self.fig.canvas.draw() + # self.fig.canvas.flush_events() pass @@ -173,7 +172,7 @@ def list_component_clicked(self): button.setChecked(False) self.buttonGroup_labels.buttonGroup.setExclusive(True) - # update selectedf IC + # update selected IC self._current_ic = self.list_components.currentRow() self.widget_topo.change_ic(self._ica, self._current_ic) self.widget_psd.change_ic(self._ica, self._current_ic) From cb05faad2e04eca72af2f72dc0ac27cfc5fbdb15 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 27 Jun 2022 10:47:50 -0400 Subject: [PATCH 09/55] Adding testing script --- mne_icalabel/gui/__init__.py | 24 +++++++- mne_icalabel/gui/_label_components.py | 88 ++++++++++++++++++--------- 2 files changed, 83 insertions(+), 29 deletions(-) diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py index 0e2ed7bd..d781fe00 100644 --- a/mne_icalabel/gui/__init__.py +++ b/mne_icalabel/gui/__init__.py @@ -20,8 +20,9 @@ def label_ica_components(inst, ica, verbose=None): """ from qtpy.QtWidgets import QApplication - from ._label_components import ICAComponentLabeler + from mne_icalabel.gui._label_components import ICAComponentLabeler + # from ._label_components import ICAComponentLabeler # get application app = QApplication.instance() if app is None: @@ -29,3 +30,24 @@ def label_ica_components(inst, ica, verbose=None): gui = ICAComponentLabeler(inst=inst, ica=ica, verbose=verbose) gui.show() return gui + + +if __name__ == "__main__": + from mne.datasets import sample + from mne.io import read_raw + from mne.preprocessing import ICA + + directory = sample.data_path() / "MEG" / "sample" + raw = read_raw(directory / "sample_audvis_raw.fif", preload=False) + raw.crop(0, 10).pick_types(eeg=True, exclude="bads") + raw.load_data() + # preprocess + raw.filter(l_freq=1.0, h_freq=100.0) + raw.set_eeg_reference("average") + + n_components = 15 + ica = ICA(n_components=n_components, method="picard") + ica.fit(raw) + + # annotate ICA components + gui = label_ica_components(raw, ica) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 2751cd12..292867f7 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -11,21 +11,16 @@ from matplotlib.backends.backend_qt5agg import FigureCanvas from matplotlib.figure import Figure from mne.preprocessing import ICA -from mne.viz.backends.renderer import _get_renderer +from mne.viz.utils import safe_event from qtpy import QtGui -from qtpy.QtCore import Signal, Slot +from qtpy.QtCore import Slot from qtpy.QtWidgets import ( QAbstractItemView, - QComboBox, QGridLayout, QHBoxLayout, - QLabel, QListView, QMainWindow, QMessageBox, - QPlainTextEdit, - QPushButton, - QSlider, QVBoxLayout, QWidget, ) @@ -39,6 +34,7 @@ _CH_MENU_WIDTH = 30 if platform.system() == "Windows" else 10 +# TODO: remove def _make_topo_plot(width=4, height=4, dpi=300): """Make subplot for the topomap.""" fig = Figure(figsize=(width, height), dpi=dpi) @@ -53,6 +49,7 @@ def _make_topo_plot(width=4, height=4, dpi=300): return canvas, fig +# TODO: remove def _make_ts_plot(width=4, height=4, dpi=300): """Make subplot for the component time-series.""" fig = Figure(figsize=(width, height), dpi=dpi) @@ -60,13 +57,13 @@ def _make_ts_plot(width=4, height=4, dpi=300): ax = fig.subplots() fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) ax.set_facecolor("k") - # clean up excess plot text, invert - ax.invert_yaxis() + # clean up excess plot text ax.set_xticks([]) ax.set_yticks([]) return canvas, fig +# TODO: remove def _make_spectrum_plot(width=4, height=4, dpi=300): """Make subplot for the spectrum.""" fig = Figure(figsize=(width, height), dpi=dpi) @@ -74,22 +71,69 @@ def _make_spectrum_plot(width=4, height=4, dpi=300): ax = fig.subplots() fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) ax.set_facecolor("k") - # clean up excess plot text, invert - ax.invert_yaxis() + # clean up excess plot text ax.set_xticks([]) ax.set_yticks([]) return canvas, fig +class TimeSeriesFig(FigureCanvas): + """Spectrum map widget.""" + + def __init__(self, width=4, height=4, dpi=300): + """Make subplot for the spectrum.""" + fig = Figure(figsize=(width, height), dpi=dpi) + canvas = FigureCanvas(fig) + ax = fig.subplots() + fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + ax.set_facecolor("k") + # clean up excess plot text + ax.set_xticks([]) + ax.set_yticks([]) + super().__init__(fig) + + +class SpectrumFig(FigureCanvas): + """Spectrum map widget.""" + + def __init__(self, width=4, height=4, dpi=300): + """Make subplot for the spectrum.""" + fig = Figure(figsize=(width, height), dpi=dpi) + canvas = FigureCanvas(fig) + ax = fig.subplots() + fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + ax.set_facecolor("k") + # clean up excess plot text + ax.set_xticks([]) + ax.set_yticks([]) + super().__init__(fig) + + +class TopomapFig(FigureCanvas): + """Topographic map widget.""" + + def __init__(self, width=4, height=4, dpi=300): + fig = Figure(figsize=(width, height), dpi=dpi) + ax = fig.subplots() + fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + ax.set_facecolor("k") + # clean up excess plot text + ax.set_xticks([]) + ax.set_yticks([]) + super().__init__(fig) + + # TODO: # ? - plot_properties plot - topoplot, ICA time-series # ? - update ICA components # ? - menu with save, load class ICAComponentLabeler(QMainWindow): - def __init__(self, inst, ica: ICA) -> None: + def __init__(self, inst, ica: ICA, verbose: bool = False) -> None: # initialize QMainWindow class super().__init__() + self.verbose = verbose + # keep an internal pointer to the ICA and Raw self._ica = ica self._inst = inst @@ -104,19 +148,19 @@ def __init__(self, inst, ica: ICA) -> None: plt_grid.addWidget(plts[2][0], 1, 0) # TODO: is this the correct function to use to render? or nah... since we don't have 3D? - self._renderer = _get_renderer(name="ICA Component Labeler", size=(400, 400), bgcolor="w") - plt_grid.addWidget(self._renderer.plotter) + # self._renderer = _get_renderer(name="ICA Component Labeler", size=(400, 400), bgcolor="w") + # plt_grid.addWidget(self._renderer.plotter) # initialize channel data self._component_index = 0 # component names are just a list of numbers from 0 to n_components - self._component_names = list(range(ica.n_components_)) + self._component_names = [f"ICA-{idx}" for idx in range(ica.n_components_)] # Component selector in a clickable selection list self._component_list = QListView() self._component_list.setSelectionMode(QAbstractItemView.SingleSelection) - max_comp_name_len = max([len(name) for name in self._component_list]) + max_comp_name_len = max([len(name) for name in self._component_names]) self._component_list.setMinimumWidth(max_comp_name_len * _CH_MENU_WIDTH) self._component_list.setMaximumWidth(max_comp_name_len * _CH_MENU_WIDTH) self._set_component_names() @@ -129,12 +173,6 @@ def __init__(self, inst, ica: ICA) -> None: # slider_hbox = self._get_slider_bar() # bottom_hbox = self._get_bottom_bar() - # Add lines - self._lines = dict() - self._lines_2D = dict() - for group in set(self._groups.values()): - self._update_lines(group) - # Put everything together plot_component_hbox = QHBoxLayout() plot_component_hbox.addLayout(plt_grid) @@ -179,12 +217,6 @@ def _update_component_selection(self): self._component_list.setCurrentIndex( self._component_list_model.index(self._component_index, 0) ) - # self._group_selector.setCurrentIndex(self._groups[name]) - # self._update_group() - # if not np.isnan(self._chs[name]).any(): - # self._set_ras(self._chs[name]) - # self._update_camera(render=True) - # self._draw() def _plot_images(self): # TODO: embed the matplotlib figure in each FigureCanvas From 0b4a6ede9e6247b1659b3874cea02f3210632aab Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 6 Jul 2022 14:26:21 +0200 Subject: [PATCH 10/55] remove old version --- mne_icalabel/gui/__init__.py | 4 +- mne_icalabel/gui/_label_components.py | 335 ++++++++++-------------- mne_icalabel/gui/_label_components_2.py | 187 ------------- 3 files changed, 138 insertions(+), 388 deletions(-) delete mode 100644 mne_icalabel/gui/_label_components_2.py diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py index 59e8a2b1..2ddb6d65 100644 --- a/mne_icalabel/gui/__init__.py +++ b/mne_icalabel/gui/__init__.py @@ -20,10 +20,8 @@ def label_ica_components(ica, inst=None, verbose=None): """ from qtpy.QtWidgets import QApplication - from mne_icalabel.gui._label_components_2 import ICAComponentLabeler - # from ._label_components import ICAComponentLabeler + from mne_icalabel.gui._label_components import ICAComponentLabeler - # from ._label_components import ICAComponentLabeler # get application app = QApplication.instance() if app is None: diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 292867f7..703937fd 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -1,248 +1,187 @@ -# -*- coding: utf-8 -*- -"""ICA GUI for labeling components.""" - -# Authors: Adam Li -# -# License: BSD (3-clause) - - import platform -from matplotlib.backends.backend_qt5agg import FigureCanvas +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from matplotlib.figure import Figure -from mne.preprocessing import ICA -from mne.viz.utils import safe_event -from qtpy import QtGui -from qtpy.QtCore import Slot +from qtpy.QtCore import Qt, Slot from qtpy.QtWidgets import ( QAbstractItemView, + QButtonGroup, QGridLayout, - QHBoxLayout, - QListView, + QListWidget, QMainWindow, - QMessageBox, + QPushButton, QVBoxLayout, QWidget, ) -# _IMG_LABELS = [['I', 'P'], ['I', 'L'], ['P', 'L']] -# _CH_PLOT_SIZE = 1024 -# _ZOOM_STEP_SIZE = 5 -# _RADIUS_SCALAR = 0.4 -# _TUBE_SCALAR = 0.1 -# _BOLT_SCALAR = 30 # mm _CH_MENU_WIDTH = 30 if platform.system() == "Windows" else 10 -# TODO: remove -def _make_topo_plot(width=4, height=4, dpi=300): - """Make subplot for the topomap.""" - fig = Figure(figsize=(width, height), dpi=dpi) - canvas = FigureCanvas(fig) - ax = fig.subplots() - fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - ax.set_facecolor("k") - # clean up excess plot text, invert - ax.invert_yaxis() - ax.set_xticks([]) - ax.set_yticks([]) - return canvas, fig - - -# TODO: remove -def _make_ts_plot(width=4, height=4, dpi=300): - """Make subplot for the component time-series.""" - fig = Figure(figsize=(width, height), dpi=dpi) - canvas = FigureCanvas(fig) - ax = fig.subplots() - fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - ax.set_facecolor("k") - # clean up excess plot text - ax.set_xticks([]) - ax.set_yticks([]) - return canvas, fig - - -# TODO: remove -def _make_spectrum_plot(width=4, height=4, dpi=300): - """Make subplot for the spectrum.""" - fig = Figure(figsize=(width, height), dpi=dpi) - canvas = FigureCanvas(fig) - ax = fig.subplots() - fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - ax.set_facecolor("k") - # clean up excess plot text - ax.set_xticks([]) - ax.set_yticks([]) - return canvas, fig - - -class TimeSeriesFig(FigureCanvas): - """Spectrum map widget.""" +class TopomapFig(FigureCanvasQTAgg): + """Topographic map figure widget.""" def __init__(self, width=4, height=4, dpi=300): - """Make subplot for the spectrum.""" - fig = Figure(figsize=(width, height), dpi=dpi) - canvas = FigureCanvas(fig) - ax = fig.subplots() - fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - ax.set_facecolor("k") - # clean up excess plot text - ax.set_xticks([]) - ax.set_yticks([]) - super().__init__(fig) + self.fig = Figure(figsize=(width, height), dpi=dpi) + self.axes = self.fig.subplots() + self.fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + super().__init__(self.fig) + + def change_ic(self, ica, idx): + # self.axes.clear() + # ica.plot_components(picks=idx, topomap_args=dict(axes=self.axes)) + # self.fig.canvas.draw() + # self.fig.canvas.flush_events() + pass -class SpectrumFig(FigureCanvas): - """Spectrum map widget.""" +class PowerSpectralDensityFig(FigureCanvasQTAgg): + """PSD figure widget.""" def __init__(self, width=4, height=4, dpi=300): - """Make subplot for the spectrum.""" fig = Figure(figsize=(width, height), dpi=dpi) - canvas = FigureCanvas(fig) ax = fig.subplots() fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - ax.set_facecolor("k") - # clean up excess plot text + # clean up excess plot text, invert ax.set_xticks([]) ax.set_yticks([]) super().__init__(fig) + def change_ic(self, ica, idx): + pass + -class TopomapFig(FigureCanvas): - """Topographic map widget.""" +class TimeSeriesFig(FigureCanvasQTAgg): + """Time-series figure widget.""" def __init__(self, width=4, height=4, dpi=300): fig = Figure(figsize=(width, height), dpi=dpi) ax = fig.subplots() fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - ax.set_facecolor("k") - # clean up excess plot text + # clean up excess plot text, invert ax.set_xticks([]) ax.set_yticks([]) super().__init__(fig) + def change_ic(self, ica, inst, idx): + pass + + +# TODO: Maybe that should inherit from a QGroupBox? +class Labels(QWidget): + """Widget with labels as push buttons. + + Only one of the labels can be selected at once. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + labels = [ + "Brain", + "Eye", + "Heart", + "Muscle", + "Channel Noise", + "Line Noise", + "Other", + ] + + self.buttonGroup = QButtonGroup() + self.buttonGroup.setExclusive(True) + layout = QVBoxLayout() + for k, label in enumerate(labels): + pushButton = Labels.create_pushButton(label) + self.buttonGroup.addButton(pushButton, k) + layout.addWidget(pushButton) + self.setLayout(layout) + + @staticmethod + def create_pushButton(label): + pushButton = QPushButton() + pushButton.setObjectName(f"pushButton_{label.lower().replace(' ', '_')}") + pushButton.setText(label) + pushButton.setCheckable(True) + pushButton.setChecked(False) + pushButton.setEnabled(False) + return pushButton + -# TODO: -# ? - plot_properties plot - topoplot, ICA time-series -# ? - update ICA components -# ? - menu with save, load class ICAComponentLabeler(QMainWindow): - def __init__(self, inst, ica: ICA, verbose: bool = False) -> None: - # initialize QMainWindow class - super().__init__() + """Qt GUI to annotate components. + + Parameters + ---------- + inst : Raw + ica : ICA + """ - self.verbose = verbose + def __init__(self, inst, ica) -> None: + super().__init__() + self.setWindowTitle("ICA Component Labeler") + self.setContextMenuPolicy(Qt.NoContextMenu) # keep an internal pointer to the ICA and Raw self._ica = ica self._inst = inst - # GUI design to add widgets into a Layout - # Main plots: make one plot for each view: topographic, time-series, power-spectrum - plt_grid = QGridLayout() - plts = [_make_topo_plot(), _make_ts_plot(), _make_spectrum_plot()] - self._figs = [plts[0][1], plts[1][1], plts[2][1]] - plt_grid.addWidget(plts[0][0], 0, 0) - plt_grid.addWidget(plts[1][0], 0, 1) - plt_grid.addWidget(plts[2][0], 1, 0) - - # TODO: is this the correct function to use to render? or nah... since we don't have 3D? - # self._renderer = _get_renderer(name="ICA Component Labeler", size=(400, 400), bgcolor="w") - # plt_grid.addWidget(self._renderer.plotter) - - # initialize channel data - self._component_index = 0 - - # component names are just a list of numbers from 0 to n_components - self._component_names = [f"ICA-{idx}" for idx in range(ica.n_components_)] - - # Component selector in a clickable selection list - self._component_list = QListView() - self._component_list.setSelectionMode(QAbstractItemView.SingleSelection) - max_comp_name_len = max([len(name) for name in self._component_names]) - self._component_list.setMinimumWidth(max_comp_name_len * _CH_MENU_WIDTH) - self._component_list.setMaximumWidth(max_comp_name_len * _CH_MENU_WIDTH) - self._set_component_names() - - # Plots - self._plot_images() - - # TODO: Menus for user interface - # button_hbox = self._get_button_bar() - # slider_hbox = self._get_slider_bar() - # bottom_hbox = self._get_bottom_bar() - - # Put everything together - plot_component_hbox = QHBoxLayout() - plot_component_hbox.addLayout(plt_grid) - plot_component_hbox.addWidget(self._component_list) - - # TODO: add the rest of the button and other widgets/menus - main_vbox = QVBoxLayout() - main_vbox.addLayout(plot_component_hbox) - # main_vbox.addLayout(button_hbox) - # main_vbox.addLayout(slider_hbox) - # main_vbox.addLayout(bottom_hbox) - - central_widget = QWidget() - central_widget.setLayout(main_vbox) - self.setCentralWidget(central_widget) - - # ready for user - self._component_list.setFocus() # always focus on list - - def _set_component_names(self): - """Add the component names to the selector.""" - self._component_list_model = QtGui.QStandardItemModel(self._component_list) - for name in self._component_names: - self._component_list_model.appendRow(QtGui.QStandardItem(name)) - # TODO: can add a method to color code the list of items - # self._color_list_item(name=name) - self._component_list.setModel(self._component_list_model) - self._component_list.clicked.connect(self._go_to_component) - self._component_list.setCurrentIndex( - self._component_list_model.index(self._component_index, 0) - ) - self._component_list.keyPressEvent = self._key_press_event - - def _go_to_component(self, index): - """Change current channel to the item selected.""" - self._component_index = index.row() - self._update_component_selection() - - def _update_component_selection(self): - """Update which channel is selected.""" - name = self._component_names[self._component_index] - self._component_list.setCurrentIndex( - self._component_list_model.index(self._component_index, 0) - ) - - def _plot_images(self): - # TODO: embed the matplotlib figure in each FigureCanvas - pass - - def _save_component_labels(self): - pass + # create viewbox to select components + self.list_components = ICAComponentLabeler.list_components(self._ica) + # create buttons to select label + self.buttonGroup_labels = Labels() + # create figure widgets + self.widget_topo = TopomapFig() + self.widget_psd = PowerSpectralDensityFig() + self.widget_timeSeries = TimeSeriesFig() + + # add central widget and layout + self.central_widget = QWidget(self) + self.central_widget.setObjectName("central_widget") + layout = QGridLayout() + layout.addWidget(self.list_components, 0, 0, 2, 1) + layout.addWidget(self.buttonGroup_labels, 0, 1, 2, 1) + layout.addWidget(self.widget_topo, 0, 2) + layout.addWidget(self.widget_psd, 0, 3) + layout.addWidget(self.widget_timeSeries, 1, 2, 1, 2) + self.central_widget.setLayout(layout) + self.setCentralWidget(self.central_widget) + self.resize(800, 350) + + # connect signal and slots + self.connect_signals_to_slots() + + @staticmethod + def list_components(ica): + """List the components in a QListView.""" + list_components = QListWidget() + list_components.setSelectionMode(QAbstractItemView.SingleSelection) + list_components.setMinimumWidth(6 * _CH_MENU_WIDTH) + list_components.setMaximumWidth(6 * _CH_MENU_WIDTH) + list_components.addItems([f"ICA{str(k).zfill(3)}" for k in range(ica.n_components_)]) + return list_components + + def connect_signals_to_slots(self): # noqa: D102 + self.list_components.clicked.connect(self.list_component_clicked) @Slot() - def _mark_component(self): - pass + def list_component_clicked(self): + """Jump to the selected component and draw the plots.""" + # reset all buttons + self.buttonGroup_labels.buttonGroup.setExclusive(False) + for button in self.buttonGroup_labels.buttonGroup.buttons(): + button.setEnabled(True) + button.setChecked(False) + self.buttonGroup_labels.buttonGroup.setExclusive(True) + + # update selected IC + self._current_ic = self.list_components.currentRow() + self.widget_topo.change_ic(self._ica, self._current_ic) + self.widget_psd.change_ic(self._ica, self._current_ic) + self.widget_timeSeries.change_ic(self._ica, self._inst, self._current_ic) - @safe_event def closeEvent(self, event): - """Clean up upon closing the window.""" - self._renderer.plotter.close() - self.close() - - def _key_press_event(self, event): - pass + """Clean up upon closing the window. - def _show_help(self): - """Show the help menu.""" - QMessageBox.information( - self, - "Help", - "Help:\n'g': mark component as good (brain)\n" - "up/down arrow: move up/down the list of components\n", - ) + Check if any IC is not labelled and ask the user to confirm if this is + the case. Save all labels in BIDS format. + """ + event.accept() diff --git a/mne_icalabel/gui/_label_components_2.py b/mne_icalabel/gui/_label_components_2.py deleted file mode 100644 index 703937fd..00000000 --- a/mne_icalabel/gui/_label_components_2.py +++ /dev/null @@ -1,187 +0,0 @@ -import platform - -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg -from matplotlib.figure import Figure -from qtpy.QtCore import Qt, Slot -from qtpy.QtWidgets import ( - QAbstractItemView, - QButtonGroup, - QGridLayout, - QListWidget, - QMainWindow, - QPushButton, - QVBoxLayout, - QWidget, -) - -_CH_MENU_WIDTH = 30 if platform.system() == "Windows" else 10 - - -class TopomapFig(FigureCanvasQTAgg): - """Topographic map figure widget.""" - - def __init__(self, width=4, height=4, dpi=300): - self.fig = Figure(figsize=(width, height), dpi=dpi) - self.axes = self.fig.subplots() - self.fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - super().__init__(self.fig) - - def change_ic(self, ica, idx): - # self.axes.clear() - # ica.plot_components(picks=idx, topomap_args=dict(axes=self.axes)) - # self.fig.canvas.draw() - # self.fig.canvas.flush_events() - pass - - -class PowerSpectralDensityFig(FigureCanvasQTAgg): - """PSD figure widget.""" - - def __init__(self, width=4, height=4, dpi=300): - fig = Figure(figsize=(width, height), dpi=dpi) - ax = fig.subplots() - fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - # clean up excess plot text, invert - ax.set_xticks([]) - ax.set_yticks([]) - super().__init__(fig) - - def change_ic(self, ica, idx): - pass - - -class TimeSeriesFig(FigureCanvasQTAgg): - """Time-series figure widget.""" - - def __init__(self, width=4, height=4, dpi=300): - fig = Figure(figsize=(width, height), dpi=dpi) - ax = fig.subplots() - fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - # clean up excess plot text, invert - ax.set_xticks([]) - ax.set_yticks([]) - super().__init__(fig) - - def change_ic(self, ica, inst, idx): - pass - - -# TODO: Maybe that should inherit from a QGroupBox? -class Labels(QWidget): - """Widget with labels as push buttons. - - Only one of the labels can be selected at once. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - labels = [ - "Brain", - "Eye", - "Heart", - "Muscle", - "Channel Noise", - "Line Noise", - "Other", - ] - - self.buttonGroup = QButtonGroup() - self.buttonGroup.setExclusive(True) - layout = QVBoxLayout() - for k, label in enumerate(labels): - pushButton = Labels.create_pushButton(label) - self.buttonGroup.addButton(pushButton, k) - layout.addWidget(pushButton) - self.setLayout(layout) - - @staticmethod - def create_pushButton(label): - pushButton = QPushButton() - pushButton.setObjectName(f"pushButton_{label.lower().replace(' ', '_')}") - pushButton.setText(label) - pushButton.setCheckable(True) - pushButton.setChecked(False) - pushButton.setEnabled(False) - return pushButton - - -class ICAComponentLabeler(QMainWindow): - """Qt GUI to annotate components. - - Parameters - ---------- - inst : Raw - ica : ICA - """ - - def __init__(self, inst, ica) -> None: - super().__init__() - self.setWindowTitle("ICA Component Labeler") - self.setContextMenuPolicy(Qt.NoContextMenu) - - # keep an internal pointer to the ICA and Raw - self._ica = ica - self._inst = inst - - # create viewbox to select components - self.list_components = ICAComponentLabeler.list_components(self._ica) - # create buttons to select label - self.buttonGroup_labels = Labels() - # create figure widgets - self.widget_topo = TopomapFig() - self.widget_psd = PowerSpectralDensityFig() - self.widget_timeSeries = TimeSeriesFig() - - # add central widget and layout - self.central_widget = QWidget(self) - self.central_widget.setObjectName("central_widget") - layout = QGridLayout() - layout.addWidget(self.list_components, 0, 0, 2, 1) - layout.addWidget(self.buttonGroup_labels, 0, 1, 2, 1) - layout.addWidget(self.widget_topo, 0, 2) - layout.addWidget(self.widget_psd, 0, 3) - layout.addWidget(self.widget_timeSeries, 1, 2, 1, 2) - self.central_widget.setLayout(layout) - self.setCentralWidget(self.central_widget) - self.resize(800, 350) - - # connect signal and slots - self.connect_signals_to_slots() - - @staticmethod - def list_components(ica): - """List the components in a QListView.""" - list_components = QListWidget() - list_components.setSelectionMode(QAbstractItemView.SingleSelection) - list_components.setMinimumWidth(6 * _CH_MENU_WIDTH) - list_components.setMaximumWidth(6 * _CH_MENU_WIDTH) - list_components.addItems([f"ICA{str(k).zfill(3)}" for k in range(ica.n_components_)]) - return list_components - - def connect_signals_to_slots(self): # noqa: D102 - self.list_components.clicked.connect(self.list_component_clicked) - - @Slot() - def list_component_clicked(self): - """Jump to the selected component and draw the plots.""" - # reset all buttons - self.buttonGroup_labels.buttonGroup.setExclusive(False) - for button in self.buttonGroup_labels.buttonGroup.buttons(): - button.setEnabled(True) - button.setChecked(False) - self.buttonGroup_labels.buttonGroup.setExclusive(True) - - # update selected IC - self._current_ic = self.list_components.currentRow() - self.widget_topo.change_ic(self._ica, self._current_ic) - self.widget_psd.change_ic(self._ica, self._current_ic) - self.widget_timeSeries.change_ic(self._ica, self._inst, self._current_ic) - - def closeEvent(self, event): - """Clean up upon closing the window. - - Check if any IC is not labelled and ask the user to confirm if this is - the case. Save all labels in BIDS format. - """ - event.accept() From a3af1d8e4e04daad5f81e04af930483c8a5fb6d0 Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 6 Jul 2022 14:33:47 +0200 Subject: [PATCH 11/55] plot psd and topomap --- mne_icalabel/gui/_label_components.py | 48 +++++++++++++++++---------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 703937fd..1629d872 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -1,5 +1,6 @@ import platform +from matplotlib import pyplot as plt from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from matplotlib.figure import Figure from qtpy.QtCore import Qt, Slot @@ -26,28 +27,28 @@ def __init__(self, width=4, height=4, dpi=300): self.fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) super().__init__(self.fig) - def change_ic(self, ica, idx): - # self.axes.clear() - # ica.plot_components(picks=idx, topomap_args=dict(axes=self.axes)) - # self.fig.canvas.draw() - # self.fig.canvas.flush_events() - pass + def reset(self) -> None: + self.axes.clear() + + def redraw(self) -> None: + self.fig.canvas.draw() + self.fig.canvas.flush_events() class PowerSpectralDensityFig(FigureCanvasQTAgg): """PSD figure widget.""" def __init__(self, width=4, height=4, dpi=300): - fig = Figure(figsize=(width, height), dpi=dpi) - ax = fig.subplots() - fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - # clean up excess plot text, invert - ax.set_xticks([]) - ax.set_yticks([]) - super().__init__(fig) + self.fig = Figure(figsize=(width, height), dpi=dpi) + self.axes = self.fig.subplots() + super().__init__(self.fig) - def change_ic(self, ica, idx): - pass + def reset(self) -> None: + self.axes.clear() + + def redraw(self) -> None: + self.fig.canvas.draw() + self.fig.canvas.flush_events() class TimeSeriesFig(FigureCanvasQTAgg): @@ -172,11 +173,22 @@ def list_component_clicked(self): button.setChecked(False) self.buttonGroup_labels.buttonGroup.setExclusive(True) + # reset figures + self.widget_topo.reset() + self.widget_psd.reset() + # update selected IC self._current_ic = self.list_components.currentRow() - self.widget_topo.change_ic(self._ica, self._current_ic) - self.widget_psd.change_ic(self._ica, self._current_ic) - self.widget_timeSeries.change_ic(self._ica, self._inst, self._current_ic) + + # create dummy axes + dummy_fig, dummy_axes = plt.subplots(3) + axes = [self.widget_topo.axes, dummy_axes[0], dummy_axes[1], self.widget_psd.axes, dummy_axes[2]] + self._ica.plot_properties(self._inst, axes=axes, picks=self._current_ic, show=False) + del dummy_fig + + # update figures + self.widget_topo.redraw() + self.widget_psd.redraw() def closeEvent(self, event): """Clean up upon closing the window. From d5bd5162270542e4fb08544f72083f87f0b15dc2 Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 6 Jul 2022 14:37:15 +0200 Subject: [PATCH 12/55] change dpi --- mne_icalabel/gui/_label_components.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 1629d872..2c5a455d 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -21,7 +21,7 @@ class TopomapFig(FigureCanvasQTAgg): """Topographic map figure widget.""" - def __init__(self, width=4, height=4, dpi=300): + def __init__(self, width=4, height=4, dpi=100): self.fig = Figure(figsize=(width, height), dpi=dpi) self.axes = self.fig.subplots() self.fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) @@ -38,9 +38,11 @@ def redraw(self) -> None: class PowerSpectralDensityFig(FigureCanvasQTAgg): """PSD figure widget.""" - def __init__(self, width=4, height=4, dpi=300): + def __init__(self, width=4, height=4, dpi=100): self.fig = Figure(figsize=(width, height), dpi=dpi) self.axes = self.fig.subplots() + self.axes.set_xticks([]) + self.axes.set_yticks([]) super().__init__(self.fig) def reset(self) -> None: @@ -54,7 +56,7 @@ def redraw(self) -> None: class TimeSeriesFig(FigureCanvasQTAgg): """Time-series figure widget.""" - def __init__(self, width=4, height=4, dpi=300): + def __init__(self, width=4, height=4, dpi=100): fig = Figure(figsize=(width, height), dpi=dpi) ax = fig.subplots() fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) From d9d912a814f33f3a4d603270255c69103d0f24fb Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 6 Jul 2022 14:40:11 +0200 Subject: [PATCH 13/55] resize interface --- mne_icalabel/gui/_label_components.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 2c5a455d..f1b153b7 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -41,14 +41,15 @@ class PowerSpectralDensityFig(FigureCanvasQTAgg): def __init__(self, width=4, height=4, dpi=100): self.fig = Figure(figsize=(width, height), dpi=dpi) self.axes = self.fig.subplots() - self.axes.set_xticks([]) - self.axes.set_yticks([]) + self.axes.axis("off") super().__init__(self.fig) def reset(self) -> None: self.axes.clear() def redraw(self) -> None: + self.axes.set_title("") + self.fig.tight_layout() self.fig.canvas.draw() self.fig.canvas.flush_events() @@ -147,7 +148,7 @@ def __init__(self, inst, ica) -> None: layout.addWidget(self.widget_timeSeries, 1, 2, 1, 2) self.central_widget.setLayout(layout) self.setCentralWidget(self.central_widget) - self.resize(800, 350) + self.resize(1500, 600) # connect signal and slots self.connect_signals_to_slots() From 65a5bebfa2ebd7b8f61cefa7cd76ec5fc8b3f718 Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 6 Jul 2022 14:40:30 +0200 Subject: [PATCH 14/55] fix style --- mne_icalabel/gui/_label_components.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index f1b153b7..659f0edb 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -185,7 +185,13 @@ def list_component_clicked(self): # create dummy axes dummy_fig, dummy_axes = plt.subplots(3) - axes = [self.widget_topo.axes, dummy_axes[0], dummy_axes[1], self.widget_psd.axes, dummy_axes[2]] + axes = [ + self.widget_topo.axes, + dummy_axes[0], + dummy_axes[1], + self.widget_psd.axes, + dummy_axes[2], + ] self._ica.plot_properties(self._inst, axes=axes, picks=self._current_ic, show=False) del dummy_fig From 8dfdb8e5e4bcc379bec4c900b47ba97497bb67a2 Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 6 Jul 2022 14:54:41 +0200 Subject: [PATCH 15/55] add entry-point to run the GUI and app.exec() to start the Qt event loop --- mne_icalabel/commands/__init__.py | 1 + .../commands/mne_gui_ic_annotation.py | 40 ++++++++++++++ mne_icalabel/gui/__init__.py | 52 ------------------- setup.cfg | 4 ++ 4 files changed, 45 insertions(+), 52 deletions(-) create mode 100644 mne_icalabel/commands/__init__.py create mode 100644 mne_icalabel/commands/mne_gui_ic_annotation.py diff --git a/mne_icalabel/commands/__init__.py b/mne_icalabel/commands/__init__.py new file mode 100644 index 00000000..be522b8f --- /dev/null +++ b/mne_icalabel/commands/__init__.py @@ -0,0 +1 @@ +"""Entry-points for mne-icalabel commands.""" diff --git a/mne_icalabel/commands/mne_gui_ic_annotation.py b/mne_icalabel/commands/mne_gui_ic_annotation.py new file mode 100644 index 00000000..8c5d4260 --- /dev/null +++ b/mne_icalabel/commands/mne_gui_ic_annotation.py @@ -0,0 +1,40 @@ +import argparse + +from qtpy.QtWidgets import QApplication + +from mne_icalabel.gui._label_components import ICAComponentLabeler + + +def main(): + """Entry point for mne_gui_ic_annotation.""" + parser = argparse.ArgumentParser( + prog="mne-icalabel", description="IC annotation GUI" + ) + parser.add_argument( + "--dev", help="loads a sample dataset.", action="store_true" + ) + args = parser.parse_args() + + if not args.dev: + raise NotImplementedError + else: + from mne.datasets import sample + from mne.io import read_raw + from mne.preprocessing import ICA + + directory = sample.data_path() / "MEG" / "sample" + raw = read_raw(directory / "sample_audvis_raw.fif", preload=False) + raw.crop(0, 10).pick_types(eeg=True, exclude="bads") + raw.load_data() + # preprocess + raw.filter(l_freq=1.0, h_freq=100.0) + raw.set_eeg_reference("average") + + n_components = 15 + ica = ICA(n_components=n_components, method="picard") + ica.fit(raw) + + app = QApplication([]) + window = ICAComponentLabeler(inst=raw, ica=ica) + window.show() + app.exec() diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py index 2ddb6d65..e69de29b 100644 --- a/mne_icalabel/gui/__init__.py +++ b/mne_icalabel/gui/__init__.py @@ -1,52 +0,0 @@ -from mne.utils import verbose - - -@verbose -def label_ica_components(ica, inst=None, verbose=None): - """Label ICA components. - - Parameters - ---------- - ica : ICA - The fitted ICA instance. - inst : Raw | Epochs - The raw data instance that was used for ICA. - %(verbose)s - - Returns - ------- - gui : instance of ICAComponentLabeler - The graphical user interface (GUI) window. - """ - from qtpy.QtWidgets import QApplication - - from mne_icalabel.gui._label_components import ICAComponentLabeler - - # get application - app = QApplication.instance() - if app is None: - app = QApplication(["ICA Component Labeler"]) - gui = ICAComponentLabeler(inst=inst, ica=ica) - gui.show() - return gui - - -if __name__ == "__main__": - from mne.datasets import sample - from mne.io import read_raw - from mne.preprocessing import ICA - - directory = sample.data_path() / "MEG" / "sample" - raw = read_raw(directory / "sample_audvis_raw.fif", preload=False) - raw.crop(0, 10).pick_types(eeg=True, exclude="bads") - raw.load_data() - # preprocess - raw.filter(l_freq=1.0, h_freq=100.0) - raw.set_eeg_reference("average") - - n_components = 15 - ica = ICA(n_components=n_components, method="picard") - ica.fit(raw) - - # annotate ICA components - gui = label_ica_components(ica, raw) diff --git a/setup.cfg b/setup.cfg index adaa5667..9651006a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,6 +44,10 @@ install_requires = packages = find: include_package_data = True +[options.entry_points] +console_scripts = + mne_gui_ic_annotation = mne_icalabel.commands.mne_gui_ic_annotation:main + # Building package [bdist_wheel] universal = true From 630a49d71edbdb9f3b36849477ade6741b861d73 Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 6 Jul 2022 14:55:08 +0200 Subject: [PATCH 16/55] fix style --- mne_icalabel/commands/mne_gui_ic_annotation.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/mne_icalabel/commands/mne_gui_ic_annotation.py b/mne_icalabel/commands/mne_gui_ic_annotation.py index 8c5d4260..133276d9 100644 --- a/mne_icalabel/commands/mne_gui_ic_annotation.py +++ b/mne_icalabel/commands/mne_gui_ic_annotation.py @@ -7,12 +7,8 @@ def main(): """Entry point for mne_gui_ic_annotation.""" - parser = argparse.ArgumentParser( - prog="mne-icalabel", description="IC annotation GUI" - ) - parser.add_argument( - "--dev", help="loads a sample dataset.", action="store_true" - ) + parser = argparse.ArgumentParser(prog="mne-icalabel", description="IC annotation GUI") + parser.add_argument("--dev", help="loads a sample dataset.", action="store_true") args = parser.parse_args() if not args.dev: From 59d23a50ba320ad2cf33de78b7f207dfcc30520d Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Sun, 10 Jul 2022 13:15:53 +0200 Subject: [PATCH 17/55] add saving of the selected labels to self.saved_labels and add a reset button --- mne_icalabel/gui/_label_components.py | 68 +++++++++++++++++++-------- 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 659f0edb..7b7892fa 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -77,23 +77,12 @@ class Labels(QWidget): Only one of the labels can be selected at once. """ - def __init__(self, *args, **kwargs): + def __init__(self, labels, *args, **kwargs): super().__init__(*args, **kwargs) - - labels = [ - "Brain", - "Eye", - "Heart", - "Muscle", - "Channel Noise", - "Line Noise", - "Other", - ] - self.buttonGroup = QButtonGroup() self.buttonGroup.setExclusive(True) layout = QVBoxLayout() - for k, label in enumerate(labels): + for k, label in enumerate(labels + ["Reset"]): pushButton = Labels.create_pushButton(label) self.buttonGroup.addButton(pushButton, k) layout.addWidget(pushButton) @@ -128,10 +117,20 @@ def __init__(self, inst, ica) -> None: self._ica = ica self._inst = inst + # define valid labels + self.labels = [ + "Brain", + "Eye", + "Heart", + "Muscle", + "Channel Noise", + "Line Noise", + "Other", + ] # create viewbox to select components self.list_components = ICAComponentLabeler.list_components(self._ica) # create buttons to select label - self.buttonGroup_labels = Labels() + self.buttonGroup_labels = Labels(self.labels) # create figure widgets self.widget_topo = TopomapFig() self.widget_psd = PowerSpectralDensityFig() @@ -150,6 +149,9 @@ def __init__(self, inst, ica) -> None: self.setCentralWidget(self.central_widget) self.resize(1500, 600) + # dictionary to remember selected labels + self.saved_labels = dict() + # connect signal and slots self.connect_signals_to_slots() @@ -165,16 +167,13 @@ def list_components(ica): def connect_signals_to_slots(self): # noqa: D102 self.list_components.clicked.connect(self.list_component_clicked) + self.buttonGroup_labels.buttonGroup.buttons()[-1].clicked.connect(self.reset) @Slot() def list_component_clicked(self): """Jump to the selected component and draw the plots.""" - # reset all buttons - self.buttonGroup_labels.buttonGroup.setExclusive(False) - for button in self.buttonGroup_labels.buttonGroup.buttons(): - button.setEnabled(True) - button.setChecked(False) - self.buttonGroup_labels.buttonGroup.setExclusive(True) + self.update_saved_labels() + self._reset_all_buttons() # reset figures self.widget_topo.reset() @@ -199,10 +198,39 @@ def list_component_clicked(self): self.widget_topo.redraw() self.widget_psd.redraw() + # update selected label if one was saved + if self._current_ic in self.saved_labels: + label = self.saved_labels[self._current_ic] + idx = self.labels.index(label) + self.buttonGroup_labels.buttonGroup.button(idx).setChecked(True) + + def update_saved_labels(self): + """Update the labels saved.""" + selected = self.buttonGroup_labels.buttonGroup.checkedButton() + if selected is not None: + self.saved_labels[self._current_ic] = selected.text() + + @Slot() + def reset(self): + """Action for the reset button.""" + self._reset_all_buttons() + if self._current_ic in self.saved_labels: + del self.saved_labels[self._current_ic] + + def _reset_all_buttons(self): + """Reset all buttons.""" + self.buttonGroup_labels.buttonGroup.setExclusive(False) + for button in self.buttonGroup_labels.buttonGroup.buttons(): + button.setEnabled(True) + button.setChecked(False) + self.buttonGroup_labels.buttonGroup.setExclusive(True) + def closeEvent(self, event): """Clean up upon closing the window. Check if any IC is not labelled and ask the user to confirm if this is the case. Save all labels in BIDS format. """ + self.update_saved_labels() + print (self.saved_labels) event.accept() From cf37d9a66fd9892a90cb290616f134222b51b2b3 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 18 Jul 2022 16:02:13 -0400 Subject: [PATCH 18/55] Adding updated api TO SAVE components to ICA isntance. --- mne_icalabel/gui/_label_components.py | 31 +++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 7b7892fa..55f2858f 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -15,8 +15,21 @@ QWidget, ) +from mne.preprocessing import ICA +from mne_icalabel.annotation import write_components_tsv + _CH_MENU_WIDTH = 30 if platform.system() == "Windows" else 10 +# map ICLabel labels to MNE str format +ICLABEL_LABELS_TO_MNE = { + 'Brain': 'brain', + 'Eye': 'eog', + 'Heart': 'ecg', + 'Muscle': 'muscle', + 'Channel Noise': 'ch_noise', + 'Line Noise': 'line_noise', + 'Other': 'other' +} class TopomapFig(FigureCanvasQTAgg): """Topographic map figure widget.""" @@ -108,7 +121,7 @@ class ICAComponentLabeler(QMainWindow): ica : ICA """ - def __init__(self, inst, ica) -> None: + def __init__(self, inst, ica: ICA) -> None: super().__init__() self.setWindowTitle("ICA Component Labeler") self.setContextMenuPolicy(Qt.NoContextMenu) @@ -149,7 +162,8 @@ def __init__(self, inst, ica) -> None: self.setCentralWidget(self.central_widget) self.resize(1500, 600) - # dictionary to remember selected labels + # dictionary to remember selected labels, with the key + # as the 'label' and the values as list of ICA components self.saved_labels = dict() # connect signal and slots @@ -209,6 +223,7 @@ def update_saved_labels(self): selected = self.buttonGroup_labels.buttonGroup.checkedButton() if selected is not None: self.saved_labels[self._current_ic] = selected.text() + self._save_component_labels() @Slot() def reset(self): @@ -225,6 +240,18 @@ def _reset_all_buttons(self): button.setChecked(False) self.buttonGroup_labels.buttonGroup.setExclusive(True) + def _save_component_labels(self): + """Save component labels to the ICA instance.""" + for label, comp_list in self.saved_labels.items(): + mne_label = ICLABEL_LABELS_TO_MNE[label] + if mne_label not in self._ica.labels_: + self._ica.labels_[mne_label] = [] + + # add component labels to the ICA instance + for comp in comp_list: + if comp not in self._ica.labels_[mne_label]: + self._ica.labels_[mne_label].append(comp) + def closeEvent(self, event): """Clean up upon closing the window. From fb6fb47862f38d4dc251655e40ff1aacd3dfdf57 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 18 Jul 2022 16:08:32 -0400 Subject: [PATCH 19/55] Adding test file for TODO --- mne_icalabel/gui/tests/test_label_components.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 mne_icalabel/gui/tests/test_label_components.py diff --git a/mne_icalabel/gui/tests/test_label_components.py b/mne_icalabel/gui/tests/test_label_components.py new file mode 100644 index 00000000..e69de29b From c151acc3b3a97f88676a3aa5632d6df8c0350679 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 18 Jul 2022 22:38:09 -0400 Subject: [PATCH 20/55] Export to BIDS should work and then try to get time-serires plot --- doc/whats_new.rst | 5 +- examples/label_components.py | 28 ++++++- mne_icalabel/annotation/bids.py | 21 +++++- mne_icalabel/commands/__init__.py | 2 + mne_icalabel/config.py | 11 +++ mne_icalabel/gui/__init__.py | 11 +++ mne_icalabel/gui/_label_components.py | 73 +++++++++++++------ .../gui/tests/test_label_components.py | 68 +++++++++++++++++ 8 files changed, 187 insertions(+), 32 deletions(-) diff --git a/doc/whats_new.rst b/doc/whats_new.rst index 812b0a42..39700222 100644 --- a/doc/whats_new.rst +++ b/doc/whats_new.rst @@ -24,7 +24,7 @@ Version 0.3 (Unreleased) Enhancements ~~~~~~~~~~~~ -- +- Adding a GUI to facilitate the labeling of ICA components, by `Adam Li`_ and `Mathieu Scheltienne`_ (:gh:`66`) Bug ~~~ @@ -39,7 +39,8 @@ API Authors ~~~~~~~ -* +* `Adam Li`_ +* `Mathieu Scheltienne`_ :doc:`Find out what was new in previous releases ` diff --git a/examples/label_components.py b/examples/label_components.py index 17d7f048..9bbf114c 100644 --- a/examples/label_components.py +++ b/examples/label_components.py @@ -15,7 +15,10 @@ import mne from mne.preprocessing import ICA -import mne_icalabel +from mne_icalabel.gui import label_ica_components + +# %% +# Load in some sample data sample_data_folder = mne.datasets.sample.data_path() sample_data_raw_file = os.path.join( @@ -27,14 +30,23 @@ raw.crop(tmax=60.0).pick_types(meg="mag", eeg=True, stim=True, eog=True) raw.load_data() +# %% +# Preprocess and run ICA on the data +# ---------------------------------- +# Before labeling components with the GUI, one needs to filter the data +# and then fit the ICA instance. Afterwards, one can run the GUI using the +# `Raw` data object and the fitted `ICA` instance. The GUI will modify +# the ICA instance in place, and add the labels of each component to +# the ``labels_`` attribute. + # high-pass filter the data and then perform ICA filt_raw = raw.copy().filter(l_freq=1.0, h_freq=None) ica = ICA(n_components=15, max_iter="auto", random_state=97) ica.fit(filt_raw) -# now label +# now label the components using a GUI mne.set_log_level("DEBUG") -gui = mne_icalabel.gui.label_ica_components(raw, ica) +gui = label_ica_components(raw, ica) # The `ica` object is modified to contain the component labels # after closing the GUI and can now be saved @@ -42,3 +54,13 @@ # Now, we can take a look at the components, which can be # saved into the BIDs directory. +print(ica.labels_) + +# %% +# Save the labeled components +# --------------------------- +# After the GUI labels, save the components using the `write_components_tsv` +# function. + +# fname = '' +# write_components_tsv(ica, fname) diff --git a/mne_icalabel/annotation/bids.py b/mne_icalabel/annotation/bids.py index d1f44759..3910fa3d 100644 --- a/mne_icalabel/annotation/bids.py +++ b/mne_icalabel/annotation/bids.py @@ -4,6 +4,7 @@ from mne.preprocessing import ICA from mne.utils import _check_pandas_installed +from ..config import ICLABEL_LABELS_TO_MNE from ..iclabel.config import ICLABEL_STRING_TO_NUMERICAL @@ -41,17 +42,31 @@ def write_components_tsv(ica: ICA, fname): if not isinstance(fname, BIDSPath): fname = get_bids_path_from_fname(fname) + # initialize status, description and IC type + status = ["good"] * ica.n_components_ + status_description = ["n/a"] * ica.n_components_ + ic_type = ["n/a"] * ica.n_components_ + + # extract the component labels if they are present in the ICA instance + if ica.labels_: + for label, comps in ica.labels_.items(): + this_status = "good" if label != "brain" else "bad" + if label in ICLABEL_LABELS_TO_MNE: + for comp in comps: + status[comp] = this_status + ic_type[comp] = label + # Create TSV. tsv_data = pd.DataFrame( dict( component=list(range(ica.n_components_)), type=["ica"] * ica.n_components_, description=["Independent Component"] * ica.n_components_, - status=["good"] * ica.n_components_, - status_description=["n/a"] * ica.n_components_, + status=status, + status_description=status_description, annotate_method=["n/a"] * ica.n_components_, annotate_author=["n/a"] * ica.n_components_, - ic_type=["n/a"] * ica.n_components_, + ic_type=ic_type, ) ) # make sure parent directories exist diff --git a/mne_icalabel/commands/__init__.py b/mne_icalabel/commands/__init__.py index be522b8f..9a7bfc08 100644 --- a/mne_icalabel/commands/__init__.py +++ b/mne_icalabel/commands/__init__.py @@ -1 +1,3 @@ """Entry-points for mne-icalabel commands.""" + +from .mne_gui_ic_annotation import main diff --git a/mne_icalabel/config.py b/mne_icalabel/config.py index fb4e801d..71442a6f 100644 --- a/mne_icalabel/config.py +++ b/mne_icalabel/config.py @@ -4,3 +4,14 @@ "iclabel": iclabel_label_components, "manual": None, } + +# map ICLabel labels to MNE str format +ICLABEL_LABELS_TO_MNE = { + "Brain": "brain", + "Eye": "eog", + "Heart": "ecg", + "Muscle": "muscle", + "Channel Noise": "ch_noise", + "Line Noise": "line_noise", + "Other": "other", +} diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py index e69de29b..fd45dfb4 100644 --- a/mne_icalabel/gui/__init__.py +++ b/mne_icalabel/gui/__init__.py @@ -0,0 +1,11 @@ +from qtpy.QtWidgets import QApplication + +from ._label_components import ICAComponentLabeler + + +def label_ica_components(inst, ica): + app = QApplication([]) + window = ICAComponentLabeler(inst=inst, ica=ica) + window.show() + app.exec() + return app diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 55f2858f..e1fc9655 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -1,8 +1,10 @@ import platform +from typing import Dict, List from matplotlib import pyplot as plt from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from matplotlib.figure import Figure +from mne.preprocessing import ICA from qtpy.QtCore import Qt, Slot from qtpy.QtWidgets import ( QAbstractItemView, @@ -15,21 +17,10 @@ QWidget, ) -from mne.preprocessing import ICA -from mne_icalabel.annotation import write_components_tsv +from mne_icalabel.config import ICLABEL_LABELS_TO_MNE _CH_MENU_WIDTH = 30 if platform.system() == "Windows" else 10 -# map ICLabel labels to MNE str format -ICLABEL_LABELS_TO_MNE = { - 'Brain': 'brain', - 'Eye': 'eog', - 'Heart': 'ecg', - 'Muscle': 'muscle', - 'Channel Noise': 'ch_noise', - 'Line Noise': 'line_noise', - 'Other': 'other' -} class TopomapFig(FigureCanvasQTAgg): """Topographic map figure widget.""" @@ -41,9 +32,11 @@ def __init__(self, width=4, height=4, dpi=100): super().__init__(self.fig) def reset(self) -> None: + """Reset the topographic plot.""" self.axes.clear() def redraw(self) -> None: + """Redraw the data.""" self.fig.canvas.draw() self.fig.canvas.flush_events() @@ -58,9 +51,11 @@ def __init__(self, width=4, height=4, dpi=100): super().__init__(self.fig) def reset(self) -> None: + """Reset the PSD plot.""" self.axes.clear() def redraw(self) -> None: + """Redraw the data.""" self.axes.set_title("") self.fig.tight_layout() self.fig.canvas.draw() @@ -71,16 +66,29 @@ class TimeSeriesFig(FigureCanvasQTAgg): """Time-series figure widget.""" def __init__(self, width=4, height=4, dpi=100): - fig = Figure(figsize=(width, height), dpi=dpi) - ax = fig.subplots() - fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + self.fig = Figure(figsize=(width, height), dpi=dpi) + self.axes = self.fig.subplots() + self.fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) # clean up excess plot text, invert - ax.set_xticks([]) - ax.set_yticks([]) - super().__init__(fig) + self.axes.set_xticks([]) + self.axes.set_yticks([]) + super().__init__(self.fig) - def change_ic(self, ica, inst, idx): - pass + def reset(self) -> None: + """Reset the time-series plot.""" + self.axes.clear() + + def change_ic(self, inst, ica, idx): + """Change the component time-series source to plot.""" + # TODO: change to use ica + # TODO: make this work and actually embed in the plot + fig = inst.plot(block=False, order=[idx]) + + self.axes.set_title("") + self.fig = fig + self.fig.tight_layout() + self.fig.canvas.draw() + self.fig.canvas.flush_events() # TODO: Maybe that should inherit from a QGroupBox? @@ -103,6 +111,10 @@ def __init__(self, labels, *args, **kwargs): @staticmethod def create_pushButton(label): + """Create a push button widget. + + Sets the properties of the push button widget. + """ pushButton = QPushButton() pushButton.setObjectName(f"pushButton_{label.lower().replace(' ', '_')}") pushButton.setText(label) @@ -123,6 +135,14 @@ class ICAComponentLabeler(QMainWindow): def __init__(self, inst, ica: ICA) -> None: super().__init__() + + # error check to see if ICA was fitted already + if ica.current_fit == "unfitted": + raise ValueError( + "ICA instance should be fit on the raw data before " + "running the ICA labeling GUI. Run `ica.fit(inst)`." + ) + self.setWindowTitle("ICA Component Labeler") self.setContextMenuPolicy(Qt.NoContextMenu) @@ -141,7 +161,7 @@ def __init__(self, inst, ica: ICA) -> None: "Other", ] # create viewbox to select components - self.list_components = ICAComponentLabeler.list_components(self._ica) + self.list_components = ICAComponentLabeler.list_components(self._ica) # type: ignore # create buttons to select label self.buttonGroup_labels = Labels(self.labels) # create figure widgets @@ -164,7 +184,7 @@ def __init__(self, inst, ica: ICA) -> None: # dictionary to remember selected labels, with the key # as the 'label' and the values as list of ICA components - self.saved_labels = dict() + self.saved_labels: Dict[str, List] = dict() # connect signal and slots self.connect_signals_to_slots() @@ -180,7 +200,11 @@ def list_components(ica): return list_components def connect_signals_to_slots(self): # noqa: D102 + # connect click to function self.list_components.clicked.connect(self.list_component_clicked) + + # TODO: connect selection (i.e. with up/down arrow) to function + # self.list_components.currentIndexChanged.connect(self.list_component_clicked) self.buttonGroup_labels.buttonGroup.buttons()[-1].clicked.connect(self.reset) @Slot() @@ -211,6 +235,7 @@ def list_component_clicked(self): # update figures self.widget_topo.redraw() self.widget_psd.redraw() + self.widget_timeSeries.change_ic(self._inst, self._ica, self._current_ic) # update selected label if one was saved if self._current_ic in self.saved_labels: @@ -227,7 +252,7 @@ def update_saved_labels(self): @Slot() def reset(self): - """Action for the reset button.""" + """Slot action for the reset button.""" self._reset_all_buttons() if self._current_ic in self.saved_labels: del self.saved_labels[self._current_ic] @@ -259,5 +284,5 @@ def closeEvent(self, event): the case. Save all labels in BIDS format. """ self.update_saved_labels() - print (self.saved_labels) + print(self.saved_labels) event.accept() diff --git a/mne_icalabel/gui/tests/test_label_components.py b/mne_icalabel/gui/tests/test_label_components.py index e69de29b..b6b971b4 100644 --- a/mne_icalabel/gui/tests/test_label_components.py +++ b/mne_icalabel/gui/tests/test_label_components.py @@ -0,0 +1,68 @@ +import os.path as op + +import pytest +from mne.datasets import testing +from mne.io import read_raw_edf +from mne.preprocessing import ICA + +import mne_icalabel + + +@pytest.fixture +def _label_ica_components(renderer_interactive_pyvistaqt): + # Use a fixture to create these classes so we can ensure that they + # are closed at the end of the test + guis = list() + + def fun(*args, **kwargs): + guis.append(mne_icalabel.gui.label_ica_components(*args, **kwargs)) + return guis[-1] + + yield fun + + for gui in guis: + try: + gui.close() + except Exception: + pass + + +@pytest.fixture(scope="module") +def load_raw_and_fit_ica(): + data_path = op.join(testing.data_path(), "EDF") + raw_fname = op.join(data_path, "test_reduced.edf") + raw = read_raw_edf(raw_fname) + + # compute ICA + ica = ICA(n_components=15) + ica.fit(raw) + return raw, ica + + +@pytest.fixture(scope="function") +def _fitted_ica(load_raw_and_fit_ica): + raw, ica = load_raw_and_fit_ica + return raw, ica.copy() + + +@testing.requires_testing_data +def test_label_components_gui_io(_fitted_ica, _label_ica_components): + """Test the input/output of the labeling ICA components GUI.""" + # get the Raw and fitted ICA instance + raw, ica = _fitted_ica + ica_copy = ica.copy() + + with pytest.raises(ValueError, match="ICA instance should be fit on"): + ica_copy.current_fit = "unfitted" + _label_ica_components(raw, ica_copy) + + +@testing.requires_testing_data +def test_label_components_gui_display(_fitted_ica, _label_ica_components): + raw, ica = _fitted_ica + + # test functions + gui = _label_ica_components(raw, ica) + assert gui.reset() + + # test setting the label From 08af2191f1679281faf567dae81864c7b4ed504e Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 18 Jul 2022 22:44:30 -0400 Subject: [PATCH 21/55] Try again --- mne_icalabel/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mne_icalabel/__init__.py b/mne_icalabel/__init__.py index a3e98b31..19388c4f 100644 --- a/mne_icalabel/__init__.py +++ b/mne_icalabel/__init__.py @@ -7,5 +7,4 @@ __version__ = "0.3dev0" -from . import gui from .label_components import label_components # noqa: F401 From a5f53a7aa10b49e7226e903dc365f9ce718fbfeb Mon Sep 17 00:00:00 2001 From: mscheltienne Date: Tue, 19 Jul 2022 14:47:03 +0200 Subject: [PATCH 22/55] adad time-series widget --- mne_icalabel/gui/_label_components.py | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index e1fc9655..8a2b9517 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -5,6 +5,7 @@ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from matplotlib.figure import Figure from mne.preprocessing import ICA +from mne.viz import set_browser_backend from qtpy.QtCore import Qt, Slot from qtpy.QtWidgets import ( QAbstractItemView, @@ -63,7 +64,7 @@ def redraw(self) -> None: class TimeSeriesFig(FigureCanvasQTAgg): - """Time-series figure widget.""" + """Dummy time-series figure widget.""" def __init__(self, width=4, height=4, dpi=100): self.fig = Figure(figsize=(width, height), dpi=dpi) @@ -74,22 +75,6 @@ def __init__(self, width=4, height=4, dpi=100): self.axes.set_yticks([]) super().__init__(self.fig) - def reset(self) -> None: - """Reset the time-series plot.""" - self.axes.clear() - - def change_ic(self, inst, ica, idx): - """Change the component time-series source to plot.""" - # TODO: change to use ica - # TODO: make this work and actually embed in the plot - fig = inst.plot(block=False, order=[idx]) - - self.axes.set_title("") - self.fig = fig - self.fig.tight_layout() - self.fig.canvas.draw() - self.fig.canvas.flush_events() - # TODO: Maybe that should inherit from a QGroupBox? class Labels(QWidget): @@ -135,6 +120,7 @@ class ICAComponentLabeler(QMainWindow): def __init__(self, inst, ica: ICA) -> None: super().__init__() + set_browser_backend("qt") # error check to see if ICA was fitted already if ica.current_fit == "unfitted": @@ -235,7 +221,11 @@ def list_component_clicked(self): # update figures self.widget_topo.redraw() self.widget_psd.redraw() - self.widget_timeSeries.change_ic(self._inst, self._ica, self._current_ic) + # retrieve layout and swaap timeSeries widget + self.central_widget.layout().replaceWidget( + self.widget_timeSeries, + self._ica.plot_sources(self._inst, picks=[self._current_ic]), + ) # update selected label if one was saved if self._current_ic in self.saved_labels: From 6dfdc808626f5da0e7c809227645a801ee89c83d Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 20 Jul 2022 14:25:06 +0200 Subject: [PATCH 23/55] fix timeSeries opening in new window --- mne_icalabel/gui/_label_components.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 8a2b9517..1a59d1b1 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -222,10 +222,9 @@ def list_component_clicked(self): self.widget_topo.redraw() self.widget_psd.redraw() # retrieve layout and swaap timeSeries widget - self.central_widget.layout().replaceWidget( - self.widget_timeSeries, - self._ica.plot_sources(self._inst, picks=[self._current_ic]), - ) + widget_timeSeries = self._ica.plot_sources(self._inst, picks=[self._current_ic]) + self.central_widget.layout().replaceWidget(self.widget_timeSeries, widget_timeSeries) + self.widget_timeSeries = widget_timeSeries # update selected label if one was saved if self._current_ic in self.saved_labels: From 2517f3ae59cf13ee49492e3c26316b953097dcf2 Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 20 Jul 2022 17:49:20 +0200 Subject: [PATCH 24/55] refactor and tests --- mne_icalabel/gui/_label_components.py | 442 +++++++++++++------------- 1 file changed, 225 insertions(+), 217 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 1a59d1b1..cd4a3abf 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -1,277 +1,285 @@ -import platform -from typing import Dict, List +from typing import Dict, List, Tuple, Union from matplotlib import pyplot as plt from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg -from matplotlib.figure import Figure +from mne import BaseEpochs +from mne.io import BaseRaw from mne.preprocessing import ICA +from mne.utils import _validate_type from mne.viz import set_browser_backend from qtpy.QtCore import Qt, Slot from qtpy.QtWidgets import ( QAbstractItemView, QButtonGroup, - QGridLayout, + QLayout, QListWidget, + QGridLayout, QMainWindow, QPushButton, QVBoxLayout, QWidget, -) + ) from mne_icalabel.config import ICLABEL_LABELS_TO_MNE -_CH_MENU_WIDTH = 30 if platform.system() == "Windows" else 10 - - -class TopomapFig(FigureCanvasQTAgg): - """Topographic map figure widget.""" - - def __init__(self, width=4, height=4, dpi=100): - self.fig = Figure(figsize=(width, height), dpi=dpi) - self.axes = self.fig.subplots() - self.fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - super().__init__(self.fig) - - def reset(self) -> None: - """Reset the topographic plot.""" - self.axes.clear() - - def redraw(self) -> None: - """Redraw the data.""" - self.fig.canvas.draw() - self.fig.canvas.flush_events() - - -class PowerSpectralDensityFig(FigureCanvasQTAgg): - """PSD figure widget.""" - - def __init__(self, width=4, height=4, dpi=100): - self.fig = Figure(figsize=(width, height), dpi=dpi) - self.axes = self.fig.subplots() - self.axes.axis("off") - super().__init__(self.fig) - - def reset(self) -> None: - """Reset the PSD plot.""" - self.axes.clear() - - def redraw(self) -> None: - """Redraw the data.""" - self.axes.set_title("") - self.fig.tight_layout() - self.fig.canvas.draw() - self.fig.canvas.flush_events() - - -class TimeSeriesFig(FigureCanvasQTAgg): - """Dummy time-series figure widget.""" - - def __init__(self, width=4, height=4, dpi=100): - self.fig = Figure(figsize=(width, height), dpi=dpi) - self.axes = self.fig.subplots() - self.fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) - # clean up excess plot text, invert - self.axes.set_xticks([]) - self.axes.set_yticks([]) - super().__init__(self.fig) - - -# TODO: Maybe that should inherit from a QGroupBox? -class Labels(QWidget): - """Widget with labels as push buttons. - - Only one of the labels can be selected at once. - """ - - def __init__(self, labels, *args, **kwargs): - super().__init__(*args, **kwargs) - self.buttonGroup = QButtonGroup() - self.buttonGroup.setExclusive(True) - layout = QVBoxLayout() - for k, label in enumerate(labels + ["Reset"]): - pushButton = Labels.create_pushButton(label) - self.buttonGroup.addButton(pushButton, k) - layout.addWidget(pushButton) - self.setLayout(layout) - - @staticmethod - def create_pushButton(label): - """Create a push button widget. - - Sets the properties of the push button widget. - """ - pushButton = QPushButton() - pushButton.setObjectName(f"pushButton_{label.lower().replace(' ', '_')}") - pushButton.setText(label) - pushButton.setCheckable(True) - pushButton.setChecked(False) - pushButton.setEnabled(False) - return pushButton - class ICAComponentLabeler(QMainWindow): """Qt GUI to annotate components. Parameters ---------- - inst : Raw + inst : Raw | Epochs ica : ICA """ - def __init__(self, inst, ica: ICA) -> None: - super().__init__() - set_browser_backend("qt") + def __init__(self, inst: Union[BaseRaw, BaseEpochs], ica: ICA) -> None: + ICAComponentLabeler._check_inst_ica(inst, ica) + super().__init__() # initialize the QMainwindow + set_browser_backend("qt") # force MNE to use the QT Browser - # error check to see if ICA was fitted already - if ica.current_fit == "unfitted": - raise ValueError( - "ICA instance should be fit on the raw data before " - "running the ICA labeling GUI. Run `ica.fit(inst)`." - ) + # keep an internal pointer to the instance and to the ICA + self._inst = inst + self._ica = ica + # define valid labels + self._labels = list(ICLABEL_LABELS_TO_MNE.keys()) + # prepare the GUI + self._load_ui() + + # dictionary to remember selected labels, with the key as the 'indice' + # of the component and the value as the 'label'. + self.selected_labels: Dict[int, str] = dict() + + # connect signal to slots + self._connect_signals_to_slots() + + # select first IC + self._selected_component = 0 + self._components_listWidget.setCurrentRow(0) # emit signal + + def _save_labels(self): + """Save the selected labels to the ICA instance.""" + # convert the dict[int, str] to dict[str, List[int]] with the key as + # 'label' and value as a list of component indices. + labels2save = {key: [] for key in self.labels} + for component, label in self.selected_labels.items(): + labels2save[label].append(component) + # sanity-check: uniqueness + assert all(len(elt) == len(set(elt)) for elt in labels2save.values()) + + for label, comp_list in labels2save.items(): + mne_label = ICLABEL_LABELS_TO_MNE[label] + if mne_label not in self._ica.labels_: + self._ica.labels_[mne_label] = comp_list + continue + for comp in comp_list: + if comp not in self._ica.labels_[mne_label]: + self._ica.labels_[mne_label].append(comp) + # - UI -------------------------------------------------------------------- + def _load_ui(self): + """Prepare the GUI. + + Widgets + ------- + self._components_listWidget + self._labels_buttonGroup + self._mpl_widgets (dict) + - topomap + - psd + self._timeSeries_widget + + Matplotlib figures + ------------------ + self._mpl_figures (dict) + - topomap + - psd + """ self.setWindowTitle("ICA Component Labeler") self.setContextMenuPolicy(Qt.NoContextMenu) - # keep an internal pointer to the ICA and Raw - self._ica = ica - self._inst = inst + # QListWidget with the components' names. + self._components_listWidget = QListWidget() + self._components_listWidget.setSelectionMode(QAbstractItemView.SingleSelection) + self._components_listWidget.addItems( + [f"ICA{str(k).zfill(3)}" for k in range(self.n_components_)] + ) + + # buttons to select labels + self._labels_buttonGroup, buttonGroup_layout = ICAComponentLabeler._labels_buttonGroup( + self.labels + ) + + # matplotlib figures + self._mpl_figures = dict() + self._mpl_widgets = dict() + + # topographic map + fig, _ = plt.subplots(1, 1, figsize=(4, 4), dpi=100) + fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + self._mpl_figures["topomap"] = fig + self._mpl_widgets["topomap"] = FigureCanvasQTAgg(fig) + + # PSD + fig, _ = plt.subplots(1, 1, figsize=(4, 4), dpi=100) + fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) + self._mpl_figures["psd"] = fig + self._mpl_widgets["psd"] = FigureCanvasQTAgg(fig) + + # Time-series, initialized with the first IC since it's easier than + # creating an empty browser and providing all the arguments for + # _get_browser(). + self._timeSeries_widget = self.ica.plot_sources(self.inst, picks=[0]) + + # load the layouts + self._load_layout(buttonGroup_layout) - # define valid labels - self.labels = [ - "Brain", - "Eye", - "Heart", - "Muscle", - "Channel Noise", - "Line Noise", - "Other", - ] - # create viewbox to select components - self.list_components = ICAComponentLabeler.list_components(self._ica) # type: ignore - # create buttons to select label - self.buttonGroup_labels = Labels(self.labels) - # create figure widgets - self.widget_topo = TopomapFig() - self.widget_psd = PowerSpectralDensityFig() - self.widget_timeSeries = TimeSeriesFig() - - # add central widget and layout - self.central_widget = QWidget(self) - self.central_widget.setObjectName("central_widget") + @staticmethod + def _labels_buttonGroup(labels: List[str]) -> Tuple[QButtonGroup, QLayout]: + """Create the ButtonGroup that holds the labels and the reset.""" + buttonGroup = QButtonGroup() + buttonGroup_layout = QVBoxLayout() + buttonGroup.setExclusive(True) + for k, label in enumerate(labels): + pushButton = QPushButton() + pushButton.setObjectName(f"pushButton_{label.lower().replace(' ', '_')}") + pushButton.setText(label) + pushButton.setCheckable(True) + pushButton.setChecked(False) + pushButton.setEnabled(False) + # buttons are ordered in the same order as labels + buttonGroup.addButton(pushButton, k) + buttonGroup_layout.addWidget(pushButton) + return buttonGroup, buttonGroup_layout + + def _load_layout(self, buttonGroup_layout): + """Load and set the layout of the GUI.""" + self._central_widget = QWidget(self) + self._central_widget.setObjectName("central_widget") layout = QGridLayout() - layout.addWidget(self.list_components, 0, 0, 2, 1) - layout.addWidget(self.buttonGroup_labels, 0, 1, 2, 1) - layout.addWidget(self.widget_topo, 0, 2) - layout.addWidget(self.widget_psd, 0, 3) - layout.addWidget(self.widget_timeSeries, 1, 2, 1, 2) - self.central_widget.setLayout(layout) - self.setCentralWidget(self.central_widget) - self.resize(1500, 600) - - # dictionary to remember selected labels, with the key - # as the 'label' and the values as list of ICA components - self.saved_labels: Dict[str, List] = dict() - - # connect signal and slots - self.connect_signals_to_slots() - + layout.addWidget(self._components_listWidget, 0, 0, 2, 1) + layout.addLayout(buttonGroup_layout, 0, 1, 2, 1) + layout.addWidget(self._mpl_widgets["topomap"], 0, 2) + layout.addWidget(self._mpl_widgets["psd"], 0, 3) + layout.addWidget(self._timeSeries_widget, 1, 2, 1, 2) + self._central_widget.setLayout(layout) + self.setCentralWidget(self._central_widget) + + # - Checkers -------------------------------------------------------------- @staticmethod - def list_components(ica): - """List the components in a QListView.""" - list_components = QListWidget() - list_components.setSelectionMode(QAbstractItemView.SingleSelection) - list_components.setMinimumWidth(6 * _CH_MENU_WIDTH) - list_components.setMaximumWidth(6 * _CH_MENU_WIDTH) - list_components.addItems([f"ICA{str(k).zfill(3)}" for k in range(ica.n_components_)]) - return list_components - - def connect_signals_to_slots(self): # noqa: D102 - # connect click to function - self.list_components.clicked.connect(self.list_component_clicked) - - # TODO: connect selection (i.e. with up/down arrow) to function - # self.list_components.currentIndexChanged.connect(self.list_component_clicked) - self.buttonGroup_labels.buttonGroup.buttons()[-1].clicked.connect(self.reset) + def _check_inst_ica(inst: Union[BaseRaw, BaseEpochs], ica: ICA) -> None: + """Check if the ICA was fitted.""" + _validate_type(inst, (BaseRaw, BaseEpochs), "inst", "raw or epochs") + _validate_type(ica, ICA, "ica", "ICA") + if ica.current_fit == "unfitted": + raise ValueError( + "ICA instance should be fit on the raw data before " + "running the ICA labeling GUI. Run `ica.fit(inst)`." + ) - @Slot() - def list_component_clicked(self): - """Jump to the selected component and draw the plots.""" - self.update_saved_labels() - self._reset_all_buttons() + # - Properties ------------------------------------------------------------ + @property + def inst(self) -> Union[BaseRaw, BaseEpochs]: + """Instance on which the ICA has been fitted.""" + return self._inst + + @property + def ica(self) -> ICA: + """Fitted ICA decomposition.""" + return self._ica + + @property + def n_components_(self) -> int: + """The number of fitted components.""" + return self._ica.n_components_ + + @property + def labels(self) -> List[str]: + """List of valid labels.""" + return self._labels + + @property + def selected_component(self) -> int: + """IC selected and displayed.""" + return self._selected_component + + # - Slots ----------------------------------------------------------------- + def _connect_signals_to_slots(self) -> None: + """Connects all the signals and slots of the GUI.""" + self._components_listWidget.currentRowChanged.connect( + self._components_listWidget_clicked + ) + self._labels_buttonGroup.buttons()[-1].clicked.connect(self._reset) - # reset figures - self.widget_topo.reset() - self.widget_psd.reset() + @Slot() + def _components_listWidget_clicked(self) -> None: + """Update the plots and the saved labels accordingly.""" + self._update_selected_labels() + self._reset_buttons() # update selected IC - self._current_ic = self.list_components.currentRow() + self._selected_component = self._components_listWidget.currentRow() - # create dummy axes + # reset matplotlib figures + for fig in self._mpl_figures.values(): + fig.axes[0].clear() + # create dummy figure and axes to hold the unused plots from plot_properties dummy_fig, dummy_axes = plt.subplots(3) + # create axes argument provided to plot_properties axes = [ - self.widget_topo.axes, + self._mpl_figures["topomap"].axes[0], dummy_axes[0], dummy_axes[1], - self.widget_psd.axes, + self._mpl_figures["psd"].axes[0], dummy_axes[2], - ] - self._ica.plot_properties(self._inst, axes=axes, picks=self._current_ic, show=False) + ] + # upate matplotlib plots with plot_properties + self.ica.plot_properties(self.inst, axes=axes, picks=self.selected_component, show=False) del dummy_fig - - # update figures - self.widget_topo.redraw() - self.widget_psd.redraw() - # retrieve layout and swaap timeSeries widget - widget_timeSeries = self._ica.plot_sources(self._inst, picks=[self._current_ic]) - self.central_widget.layout().replaceWidget(self.widget_timeSeries, widget_timeSeries) - self.widget_timeSeries = widget_timeSeries - - # update selected label if one was saved - if self._current_ic in self.saved_labels: - label = self.saved_labels[self._current_ic] - idx = self.labels.index(label) - self.buttonGroup_labels.buttonGroup.button(idx).setChecked(True) - - def update_saved_labels(self): + # remove title from topomap axes + self._mpl_figures["topomap"].axes[0].set_title("") + # update the matplotlib canvas + for fig in self._mpl_figures.values(): + fig.tight_layout() + fig.canvas.draw() + fig.canvas.flush_events() + + # swap timeSeries widget + timeSeries_widget = self.ica.plot_sources(self.inst, picks=[self.selected_component]) + self._central_widget.layout().replaceWidget(self._timeSeries_widget, timeSeries_widget) + self._timeSeries_widget = timeSeries_widget + + # select buttons that were previously selected for this IC + if self.selected_component in self.selected_labels: + idx = self.labels.index(self.selected_labels[self.selected_component]) + self._labels_buttonGroup.button(idx).setChecked(True) + + def _update_selected_labels(self) -> None: """Update the labels saved.""" - selected = self.buttonGroup_labels.buttonGroup.checkedButton() + selected = self._labels_buttonGroup.checkedButton() if selected is not None: - self.saved_labels[self._current_ic] = selected.text() - self._save_component_labels() + self.selected_labels[self.selected_component] = selected.text() + self._save_labels() # updates the ICA instance every time @Slot() - def reset(self): - """Slot action for the reset button.""" + def _reset(self) -> None: + """Action of the reset button.""" self._reset_all_buttons() - if self._current_ic in self.saved_labels: - del self.saved_labels[self._current_ic] + if self.selected_component in self.selected_labels: + del self.selected_labels[self.selected_component] - def _reset_all_buttons(self): + def _reset_buttons(self) -> None: """Reset all buttons.""" - self.buttonGroup_labels.buttonGroup.setExclusive(False) - for button in self.buttonGroup_labels.buttonGroup.buttons(): + self._labels_buttonGroup.setExclusive(False) + for button in self._labels_buttonGroup.buttons(): button.setEnabled(True) button.setChecked(False) - self.buttonGroup_labels.buttonGroup.setExclusive(True) - - def _save_component_labels(self): - """Save component labels to the ICA instance.""" - for label, comp_list in self.saved_labels.items(): - mne_label = ICLABEL_LABELS_TO_MNE[label] - if mne_label not in self._ica.labels_: - self._ica.labels_[mne_label] = [] - - # add component labels to the ICA instance - for comp in comp_list: - if comp not in self._ica.labels_[mne_label]: - self._ica.labels_[mne_label].append(comp) + self._labels_buttonGroup.setExclusive(True) def closeEvent(self, event): """Clean up upon closing the window. - Check if any IC is not labelled and ask the user to confirm if this is - the case. Save all labels in BIDS format. + Update the labels since the user might have selected one for the + currently being displayed IC. """ - self.update_saved_labels() - print(self.saved_labels) + self._update_selected_labels() event.accept() From 612dd412853f860efaefab7a66ab3360248b0f53 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Wed, 20 Jul 2022 15:38:49 -0400 Subject: [PATCH 25/55] Fix style --- mne_icalabel/gui/_label_components.py | 10 ++++------ mne_icalabel/gui/tests/test_label_components.py | 3 +++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index cd4a3abf..a6a1a481 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -11,14 +11,14 @@ from qtpy.QtWidgets import ( QAbstractItemView, QButtonGroup, + QGridLayout, QLayout, QListWidget, - QGridLayout, QMainWindow, QPushButton, QVBoxLayout, QWidget, - ) +) from mne_icalabel.config import ICLABEL_LABELS_TO_MNE @@ -205,9 +205,7 @@ def selected_component(self) -> int: # - Slots ----------------------------------------------------------------- def _connect_signals_to_slots(self) -> None: """Connects all the signals and slots of the GUI.""" - self._components_listWidget.currentRowChanged.connect( - self._components_listWidget_clicked - ) + self._components_listWidget.currentRowChanged.connect(self._components_listWidget_clicked) self._labels_buttonGroup.buttons()[-1].clicked.connect(self._reset) @Slot() @@ -231,7 +229,7 @@ def _components_listWidget_clicked(self) -> None: dummy_axes[1], self._mpl_figures["psd"].axes[0], dummy_axes[2], - ] + ] # upate matplotlib plots with plot_properties self.ica.plot_properties(self.inst, axes=axes, picks=self.selected_component, show=False) del dummy_fig diff --git a/mne_icalabel/gui/tests/test_label_components.py b/mne_icalabel/gui/tests/test_label_components.py index b6b971b4..211d351d 100644 --- a/mne_icalabel/gui/tests/test_label_components.py +++ b/mne_icalabel/gui/tests/test_label_components.py @@ -33,6 +33,9 @@ def load_raw_and_fit_ica(): raw_fname = op.join(data_path, "test_reduced.edf") raw = read_raw_edf(raw_fname) + # high-pass filter + raw.filter(l_freq=1, h_freq=100) + # compute ICA ica = ICA(n_components=15) ica.fit(raw) From 0a68ae9ba958237f35bc19f749101089764e3cdc Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 20 Jul 2022 23:19:16 +0200 Subject: [PATCH 26/55] simpler handling of the central widget and its layout --- mne_icalabel/gui/_label_components.py | 77 +++++++++++---------------- 1 file changed, 32 insertions(+), 45 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index a6a1a481..0b4e4621 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Tuple, Union +from typing import Dict, List, Union from matplotlib import pyplot as plt from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg @@ -12,7 +12,6 @@ QAbstractItemView, QButtonGroup, QGridLayout, - QLayout, QListWidget, QMainWindow, QPushButton, @@ -56,7 +55,7 @@ def __init__(self, inst: Union[BaseRaw, BaseEpochs], ica: ICA) -> None: self._selected_component = 0 self._components_listWidget.setCurrentRow(0) # emit signal - def _save_labels(self): + def _save_labels(self) -> None: """Save the selected labels to the ICA instance.""" # convert the dict[int, str] to dict[str, List[int]] with the key as # 'label' and value as a list of component indices. @@ -76,7 +75,7 @@ def _save_labels(self): self._ica.labels_[mne_label].append(comp) # - UI -------------------------------------------------------------------- - def _load_ui(self): + def _load_ui(self) -> None: """Prepare the GUI. Widgets @@ -97,17 +96,36 @@ def _load_ui(self): self.setWindowTitle("ICA Component Labeler") self.setContextMenuPolicy(Qt.NoContextMenu) + # create central widget and main layout + self._central_widget = QWidget(self) + self._central_widget.setObjectName("central_widget") + grid_layout = QGridLayout(self) + self._central_widget.setLayout(grid_layout) + self.setCentralWidget(self._central_widget) + # QListWidget with the components' names. - self._components_listWidget = QListWidget() + self._components_listWidget = QListWidget(self) self._components_listWidget.setSelectionMode(QAbstractItemView.SingleSelection) self._components_listWidget.addItems( [f"ICA{str(k).zfill(3)}" for k in range(self.n_components_)] ) + grid_layout.addWidget(self._components_listWidget, 0, 0, 2, 1) # buttons to select labels - self._labels_buttonGroup, buttonGroup_layout = ICAComponentLabeler._labels_buttonGroup( - self.labels - ) + self._labels_buttonGroup = QButtonGroup(self) + buttonGroup_layout = QVBoxLayout(self) + self._labels_buttonGroup.setExclusive(True) + for k, label in enumerate(self.labels): + pushButton = QPushButton(self) + pushButton.setObjectName(f"pushButton_{label.lower().replace(' ', '_')}") + pushButton.setText(label) + pushButton.setCheckable(True) + pushButton.setChecked(False) + pushButton.setEnabled(False) + # buttons are ordered in the same order as labels + self._labels_buttonGroup.addButton(pushButton, k) + buttonGroup_layout.addWidget(pushButton) + grid_layout.addLayout(buttonGroup_layout, 0, 1, 2, 1) # matplotlib figures self._mpl_figures = dict() @@ -118,51 +136,20 @@ def _load_ui(self): fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) self._mpl_figures["topomap"] = fig self._mpl_widgets["topomap"] = FigureCanvasQTAgg(fig) + grid_layout.addWidget(self._mpl_widgets["topomap"], 0, 2) # PSD fig, _ = plt.subplots(1, 1, figsize=(4, 4), dpi=100) fig.subplots_adjust(bottom=0, left=0, right=1, top=1, wspace=0, hspace=0) self._mpl_figures["psd"] = fig self._mpl_widgets["psd"] = FigureCanvasQTAgg(fig) + grid_layout.addWidget(self._mpl_widgets["psd"], 0, 3) - # Time-series, initialized with the first IC since it's easier than + # time-series, initialized with the first IC since it's easier than # creating an empty browser and providing all the arguments for # _get_browser(). self._timeSeries_widget = self.ica.plot_sources(self.inst, picks=[0]) - - # load the layouts - self._load_layout(buttonGroup_layout) - - @staticmethod - def _labels_buttonGroup(labels: List[str]) -> Tuple[QButtonGroup, QLayout]: - """Create the ButtonGroup that holds the labels and the reset.""" - buttonGroup = QButtonGroup() - buttonGroup_layout = QVBoxLayout() - buttonGroup.setExclusive(True) - for k, label in enumerate(labels): - pushButton = QPushButton() - pushButton.setObjectName(f"pushButton_{label.lower().replace(' ', '_')}") - pushButton.setText(label) - pushButton.setCheckable(True) - pushButton.setChecked(False) - pushButton.setEnabled(False) - # buttons are ordered in the same order as labels - buttonGroup.addButton(pushButton, k) - buttonGroup_layout.addWidget(pushButton) - return buttonGroup, buttonGroup_layout - - def _load_layout(self, buttonGroup_layout): - """Load and set the layout of the GUI.""" - self._central_widget = QWidget(self) - self._central_widget.setObjectName("central_widget") - layout = QGridLayout() - layout.addWidget(self._components_listWidget, 0, 0, 2, 1) - layout.addLayout(buttonGroup_layout, 0, 1, 2, 1) - layout.addWidget(self._mpl_widgets["topomap"], 0, 2) - layout.addWidget(self._mpl_widgets["psd"], 0, 3) - layout.addWidget(self._timeSeries_widget, 1, 2, 1, 2) - self._central_widget.setLayout(layout) - self.setCentralWidget(self._central_widget) + grid_layout.addWidget(self._timeSeries_widget, 1, 2, 1, 2) # - Checkers -------------------------------------------------------------- @staticmethod @@ -230,7 +217,7 @@ def _components_listWidget_clicked(self) -> None: self._mpl_figures["psd"].axes[0], dummy_axes[2], ] - # upate matplotlib plots with plot_properties + # update matplotlib plots with plot_properties self.ica.plot_properties(self.inst, axes=axes, picks=self.selected_component, show=False) del dummy_fig # remove title from topomap axes @@ -273,7 +260,7 @@ def _reset_buttons(self) -> None: button.setChecked(False) self._labels_buttonGroup.setExclusive(True) - def closeEvent(self, event): + def closeEvent(self, event) -> None: """Clean up upon closing the window. Update the labels since the user might have selected one for the From e13048b00ab0902bbe91f900a70601e9bf831dfb Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Wed, 20 Jul 2022 23:23:44 +0200 Subject: [PATCH 27/55] add type hint for labels2save --- mne_icalabel/gui/_label_components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 0b4e4621..e696f2c0 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -59,7 +59,7 @@ def _save_labels(self) -> None: """Save the selected labels to the ICA instance.""" # convert the dict[int, str] to dict[str, List[int]] with the key as # 'label' and value as a list of component indices. - labels2save = {key: [] for key in self.labels} + labels2save: Dict[str, List[int]] = {key: [] for key in self.labels} for component, label in self.selected_labels.items(): labels2save[label].append(component) # sanity-check: uniqueness From 037d2a875a6da2477fb61194b43f7a54cc581547 Mon Sep 17 00:00:00 2001 From: mscheltienne Date: Thu, 21 Jul 2022 10:32:39 +0200 Subject: [PATCH 28/55] better parent widgets --- mne_icalabel/gui/_label_components.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index e696f2c0..a34d6ba3 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -99,12 +99,12 @@ def _load_ui(self) -> None: # create central widget and main layout self._central_widget = QWidget(self) self._central_widget.setObjectName("central_widget") - grid_layout = QGridLayout(self) + grid_layout = QGridLayout() self._central_widget.setLayout(grid_layout) self.setCentralWidget(self._central_widget) # QListWidget with the components' names. - self._components_listWidget = QListWidget(self) + self._components_listWidget = QListWidget(self._central_widget) self._components_listWidget.setSelectionMode(QAbstractItemView.SingleSelection) self._components_listWidget.addItems( [f"ICA{str(k).zfill(3)}" for k in range(self.n_components_)] @@ -112,11 +112,11 @@ def _load_ui(self) -> None: grid_layout.addWidget(self._components_listWidget, 0, 0, 2, 1) # buttons to select labels - self._labels_buttonGroup = QButtonGroup(self) - buttonGroup_layout = QVBoxLayout(self) + self._labels_buttonGroup = QButtonGroup(self._central_widget) + buttonGroup_layout = QVBoxLayout() self._labels_buttonGroup.setExclusive(True) for k, label in enumerate(self.labels): - pushButton = QPushButton(self) + pushButton = QPushButton(self._central_widget) pushButton.setObjectName(f"pushButton_{label.lower().replace(' ', '_')}") pushButton.setText(label) pushButton.setCheckable(True) From 7abba70146f2c12466335ce8aaf1eee0dd53ed82 Mon Sep 17 00:00:00 2001 From: mscheltienne Date: Thu, 21 Jul 2022 10:35:27 +0200 Subject: [PATCH 29/55] fix doc style --- mne_icalabel/gui/_label_components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index a34d6ba3..9cbe648b 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -191,7 +191,7 @@ def selected_component(self) -> int: # - Slots ----------------------------------------------------------------- def _connect_signals_to_slots(self) -> None: - """Connects all the signals and slots of the GUI.""" + """Connect all the signals and slots of the GUI.""" self._components_listWidget.currentRowChanged.connect(self._components_listWidget_clicked) self._labels_buttonGroup.buttons()[-1].clicked.connect(self._reset) @@ -246,7 +246,7 @@ def _update_selected_labels(self) -> None: self._save_labels() # updates the ICA instance every time @Slot() - def _reset(self) -> None: + def _reset(self) -> None: # noqa: D401 """Action of the reset button.""" self._reset_all_buttons() if self.selected_component in self.selected_labels: From 9451c8119b7b08b8731275b1812389023608e775 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Thu, 21 Jul 2022 10:41:22 -0400 Subject: [PATCH 30/55] Fix error in unit test --- mne_icalabel/gui/tests/test_label_components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne_icalabel/gui/tests/test_label_components.py b/mne_icalabel/gui/tests/test_label_components.py index 211d351d..3859c65f 100644 --- a/mne_icalabel/gui/tests/test_label_components.py +++ b/mne_icalabel/gui/tests/test_label_components.py @@ -31,7 +31,7 @@ def fun(*args, **kwargs): def load_raw_and_fit_ica(): data_path = op.join(testing.data_path(), "EDF") raw_fname = op.join(data_path, "test_reduced.edf") - raw = read_raw_edf(raw_fname) + raw = read_raw_edf(raw_fname, preload=True) # high-pass filter raw.filter(l_freq=1, h_freq=100) From 5594346c76509c66a6a1c399af2516b61f521fa8 Mon Sep 17 00:00:00 2001 From: Mathieu Scheltienne Date: Thu, 21 Jul 2022 22:53:19 +0200 Subject: [PATCH 31/55] fix reset button --- mne_icalabel/gui/_label_components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 9cbe648b..738fc43a 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -115,7 +115,7 @@ def _load_ui(self) -> None: self._labels_buttonGroup = QButtonGroup(self._central_widget) buttonGroup_layout = QVBoxLayout() self._labels_buttonGroup.setExclusive(True) - for k, label in enumerate(self.labels): + for k, label in enumerate(self.labels + ["Reset"]): pushButton = QPushButton(self._central_widget) pushButton.setObjectName(f"pushButton_{label.lower().replace(' ', '_')}") pushButton.setText(label) @@ -248,7 +248,7 @@ def _update_selected_labels(self) -> None: @Slot() def _reset(self) -> None: # noqa: D401 """Action of the reset button.""" - self._reset_all_buttons() + self._reset_buttons() if self.selected_component in self.selected_labels: del self.selected_labels[self.selected_component] From 3512a8a8e0ea0f97ad15501f48dddd084140fb50 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Sat, 23 Jul 2022 13:50:22 -0400 Subject: [PATCH 32/55] Add reqs --- examples/label_components.py | 6 +++++- mne_icalabel/conftest.py | 42 +++++++++++++++++++++++++++++++++++- requirements_doc.txt | 5 ++++- requirements_testing.txt | 4 +++- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/examples/label_components.py b/examples/label_components.py index 9bbf114c..97251c7c 100644 --- a/examples/label_components.py +++ b/examples/label_components.py @@ -60,7 +60,11 @@ # Save the labeled components # --------------------------- # After the GUI labels, save the components using the `write_components_tsv` -# function. +# function. This will save the ICA annotations to disc in BIDS-Derivative for +# EEG data format. +# +# Note: BIDS-EEG-Derivatives is not fully specified, so this functionality +# may change in the future without notice. # fname = '' # write_components_tsv(ica, fname) diff --git a/mne_icalabel/conftest.py b/mne_icalabel/conftest.py index eac9d8c4..397aebae 100644 --- a/mne_icalabel/conftest.py +++ b/mne_icalabel/conftest.py @@ -2,10 +2,11 @@ # Author: Eric Larson # # License: BSD-3-Clause - import warnings +from contextlib import contextmanager import pytest +from mne.utils import _check_qt_version # most of this adapted from MNE-Python @@ -29,6 +30,45 @@ def pytest_configure(config): config.addinivalue_line("filterwarnings", warning_line) +def _check_skip_backend(name): + from mne.viz.backends.tests._utils import ( + has_imageio_ffmpeg, + has_pyvista, + has_pyvistaqt, + ) + + if name in ("pyvistaqt", "notebook"): + if not has_pyvista(): + pytest.skip("Test skipped, requires pyvista.") + if not has_imageio_ffmpeg(): + pytest.skip("Test skipped, requires imageio-ffmpeg") + if name == "pyvistaqt" and not _check_qt_version(): + pytest.skip("Test skipped, requires Qt.") + if name == "pyvistaqt" and not has_pyvistaqt(): + pytest.skip("Test skipped, requires pyvistaqt") + + +@contextmanager +def _use_backend(backend_name, interactive): + from mne.viz.backends.renderer import _use_test_3d_backend + + _check_skip_backend(backend_name) + with _use_test_3d_backend(backend_name, interactive=interactive): + from mne.viz.backends import renderer + + try: + yield renderer + finally: + renderer.backend._close_all() + + +@pytest.fixture(scope="module", params=["pyvistaqt"]) +def renderer_interactive_pyvistaqt(request, options_3d): + """Yield the interactive PyVista backend.""" + with _use_backend(request.param, interactive=True) as renderer: + yield renderer + + @pytest.fixture(scope="session") def matplotlib_config(): """Configure matplotlib for viz tests.""" diff --git a/requirements_doc.txt b/requirements_doc.txt index 854c15f2..70c2bffa 100644 --- a/requirements_doc.txt +++ b/requirements_doc.txt @@ -10,4 +10,7 @@ sphinx-autodoc-typehints sphinxcontrib-bibtex mne-bids pooch -matplotlib \ No newline at end of file +matplotlib +qtpy +pyvista>=0.32 +pyvistaqt>=0.4 \ No newline at end of file diff --git a/requirements_testing.txt b/requirements_testing.txt index df01a8a1..a952ab57 100644 --- a/requirements_testing.txt +++ b/requirements_testing.txt @@ -23,4 +23,6 @@ python-picard joblib scikit-learn pandas -qtpy \ No newline at end of file +qtpy +pyvista>=0.32 +pyvistaqt>=0.4 \ No newline at end of file From 1cb507c1de8479f0f9403a8d40c1cdb7e8f62cd5 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Sat, 23 Jul 2022 22:55:00 -0400 Subject: [PATCH 33/55] Fix example by closing figure --- mne_icalabel/gui/__init__.py | 35 +++++++++++++++++++++------ mne_icalabel/gui/_label_components.py | 5 +++- mne_icalabel/utils/__init__.py | 1 + requirements_doc.txt | 5 +--- 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py index fd45dfb4..4f23de25 100644 --- a/mne_icalabel/gui/__init__.py +++ b/mne_icalabel/gui/__init__.py @@ -1,11 +1,32 @@ -from qtpy.QtWidgets import QApplication +from mne.preprocessing import ICA -from ._label_components import ICAComponentLabeler +def label_ica_components(inst, ica: ICA, show: bool = True, block: bool = False): + """Locate intracranial electrode contacts. + + Parameters + ---------- + inst : : Raw | Epochs + The epochs or raw object. + ica : ICA + The ICA object fitted on `inst`. + show : bool + Show the GUI if True. + block : bool + Whether to halt program execution until the figure is closed. + + Returns + ------- + gui : instance of ICAComponentLabeler + The graphical user interface (GUI) window. + """ + from mne.viz.backends._utils import _qt_app_exec + from qtpy.QtWidgets import QApplication + + from ._label_components import ICAComponentLabeler -def label_ica_components(inst, ica): app = QApplication([]) - window = ICAComponentLabeler(inst=inst, ica=ica) - window.show() - app.exec() - return app + gui = ICAComponentLabeler(inst=inst, ica=ica, show=show) + if block: + _qt_app_exec(app) + return gui diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 738fc43a..30009b7e 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -31,7 +31,7 @@ class ICAComponentLabeler(QMainWindow): ica : ICA """ - def __init__(self, inst: Union[BaseRaw, BaseEpochs], ica: ICA) -> None: + def __init__(self, inst: Union[BaseRaw, BaseEpochs], ica: ICA, show: bool = True) -> None: ICAComponentLabeler._check_inst_ica(inst, ica) super().__init__() # initialize the QMainwindow set_browser_backend("qt") # force MNE to use the QT Browser @@ -55,6 +55,9 @@ def __init__(self, inst: Union[BaseRaw, BaseEpochs], ica: ICA) -> None: self._selected_component = 0 self._components_listWidget.setCurrentRow(0) # emit signal + if show: + self.show() + def _save_labels(self) -> None: """Save the selected labels to the ICA instance.""" # convert the dict[int, str] to dict[str, List[int]] with the key as diff --git a/mne_icalabel/utils/__init__.py b/mne_icalabel/utils/__init__.py index e69de29b..4802f255 100644 --- a/mne_icalabel/utils/__init__.py +++ b/mne_icalabel/utils/__init__.py @@ -0,0 +1 @@ +from ._docs import fill_doc diff --git a/requirements_doc.txt b/requirements_doc.txt index 70c2bffa..854c15f2 100644 --- a/requirements_doc.txt +++ b/requirements_doc.txt @@ -10,7 +10,4 @@ sphinx-autodoc-typehints sphinxcontrib-bibtex mne-bids pooch -matplotlib -qtpy -pyvista>=0.32 -pyvistaqt>=0.4 \ No newline at end of file +matplotlib \ No newline at end of file From d25e0f6f855798b3d4ecff84ab3c749246a6ed70 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Sun, 24 Jul 2022 11:07:23 -0400 Subject: [PATCH 34/55] Adding updated example --- examples/label_components.py | 4 ++-- mne_icalabel/conftest.py | 2 +- requirements_testing.txt | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/label_components.py b/examples/label_components.py index 97251c7c..f0c51156 100644 --- a/examples/label_components.py +++ b/examples/label_components.py @@ -35,7 +35,7 @@ # ---------------------------------- # Before labeling components with the GUI, one needs to filter the data # and then fit the ICA instance. Afterwards, one can run the GUI using the -# `Raw` data object and the fitted `ICA` instance. The GUI will modify +# ``Raw`` data object and the fitted ``ICA`` instance. The GUI will modify # the ICA instance in place, and add the labels of each component to # the ``labels_`` attribute. @@ -59,7 +59,7 @@ # %% # Save the labeled components # --------------------------- -# After the GUI labels, save the components using the `write_components_tsv` +# After the GUI labels, save the components using the ``write_components_tsv``` # function. This will save the ICA annotations to disc in BIDS-Derivative for # EEG data format. # diff --git a/mne_icalabel/conftest.py b/mne_icalabel/conftest.py index 397aebae..188a29d2 100644 --- a/mne_icalabel/conftest.py +++ b/mne_icalabel/conftest.py @@ -63,7 +63,7 @@ def _use_backend(backend_name, interactive): @pytest.fixture(scope="module", params=["pyvistaqt"]) -def renderer_interactive_pyvistaqt(request, options_3d): +def renderer_interactive_pyvistaqt(request): """Yield the interactive PyVista backend.""" with _use_backend(request.param, interactive=True) as renderer: yield renderer diff --git a/requirements_testing.txt b/requirements_testing.txt index a952ab57..d92f3aa6 100644 --- a/requirements_testing.txt +++ b/requirements_testing.txt @@ -25,4 +25,5 @@ scikit-learn pandas qtpy pyvista>=0.32 -pyvistaqt>=0.4 \ No newline at end of file +pyvistaqt>=0.4 +mne-qt-browser \ No newline at end of file From 1e379c221de4082c0813ffc6b9192d17093d47a0 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Sun, 24 Jul 2022 11:08:20 -0400 Subject: [PATCH 35/55] Apply suggestions from code review Co-authored-by: Mathieu Scheltienne --- mne_icalabel/annotation/bids.py | 2 +- mne_icalabel/gui/__init__.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mne_icalabel/annotation/bids.py b/mne_icalabel/annotation/bids.py index 3910fa3d..262fa5fc 100644 --- a/mne_icalabel/annotation/bids.py +++ b/mne_icalabel/annotation/bids.py @@ -50,7 +50,7 @@ def write_components_tsv(ica: ICA, fname): # extract the component labels if they are present in the ICA instance if ica.labels_: for label, comps in ica.labels_.items(): - this_status = "good" if label != "brain" else "bad" + this_status = "good" if label == "brain" else "bad" if label in ICLABEL_LABELS_TO_MNE: for comp in comps: status[comp] = this_status diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py index 4f23de25..8d19de1d 100644 --- a/mne_icalabel/gui/__init__.py +++ b/mne_icalabel/gui/__init__.py @@ -2,12 +2,12 @@ def label_ica_components(inst, ica: ICA, show: bool = True, block: bool = False): - """Locate intracranial electrode contacts. + """Launch the IC labelling GUI. Parameters ---------- inst : : Raw | Epochs - The epochs or raw object. + `~mne.io.Raw` or `~mne.Epochs` instance used to fit the `~mne.preprocessing.ICA` decomposition. ica : ICA The ICA object fitted on `inst`. show : bool From 928274bfb8e6feb1d8d713e710d60b21838e2585 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Sun, 24 Jul 2022 11:08:33 -0400 Subject: [PATCH 36/55] Update mne_icalabel/commands/__init__.py Co-authored-by: Mathieu Scheltienne --- mne_icalabel/commands/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mne_icalabel/commands/__init__.py b/mne_icalabel/commands/__init__.py index 9a7bfc08..3d5d5fb5 100644 --- a/mne_icalabel/commands/__init__.py +++ b/mne_icalabel/commands/__init__.py @@ -1,3 +1,2 @@ """Entry-points for mne-icalabel commands.""" -from .mne_gui_ic_annotation import main From f47c8367921ace1a0e50194aff55a2ff91a0274f Mon Sep 17 00:00:00 2001 From: Adam Li Date: Sun, 24 Jul 2022 11:08:58 -0400 Subject: [PATCH 37/55] Fix style --- examples/label_components.py | 2 +- mne_icalabel/commands/__init__.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/label_components.py b/examples/label_components.py index f0c51156..a158177f 100644 --- a/examples/label_components.py +++ b/examples/label_components.py @@ -59,7 +59,7 @@ # %% # Save the labeled components # --------------------------- -# After the GUI labels, save the components using the ``write_components_tsv``` +# After the GUI labels, save the components using the ``write_components_tsv`` # function. This will save the ICA annotations to disc in BIDS-Derivative for # EEG data format. # diff --git a/mne_icalabel/commands/__init__.py b/mne_icalabel/commands/__init__.py index 3d5d5fb5..be522b8f 100644 --- a/mne_icalabel/commands/__init__.py +++ b/mne_icalabel/commands/__init__.py @@ -1,2 +1 @@ """Entry-points for mne-icalabel commands.""" - From d4d10449b1b13087e08e3d21daef454025f2262c Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 25 Jul 2022 12:19:33 -0400 Subject: [PATCH 38/55] Trya gain --- .circleci/config.yml | 9 ++++- mne_icalabel/conftest.py | 40 ------------------- .../gui/tests/test_label_components.py | 2 +- mne_icalabel/utils/__init__.py | 1 + mne_icalabel/utils/_checks.py | 29 +++++++++++++- requirements_testing.txt | 3 +- 6 files changed, 39 insertions(+), 45 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3384ba89..7fb13b04 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -92,7 +92,10 @@ jobs: pip install --progress-bar off . pip install --upgrade --progress-bar off -r requirements_testing.txt pip install --upgrade --progress-bar off -r requirements_doc.txt - + python -m pip uninstall -yq sphinx-gallery mne-qt-browser + # TODO: Revert to upstream/main once https://github.com/mne-tools/mne-qt-browser/pull/105 is merged + python -m pip install --upgrade --progress-bar off https://github.com/mne-tools/mne-qt-browser/zipball/main https://github.com/sphinx-gallery/sphinx-gallery/zipball/master + - save_cache: key: pip-cache paths: @@ -104,6 +107,10 @@ jobs: - ~/.local/lib/python3.8/site-packages - ~/.local/bin + - run: + name: Check Qt + command: LD_DEBUG=libs python -c "from PySide6.QtWidgets import QApplication, QWidget; app = QApplication([])" + # Look at what we have and fail early if there is some library conflict - run: name: Check installation diff --git a/mne_icalabel/conftest.py b/mne_icalabel/conftest.py index 188a29d2..4fcfa20e 100644 --- a/mne_icalabel/conftest.py +++ b/mne_icalabel/conftest.py @@ -6,7 +6,6 @@ from contextlib import contextmanager import pytest -from mne.utils import _check_qt_version # most of this adapted from MNE-Python @@ -30,45 +29,6 @@ def pytest_configure(config): config.addinivalue_line("filterwarnings", warning_line) -def _check_skip_backend(name): - from mne.viz.backends.tests._utils import ( - has_imageio_ffmpeg, - has_pyvista, - has_pyvistaqt, - ) - - if name in ("pyvistaqt", "notebook"): - if not has_pyvista(): - pytest.skip("Test skipped, requires pyvista.") - if not has_imageio_ffmpeg(): - pytest.skip("Test skipped, requires imageio-ffmpeg") - if name == "pyvistaqt" and not _check_qt_version(): - pytest.skip("Test skipped, requires Qt.") - if name == "pyvistaqt" and not has_pyvistaqt(): - pytest.skip("Test skipped, requires pyvistaqt") - - -@contextmanager -def _use_backend(backend_name, interactive): - from mne.viz.backends.renderer import _use_test_3d_backend - - _check_skip_backend(backend_name) - with _use_test_3d_backend(backend_name, interactive=interactive): - from mne.viz.backends import renderer - - try: - yield renderer - finally: - renderer.backend._close_all() - - -@pytest.fixture(scope="module", params=["pyvistaqt"]) -def renderer_interactive_pyvistaqt(request): - """Yield the interactive PyVista backend.""" - with _use_backend(request.param, interactive=True) as renderer: - yield renderer - - @pytest.fixture(scope="session") def matplotlib_config(): """Configure matplotlib for viz tests.""" diff --git a/mne_icalabel/gui/tests/test_label_components.py b/mne_icalabel/gui/tests/test_label_components.py index 3859c65f..578be998 100644 --- a/mne_icalabel/gui/tests/test_label_components.py +++ b/mne_icalabel/gui/tests/test_label_components.py @@ -9,7 +9,7 @@ @pytest.fixture -def _label_ica_components(renderer_interactive_pyvistaqt): +def _label_ica_components(): # Use a fixture to create these classes so we can ensure that they # are closed at the end of the test guis = list() diff --git a/mne_icalabel/utils/__init__.py b/mne_icalabel/utils/__init__.py index 4802f255..341cd59a 100644 --- a/mne_icalabel/utils/__init__.py +++ b/mne_icalabel/utils/__init__.py @@ -1 +1,2 @@ +from ._checks import _check_qt_version, _validate_inst_and_ica from ._docs import fill_doc diff --git a/mne_icalabel/utils/_checks.py b/mne_icalabel/utils/_checks.py index 42cba946..0bdf35d3 100644 --- a/mne_icalabel/utils/_checks.py +++ b/mne_icalabel/utils/_checks.py @@ -1,9 +1,11 @@ +import sys from typing import Union from mne import BaseEpochs +from mne.fixes import _compare_version from mne.io import BaseRaw from mne.preprocessing import ICA -from mne.utils import _validate_type +from mne.utils import _validate_type, warn def _validate_inst_and_ica(inst: Union[BaseRaw, BaseEpochs], ica: ICA): @@ -16,3 +18,28 @@ def _validate_inst_and_ica(inst: Union[BaseRaw, BaseEpochs], ica: ICA): "The provided ICA instance was not fitted. Please use the '.fit()' method to " "determine the independent components before trying to label them." ) + + +def _check_qt_version(*, return_api=False): + """Check if Qt is installed.""" + try: + from qtpy import API_NAME as api + from qtpy import QtCore + except Exception: + api = version = None + else: + try: # pyside + version = QtCore.__version__ + except AttributeError: + version = QtCore.QT_VERSION_STR + if sys.platform == "darwin" and api in ("PyQt5", "PySide2"): + if not _compare_version(version, ">=", "5.10"): + warn( + f"macOS users should use {api} >= 5.10 for GUIs, " + f"got {version}. Please upgrade e.g. with:\n\n" + f' pip install "{api}>=5.10"\n' + ) + if return_api: + return version, api + else: + return version diff --git a/requirements_testing.txt b/requirements_testing.txt index d92f3aa6..7c8b92ee 100644 --- a/requirements_testing.txt +++ b/requirements_testing.txt @@ -24,6 +24,5 @@ joblib scikit-learn pandas qtpy -pyvista>=0.32 -pyvistaqt>=0.4 +pyqt6 mne-qt-browser \ No newline at end of file From 766aa2231d6ce89bb9cd17783395c51acb1185d9 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 25 Jul 2022 12:20:51 -0400 Subject: [PATCH 39/55] Fix flake --- mne_icalabel/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mne_icalabel/conftest.py b/mne_icalabel/conftest.py index 4fcfa20e..0e752a82 100644 --- a/mne_icalabel/conftest.py +++ b/mne_icalabel/conftest.py @@ -3,7 +3,6 @@ # # License: BSD-3-Clause import warnings -from contextlib import contextmanager import pytest From ebb939bb758be1274457e9e399993e3184881d3d Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 25 Jul 2022 13:51:59 -0400 Subject: [PATCH 40/55] Fix example again --- mne_icalabel/__init__.py | 1 + mne_icalabel/gui/tests/test_label_components.py | 7 ++++++- requirements_testing.txt | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/mne_icalabel/__init__.py b/mne_icalabel/__init__.py index 471e7f1d..9f4eef5c 100644 --- a/mne_icalabel/__init__.py +++ b/mne_icalabel/__init__.py @@ -7,4 +7,5 @@ __version__ = "0.3.dev0" +from . import gui from .label_components import label_components # noqa: F401 diff --git a/mne_icalabel/gui/tests/test_label_components.py b/mne_icalabel/gui/tests/test_label_components.py index 578be998..19db7651 100644 --- a/mne_icalabel/gui/tests/test_label_components.py +++ b/mne_icalabel/gui/tests/test_label_components.py @@ -66,6 +66,11 @@ def test_label_components_gui_display(_fitted_ica, _label_ica_components): # test functions gui = _label_ica_components(raw, ica) - assert gui.reset() # test setting the label + assert gui.inst == raw + assert gui.ica == ica + assert gui.n_components_ == ica.n_components_ + + # the initial component should be 0 + assert gui.selected_component == 0 diff --git a/requirements_testing.txt b/requirements_testing.txt index 7c8b92ee..d6395a3d 100644 --- a/requirements_testing.txt +++ b/requirements_testing.txt @@ -24,5 +24,5 @@ joblib scikit-learn pandas qtpy -pyqt6 +PySide6!=6.3.0 mne-qt-browser \ No newline at end of file From 46a116e97b0ced9dae5abe1e17d3f7ae0dc309ba Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 25 Jul 2022 14:00:29 -0400 Subject: [PATCH 41/55] Get pyqt working --- .circleci/config.yml | 1 + .github/workflows/unit_tests.yml | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7fb13b04..afc0ee6b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -92,6 +92,7 @@ jobs: pip install --progress-bar off . pip install --upgrade --progress-bar off -r requirements_testing.txt pip install --upgrade --progress-bar off -r requirements_doc.txt + pip install --upgrade --progress-bar off PyQt5 python -m pip uninstall -yq sphinx-gallery mne-qt-browser # TODO: Revert to upstream/main once https://github.com/mne-tools/mne-qt-browser/pull/105 is merged python -m pip install --upgrade --progress-bar off https://github.com/mne-tools/mne-qt-browser/zipball/main https://github.com/sphinx-gallery/sphinx-gallery/zipball/master diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 01c5f2a8..66556eed 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -52,7 +52,8 @@ jobs: - name: Install dependencies run: | pip install --upgrade --progress-bar off pip setuptools wheel - + pip install $STD_ARGS --only-binary ":all:" PyQt6 PyQt6-sip PyQt6-Qt6 + # build with sdist directly - uses: actions/checkout@v3 - name: Build sdist From 23bfa964f77e4a38dbcfa6ae7f897c12806c9438 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 25 Jul 2022 14:17:24 -0400 Subject: [PATCH 42/55] Try GH actions again --- .github/workflows/unit_tests.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 66556eed..466b1aba 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -53,7 +53,7 @@ jobs: run: | pip install --upgrade --progress-bar off pip setuptools wheel pip install $STD_ARGS --only-binary ":all:" PyQt6 PyQt6-sip PyQt6-Qt6 - + # build with sdist directly - uses: actions/checkout@v3 - name: Build sdist @@ -119,6 +119,10 @@ jobs: - uses: actions/checkout@v3 + - name: Install QT dependencies + run: | + sudo apt install -qq graphviz optipng libxft2 ffmpeg libsm6 libxext6 + - name: Install mne-icalabel run: | pip install --upgrade --progress-bar off pip setuptools wheel From d8b22f966717b13fb6e142c7cea873f2d2a0afcc Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 25 Jul 2022 16:06:07 -0400 Subject: [PATCH 43/55] Try again --- .github/workflows/unit_tests.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 466b1aba..b5bf37f0 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -119,9 +119,8 @@ jobs: - uses: actions/checkout@v3 - - name: Install QT dependencies - run: | - sudo apt install -qq graphviz optipng libxft2 ffmpeg libsm6 libxext6 + - run: ./tools/setup_circleci.sh + name: 'Setup xvfb' - name: Install mne-icalabel run: | From 9405cd9c3f93d4aeea2579af14412895a2896c3c Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 25 Jul 2022 16:07:57 -0400 Subject: [PATCH 44/55] Fix this --- .github/workflows/unit_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index b5bf37f0..6f05e78b 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -119,7 +119,7 @@ jobs: - uses: actions/checkout@v3 - - run: ./tools/setup_circleci.sh + - run: ./scripts/setup_circleci.sh name: 'Setup xvfb' - name: Install mne-icalabel From ffc3be2f5eb7f42b8230e31bf542204f08351e0d Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 25 Jul 2022 16:20:46 -0400 Subject: [PATCH 45/55] Fix unit test --- mne_icalabel/gui/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py index 8d19de1d..7a696f05 100644 --- a/mne_icalabel/gui/__init__.py +++ b/mne_icalabel/gui/__init__.py @@ -25,7 +25,10 @@ def label_ica_components(inst, ica: ICA, show: bool = True, block: bool = False) from ._label_components import ICAComponentLabeler - app = QApplication([]) + # get application + app = QApplication.instance() + if app is None: + app = QApplication(["ICA Component Annotator"]) gui = ICAComponentLabeler(inst=inst, ica=ica, show=show) if block: _qt_app_exec(app) From 8459966f925ebefa563c5b76481559f99b0b289c Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 25 Jul 2022 18:06:01 -0400 Subject: [PATCH 46/55] Add version restriction --- .github/workflows/unit_tests.yml | 5 +++-- examples/label_components.py | 2 +- mne_icalabel/gui/__init__.py | 4 ++-- mne_icalabel/gui/tests/test_label_components.py | 3 +++ 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 6f05e78b..3b65f750 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -119,8 +119,9 @@ jobs: - uses: actions/checkout@v3 - - run: ./scripts/setup_circleci.sh - name: 'Setup xvfb' + - name: 'Setup xvfb' + if: "matrix.os == 'ubuntu-latest'" + run: ./scripts/setup_circleci.sh - name: Install mne-icalabel run: | diff --git a/examples/label_components.py b/examples/label_components.py index a158177f..05663c6b 100644 --- a/examples/label_components.py +++ b/examples/label_components.py @@ -46,7 +46,7 @@ # now label the components using a GUI mne.set_log_level("DEBUG") -gui = label_ica_components(raw, ica) +gui = label_ica_components(raw, ica, block=True) # The `ica` object is modified to contain the component labels # after closing the GUI and can now be saved diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py index 7a696f05..7821a7ad 100644 --- a/mne_icalabel/gui/__init__.py +++ b/mne_icalabel/gui/__init__.py @@ -20,13 +20,13 @@ def label_ica_components(inst, ica: ICA, show: bool = True, block: bool = False) gui : instance of ICAComponentLabeler The graphical user interface (GUI) window. """ - from mne.viz.backends._utils import _qt_app_exec + from mne.viz.backends._utils import _init_mne_qtapp, _qt_app_exec from qtpy.QtWidgets import QApplication from ._label_components import ICAComponentLabeler # get application - app = QApplication.instance() + app = _init_mne_qtapp() if app is None: app = QApplication(["ICA Component Annotator"]) gui = ICAComponentLabeler(inst=inst, ica=ica, show=show) diff --git a/mne_icalabel/gui/tests/test_label_components.py b/mne_icalabel/gui/tests/test_label_components.py index 19db7651..3e4a39fd 100644 --- a/mne_icalabel/gui/tests/test_label_components.py +++ b/mne_icalabel/gui/tests/test_label_components.py @@ -4,6 +4,7 @@ from mne.datasets import testing from mne.io import read_raw_edf from mne.preprocessing import ICA +from mne.utils import requires_version import mne_icalabel @@ -48,6 +49,7 @@ def _fitted_ica(load_raw_and_fit_ica): return raw, ica.copy() +@requires_version("mne", "1.1dev0") @testing.requires_testing_data def test_label_components_gui_io(_fitted_ica, _label_ica_components): """Test the input/output of the labeling ICA components GUI.""" @@ -60,6 +62,7 @@ def test_label_components_gui_io(_fitted_ica, _label_ica_components): _label_ica_components(raw, ica_copy) +@requires_version("mne", "1.1dev0") @testing.requires_testing_data def test_label_components_gui_display(_fitted_ica, _label_ica_components): raw, ica = _fitted_ica From 0917b64c8a0a9f0abbd9a308f5200ca3ac7ff434 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Mon, 25 Jul 2022 21:23:36 -0400 Subject: [PATCH 47/55] Try again --- .circleci/config.yml | 2 +- .github/workflows/unit_tests.yml | 2 +- examples/label_components.py | 2 +- scripts/{setup_circleci.sh => setup_xvfb.sh} | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename scripts/{setup_circleci.sh => setup_xvfb.sh} (89%) diff --git a/.circleci/config.yml b/.circleci/config.yml index afc0ee6b..5bdad1d9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -43,7 +43,7 @@ jobs: name: Set BASH_ENV command: | set -e - ./scripts/setup_circleci.sh + ./scripts/setup_xvfb.sh sudo apt install -qq graphviz optipng python3.8-venv python3-venv libxft2 ffmpeg python3.8 -m venv ~/python_env echo "set -e" >> $BASH_ENV diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 3b65f750..49b54dbd 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -121,7 +121,7 @@ jobs: - name: 'Setup xvfb' if: "matrix.os == 'ubuntu-latest'" - run: ./scripts/setup_circleci.sh + run: ./scripts/setup_xvfb.sh - name: Install mne-icalabel run: | diff --git a/examples/label_components.py b/examples/label_components.py index 05663c6b..a158177f 100644 --- a/examples/label_components.py +++ b/examples/label_components.py @@ -46,7 +46,7 @@ # now label the components using a GUI mne.set_log_level("DEBUG") -gui = label_ica_components(raw, ica, block=True) +gui = label_ica_components(raw, ica) # The `ica` object is modified to contain the component labels # after closing the GUI and can now be saved diff --git a/scripts/setup_circleci.sh b/scripts/setup_xvfb.sh similarity index 89% rename from scripts/setup_circleci.sh rename to scripts/setup_xvfb.sh index 67d3dc83..040541ea 100755 --- a/scripts/setup_circleci.sh +++ b/scripts/setup_xvfb.sh @@ -11,5 +11,5 @@ done # This also includes the libraries necessary for PyQt5/PyQt6 sudo apt update -sudo apt install -yqq xvfb libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libopengl0 libegl1 +sudo apt install -yqq xvfb libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libopengl0 libegl1 libosmesa6 mesa-utils libxcb-shape0 /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1400x900x24 -ac +extension GLX +render -noreset From f6f879407379bc361e75567bb78b6c857936672a Mon Sep 17 00:00:00 2001 From: mscheltienne Date: Thu, 28 Jul 2022 13:29:51 +0200 Subject: [PATCH 48/55] delete old browsers --- mne_icalabel/gui/_label_components.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mne_icalabel/gui/_label_components.py b/mne_icalabel/gui/_label_components.py index 30009b7e..630226b4 100644 --- a/mne_icalabel/gui/_label_components.py +++ b/mne_icalabel/gui/_label_components.py @@ -148,10 +148,10 @@ def _load_ui(self) -> None: self._mpl_widgets["psd"] = FigureCanvasQTAgg(fig) grid_layout.addWidget(self._mpl_widgets["psd"], 0, 3) - # time-series, initialized with the first IC since it's easier than - # creating an empty browser and providing all the arguments for - # _get_browser(). - self._timeSeries_widget = self.ica.plot_sources(self.inst, picks=[0]) + # time-series, initialized with an empty widget. + # TODO: When the browser supports changing the instance displayed, this + # should be initialized to a browser with the first IC. + self._timeSeries_widget = QWidget() grid_layout.addWidget(self._timeSeries_widget, 1, 2, 1, 2) # - Checkers -------------------------------------------------------------- @@ -234,6 +234,7 @@ def _components_listWidget_clicked(self) -> None: # swap timeSeries widget timeSeries_widget = self.ica.plot_sources(self.inst, picks=[self.selected_component]) self._central_widget.layout().replaceWidget(self._timeSeries_widget, timeSeries_widget) + self._timeSeries_widget.setParent(None) self._timeSeries_widget = timeSeries_widget # select buttons that were previously selected for this IC From 7a470f06c5170ce07843653020eef122feaea016 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Thu, 28 Jul 2022 09:23:56 -0400 Subject: [PATCH 49/55] Merge --- mne_icalabel/gui/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py index 7821a7ad..c95740cc 100644 --- a/mne_icalabel/gui/__init__.py +++ b/mne_icalabel/gui/__init__.py @@ -21,14 +21,10 @@ def label_ica_components(inst, ica: ICA, show: bool = True, block: bool = False) The graphical user interface (GUI) window. """ from mne.viz.backends._utils import _init_mne_qtapp, _qt_app_exec - from qtpy.QtWidgets import QApplication - from ._label_components import ICAComponentLabeler # get application app = _init_mne_qtapp() - if app is None: - app = QApplication(["ICA Component Annotator"]) gui = ICAComponentLabeler(inst=inst, ica=ica, show=show) if block: _qt_app_exec(app) From 59281952b8ee015887a1974c7b7f4eca308005e7 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Thu, 28 Jul 2022 09:56:30 -0400 Subject: [PATCH 50/55] Not block --- mne_icalabel/annotation/bids.py | 2 +- mne_icalabel/annotation/tests/test_bids.py | 7 +++- mne_icalabel/config.py | 4 +- mne_icalabel/gui/__init__.py | 1 + .../gui/tests/test_label_components.py | 5 +++ mne_icalabel/iclabel/label_components.py | 39 ++++++++++++++----- mne_icalabel/iclabel/network.py | 2 +- 7 files changed, 46 insertions(+), 14 deletions(-) diff --git a/mne_icalabel/annotation/bids.py b/mne_icalabel/annotation/bids.py index 262fa5fc..d820442f 100644 --- a/mne_icalabel/annotation/bids.py +++ b/mne_icalabel/annotation/bids.py @@ -51,7 +51,7 @@ def write_components_tsv(ica: ICA, fname): if ica.labels_: for label, comps in ica.labels_.items(): this_status = "good" if label == "brain" else "bad" - if label in ICLABEL_LABELS_TO_MNE: + if label in ICLABEL_LABELS_TO_MNE.values(): for comp in comps: status[comp] = this_status ic_type[comp] = label diff --git a/mne_icalabel/annotation/tests/test_bids.py b/mne_icalabel/annotation/tests/test_bids.py index 6807d0ee..a480e763 100644 --- a/mne_icalabel/annotation/tests/test_bids.py +++ b/mne_icalabel/annotation/tests/test_bids.py @@ -52,6 +52,9 @@ def test_write_channels_tsv(_ica, _tmp_bids_path): suffix="channels", extension=".tsv", ) + _ica = _ica.copy() + _ica.labels_["ecg"] = [0] + write_components_tsv(_ica, deriv_fname) assert deriv_fname.fpath.exists() @@ -59,7 +62,9 @@ def test_write_channels_tsv(_ica, _tmp_bids_path): assert expected_json.fpath.exists() ch_tsv = pd.read_csv(deriv_fname, sep="\t") - assert all(status == "good" for status in ch_tsv["status"]) + assert all(status == "good" for status in ch_tsv["status"][1:]) + assert ch_tsv["status"][0] == "bad" + assert ch_tsv["ic_type"].values[0] == "ecg" def test_mark_components(_ica, _tmp_bids_path): diff --git a/mne_icalabel/config.py b/mne_icalabel/config.py index 71442a6f..3b662248 100644 --- a/mne_icalabel/config.py +++ b/mne_icalabel/config.py @@ -8,10 +8,10 @@ # map ICLabel labels to MNE str format ICLABEL_LABELS_TO_MNE = { "Brain": "brain", + "Muscle": "muscle", "Eye": "eog", "Heart": "ecg", - "Muscle": "muscle", - "Channel Noise": "ch_noise", "Line Noise": "line_noise", + "Channel Noise": "ch_noise", "Other": "other", } diff --git a/mne_icalabel/gui/__init__.py b/mne_icalabel/gui/__init__.py index c95740cc..7bc75eb0 100644 --- a/mne_icalabel/gui/__init__.py +++ b/mne_icalabel/gui/__init__.py @@ -21,6 +21,7 @@ def label_ica_components(inst, ica: ICA, show: bool = True, block: bool = False) The graphical user interface (GUI) window. """ from mne.viz.backends._utils import _init_mne_qtapp, _qt_app_exec + from ._label_components import ICAComponentLabeler # get application diff --git a/mne_icalabel/gui/tests/test_label_components.py b/mne_icalabel/gui/tests/test_label_components.py index 3e4a39fd..33b3ee49 100644 --- a/mne_icalabel/gui/tests/test_label_components.py +++ b/mne_icalabel/gui/tests/test_label_components.py @@ -1,5 +1,6 @@ import os.path as op +import matplotlib.pyplot as plt import pytest from mne.datasets import testing from mne.io import read_raw_edf @@ -77,3 +78,7 @@ def test_label_components_gui_display(_fitted_ica, _label_ica_components): # the initial component should be 0 assert gui.selected_component == 0 + + # there should be three figures inside the QT window + figs = list(map(plt.figure, plt.get_fignums())) + assert len(figs) == 3 diff --git a/mne_icalabel/iclabel/label_components.py b/mne_icalabel/iclabel/label_components.py index de9a094e..b2a7cd46 100644 --- a/mne_icalabel/iclabel/label_components.py +++ b/mne_icalabel/iclabel/label_components.py @@ -1,5 +1,6 @@ from typing import Union +import numpy as np from mne import BaseEpochs from mne.io import BaseRaw from mne.preprocessing import ICA @@ -8,7 +9,7 @@ from .network import run_iclabel -def iclabel_label_components(inst: Union[BaseRaw, BaseEpochs], ica: ICA): +def iclabel_label_components(inst: Union[BaseRaw, BaseEpochs], ica: ICA, inplace: bool = True): """Label the provided ICA components with the ICLabel neural network. This network uses 3 features: @@ -24,19 +25,22 @@ def iclabel_label_components(inst: Union[BaseRaw, BaseEpochs], ica: ICA): Parameters ---------- inst : Raw | Epochs - Instance used to fit the ICA decomposition. The instance should be - referenced to a common average and bandpass filtered between 1 and - 100 Hz. + Instance used to fit the ICA decomposition. The instance should be + referenced to a common average and bandpass filtered between 1 and + 100 Hz. ica : ICA - ICA decomposition of the provided instance. + ICA decomposition of the provided instance. + inplace : bool + Whether to modify the ``ica`` instance in place by adding the automatic + annotations to the ``labels_`` property. By default True. Returns ------- labels_pred_proba : numpy.ndarray of shape (n_components, n_classes) - The estimated corresponding predicted probabilities of output classes - for each independent component. Columns are ordered with 'brain', - 'muscle artifact', 'eye blink', 'heart beat', 'line noise', - 'channel noise', 'other'. + The estimated corresponding predicted probabilities of output classes + for each independent component. Columns are ordered with 'brain', + 'muscle artifact', 'eye blink', 'heart beat', 'line noise', + 'channel noise', 'other'. References ---------- @@ -44,4 +48,21 @@ def iclabel_label_components(inst: Union[BaseRaw, BaseEpochs], ica: ICA): """ features = get_iclabel_features(inst, ica) labels_pred_proba = run_iclabel(*features) + + if inplace: + from mne_icalabel.config import ICLABEL_LABELS_TO_MNE + + ica.labels_scores_ = labels_pred_proba + argmax_labels = np.argmax(labels_pred_proba, axis=1) + + # add labels to the ICA instance + for idx, (_, mne_label) in enumerate(ICLABEL_LABELS_TO_MNE.items()): + auto_labels = argmax_labels.index[idx] + if mne_label not in ica.labels_: + ica.labels_[mne_label] = auto_labels + continue + for comp in auto_labels: + if comp not in ica.labels_[mne_label]: + ica.labels_[mne_label].append(comp) + return labels_pred_proba diff --git a/mne_icalabel/iclabel/network.py b/mne_icalabel/iclabel/network.py index 475e5eda..2a20d1fa 100644 --- a/mne_icalabel/iclabel/network.py +++ b/mne_icalabel/iclabel/network.py @@ -209,7 +209,7 @@ def _format_input_for_torch(topo: ArrayLike, psd: ArrayLike, autocorr: ArrayLike return topo, psd, autocorr -def run_iclabel(images: ArrayLike, psds: ArrayLike, autocorr: ArrayLike): +def run_iclabel(images: ArrayLike, psds: ArrayLike, autocorr: ArrayLike) -> ArrayLike: """Run the ICLabel network on the provided set of features. The features are un-formatted and are as-returned by `~mne_icalabel.iclabel.get_iclabel_features`. From a421081dc62fffe908fa2c14559c97b590577fe8 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Thu, 28 Jul 2022 10:07:29 -0400 Subject: [PATCH 51/55] Try again --- mne_icalabel/iclabel/label_components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne_icalabel/iclabel/label_components.py b/mne_icalabel/iclabel/label_components.py index b2a7cd46..e948cc14 100644 --- a/mne_icalabel/iclabel/label_components.py +++ b/mne_icalabel/iclabel/label_components.py @@ -57,7 +57,7 @@ def iclabel_label_components(inst: Union[BaseRaw, BaseEpochs], ica: ICA, inplace # add labels to the ICA instance for idx, (_, mne_label) in enumerate(ICLABEL_LABELS_TO_MNE.items()): - auto_labels = argmax_labels.index[idx] + auto_labels = np.argwhere(argmax_labels == idx) if mne_label not in ica.labels_: ica.labels_[mne_label] = auto_labels continue From 3b9e0201a8c3d463b479b3967f56d706ff14931d Mon Sep 17 00:00:00 2001 From: Adam Li Date: Thu, 28 Jul 2022 10:38:16 -0400 Subject: [PATCH 52/55] Try again --- mne_icalabel/iclabel/network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne_icalabel/iclabel/network.py b/mne_icalabel/iclabel/network.py index 2a20d1fa..475e5eda 100644 --- a/mne_icalabel/iclabel/network.py +++ b/mne_icalabel/iclabel/network.py @@ -209,7 +209,7 @@ def _format_input_for_torch(topo: ArrayLike, psd: ArrayLike, autocorr: ArrayLike return topo, psd, autocorr -def run_iclabel(images: ArrayLike, psds: ArrayLike, autocorr: ArrayLike) -> ArrayLike: +def run_iclabel(images: ArrayLike, psds: ArrayLike, autocorr: ArrayLike): """Run the ICLabel network on the provided set of features. The features are un-formatted and are as-returned by `~mne_icalabel.iclabel.get_iclabel_features`. From e9a7c5b27ea32594581a4875acc438e32d3dfb8f Mon Sep 17 00:00:00 2001 From: Adam Li Date: Thu, 28 Jul 2022 10:51:24 -0400 Subject: [PATCH 53/55] Try again --- .github/workflows/unit_tests.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 49b54dbd..bb74e1f3 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -154,10 +154,15 @@ jobs: shell: bash -el {0} run: mne sys_info + - shell: bash -el {0} + run: | + QT_QPA_PLATFORM=xcb LIBGL_DEBUG=verbose LD_DEBUG=libs + name: 'Check Qt GL' + - name: Run pytest shell: bash run: | - python -m pytest ./mne_icalabel --cov=mne_icalabel --cov-report=xml --cov-config=setup.cfg --verbose --ignore mne-python + python -m pytest ./mne_icalabel --cov=mne_icalabel --cov-report=xml --cov-config=setup.cfg --verbose --ignore mne-python -vv mne_icalabel/gui - name: Upload coverage stats to codecov if: "matrix.os == 'ubuntu-latest'" From 5c86ae06ac263214b59747ecc50f9bdb6f5c003a Mon Sep 17 00:00:00 2001 From: Adam Li Date: Thu, 28 Jul 2022 23:35:08 -0400 Subject: [PATCH 54/55] Try again --- requirements_testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_testing.txt b/requirements_testing.txt index d6395a3d..98b516cd 100644 --- a/requirements_testing.txt +++ b/requirements_testing.txt @@ -24,5 +24,5 @@ joblib scikit-learn pandas qtpy -PySide6!=6.3.0 +PyQt5 mne-qt-browser \ No newline at end of file From b3e2f1fe7b5545328baa4c9159bddf1c98229c93 Mon Sep 17 00:00:00 2001 From: Adam Li Date: Fri, 29 Jul 2022 11:10:08 -0400 Subject: [PATCH 55/55] Try again --- .circleci/config.yml | 6 +++--- mne_icalabel/gui/tests/test_label_components.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5bdad1d9..4619a212 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -108,9 +108,9 @@ jobs: - ~/.local/lib/python3.8/site-packages - ~/.local/bin - - run: - name: Check Qt - command: LD_DEBUG=libs python -c "from PySide6.QtWidgets import QApplication, QWidget; app = QApplication([])" + # - run: + # name: Check Qt + # command: LD_DEBUG=libs python -c "from PySide6.QtWidgets import QApplication, QWidget; app = QApplication([])" # Look at what we have and fail early if there is some library conflict - run: diff --git a/mne_icalabel/gui/tests/test_label_components.py b/mne_icalabel/gui/tests/test_label_components.py index 33b3ee49..7bae8730 100644 --- a/mne_icalabel/gui/tests/test_label_components.py +++ b/mne_icalabel/gui/tests/test_label_components.py @@ -39,7 +39,7 @@ def load_raw_and_fit_ica(): raw.filter(l_freq=1, h_freq=100) # compute ICA - ica = ICA(n_components=15) + ica = ICA(n_components=15, random_state=12345) ica.fit(raw) return raw, ica