diff --git a/.circleci/config.yml b/.circleci/config.yml index 9f5a8a1..3183153 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -42,8 +42,7 @@ jobs: - run: name: Get Python running command: | - # TO DO: sphinx-gallery main -> stable on 0.17 release - pip install --upgrade PyQt6!=6.6.0 "PyQt6-Qt6!=6.6.0,!=6.7.0" git+https://github.com/sphinx-gallery/sphinx-gallery.git pydata-sphinx-theme numpydoc scikit-learn nilearn mne-bids autoreject pyvista memory_profiler sphinxcontrib.bibtex sphinxcontrib.youtube darkdetect qdarkstyle + pip install --upgrade PyQt6!=6.6.0 "PyQt6-Qt6!=6.6.0,!=6.7.0" sphinx-gallery pydata-sphinx-theme numpydoc scikit-learn nilearn mne-bids autoreject pyvista memory_profiler sphinxcontrib.bibtex sphinxcontrib.youtube darkdetect qdarkstyle pip install -ve ./mne-python . - run: name: Check Qt diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index f9478ee..bb9ed4d 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -10,8 +10,8 @@ import numpy as np from functools import partial -from qtpy import QtCore -from qtpy.QtCore import Slot, Qt +from qtpy import QtCore, QtGui +from qtpy.QtCore import Slot, Signal, Qt from qtpy.QtWidgets import ( QMainWindow, QGridLayout, @@ -21,6 +21,8 @@ QMessageBox, QWidget, QLineEdit, + QComboBox, + QPushButton, ) from matplotlib import patheffects @@ -29,6 +31,7 @@ from matplotlib.patches import Rectangle from matplotlib.colors import LinearSegmentedColormap +from mne import read_freesurfer_lut from mne.viz.backends.renderer import _get_renderer from mne.viz.utils import safe_event from mne.surface import _read_mri_surface, _marching_cubes @@ -151,6 +154,17 @@ def make_label(name): return label +class ComboBox(QComboBox): + """Dropdown menu that emits a click when popped up.""" + + clicked = Signal() + + def showPopup(self): + """Override show popup method to emit click.""" + self.clicked.emit() + super(ComboBox, self).showPopup() + + class SliceBrowser(QMainWindow): """Navigate between slices of an MRI, CT, etc. image.""" @@ -173,6 +187,10 @@ def __init__( super(SliceBrowser, self).__init__() self.setAttribute(Qt.WA_DeleteOnClose, True) + atlas_ids, colors = read_freesurfer_lut() + self._fs_lut = {atlas_id: colors[name] for name, atlas_id in atlas_ids.items()} + self._atlas_ids = {val: key for key, val in atlas_ids.items()} + self._verbose = verbose # if bad/None subject, will raise an informative error when loading MRI subject = os.environ.get("SUBJECT") if subject is None else subject @@ -203,6 +221,7 @@ def __init__( self._configure_ui() def _configure_ui(self): + toolbar = self._configure_toolbar() bottom_hbox = self._configure_status_bar() # Put everything together @@ -210,6 +229,7 @@ def _configure_ui(self): plot_ch_hbox.addLayout(self._plt_grid) main_vbox = QVBoxLayout() + main_vbox.addLayout(toolbar) main_vbox.addLayout(plot_ch_hbox) main_vbox.addLayout(bottom_hbox) @@ -219,17 +239,18 @@ def _configure_ui(self): def _load_image_data(self, base_image=None): """Get image data to display and transforms to/from vox/RAS.""" + self._using_atlas = False if self._subject_dir is None: # if the recon-all is not finished or the CT is not # downsampled to the MRI, the MRI can not be used - self._mr_data = None - self._head = None - self._lh = self._rh = None + self._mr_data = self._head = self._lh = self._rh = None + self._mr_scan_ras_ras_vox_t = None else: - mri_img = ( - "brain" - if op.isfile(op.join(self._subject_dir, "mri", "brain.mgz")) - else "T1" + mr_base_fname = op.join(self._subject_dir, "mri", "{}.mgz") + mr_fname = ( + mr_base_fname.format("brain") + if op.isfile(mr_base_fname.format("brain")) + else mr_base_fname.format("T1") ) ( self._mr_data, @@ -237,7 +258,8 @@ def _load_image_data(self, base_image=None): mr_vox_scan_ras_t, mr_ras_vox_scan_ras_t, self._mr_vol_info, - ) = _load_image(op.join(self._subject_dir, "mri", f"{mri_img}.mgz")) + ) = _load_image(mr_fname) + self._mr_scan_ras_ras_vox_t = np.linalg.inv(mr_ras_vox_scan_ras_t) # ready alternate base image if provided, otherwise use brain/T1 self._base_mr_aligned = True @@ -417,7 +439,7 @@ def _plot_images(self): [1], )[0] rr = apply_trans(self._ras_vox_scan_ras_t, rr) # base image vox -> RAS - self._renderer.mesh( + self._mc_actor, _ = self._renderer.mesh( *rr.T, triangles=tris, color="gray", @@ -425,8 +447,9 @@ def _plot_images(self): reset_camera=False, render=False, ) + self._head_actor = None else: - self._renderer.mesh( + self._head_actor, _ = self._renderer.mesh( *self._head["rr"].T, triangles=self._head["tris"], color="gray", @@ -434,6 +457,7 @@ def _plot_images(self): reset_camera=False, render=False, ) + self._mc_actor = None if self._lh is not None and self._rh is not None and self._base_mr_aligned: self._lh_actor, _ = self._renderer.mesh( *self._lh["rr"].T, @@ -460,6 +484,51 @@ def _plot_images(self): self._draw() self._renderer._update() + def _configure_toolbar(self, hbox=None): + """Make a bar at the top with tools on it.""" + hbox = QHBoxLayout() if hbox is None else hbox + + help_button = QPushButton("Help") + help_button.released.connect(self._show_help) + hbox.addWidget(help_button) + + hbox.addStretch(6) + + self._toggle_show_selector = ComboBox() + + # add title, not selectable + self._toggle_show_selector.addItem("Show/Hide") + model = self._toggle_show_selector.model() + model.itemFromIndex(model.index(0, 0)).setSelectable(False) + # color differently + color = QtGui.QColor("gray") + brush = QtGui.QBrush(color) + brush.setStyle(QtCore.Qt.SolidPattern) + model.setData(model.index(0, 0), brush, QtCore.Qt.BackgroundRole) + + if self._base_mr_aligned and hasattr(self, "_toggle_show_brain"): + self._toggle_show_selector.addItem("Show brain slices") + self._toggle_show_selector.addItem("Show atlas slices") + + if hasattr(self, "_toggle_show_mip"): + self._toggle_show_selector.addItem("Show max intensity proj") + + if hasattr(self, "_toggle_show_max"): + self._toggle_show_selector.addItem("Show local maxima") + + if self._head_actor is not None: + self._toggle_show_selector.addItem("Hide 3D head") + + if self._lh_actor is not None and self._rh_actor is not None: + self._toggle_show_selector.addItem("Hide 3D brain") + + if self._mc_actor is not None: + self._toggle_show_selector.addItem("Hide 3D rendering") + + self._toggle_show_selector.currentIndexChanged.connect(self._toggle_show) + hbox.addWidget(self._toggle_show_selector) + return hbox + def _configure_status_bar(self, hbox=None): """Make a bar at the bottom with information in it.""" hbox = QHBoxLayout() if hbox is None else hbox @@ -539,6 +608,51 @@ def _update_VOX(self, event): if ras is not None: self._set_ras(ras) + def _toggle_show(self): + """Show or hide objects in the 3D rendering.""" + text = self._toggle_show_selector.currentText() + if text == "Show/Hide": + return + idx = self._toggle_show_selector.currentIndex() + show_hide, item = text.split(" ")[0], " ".join(text.split(" ")[1:]) + show_hide_opp = "Show" if show_hide == "Hide" else "Hide" + if "slices" in item: + # atlas shown and brain already on or brain already on and atlas shown + if show_hide == "Show" and "mri" in self._images: + idx2, item2 = (2, "atlas") if self._using_atlas else (1, "brain") + self._toggle_show_selector.setItemText(idx2, f"Show {item2} slices") + self._toggle_show_brain() + mr_base_fname = op.join(self._subject_dir, "mri", "{}.mgz") + if show_hide == "Show" and "atlas" in item and not self._using_atlas: + if op.isfile(mr_base_fname.format("wmparc")): + self._mr_data = _load_image(mr_base_fname.format("wmparc"))[0] + else: + self._mr_data = _load_image(mr_base_fname.format("aparc+aseg"))[0] + self._using_atlas = True + if show_hide == "Show" and "brain" in item and self._using_atlas: + if op.isfile(mr_base_fname.format("brain")): + self._mr_data = _load_image(mr_base_fname.format("brain"))[0] + else: + self._mr_data = _load_image(mr_base_fname.format("T1"))[0] + self._using_atlas = False + self._toggle_show_brain() + self._update_moved() + elif item == "max intensity proj": + self._toggle_show_mip() + elif item == "local maxima": + self._toggle_show_max() + else: + actors = { + "3D head": [self._head_actor], + "3D brain": [self._lh_actor, self._rh_actor], + "3D rendering": [self._mc_actor], + }[item] + for actor in actors: + actor.SetVisibility(show_hide == "Show") + self._renderer._update() + self._toggle_show_selector.setItemText(idx, f"{show_hide_opp} {item}") + self._toggle_show_selector.setCurrentIndex(0) # back to title + def _convert_text(self, text, text_kind): text = text.replace("\n", "") vals = text.split(",") @@ -720,9 +834,16 @@ def _update_moved(self): self._VOX_textbox.setText( "{:3d}, {:3d}, {:3d}".format(*self._vox.round().astype(int)) ) - self._intensity_label.setText( - "intensity = {:.2f}".format(self._base_data[tuple(self._current_slice)]) + intensity_text = "intensity = {:.2f}".format( + self._base_data[tuple(self._current_slice)] ) + if self._using_atlas: + vox = ( + apply_trans(self._mr_scan_ras_ras_vox_t, self._ras).round().astype(int) + ) + label = self._atlas_ids[int(self._mr_data[tuple(vox)])] + intensity_text += f" ({label})" + self._intensity_label.setText(intensity_text) @safe_event def closeEvent(self, event): diff --git a/mne_gui_addons/_ieeg_locate.py b/mne_gui_addons/_ieeg_locate.py index c01998f..f7f5664 100644 --- a/mne_gui_addons/_ieeg_locate.py +++ b/mne_gui_addons/_ieeg_locate.py @@ -11,7 +11,7 @@ from scipy.ndimage import maximum_filter from qtpy import QtCore, QtGui -from qtpy.QtCore import Slot, Signal +from qtpy.QtCore import Slot from qtpy.QtWidgets import ( QVBoxLayout, QHBoxLayout, @@ -22,10 +22,9 @@ QListView, QSlider, QPushButton, - QComboBox, ) -from ._core import SliceBrowser, _CMAP, _N_COLORS +from ._core import SliceBrowser, ComboBox, _CMAP, _N_COLORS from mne.channels import make_dig_montage from mne.surface import _voxel_neighbors from mne.transforms import apply_trans, _get_trans, invert_transform @@ -43,20 +42,12 @@ _MISSING_PROP_OKAY = 0.25 -class ComboBox(QComboBox): - """Dropdown menu that emits a click when popped up.""" - - clicked = Signal() - - def showPopup(self): - """Override show popup method to emit click.""" - self.clicked.emit() - super(ComboBox, self).showPopup() - - class IntracranialElectrodeLocator(SliceBrowser): """Locate electrode contacts using a coregistered MRI and CT.""" + _showing_max_intensity_proj = False + _showing_local_maxima = False + def __init__( self, info, @@ -310,11 +301,10 @@ def _configure_toolbar(self): """Make a bar with buttons for user interactions.""" hbox = QHBoxLayout() - help_button = QPushButton("Help") - help_button.released.connect(self._show_help) - hbox.addWidget(help_button) + # add help and show/hide + super(IntracranialElectrodeLocator, self)._configure_toolbar(hbox=hbox) - hbox.addStretch(8) + hbox.addStretch(1) hbox.addWidget(QLabel("Snap to Center")) self._snap_button = QPushButton("Off") @@ -325,10 +315,9 @@ def _configure_toolbar(self): hbox.addStretch(1) - if self._base_mr_aligned: - self._toggle_brain_button = QPushButton("Show Brain") - self._toggle_brain_button.released.connect(self._toggle_show_brain) - hbox.addWidget(self._toggle_brain_button) + self._auto_complete_button = QPushButton("Auto Complete") + self._auto_complete_button.released.connect(self._auto_mark_group) + hbox.addWidget(self._auto_complete_button) hbox.addStretch(1) @@ -417,19 +406,7 @@ def make_slider(smin, smax, sval, sfun=None): def _configure_status_bar(self, hbox=None): hbox = QHBoxLayout() if hbox is None else hbox - self._auto_complete_button = QPushButton("Auto Complete") - self._auto_complete_button.released.connect(self._auto_mark_group) - hbox.addWidget(self._auto_complete_button) - - hbox.addStretch(3) - - self._toggle_show_mip_button = QPushButton("Show Max Intensity Proj") - self._toggle_show_mip_button.released.connect(self._toggle_show_mip) - hbox.addWidget(self._toggle_show_mip_button) - - self._toggle_show_max_button = QPushButton("Show Maxima") - self._toggle_show_max_button.released.connect(self._toggle_show_max) - hbox.addWidget(self._toggle_show_max_button) + hbox.addStretch(1) self._intensity_label = QLabel("") # update later hbox.addWidget(self._intensity_label) @@ -797,7 +774,7 @@ def _update_lines(self, group, only_2D=False): line.remove() self._lines_2D.pop(group) if only_2D: # if not in projection, don't add 2D lines - if self._toggle_show_mip_button.text() == "Show Max Intensity Proj": + if not self._showing_max_intensity_proj: return elif group in self._lines: # if updating 3D, remove first self._renderer.plotter.remove_actor(self._lines[group], render=False) @@ -827,7 +804,7 @@ def _update_lines(self, group, only_2D=False): radius=self._radius * _TUBE_SCALAR, color=_CMAP(group)[:3], )[0] - if self._toggle_show_mip_button.text() == "Hide Max Intensity Proj": + if self._showing_max_intensity_proj: # add 2D lines on each slice plot if in max intensity projection target_vox = apply_trans(self._scan_ras_ras_vox_t, pos[target_idx]) insert_vox = apply_trans( @@ -986,7 +963,7 @@ def _update_ch_images(self, axis=None, draw=False): """Update the channel image(s).""" for axis in range(3) if axis is None else [axis]: self._images["chs"][axis].set_data(self._make_ch_image(axis)) - if self._toggle_show_mip_button.text() == "Hide Max Intensity Proj": + if self._showing_max_intensity_proj: self._images["mip_chs"][axis].set_data( self._make_ch_image(axis, proj=True) ) @@ -1014,20 +991,27 @@ def _update_ct_images(self, axis=None, draw=False): if draw: self._draw(axis) + def _get_mr_slice(self, axis): + """Get the current MR slice.""" + mri_data = self._mr_data[(slice(None),) * axis + (self._current_slice[axis],)].T + if self._using_atlas: + mri_slice = mri_data.copy().astype(int) + mri_data = np.zeros(mri_slice.shape + (3,), dtype=int) + for i in range(mri_slice.shape[0]): + for j in range(mri_slice.shape[1]): + mri_data[i, j] = self._fs_lut[mri_slice[i, j]][:3] + return mri_data + def _update_mri_images(self, axis=None, draw=False): - """Update the CT image(s).""" + """Update the MR image(s).""" if "mri" in self._images: for axis in range(3) if axis is None else [axis]: - self._images["mri"][axis].set_data( - self._mr_data[ - (slice(None),) * axis + (self._current_slice[axis],) - ].T - ) + self._images["mri"][axis].set_data(self._get_mr_slice(axis)) if draw: self._draw(axis) def _update_images(self, axis=None, draw=True): - """Update CT and channel images when general changes happen.""" + """Update CT, MR and channel images when general changes happen.""" self._update_ch_images(axis=axis) self._update_mri_images(axis=axis) self._update_ct_images(axis=axis) @@ -1046,7 +1030,7 @@ def _update_ct_scale(self): def _update_radius(self): """Update channel plot radius.""" self._radius = np.round(self._radius_slider.value()).astype(int) - if self._toggle_show_max_button.text() == "Hide Maxima": + if self._showing_local_maxima: self._update_ct_maxima() self._update_ct_images() else: @@ -1096,8 +1080,8 @@ def _update_ct_maxima(self, ct_thresh=0.95): def _toggle_show_mip(self): """Toggle whether the maximum-intensity projection is shown.""" - if self._toggle_show_mip_button.text() == "Show Max Intensity Proj": - self._toggle_show_mip_button.setText("Hide Max Intensity Proj") + self._showing_max_intensity_proj = not self._showing_max_intensity_proj + if self._showing_max_intensity_proj: self._images["mip"] = list() self._images["mip_chs"] = list() ct_min, ct_max = np.nanmin(self._ct_data), np.nanmax(self._ct_data) @@ -1143,15 +1127,14 @@ def _toggle_show_mip(self): img.remove() self._images.pop("mip") self._images.pop("mip_chs") - self._toggle_show_mip_button.setText("Show Max Intensity Proj") for group in set(self._groups.values()): # remove lines self._update_lines(group, only_2D=True) self._draw() def _toggle_show_max(self): """Toggle whether to color local maxima differently.""" - if self._toggle_show_max_button.text() == "Show Maxima": - self._toggle_show_max_button.setText("Hide Maxima") + self._showing_local_maxima = not self._showing_local_maxima + if self._showing_local_maxima: # happens on initiation or if the radius is changed with it off if self._ct_maxima is None: # otherwise don't recompute self._update_ct_maxima() @@ -1175,7 +1158,6 @@ def _toggle_show_max(self): for img in self._images["local_max"]: img.remove() self._images.pop("local_max") - self._toggle_show_max_button.setText("Show Maxima") self._draw() def _toggle_show_brain(self): @@ -1184,7 +1166,6 @@ def _toggle_show_brain(self): for img in self._images["mri"]: img.remove() self._images.pop("mri") - self._toggle_brain_button.setText("Show Brain") else: self._images["mri"] = list() for axis in range(3): @@ -1192,16 +1173,13 @@ def _toggle_show_brain(self): self._figs[axis] .axes[0] .imshow( - self._mr_data[ - (slice(None),) * axis + (self._current_slice[axis],) - ].T, - cmap="hot", + self._get_mr_slice(axis), + cmap=None if self._using_atlas else "hot", aspect="auto", alpha=0.25, zorder=2, ) ) - self._toggle_brain_button.setText("Hide Brain") self._draw() def keyPressEvent(self, event): diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index 27923eb..332eb25 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -112,7 +112,7 @@ def __init__( self.show() def _configure_ui(self): - # toolbar = self._configure_toolbar() + toolbar = self._configure_toolbar() slider_bar = self._configure_sliders() status_bar = self._configure_status_bar() @@ -120,7 +120,7 @@ def _configure_ui(self): plot_layout.addLayout(self._plt_grid) main_vbox = QVBoxLayout() - # main_vbox.addLayout(toolbar) + main_vbox.addLayout(toolbar) main_vbox.addLayout(slider_bar) main_vbox.addLayout(plot_layout) main_vbox.addLayout(status_bar) diff --git a/mne_gui_addons/_vol_stc.py b/mne_gui_addons/_vol_stc.py index b3301cc..d57b097 100644 --- a/mne_gui_addons/_vol_stc.py +++ b/mne_gui_addons/_vol_stc.py @@ -581,11 +581,10 @@ def _configure_toolbar(self): """Make a bar with buttons for user interactions.""" hbox = QHBoxLayout() - help_button = QPushButton("Help") - help_button.released.connect(self._show_help) - hbox.addWidget(help_button) + # add help and show/hide + super(VolSourceEstimateViewer, self)._configure_toolbar(hbox=hbox) - hbox.addStretch(8) + hbox.addStretch(1) if self._data.shape[0] > 1: self._epoch_selector = QComboBox() diff --git a/mne_gui_addons/tests/test_core.py b/mne_gui_addons/tests/test_core.py index e61d631..10bbf00 100644 --- a/mne_gui_addons/tests/test_core.py +++ b/mne_gui_addons/tests/test_core.py @@ -56,6 +56,10 @@ def test_slice_browser_display(renderer_interactive_pyvistaqt): with pytest.warns(RuntimeWarning, match="`pial` surface not found"): gui = SliceBrowser(subject=subject, subjects_dir=subjects_dir) + # test show/hide + gui._toggle_show_selector.setCurrentIndex(1) # hide + gui._toggle_show_selector.setCurrentIndex(1) # show + # test RAS gui._RAS_textbox.setText("10 10 10") gui._RAS_textbox.focusOutEvent(event=None)