From 12054947039e6c82c06f354f2e8c64538eb63624 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Wed, 12 Apr 2023 20:48:25 -0700 Subject: [PATCH 01/45] minimal working version for tumor segmentation --- doc/conf.py | 1 + mne_gui_addons/__init__.py | 58 +++++- mne_gui_addons/_core.py | 57 +++++- mne_gui_addons/_ieeg_locate.py | 31 +-- mne_gui_addons/_segment.py | 338 +++++++++++++++++++++++++++++++++ 5 files changed, 444 insertions(+), 41 deletions(-) create mode 100644 mne_gui_addons/_segment.py diff --git a/doc/conf.py b/doc/conf.py index d39b612..18e57de 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -74,6 +74,7 @@ # not documented "IntracranialElectrodeLocator", "VolSourceEstimateViewer", + "VolumeSegmenter", } numpydoc_validate = True numpydoc_validation_checks = { diff --git a/mne_gui_addons/__init__.py b/mne_gui_addons/__init__.py index 42d69ad..a6ed36e 100644 --- a/mne_gui_addons/__init__.py +++ b/mne_gui_addons/__init__.py @@ -272,6 +272,54 @@ def itc(data): return gui +@_verbose +@_fill_doc +def segment_volume( + base_image=None, + subject=None, + subjects_dir=None, + show=True, + block=False, + verbose=None, +): + """Locate intracranial electrode contacts. + + Parameters + ---------- + base_image : path-like | nibabel.spatialimages.SpatialImage + The image on which to segment the volume. Defaults to the + freesurfer T1. Path-like inputs and nibabel image + objects are supported. + %(subject)s + %(subjects_dir)s + show : bool + Show the GUI if True. + block : bool + Whether to halt program execution until the figure is closed. + %(verbose)s + + Returns + ------- + gui : instance of VolumeSegmenter + The graphical user interface (GUI) window. + """ + from mne.viz.backends._utils import _init_mne_qtapp, _qt_app_exec + from ._segment import VolumeSegmenter + + app = _init_mne_qtapp() + + gui = VolumeSegmenter( + base_image=base_image, + subject=subject, + subjects_dir=subjects_dir, + show=show, + verbose=verbose, + ) + if block: + _qt_app_exec(app) + return gui + + class _GUIScraper(object): """Scrape GUI outputs.""" @@ -281,12 +329,20 @@ def __repr__(self): def __call__(self, block, block_vars, gallery_conf): from ._ieeg_locate import IntracranialElectrodeLocator from ._vol_stc import VolSourceEstimateViewer + from ._segment import VolumeSegmenter from sphinx_gallery.scrapers import figure_rst from qtpy import QtGui for gui in block_vars["example_globals"].values(): if ( - isinstance(gui, (IntracranialElectrodeLocator, VolSourceEstimateViewer)) + isinstance( + gui, + ( + IntracranialElectrodeLocator, + VolSourceEstimateViewer, + VolumeSegmenter, + ), + ) and not getattr(gui, "_scraped", False) and gallery_conf["builder_name"] == "html" ): diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index f960c84..0d499a4 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -27,6 +27,7 @@ from matplotlib.backends.backend_qt5agg import FigureCanvas from matplotlib.figure import Figure from matplotlib.patches import Rectangle +from matplotlib.colors import LinearSegmentedColormap from mne.viz.backends.renderer import _get_renderer from mne.viz.utils import safe_event @@ -45,6 +46,33 @@ _IMG_LABELS = [["I", "P"], ["I", "L"], ["P", "L"]] _ZOOM_STEP_SIZE = 5 +# 20 colors generated to be evenly spaced in a cube, worked better than +# matplotlib color cycle +_UNIQUE_COLORS = [ + (0.1, 0.42, 0.43), + (0.9, 0.34, 0.62), + (0.47, 0.51, 0.3), + (0.47, 0.55, 0.99), + (0.79, 0.68, 0.06), + (0.34, 0.74, 0.05), + (0.58, 0.87, 0.13), + (0.86, 0.98, 0.4), + (0.92, 0.91, 0.66), + (0.77, 0.38, 0.34), + (0.9, 0.37, 0.1), + (0.2, 0.62, 0.9), + (0.22, 0.65, 0.64), + (0.14, 0.94, 0.8), + (0.34, 0.31, 0.68), + (0.59, 0.28, 0.74), + (0.46, 0.19, 0.94), + (0.37, 0.93, 0.7), + (0.56, 0.86, 0.55), + (0.67, 0.69, 0.44), +] +_N_COLORS = len(_UNIQUE_COLORS) +_CMAP = LinearSegmentedColormap.from_list("colors", _UNIQUE_COLORS, N=_N_COLORS) + @verbose def _load_image(img, verbose=None): @@ -104,7 +132,14 @@ class SliceBrowser(QMainWindow): ) @_qt_safe_window(splash="_renderer.figure.splash", window="") - def __init__(self, base_image=None, subject=None, subjects_dir=None, verbose=None): + def __init__( + self, + base_image=None, + subject=None, + subjects_dir=None, + check_aligned=True, + verbose=None, + ): """GUI for browsing slices of anatomical images.""" # initialize QMainWindow class super(SliceBrowser, self).__init__() @@ -117,7 +152,7 @@ def __init__(self, base_image=None, subject=None, subjects_dir=None, verbose=Non self._subject_dir = ( op.join(subjects_dir, subject) if subject and subjects_dir else None ) - self._load_image_data(base_image=base_image) + self._load_image_data(base_image=base_image, check_aligned=check_aligned) # GUI design @@ -154,7 +189,7 @@ def _configure_ui(self): central_widget.setLayout(main_vbox) self.setCentralWidget(central_widget) - def _load_image_data(self, base_image=None): + def _load_image_data(self, base_image=None, check_aligned=True): """Get image data to display and transforms to/from vox/RAS.""" if self._subject_dir is None: # if the recon-all is not finished or the CT is not @@ -168,30 +203,32 @@ def _load_image_data(self, base_image=None): if op.isfile(op.join(self._subject_dir, "mri", "brain.mgz")) else "T1" ) - self._mri_data, vox_ras_t, vox_scan_ras_t = _load_image( + self._mri_data, self._mri_vox_ras_t, self._mri_vox_scan_ras_t = _load_image( op.join(self._subject_dir, "mri", f"{mri_img}.mgz") ) + self._mri_ras_vox_t = np.linalg.inv(self._mri_vox_ras_t) + self._mri_scan_ras_vox_t = np.linalg.inv(self._mri_vox_scan_ras_t) # ready alternate base image if provided, otherwise use brain/T1 if base_image is None: assert self._mri_data is not None self._base_data = self._mri_data - self._vox_ras_t = vox_ras_t - self._vox_scan_ras_t = vox_scan_ras_t + self._vox_ras_t = self._mri_vox_ras_t + self._vox_scan_ras_t = self._mri_vox_scan_ras_t else: self._base_data, self._vox_ras_t, self._vox_scan_ras_t = _load_image( base_image ) - if self._mri_data is not None: + if self._mri_data is not None and check_aligned: if self._mri_data.shape != self._base_data.shape or not np.allclose( - self._vox_ras_t, vox_ras_t, rtol=1e-6 + self._vox_ras_t, self._mri_vox_ras_t, rtol=1e-6 ): raise ValueError( "Base image is not aligned to MRI, got " f"Base shape={self._base_data.shape}, " f"MRI shape={self._mri_data.shape}, " - f"Base affine={vox_ras_t} and " - f"MRI affine={self._vox_ras_t}, " + f"Base affine={self._vox_ras_t} and " + f"MRI affine={self._mri_vox_ras_t}, " "please provide an aligned image or do not use the " "``subject`` and ``subjects_dir`` arguments" ) diff --git a/mne_gui_addons/_ieeg_locate.py b/mne_gui_addons/_ieeg_locate.py index d9ec82a..8df191d 100644 --- a/mne_gui_addons/_ieeg_locate.py +++ b/mne_gui_addons/_ieeg_locate.py @@ -25,9 +25,7 @@ QComboBox, ) -from matplotlib.colors import LinearSegmentedColormap - -from ._core import SliceBrowser +from ._core import SliceBrowser, _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 @@ -40,33 +38,6 @@ _BOLT_SCALAR = 30 # mm _CH_MENU_WIDTH = 30 if platform.system() == "Windows" else 10 -# 20 colors generated to be evenly spaced in a cube, worked better than -# matplotlib color cycle -_UNIQUE_COLORS = [ - (0.1, 0.42, 0.43), - (0.9, 0.34, 0.62), - (0.47, 0.51, 0.3), - (0.47, 0.55, 0.99), - (0.79, 0.68, 0.06), - (0.34, 0.74, 0.05), - (0.58, 0.87, 0.13), - (0.86, 0.98, 0.4), - (0.92, 0.91, 0.66), - (0.77, 0.38, 0.34), - (0.9, 0.37, 0.1), - (0.2, 0.62, 0.9), - (0.22, 0.65, 0.64), - (0.14, 0.94, 0.8), - (0.34, 0.31, 0.68), - (0.59, 0.28, 0.74), - (0.46, 0.19, 0.94), - (0.37, 0.93, 0.7), - (0.56, 0.86, 0.55), - (0.67, 0.69, 0.44), -] -_N_COLORS = len(_UNIQUE_COLORS) -_CMAP = LinearSegmentedColormap.from_list("ch_colors", _UNIQUE_COLORS, N=_N_COLORS) - class ComboBox(QComboBox): """Dropdown menu that emits a click when popped up.""" diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py new file mode 100644 index 0000000..9dd222b --- /dev/null +++ b/mne_gui_addons/_segment.py @@ -0,0 +1,338 @@ +# -*- coding: utf-8 -*- +"""Tissue Segmentation GUI for finding making 3D volumes.""" + +# Authors: Alex Rockhill +# +# License: BSD (3-clause) + +import numpy as np +from mne.surface import _marching_cubes +from mne.transforms import apply_trans + +from ._core import SliceBrowser, _CMAP, _N_COLORS + +from qtpy import QtCore +from qtpy.QtWidgets import ( + QVBoxLayout, + QHBoxLayout, + QWidget, + QLabel, + QSlider, + QPushButton +) + + +def _get_neighbors(loc, image, voxels, val, tol): + """Find all the neighbors above a threshold near a voxel.""" + neighbors = set() + for axis in range(len(loc)): + for i in (-1, 1): + next_loc = np.array(loc) + next_loc[axis] += i + next_loc = tuple(next_loc) + if abs(image[next_loc] - val) / val < tol: + neighbors.add(next_loc) + return neighbors + + +def _voxel_neighbors(seed, image, tol): + """Find voxels contiguous with a seed location within a tolerance + + Parameters + ---------- + seed : tuple | ndarray + The location in image coordinated to seed the algorithm. + image : ndarray + The image to search. + tol : float + The tolerance as a percentage. + + Returns + ------- + voxels : set + The set of locations including the ``seed`` voxel and + surrounding that meet the criteria. + """ + seed = np.array(seed).round().astype(int) + val = image[tuple(seed)] + voxels = neighbors = set([tuple(seed)]) + while neighbors: + next_neighbors = set() + for next_loc in neighbors: + voxel_neighbors = _get_neighbors(next_loc, image, voxels, + val, tol) + # prevent looping back to already visited voxels + voxel_neighbors = voxel_neighbors.difference(voxels) + # add voxels not already visited to search next + next_neighbors = next_neighbors.union(voxel_neighbors) + # add new voxels that match the criteria to the overall set + voxels = voxels.union(voxel_neighbors) + neighbors = next_neighbors # start again checking all new neighbors + return voxels + + +class VolumeSegmenter(SliceBrowser): + """GUI for segmenting volumes e.g. tumors. + + Attributes + ---------- + verts : ndarray + The vertices of the marked volume. + tris : ndarray + The triangles connecting the vertices of the marked volume. + """ + + def __init__( + self, + base_image=None, + subject=None, + subjects_dir=None, + show=True, + verbose=None, + ): + self.verts = self.tris = None + self._vol_coords = list() # store list for undo + self._alpha = 0.8 + self._vol_actor = None + + super(VolumeSegmenter, self).__init__( + base_image=base_image, subject=subject, subjects_dir=subjects_dir + ) + + self._vol_img = np.zeros(self._base_data.shape) * np.nan + self._plot_vol_images() + + if show: + self.show() + + def _configure_ui(self): + # toolbar = self._configure_toolbar() + slider_bar = self._configure_sliders() + status_bar = self._configure_status_bar() + + plot_layout = QHBoxLayout() + plot_layout.addLayout(self._plt_grid) + + main_vbox = QVBoxLayout() + # main_vbox.addLayout(toolbar) + main_vbox.addLayout(slider_bar) + main_vbox.addLayout(plot_layout) + main_vbox.addLayout(status_bar) + + central_widget = QWidget() + central_widget.setLayout(main_vbox) + self.setCentralWidget(central_widget) + + def _configure_sliders(self): + """Make a bar with sliders on it.""" + + def make_label(name): + label = QLabel(name) + label.setAlignment(QtCore.Qt.AlignCenter) + return label + + def make_slider(smin, smax, sval, sfun=None): + slider = QSlider(QtCore.Qt.Horizontal) + slider.setMinimum(int(round(smin))) + slider.setMaximum(int(round(smax))) + slider.setValue(int(round(sval))) + slider.setTracking(False) # only update on release + if sfun is not None: + slider.valueChanged.connect(sfun) + slider.keyPressEvent = self.keyPressEvent + return slider + + slider_hbox = QHBoxLayout() + + ch_vbox = QVBoxLayout() + ch_vbox.addWidget(make_label("alpha")) + ch_vbox.addWidget(make_label("tolerance")) + slider_hbox.addLayout(ch_vbox) + + slider_vbox = QVBoxLayout() + self._alpha_slider = make_slider(0, 100, self._alpha * 100, self._update_alpha) + slider_vbox.addWidget(self._alpha_slider) + # no callback needed, will only be used when marked + self._tol_slider = make_slider(0, 100, 10, None) + slider_vbox.addWidget(self._tol_slider) + + slider_hbox.addLayout(slider_vbox) + + img_vbox = QVBoxLayout() + img_vbox.addWidget(make_label("Image min")) + img_vbox.addWidget(make_label("Image max")) + slider_hbox.addLayout(img_vbox) + + img_slider_vbox = QVBoxLayout() + img_min = int(round(np.nanmin(self._base_data))) + img_max = int(round(np.nanmax(self._base_data))) + self._img_min_slider = make_slider( + img_min, img_max, img_min, self._update_img_scale + ) + img_slider_vbox.addWidget(self._img_min_slider) + self._img_max_slider = make_slider( + img_min, img_max, img_max, self._update_img_scale + ) + img_slider_vbox.addWidget(self._img_max_slider) + slider_hbox.addLayout(img_slider_vbox) + + button_vbox = QVBoxLayout() + + self._undo_button = QPushButton("Undo") + self._undo_button.setEnabled(False) + self._undo_button.released.connect(self._undo) + button_vbox.addWidget(self._undo_button) + + mark_button = QPushButton("Mark") + mark_button.released.connect(self._mark) + button_vbox.addWidget(mark_button) + + slider_hbox.addLayout(button_vbox) + + return slider_hbox + + def set_clim(self, vmin=None, vmax=None): + """Set the color limits of the image. + + Parameters + ---------- + vmin : float [0, 1] + The minimum percentage. + vmax : float [0, 1] + The maximum percentage. + """ + if vmin is not None: + self._img_min_slider.setValue(vmin) + if vmax is not None: + self._img_max_slider.setValue(vmax) + + def _update_img_scale(self): + """Update base image slider values.""" + new_min = self._img_min_slider.value() + new_max = self._img_max_slider.value() + # handle inversions + self._img_min_slider.setValue(min([new_min, new_max])) + self._img_max_slider.setValue(max([new_min, new_max])) + self._update_base_images(draw=True) + + def _update_base_images(self, axis=None, draw=False): + """Update the CT image(s).""" + for axis in range(3) if axis is None else [axis]: + img_data = np.take(self._base_data, self._current_slice[axis], axis=axis).T + img_data[img_data < self._img_min_slider.value()] = np.nan + img_data[img_data > self._img_max_slider.value()] = np.nan + self._images["base"][axis].set_data(img_data) + self._images["base"][axis].set_clim( + (self._img_min_slider.value(), self._img_max_slider.value()) + ) + if draw: + self._draw(axis) + + def _plot_vol_images(self): + self._images["vol"] = list() + for axis in range(3): + fig = self._figs[axis] + ax = fig.axes[0] + vol_data = np.take(self._vol_img, self._current_slice[axis], axis=axis).T + self._images["vol"].append( + ax.imshow( + vol_data, + aspect="auto", + zorder=3, + cmap=_CMAP, + alpha=self._alpha, + vmin=0, + vmax=_N_COLORS, + ) + ) + + def set_tolerance(self, tol): + """Set the tolerance for how different than the seed to mark the volume. + + Parameters + ---------- + tol : float [0, 1] + The tolerance from the seed voxel. + """ + self._tol_slider.setValue(int(round(tol * 100))) + + def set_alpha(self, alpha): + """Change the opacity on the slice plots and 3D rendering. + + Parameters + ---------- + alpha : float [0, 1] + The opacity value. + """ + self._alpha_slider.setValue(int(round(alpha * 100))) + + def _update_alpha(self): + """Update volume plot alpha.""" + self._alpha = self._alpha_slider.value() / 100 + for axis in range(3): + self._images["vol"][axis].set_alpha(self._alpha) + self._draw() + if self._vol_actor is not None: + self._vol_actor.GetProperty().SetOpacity(self._alpha) + self._renderer._update() + self.setFocus() # remove focus from 3d plotter + + def _undo(self): + """Undo last change to voxels.""" + self._vol_coords.pop() + if not self._vol_coords: + self._undo_button.setEnabled(False) + voxels = self._vol_coords[-1] if self._vol_coords else set() + self._vol_img = np.zeros(self._base_data.shape) * np.nan + for voxel in voxels: + self._vol_img[voxel] = 1 + self._update_vol_images(draw=True) + self._plot_3d(render=True) + + def _mark(self): + """Mark the volume with the current tolerance and location.""" + self._undo_button.setEnabled(True) + voxels = _voxel_neighbors(self._vox, self._base_data, + self._tol_slider.value() / 100) + if self._vol_coords: + voxels = voxels.union(self._vol_coords[-1]) + self._vol_coords.append(voxels) + for voxel in voxels: + self._vol_img[voxel] = 1 + self._update_vol_images(draw=True) + self._plot_3d(render=True) + + def _update_vol_images(self, axis=None, draw=False): + """Update the volume image(s).""" + for axis in range(3) if axis is None else [axis]: + vol_data = np.take(self._vol_img, self._current_slice[axis], axis=axis).T + self._images["vol"][axis].set_data(vol_data) + if draw: + self._draw(axis) + + def _plot_3d(self, render=False): + """Plot the volume in 3D.""" + if self._vol_actor is not None: + self._renderer.plotter.remove_actor(self._vol_actor, render=False) + if self._vol_coords: + verts, tris = _marching_cubes(self._vol_img, [1])[0] + verts = apply_trans(self._vox_scan_ras_t, verts) # vox -> scanner RAS + verts = apply_trans(self._mri_scan_ras_vox_t, verts) # scanner RAS -> mri vox + verts = apply_trans(self._mri_vox_ras_t, verts) # mri vox -> mri surface RAS + self._vol_actor = self._renderer.mesh( + *verts.T, + tris, + color=_CMAP(0)[:3], + opacity=self._alpha, + )[0] + self.verts = verts + self.tris = tris + if render: + self._renderer._update() + + def _update_images(self, axis=None, draw=True): + """Update images when general changes happen.""" + self._update_base_images(axis=axis) + self._update_vol_images(axis=axis) + if draw: + self._draw(axis) From 541cb728346562819d156b1e0be18f6496476d1e Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Wed, 12 Apr 2023 20:51:22 -0700 Subject: [PATCH 02/45] style --- mne_gui_addons/_segment.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index 9dd222b..b4aeb7f 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -18,7 +18,7 @@ QWidget, QLabel, QSlider, - QPushButton + QPushButton, ) @@ -59,8 +59,7 @@ def _voxel_neighbors(seed, image, tol): while neighbors: next_neighbors = set() for next_loc in neighbors: - voxel_neighbors = _get_neighbors(next_loc, image, voxels, - val, tol) + voxel_neighbors = _get_neighbors(next_loc, image, voxels, val, tol) # prevent looping back to already visited voxels voxel_neighbors = voxel_neighbors.difference(voxels) # add voxels not already visited to search next @@ -292,8 +291,9 @@ def _undo(self): def _mark(self): """Mark the volume with the current tolerance and location.""" self._undo_button.setEnabled(True) - voxels = _voxel_neighbors(self._vox, self._base_data, - self._tol_slider.value() / 100) + voxels = _voxel_neighbors( + self._vox, self._base_data, self._tol_slider.value() / 100 + ) if self._vol_coords: voxels = voxels.union(self._vol_coords[-1]) self._vol_coords.append(voxels) @@ -317,8 +317,12 @@ def _plot_3d(self, render=False): if self._vol_coords: verts, tris = _marching_cubes(self._vol_img, [1])[0] verts = apply_trans(self._vox_scan_ras_t, verts) # vox -> scanner RAS - verts = apply_trans(self._mri_scan_ras_vox_t, verts) # scanner RAS -> mri vox - verts = apply_trans(self._mri_vox_ras_t, verts) # mri vox -> mri surface RAS + verts = apply_trans( + self._mri_scan_ras_vox_t, verts + ) # scanner RAS -> mri vox + verts = apply_trans( + self._mri_vox_ras_t, verts + ) # mri vox -> mri surface RAS self._vol_actor = self._renderer.mesh( *verts.T, tris, From a7a1be0d820e49fd8f3e66dd634c8d4177638d8c Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Thu, 13 Apr 2023 12:38:09 -0700 Subject: [PATCH 03/45] add smoothing, a few more touchups, worked well for a hard patient --- mne_gui_addons/_segment.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index b4aeb7f..e365087 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -35,7 +35,7 @@ def _get_neighbors(loc, image, voxels, val, tol): return neighbors -def _voxel_neighbors(seed, image, tol): +def _voxel_neighbors(seed, image, tol, max_n_voxels=200): """Find voxels contiguous with a seed location within a tolerance Parameters @@ -67,6 +67,8 @@ def _voxel_neighbors(seed, image, tol): # add new voxels that match the criteria to the overall set voxels = voxels.union(voxel_neighbors) neighbors = next_neighbors # start again checking all new neighbors + if len(voxels) > max_n_voxels: + break return voxels @@ -95,7 +97,8 @@ def __init__( self._vol_actor = None super(VolumeSegmenter, self).__init__( - base_image=base_image, subject=subject, subjects_dir=subjects_dir + base_image=base_image, subject=subject, subjects_dir=subjects_dir, + check_aligned=False ) self._vol_img = np.zeros(self._base_data.shape) * np.nan @@ -146,6 +149,7 @@ def make_slider(smin, smax, sval, sfun=None): ch_vbox = QVBoxLayout() ch_vbox.addWidget(make_label("alpha")) ch_vbox.addWidget(make_label("tolerance")) + ch_vbox.addWidget(make_label("smooth")) slider_hbox.addLayout(ch_vbox) slider_vbox = QVBoxLayout() @@ -154,6 +158,8 @@ def make_slider(smin, smax, sval, sfun=None): # no callback needed, will only be used when marked self._tol_slider = make_slider(0, 100, 10, None) slider_vbox.addWidget(self._tol_slider) + self._smooth_slider = make_slider(0, 100, 0, lambda x: self._plot_3d(render=True)) + slider_vbox.addWidget(self._smooth_slider) slider_hbox.addLayout(slider_vbox) @@ -315,7 +321,8 @@ def _plot_3d(self, render=False): if self._vol_actor is not None: self._renderer.plotter.remove_actor(self._vol_actor, render=False) if self._vol_coords: - verts, tris = _marching_cubes(self._vol_img, [1])[0] + smooth = self._smooth_slider.value() / 100 + verts, tris = _marching_cubes(self._vol_img, [1], smooth=smooth)[0] verts = apply_trans(self._vox_scan_ras_t, verts) # vox -> scanner RAS verts = apply_trans( self._mri_scan_ras_vox_t, verts From f36f2d94a3e7a0832f81f877f071f6ccac4fff8b Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Thu, 20 Apr 2023 17:34:10 -0700 Subject: [PATCH 04/45] add export button --- mne_gui_addons/_core.py | 21 ++++++++++++++++++--- mne_gui_addons/_segment.py | 27 ++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index 0d499a4..299e2ff 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -74,6 +74,21 @@ _CMAP = LinearSegmentedColormap.from_list("colors", _UNIQUE_COLORS, N=_N_COLORS) +def _get_volume_info(img): + header = img.header + version = header['version'] + vol_info = dict(head=[20]) + if version == 1: + version = f'{version} # volume info valid' + vol_info['valid'] = version + vol_info['filename'] = img.get_filename() + vol_info['volume'] = header['dims'][:3] + vol_info['voxelsize'] = header['delta'] + vol_info['xras'], vol_info['yras'], vol_info['zras'] = header['Mdc'] + vol_info['cras'] = header['Pxyz_c'] + return vol_info + + @verbose def _load_image(img, verbose=None): """Load data from a 3D image file (e.g. CT, MR).""" @@ -95,7 +110,7 @@ def _load_image(img, verbose=None): aff_trans = nib.orientations.inv_ornt_aff(ornt_trans, img.shape) vox_ras_t = np.dot(orig_mgh.header.get_vox2ras_tkr(), aff_trans) vox_scan_ras_t = np.dot(orig_mgh.header.get_vox2ras(), aff_trans) - return img_data, vox_ras_t, vox_scan_ras_t + return img_data, vox_ras_t, vox_scan_ras_t, _get_volume_info(orig_mgh) def _make_mpl_plot( @@ -203,7 +218,7 @@ def _load_image_data(self, base_image=None, check_aligned=True): if op.isfile(op.join(self._subject_dir, "mri", "brain.mgz")) else "T1" ) - self._mri_data, self._mri_vox_ras_t, self._mri_vox_scan_ras_t = _load_image( + self._mri_data, self._mri_vox_ras_t, self._mri_vox_scan_ras_t, self._vol_info = _load_image( op.join(self._subject_dir, "mri", f"{mri_img}.mgz") ) self._mri_ras_vox_t = np.linalg.inv(self._mri_vox_ras_t) @@ -216,7 +231,7 @@ def _load_image_data(self, base_image=None, check_aligned=True): self._vox_ras_t = self._mri_vox_ras_t self._vox_scan_ras_t = self._mri_vox_scan_ras_t else: - self._base_data, self._vox_ras_t, self._vox_scan_ras_t = _load_image( + self._base_data, self._vox_ras_t, self._vox_scan_ras_t, self._vol_info = _load_image( base_image ) if self._mri_data is not None and check_aligned: diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index e365087..3951b3c 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -6,7 +6,7 @@ # License: BSD (3-clause) import numpy as np -from mne.surface import _marching_cubes +from mne.surface import _marching_cubes, write_surface from mne.transforms import apply_trans from ._core import SliceBrowser, _CMAP, _N_COLORS @@ -19,6 +19,7 @@ QLabel, QSlider, QPushButton, + QFileDialog ) @@ -196,6 +197,28 @@ def make_slider(smin, smax, sval, sfun=None): return slider_hbox + def _configure_status_bar(self): + """Configure the status bar.""" + hbox = QHBoxLayout() + + self._export_button = QPushButton('Export') + self._export_button.released.connect(self._export_surface) + self._export_button.setEnabled(False) + hbox.addWidget(self._export_button) + + hbox.addStretch(1) + + super()._configure_status_bar(hbox=hbox) + return hbox + + def _export_surface(self): + """Export the surface to a file.""" + fname, _ = QFileDialog.getSaveFileName(self, 'Export Filename') + if not fname: + return + write_surface(fname, self.verts, self.tris, volume_info=self._vol_info, + overwrite=True) + def set_clim(self, vmin=None, vmax=None): """Set the color limits of the image. @@ -287,6 +310,7 @@ def _undo(self): self._vol_coords.pop() if not self._vol_coords: self._undo_button.setEnabled(False) + self._export_button.setEnabled(False) voxels = self._vol_coords[-1] if self._vol_coords else set() self._vol_img = np.zeros(self._base_data.shape) * np.nan for voxel in voxels: @@ -297,6 +321,7 @@ def _undo(self): def _mark(self): """Mark the volume with the current tolerance and location.""" self._undo_button.setEnabled(True) + self._export_button.setEnabled(True) voxels = _voxel_neighbors( self._vox, self._base_data, self._tol_slider.value() / 100 ) From fe6f14a8527b4c81f974ee25316e6c651c92f9dd Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Fri, 21 Apr 2023 14:27:03 -0700 Subject: [PATCH 05/45] add brain alpha slider --- .gitignore | 2 ++ mne_gui_addons/_core.py | 6 ++++-- mne_gui_addons/_segment.py | 17 +++++++++++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index b6008e4..6e493fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +tmp.* + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index 299e2ff..efeb24a 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -403,7 +403,7 @@ def _plot_images(self): render=False, ) if self._lh is not None and self._rh is not None: - self._renderer.mesh( + self._lh_actor, _ = self._renderer.mesh( *self._lh["rr"].T * 1000, triangles=self._lh["tris"], color="white", @@ -411,7 +411,7 @@ def _plot_images(self): reset_camera=False, render=False, ) - self._renderer.mesh( + self._rh_actor, _ = self._renderer.mesh( *self._rh["rr"].T * 1000, triangles=self._rh["tris"], color="white", @@ -419,6 +419,8 @@ def _plot_images(self): reset_camera=False, render=False, ) + else: + self._lh_actor = self._rh_actor = None self._renderer.set_camera( azimuth=90, elevation=90, distance=300, focalpoint=tuple(self._ras) ) diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index 3951b3c..6d792d9 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -165,8 +165,9 @@ def make_slider(smin, smax, sval, sfun=None): slider_hbox.addLayout(slider_vbox) img_vbox = QVBoxLayout() - img_vbox.addWidget(make_label("Image min")) - img_vbox.addWidget(make_label("Image max")) + img_vbox.addWidget(make_label("Image Min")) + img_vbox.addWidget(make_label("Image Max")) + img_vbox.addWidget(make_label("Brain Alpha")) slider_hbox.addLayout(img_vbox) img_slider_vbox = QVBoxLayout() @@ -180,6 +181,10 @@ def make_slider(smin, smax, sval, sfun=None): img_min, img_max, img_max, self._update_img_scale ) img_slider_vbox.addWidget(self._img_max_slider) + + self._brain_alpha_slider = make_slider(0, 100, 20, self._update_brain_alpha) + img_slider_vbox.addWidget(self._brain_alpha_slider) + slider_hbox.addLayout(img_slider_vbox) button_vbox = QVBoxLayout() @@ -211,6 +216,14 @@ def _configure_status_bar(self): super()._configure_status_bar(hbox=hbox) return hbox + def _update_brain_alpha(self): + """Change the alpha level of the brain.""" + alpha = self._brain_alpha_slider.value() / 100 + for actor in (self._lh_actor, self._rh_actor): + if actor is not None: + actor.GetProperty().SetOpacity(alpha) + self._renderer._update() + def _export_surface(self): """Export the surface to a file.""" fname, _ = QFileDialog.getSaveFileName(self, 'Export Filename') From 0e736b5227f5f6e65b1702121c7d68e0a9fe93b1 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Fri, 21 Apr 2023 14:27:16 -0700 Subject: [PATCH 06/45] fix style --- mne_gui_addons/_core.py | 34 ++++++++++++++++++++-------------- mne_gui_addons/_segment.py | 21 +++++++++++++-------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index efeb24a..21c0da6 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -76,16 +76,16 @@ def _get_volume_info(img): header = img.header - version = header['version'] + version = header["version"] vol_info = dict(head=[20]) if version == 1: - version = f'{version} # volume info valid' - vol_info['valid'] = version - vol_info['filename'] = img.get_filename() - vol_info['volume'] = header['dims'][:3] - vol_info['voxelsize'] = header['delta'] - vol_info['xras'], vol_info['yras'], vol_info['zras'] = header['Mdc'] - vol_info['cras'] = header['Pxyz_c'] + version = f"{version} # volume info valid" + vol_info["valid"] = version + vol_info["filename"] = img.get_filename() + vol_info["volume"] = header["dims"][:3] + vol_info["voxelsize"] = header["delta"] + vol_info["xras"], vol_info["yras"], vol_info["zras"] = header["Mdc"] + vol_info["cras"] = header["Pxyz_c"] return vol_info @@ -218,9 +218,12 @@ def _load_image_data(self, base_image=None, check_aligned=True): if op.isfile(op.join(self._subject_dir, "mri", "brain.mgz")) else "T1" ) - self._mri_data, self._mri_vox_ras_t, self._mri_vox_scan_ras_t, self._vol_info = _load_image( - op.join(self._subject_dir, "mri", f"{mri_img}.mgz") - ) + ( + self._mri_data, + self._mri_vox_ras_t, + self._mri_vox_scan_ras_t, + self._vol_info, + ) = _load_image(op.join(self._subject_dir, "mri", f"{mri_img}.mgz")) self._mri_ras_vox_t = np.linalg.inv(self._mri_vox_ras_t) self._mri_scan_ras_vox_t = np.linalg.inv(self._mri_vox_scan_ras_t) @@ -231,9 +234,12 @@ def _load_image_data(self, base_image=None, check_aligned=True): self._vox_ras_t = self._mri_vox_ras_t self._vox_scan_ras_t = self._mri_vox_scan_ras_t else: - self._base_data, self._vox_ras_t, self._vox_scan_ras_t, self._vol_info = _load_image( - base_image - ) + ( + self._base_data, + self._vox_ras_t, + self._vox_scan_ras_t, + self._vol_info, + ) = _load_image(base_image) if self._mri_data is not None and check_aligned: if self._mri_data.shape != self._base_data.shape or not np.allclose( self._vox_ras_t, self._mri_vox_ras_t, rtol=1e-6 diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index 6d792d9..f3c5a1c 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -19,7 +19,7 @@ QLabel, QSlider, QPushButton, - QFileDialog + QFileDialog, ) @@ -98,8 +98,10 @@ def __init__( self._vol_actor = None super(VolumeSegmenter, self).__init__( - base_image=base_image, subject=subject, subjects_dir=subjects_dir, - check_aligned=False + base_image=base_image, + subject=subject, + subjects_dir=subjects_dir, + check_aligned=False, ) self._vol_img = np.zeros(self._base_data.shape) * np.nan @@ -159,7 +161,9 @@ def make_slider(smin, smax, sval, sfun=None): # no callback needed, will only be used when marked self._tol_slider = make_slider(0, 100, 10, None) slider_vbox.addWidget(self._tol_slider) - self._smooth_slider = make_slider(0, 100, 0, lambda x: self._plot_3d(render=True)) + self._smooth_slider = make_slider( + 0, 100, 0, lambda x: self._plot_3d(render=True) + ) slider_vbox.addWidget(self._smooth_slider) slider_hbox.addLayout(slider_vbox) @@ -206,7 +210,7 @@ def _configure_status_bar(self): """Configure the status bar.""" hbox = QHBoxLayout() - self._export_button = QPushButton('Export') + self._export_button = QPushButton("Export") self._export_button.released.connect(self._export_surface) self._export_button.setEnabled(False) hbox.addWidget(self._export_button) @@ -226,11 +230,12 @@ def _update_brain_alpha(self): def _export_surface(self): """Export the surface to a file.""" - fname, _ = QFileDialog.getSaveFileName(self, 'Export Filename') + fname, _ = QFileDialog.getSaveFileName(self, "Export Filename") if not fname: return - write_surface(fname, self.verts, self.tris, volume_info=self._vol_info, - overwrite=True) + write_surface( + fname, self.verts, self.tris, volume_info=self._vol_info, overwrite=True + ) def set_clim(self, vmin=None, vmax=None): """Set the color limits of the image. From 69c022fa05c441e5b9415a09a28c7c5dc4215b8f Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 24 Apr 2023 10:30:44 -0700 Subject: [PATCH 07/45] add max n voxels --- mne_gui_addons/_core.py | 6 ++++++ mne_gui_addons/_segment.py | 22 ++++++++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index 21c0da6..96a4839 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -137,6 +137,12 @@ def _make_mpl_plot( return canvas, fig +def make_label(name): + label = QLabel(name) + label.setAlignment(QtCore.Qt.AlignCenter) + return label + + class SliceBrowser(QMainWindow): """Navigate between slices of an MRI, CT, etc. image.""" diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index f3c5a1c..d0a00d9 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -9,17 +9,17 @@ from mne.surface import _marching_cubes, write_surface from mne.transforms import apply_trans -from ._core import SliceBrowser, _CMAP, _N_COLORS +from ._core import SliceBrowser, _CMAP, _N_COLORS, make_label from qtpy import QtCore from qtpy.QtWidgets import ( QVBoxLayout, QHBoxLayout, QWidget, - QLabel, QSlider, QPushButton, QFileDialog, + QSpinBox, ) @@ -131,11 +131,6 @@ def _configure_ui(self): def _configure_sliders(self): """Make a bar with sliders on it.""" - def make_label(name): - label = QLabel(name) - label.setAlignment(QtCore.Qt.AlignCenter) - return label - def make_slider(smin, smax, sval, sfun=None): slider = QSlider(QtCore.Qt.Horizontal) slider.setMinimum(int(round(smin))) @@ -215,6 +210,14 @@ def _configure_status_bar(self): self._export_button.setEnabled(False) hbox.addWidget(self._export_button) + hbox.addWidget(make_label(' ')) # small break + hbox.addWidget(make_label('Max # Voxels')) + self._max_n_voxels_spin_box = QSpinBox() + self._max_n_voxels_spin_box.setRange(0, 10000) + self._max_n_voxels_spin_box.setValue(200) + self._max_n_voxels_spin_box.setSingleStep(10) + hbox.addWidget(self._max_n_voxels_spin_box) + hbox.addStretch(1) super()._configure_status_bar(hbox=hbox) @@ -335,13 +338,15 @@ def _undo(self): self._vol_img[voxel] = 1 self._update_vol_images(draw=True) self._plot_3d(render=True) + self.setFocus() def _mark(self): """Mark the volume with the current tolerance and location.""" self._undo_button.setEnabled(True) self._export_button.setEnabled(True) voxels = _voxel_neighbors( - self._vox, self._base_data, self._tol_slider.value() / 100 + self._vox, self._base_data, self._tol_slider.value() / 100, + self._max_n_voxels_spin_box.value() ) if self._vol_coords: voxels = voxels.union(self._vol_coords[-1]) @@ -350,6 +355,7 @@ def _mark(self): self._vol_img[voxel] = 1 self._update_vol_images(draw=True) self._plot_3d(render=True) + self.setFocus() def _update_vol_images(self, axis=None, draw=False): """Update the volume image(s).""" From 95c56a9c690e309386ec8456a43323fee02fc372 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 24 Apr 2023 10:31:05 -0700 Subject: [PATCH 08/45] black --- mne_gui_addons/_segment.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index d0a00d9..288bed6 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -210,8 +210,8 @@ def _configure_status_bar(self): self._export_button.setEnabled(False) hbox.addWidget(self._export_button) - hbox.addWidget(make_label(' ')) # small break - hbox.addWidget(make_label('Max # Voxels')) + hbox.addWidget(make_label(" ")) # small break + hbox.addWidget(make_label("Max # Voxels")) self._max_n_voxels_spin_box = QSpinBox() self._max_n_voxels_spin_box.setRange(0, 10000) self._max_n_voxels_spin_box.setValue(200) @@ -345,8 +345,10 @@ def _mark(self): self._undo_button.setEnabled(True) self._export_button.setEnabled(True) voxels = _voxel_neighbors( - self._vox, self._base_data, self._tol_slider.value() / 100, - self._max_n_voxels_spin_box.value() + self._vox, + self._base_data, + self._tol_slider.value() / 100, + self._max_n_voxels_spin_box.value(), ) if self._vol_coords: voxels = voxels.union(self._vol_coords[-1]) From 0aac17d64cd10b064c66e52a3db18aa2b85b6a30 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Tue, 25 Apr 2023 10:03:44 -0700 Subject: [PATCH 09/45] add mark global function, add brainmask (slow because does not have to be aligned) --- mne_gui_addons/_core.py | 1 + mne_gui_addons/_segment.py | 72 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index 96a4839..e225c43 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -574,6 +574,7 @@ def _set_ras(self, ras, update_plots=True): logger.debug(f"Setting RAS: ({msg}) mm") if update_plots: self._move_cursors_to_pos() + self.setFocus() # focus back to main def set_vox(self, vox): """Set the crosshairs to a given voxel coordinate. diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index 288bed6..3b04f86 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -5,11 +5,12 @@ # # License: BSD (3-clause) +import os.path as op import numpy as np from mne.surface import _marching_cubes, write_surface from mne.transforms import apply_trans -from ._core import SliceBrowser, _CMAP, _N_COLORS, make_label +from ._core import SliceBrowser, _CMAP, _N_COLORS, make_label, _load_image from qtpy import QtCore from qtpy.QtWidgets import ( @@ -19,6 +20,7 @@ QSlider, QPushButton, QFileDialog, + QMessageBox, QSpinBox, ) @@ -197,6 +199,10 @@ def make_slider(smin, smax, sval, sfun=None): mark_button.released.connect(self._mark) button_vbox.addWidget(mark_button) + mark_all_button = QPushButton("Mark All") + mark_all_button.released.connect(self._mark_all) + button_vbox.addWidget(mark_all_button) + slider_hbox.addLayout(button_vbox) return slider_hbox @@ -210,7 +216,7 @@ def _configure_status_bar(self): self._export_button.setEnabled(False) hbox.addWidget(self._export_button) - hbox.addWidget(make_label(" ")) # small break + hbox.addWidget(make_label("\t")) # small break hbox.addWidget(make_label("Max # Voxels")) self._max_n_voxels_spin_box = QSpinBox() self._max_n_voxels_spin_box.setRange(0, 10000) @@ -218,11 +224,50 @@ def _configure_status_bar(self): self._max_n_voxels_spin_box.setSingleStep(10) hbox.addWidget(self._max_n_voxels_spin_box) + hbox.addWidget(make_label("\t")) # small break + brainmask_button = QPushButton("Add Brainmask") + brainmask_button.released.connect(self._apply_brainmask) + hbox.addWidget(brainmask_button) + hbox.addStretch(1) super()._configure_status_bar(hbox=hbox) return hbox + def _apply_brainmask(self): + """Mask the volume using the brainmask""" + if self._subject_dir is None or not op.isfile( + op.join(self._subject_dir, "mri", "brainmask.mgz") + ): + QMessageBox.information( + self, + "Recon-all Not Computed", + "The brainmask was not found, please pass the 'subject' " + "and 'subjects_dir' arguments for a completed recon-all", + ) + return + QMessageBox.information( + self, + "Applying Brainmask", + "Applying the brainmask, this will take ~30 seconds", + ) + img_data, _, vox_scan_ras_t, _ = _load_image( + op.join(self._subject_dir, "mri", "brainmask.mgz")) + idxs = np.meshgrid(np.arange(self._base_data.shape[0]), + np.arange(self._base_data.shape[1]), + np.arange(self._base_data.shape[2]), + indexing='ij') + idxs = np.array(idxs) # (3, *image_data.shape) + idxs = np.transpose(idxs, [1, 2, 3, 0]) # (*image_data.shape, 3) + idxs = idxs.reshape(-1, 3) # (n_voxels, 3) + idxs = apply_trans(self._vox_scan_ras_t, idxs) # vox -> scanner RAS + idxs = apply_trans(np.linalg.inv(vox_scan_ras_t), idxs) # scanner RAS -> mri vox + idxs = idxs.round().astype(int) # round to nearest voxel + brain = set([(x, y, z) for x, y, z in np.array(np.where(img_data > 0)).T]) + mask = np.array([tuple(idx) not in brain for idx in idxs]) + self._base_data[mask.reshape(self._base_data.shape)] = 0 + self._update_images() + def _update_brain_alpha(self): """Change the alpha level of the brain.""" alpha = self._brain_alpha_slider.value() / 100 @@ -359,6 +404,29 @@ def _mark(self): self._plot_3d(render=True) self.setFocus() + def _mark_all(self): + """Mark the volume globally with the current tolerance and location.""" + self._undo_button.setEnabled(True) + self._export_button.setEnabled(True) + val = self._base_data[tuple(self._vox.round().astype(int))] + tol = self._tol_slider.value() / 100 + voxels = set( + [ + (x, y, z) + for x, y, z in np.array( + np.where(abs(self._base_data - val) / val <= tol) + ).T + ] + ) + if self._vol_coords: + voxels = voxels.union(self._vol_coords[-1]) + self._vol_coords.append(voxels) + for voxel in voxels: + self._vol_img[voxel] = 1 + self._update_vol_images(draw=True) + self._plot_3d(render=True) + self.setFocus() + def _update_vol_images(self, axis=None, draw=False): """Update the volume image(s).""" for axis in range(3) if axis is None else [axis]: From 55a0b30998423a7336c90ccbc4a1786ab5d53819 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Tue, 25 Apr 2023 10:03:58 -0700 Subject: [PATCH 10/45] black --- mne_gui_addons/_segment.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index 3b04f86..16008b2 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -252,16 +252,21 @@ def _apply_brainmask(self): "Applying the brainmask, this will take ~30 seconds", ) img_data, _, vox_scan_ras_t, _ = _load_image( - op.join(self._subject_dir, "mri", "brainmask.mgz")) - idxs = np.meshgrid(np.arange(self._base_data.shape[0]), - np.arange(self._base_data.shape[1]), - np.arange(self._base_data.shape[2]), - indexing='ij') + op.join(self._subject_dir, "mri", "brainmask.mgz") + ) + idxs = np.meshgrid( + np.arange(self._base_data.shape[0]), + np.arange(self._base_data.shape[1]), + np.arange(self._base_data.shape[2]), + indexing="ij", + ) idxs = np.array(idxs) # (3, *image_data.shape) idxs = np.transpose(idxs, [1, 2, 3, 0]) # (*image_data.shape, 3) idxs = idxs.reshape(-1, 3) # (n_voxels, 3) idxs = apply_trans(self._vox_scan_ras_t, idxs) # vox -> scanner RAS - idxs = apply_trans(np.linalg.inv(vox_scan_ras_t), idxs) # scanner RAS -> mri vox + idxs = apply_trans( + np.linalg.inv(vox_scan_ras_t), idxs + ) # scanner RAS -> mri vox idxs = idxs.round().astype(int) # round to nearest voxel brain = set([(x, y, z) for x, y, z in np.array(np.where(img_data > 0)).T]) mask = np.array([tuple(idx) not in brain for idx in idxs]) From 643c47d9d6bcf73125fb174ac8dedcaca5d6560c Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Fri, 1 Dec 2023 14:05:32 -0800 Subject: [PATCH 11/45] resolve conflicts --- mne_gui_addons/_core.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index e225c43..aed1d58 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -107,10 +107,11 @@ def _load_image(img, verbose=None): ornt_trans = nib.orientations.ornt_transform(ornt, ras_ornt) img_data = nib.orientations.apply_orientation(orig_data, ornt_trans) orig_mgh = nib.MGHImage(orig_data, img.affine) + vox_scan_ras_t = orig_mgh.header.get_vox2ras() + vox_mri_t = orig_mgh.header.get_vox2ras_tkr() aff_trans = nib.orientations.inv_ornt_aff(ornt_trans, img.shape) - vox_ras_t = np.dot(orig_mgh.header.get_vox2ras_tkr(), aff_trans) - vox_scan_ras_t = np.dot(orig_mgh.header.get_vox2ras(), aff_trans) - return img_data, vox_ras_t, vox_scan_ras_t, _get_volume_info(orig_mgh) + ras_vox_scan_ras_t = np.dot(vox_scan_ras_t, aff_trans) + return img_data, vox_mri_t, vox_scan_ras_t, ras_vox_scan_ras_t, _get_volume_info(orig_mgh) def _make_mpl_plot( @@ -228,6 +229,7 @@ def _load_image_data(self, base_image=None, check_aligned=True): self._mri_data, self._mri_vox_ras_t, self._mri_vox_scan_ras_t, + self._mri_ras_vox_scan_ras_t, self._vol_info, ) = _load_image(op.join(self._subject_dir, "mri", f"{mri_img}.mgz")) self._mri_ras_vox_t = np.linalg.inv(self._mri_vox_ras_t) @@ -239,16 +241,18 @@ def _load_image_data(self, base_image=None, check_aligned=True): self._base_data = self._mri_data self._vox_ras_t = self._mri_vox_ras_t self._vox_scan_ras_t = self._mri_vox_scan_ras_t + self._ras_vox_scan_ras_t = self._mri_ras_vox_scan_ras_t else: ( self._base_data, self._vox_ras_t, self._vox_scan_ras_t, + self._ras_vox_scan_ras_t, self._vol_info, ) = _load_image(base_image) if self._mri_data is not None and check_aligned: if self._mri_data.shape != self._base_data.shape or not np.allclose( - self._vox_ras_t, self._mri_vox_ras_t, rtol=1e-6 + self._vox_scan_ras_t, vox_scan_ras_t, rtol=1e-6 ): raise ValueError( "Base image is not aligned to MRI, got " From ae46a97d40f170298d6b1f795e0a2bff3161c670 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Fri, 1 Dec 2023 14:08:13 -0800 Subject: [PATCH 12/45] style --- mne_gui_addons/_core.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index 9d1508a..3812f51 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -112,7 +112,13 @@ def _load_image(img, verbose=None): vox_mri_t = orig_mgh.header.get_vox2ras_tkr() aff_trans = nib.orientations.inv_ornt_aff(ornt_trans, img.shape) ras_vox_scan_ras_t = np.dot(vox_scan_ras_t, aff_trans) - return img_data, vox_mri_t, vox_scan_ras_t, ras_vox_scan_ras_t, _get_volume_info(orig_mgh) + return ( + img_data, + vox_mri_t, + vox_scan_ras_t, + ras_vox_scan_ras_t, + _get_volume_info(orig_mgh), + ) def _make_mpl_plot( From 6d8ed5e2eb9c3e952c1059420a391206d2289cb8 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Fri, 1 Dec 2023 14:30:53 -0800 Subject: [PATCH 13/45] fix transforms --- mne_gui_addons/_core.py | 46 +++++++++++++++++++--------------- mne_gui_addons/_ieeg_locate.py | 4 +-- mne_gui_addons/_segment.py | 4 +-- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index 3812f51..429e2f8 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -223,7 +223,7 @@ def _load_image_data(self, base_image=None, check_aligned=True): 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._mri_data = None + self._mr_data = None self._head = None self._lh = self._rh = None else: @@ -233,44 +233,50 @@ def _load_image_data(self, base_image=None, check_aligned=True): else "T1" ) ( - self._mri_data, - self._mri_vox_ras_t, - self._mri_vox_scan_ras_t, - self._mri_ras_vox_scan_ras_t, + self._mr_data, + self._mr_vox_mri_t, + self._mr_vox_scan_ras_t, + self._mr_ras_vox_scan_ras_t, self._vol_info, ) = _load_image(op.join(self._subject_dir, "mri", f"{mri_img}.mgz")) - self._mri_ras_vox_t = np.linalg.inv(self._mri_vox_ras_t) - self._mri_scan_ras_vox_t = np.linalg.inv(self._mri_vox_scan_ras_t) # ready alternate base image if provided, otherwise use brain/T1 if base_image is None: - assert self._mri_data is not None - self._base_data = self._mri_data - self._vox_ras_t = self._mri_vox_ras_t - self._vox_scan_ras_t = self._mri_vox_scan_ras_t - self._ras_vox_scan_ras_t = self._mri_ras_vox_scan_ras_t + assert self._mr_data is not None + self._base_data = self._mr_data + self._vox_mri_t = self._mr_vox_mri_t + self._vox_scan_ras_t = self._mr_vox_scan_ras_t + self._ras_vox_scan_ras_t = self._mr_ras_vox_scan_ras_t else: ( self._base_data, - self._vox_ras_t, + self._vox_mri_t, self._vox_scan_ras_t, self._ras_vox_scan_ras_t, self._vol_info, ) = _load_image(base_image) - if self._mri_data is not None and check_aligned: - if self._mri_data.shape != self._base_data.shape or not np.allclose( - self._vox_scan_ras_t, vox_scan_ras_t, rtol=1e-6 + if self._mr_data is not None and check_aligned: + if self._mr_data.shape != self._base_data.shape or not np.allclose( + self._vox_scan_ras_t, self._mr_vox_scan_ras_t, rtol=1e-6 ): raise ValueError( "Base image is not aligned to MRI, got " f"Base shape={self._base_data.shape}, " - f"MRI shape={self._mri_data.shape}, " - f"Base affine={self._vox_ras_t} and " - f"MRI affine={self._mri_vox_ras_t}, " + f"MRI shape={self._mr_data.shape}, " + f"Base affine={self._vox_scan_ras_t} and " + f"MRI affine={self._mr_vox_scan_ras_t}, " "please provide an aligned image or do not use the " "``subject`` and ``subjects_dir`` arguments" ) - + if self._mr_data is None: + # if no Freesurfer subjects directory provided, send 3D + # renderings to surface RAS of the base image + self._mr_vox_mri_t = self._vox_mri_t + self._mr_vox_scan_ras_t = self._vox_scan_ras_t + + self._mr_mri_vox_t = np.linalg.inv(self._mr_vox_mri_t) + self._mr_scan_ras_vox_t = np.linalg.inv(self._mr_vox_scan_ras_t) + self._mri_vox_t = np.linalg.inv(self._vox_mri_t) self._scan_ras_vox_t = np.linalg.inv(self._vox_scan_ras_t) self._scan_ras_ras_vox_t = np.linalg.inv( diff --git a/mne_gui_addons/_ieeg_locate.py b/mne_gui_addons/_ieeg_locate.py index c9507fe..01f7324 100644 --- a/mne_gui_addons/_ieeg_locate.py +++ b/mne_gui_addons/_ieeg_locate.py @@ -683,7 +683,7 @@ def _update_mri_images(self, axis=None, draw=False): if "mri" in self._images: for axis in range(3) if axis is None else [axis]: self._images["mri"][axis].set_data( - np.take(self._mri_data, self._current_slice[axis], axis=axis).T + np.take(self._mr_data, self._current_slice[axis], axis=axis).T ) if draw: self._draw(axis) @@ -847,7 +847,7 @@ def _toggle_show_brain(self): self._images["mri"] = list() for axis in range(3): mri_data = np.take( - self._mri_data, self._current_slice[axis], axis=axis + self._mr_data, self._current_slice[axis], axis=axis ).T self._images["mri"].append( self._figs[axis] diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index 16008b2..6bde7f9 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -452,8 +452,8 @@ def _plot_3d(self, render=False): self._mri_scan_ras_vox_t, verts ) # scanner RAS -> mri vox verts = apply_trans( - self._mri_vox_ras_t, verts - ) # mri vox -> mri surface RAS + self._mr_vox_mri_t, verts + ) # mr voxels -> surface RAS self._vol_actor = self._renderer.mesh( *verts.T, tris, From bbc457226e4e4e87c99477e84f6aecbbbfe1ae88 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Fri, 1 Dec 2023 14:31:31 -0800 Subject: [PATCH 14/45] style --- mne_gui_addons/_core.py | 2 +- mne_gui_addons/_segment.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index 429e2f8..d91a399 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -276,7 +276,7 @@ def _load_image_data(self, base_image=None, check_aligned=True): self._mr_mri_vox_t = np.linalg.inv(self._mr_vox_mri_t) self._mr_scan_ras_vox_t = np.linalg.inv(self._mr_vox_scan_ras_t) - + self._mri_vox_t = np.linalg.inv(self._vox_mri_t) self._scan_ras_vox_t = np.linalg.inv(self._vox_scan_ras_t) self._scan_ras_ras_vox_t = np.linalg.inv( diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index 6bde7f9..8eef8b8 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -451,9 +451,7 @@ def _plot_3d(self, render=False): verts = apply_trans( self._mri_scan_ras_vox_t, verts ) # scanner RAS -> mri vox - verts = apply_trans( - self._mr_vox_mri_t, verts - ) # mr voxels -> surface RAS + verts = apply_trans(self._mr_vox_mri_t, verts) # mr voxels -> surface RAS self._vol_actor = self._renderer.mesh( *verts.T, tris, From 7487724227cd2ee2b4beb43a9aceb0def33d0e7c Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 13:13:12 -0800 Subject: [PATCH 15/45] [BUG] Fix marching cubes not in the right space when CT is not aligned --- README.rst | 11 ++++++- examples/locate_ieeg_micro.py | 18 ++++++----- mne_gui_addons/_core.py | 57 +++++++++++++++++++--------------- mne_gui_addons/_ieeg_locate.py | 42 ++++++++++++++----------- 4 files changed, 76 insertions(+), 52 deletions(-) diff --git a/README.rst b/README.rst index 1f6fac0..76ab385 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,13 @@ MNE-GUI-Addons -------------- -MNE-Python GUI addons. +This project contains graphical user interface (GUI) addons that complement the +scripting functionality in ``mne-python``, ``mne-bids`` and other ``mne`` projects +for tasks that require a user to interact with data, usually that is 3D, in order +for them to be accomplished. For example: + +- Locating intracranial electrode contacts from a computed tomography (CT) or + magnetic resonance (MR) scan +- Viewing time-frequency source estimate data + +Please see the documentation: https://mne.tools/mne-gui-addons/. diff --git a/examples/locate_ieeg_micro.py b/examples/locate_ieeg_micro.py index 22e3542..467ac44 100644 --- a/examples/locate_ieeg_micro.py +++ b/examples/locate_ieeg_micro.py @@ -58,18 +58,20 @@ # launch the viewer with only the CT (note, we won't be able to use # the MR in this case to help determine which brain area the contact is # in), and use the user interface to find the locations of the contacts -gui = mne_gui.locate_ieeg(raw.info, head_ct_t, CT_orig) +gui = mne_gui.locate_ieeg( + raw.info, head_ct_t, CT_orig, subject="sample_seeg", subjects_dir=subjects_dir +) # we'll programmatically mark all the contacts on one electrode shaft for i, pos in enumerate( [ - (-52.66, -40.84, -26.99), - (-55.47, -38.03, -27.92), - (-57.68, -36.27, -28.85), - (-59.89, -33.81, -29.32), - (-62.57, -31.35, -30.37), - (-65.13, -29.07, -31.30), - (-67.57, -26.26, -31.88), + (-158.90, -78.84, -119.97), + (-161.71, -77.91, -117.16), + (-163.92, -76.98, -115.40), + (-166.13, -76.51, -112.94), + (-168.81, -75.46, -110.49), + (-171.37, -74.53, -108.20), + (-173.81, -73.95, -105.40), ] ): gui.set_RAS(pos) diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index 19c4a33..b79bb9d 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -161,7 +161,7 @@ def _load_image_data(self, base_image=None): 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._mri_data = None + self._mr_data = None self._head = None self._lh = self._rh = None else: @@ -170,17 +170,21 @@ def _load_image_data(self, base_image=None): if op.isfile(op.join(self._subject_dir, "mri", "brain.mgz")) else "T1" ) - self._mri_data, vox_mri_t, vox_scan_ras_t, ras_vox_scan_ras_t = _load_image( - op.join(self._subject_dir, "mri", f"{mri_img}.mgz") - ) + ( + self._mr_data, + self._mr_vox_mri_t, + self._mr_vox_scan_ras_t, + self._mr_ras_vox_scan_ras_t, + ) = _load_image(op.join(self._subject_dir, "mri", f"{mri_img}.mgz")) # ready alternate base image if provided, otherwise use brain/T1 + self._base_mr_aligned = True if base_image is None: - assert self._mri_data is not None - self._base_data = self._mri_data - self._vox_mri_t = vox_mri_t - self._vox_scan_ras_t = vox_scan_ras_t - self._ras_vox_scan_ras_t = ras_vox_scan_ras_t + assert self._mr_data is not None + self._base_data = self._mr_data + self._vox_mri_t = self._mr_vox_mri_t + self._vox_scan_ras_t = self._mr_vox_scan_ras_t + self._ras_vox_scan_ras_t = self._mr_ras_vox_scan_ras_t else: ( self._base_data, @@ -188,27 +192,28 @@ def _load_image_data(self, base_image=None): self._vox_scan_ras_t, self._ras_vox_scan_ras_t, ) = _load_image(base_image) - if self._mri_data is not None: - if self._mri_data.shape != self._base_data.shape or not np.allclose( - self._vox_scan_ras_t, vox_scan_ras_t, rtol=1e-6 + if self._mr_data is None: + # if no Freesurfer subjects directory provided, send 3D + # renderings to surface RAS of the base image + self._mr_vox_mri_t = self._vox_mri_t + self._mr_vox_scan_ras_t = self._vox_scan_ras_t + self._mr_ras_vox_scan_ras_t = self._ras_vox_scan_ras_t + else: + if self._mr_data.shape != self._base_data.shape or not np.allclose( + self._vox_scan_ras_t, self._mr_vox_scan_ras_t, rtol=1e-6 ): - raise ValueError( - "Base image is not aligned to MRI, got " - f"Base shape={self._base_data.shape}, " - f"MRI shape={self._mri_data.shape}, " - f"Base affine={vox_scan_ras_t} and " - f"MRI affine={self._vox_scan_ras_t}, " - "please provide an aligned image or do not use the " - "``subject`` and ``subjects_dir`` arguments" - ) + self._base_mr_aligned = False self._mri_vox_t = np.linalg.inv(self._vox_mri_t) + self._mr_mri_vox_t = np.linalg.inv(self._mr_vox_mri_t) self._scan_ras_vox_t = np.linalg.inv(self._vox_scan_ras_t) - self._scan_ras_ras_vox_t = np.linalg.inv( - self._ras_vox_scan_ras_t - ) # to RAS voxels + self._mr_scan_ras_vox_t = np.linalg.inv(self._mr_vox_scan_ras_t) + self._scan_ras_ras_vox_t = np.linalg.inv(self._ras_vox_scan_ras_t) + self._mr_scan_ras_ras_vox_t = np.linalg.inv(self._mr_ras_vox_scan_ras_t) + self._scan_ras_mri_t = np.dot(self._vox_mri_t, self._scan_ras_vox_t) self._mri_scan_ras_t = np.dot(self._vox_scan_ras_t, self._mri_vox_t) + self._voxel_sizes = np.array(self._base_data.shape) self._voxel_ratios = self._voxel_sizes / self._voxel_sizes.min() @@ -341,7 +346,9 @@ def _plot_images(self): np.where(self._base_data < np.quantile(self._base_data, 0.95), 0, 1), [1], )[0] - rr = apply_trans(self._vox_mri_t, rr) + rr = apply_trans(self._vox_scan_ras_t, rr) # base image vox -> RAS + rr = apply_trans(self._mr_scan_ras_vox_t, rr) # RAS -> MR voxels + rr = apply_trans(self._mr_vox_mri_t, rr) # MR voxels -> MR surface RAS self._renderer.mesh( *rr.T, triangles=tris, diff --git a/mne_gui_addons/_ieeg_locate.py b/mne_gui_addons/_ieeg_locate.py index 3762d39..f110d46 100644 --- a/mne_gui_addons/_ieeg_locate.py +++ b/mne_gui_addons/_ieeg_locate.py @@ -147,15 +147,6 @@ def __init__( if targets: self.auto_find_contacts(targets) - # set current position as current contact location if exists - if not np.isnan(self._chs[self._ch_names[self._ch_index]]).any(): - self._set_ras( - apply_trans( - self._mri_scan_ras_t, self._chs[self._ch_names[self._ch_index]] - ), - update_plots=False, - ) - # add plots of contacts on top self._plot_ch_images() @@ -166,7 +157,21 @@ def __init__( self._update_lines(group) # ready for user - self._move_cursors_to_pos() + # set current position as (0, 0, 0) surface RAS (center of mass roughly) if no positions + if np.isnan(self._chs[self._ch_names[self._ch_index]]).any(): + self._set_ras( + apply_trans( + self._vox_scan_ras_t, apply_trans(self._mri_vox_t, (0, 0, 0)) + ) + ) + # set current position as current contact location if exists + else: + self._set_ras( + apply_trans( + self._mri_scan_ras_t, self._chs[self._ch_names[self._ch_index]] + ), + update_plots=False, + ) self._ch_list.setFocus() # always focus on list if show: @@ -345,9 +350,10 @@ def _configure_toolbar(self): hbox.addStretch(1) - self._toggle_brain_button = QPushButton("Show Brain") - self._toggle_brain_button.released.connect(self._toggle_show_brain) - hbox.addWidget(self._toggle_brain_button) + 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) hbox.addStretch(1) @@ -1043,7 +1049,7 @@ def _update_mri_images(self, axis=None, draw=False): if "mri" in self._images: for axis in range(3) if axis is None else [axis]: self._images["mri"][axis].set_data( - np.take(self._mri_data, self._current_slice[axis], axis=axis).T + np.take(self._mr_data, self._current_slice[axis], axis=axis).T ) if draw: self._draw(axis) @@ -1112,8 +1118,8 @@ def _update_ct_maxima(self, ct_thresh=0.95): maximum_filter(self._ct_data, (self._radius,) * 3) == self._ct_data ) self._ct_maxima[self._ct_data <= self._ct_data.max() * ct_thresh] = False - if self._mri_data is not None: - self._ct_maxima[self._mri_data == 0] = False + if self._mr_data is not None: + self._ct_maxima[self._mr_data == 0] = False self._ct_maxima = np.where(self._ct_maxima, 1, np.nan) # transparent def _toggle_show_mip(self): @@ -1212,7 +1218,7 @@ def _toggle_show_brain(self): self._images["mri"] = list() for axis in range(3): mri_data = np.take( - self._mri_data, self._current_slice[axis], axis=axis + self._mr_data, self._current_slice[axis], axis=axis ).T self._images["mri"].append( self._figs[axis] @@ -1232,7 +1238,7 @@ def keyPressEvent(self, event): if event.text() == "r": self.remove_channel() - if event.text() == "b": + if event.text() == "b" and self._base_mr_aligned: self._toggle_show_brain() if event.text() == "c": From 3ee1c8db64e1d73142d896503f67a344dc57b23b Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 13:21:42 -0800 Subject: [PATCH 16/45] try restricting pyqt version --- .circleci/config.yml | 2 +- .github/workflows/tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 1e93ed4..44e538a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -42,7 +42,7 @@ jobs: - run: name: Get Python running command: | - pip install --upgrade PyQt6 "PyQt6-Qt6!=6.6.1" sphinx-gallery pydata-sphinx-theme numpydoc scikit-learn nilearn mne-bids autoreject git+https://github.com/pyvista/pyvista@main memory_profiler sphinxcontrib.bibtex sphinxcontrib.youtube + pip install --upgrade PyQt6!=6.6.1 "PyQt6-Qt6!=6.6.1" sphinx-gallery pydata-sphinx-theme numpydoc scikit-learn nilearn mne-bids autoreject git+https://github.com/pyvista/pyvista@main memory_profiler sphinxcontrib.bibtex sphinxcontrib.youtube pip install -ve ./mne-python . - run: name: Check Qt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 503521e..7b378d7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,7 +62,7 @@ jobs: if: "matrix.mne-version == 'mne-main'" run: git clone --single-branch --branch main https://github.com/mne-tools/mne-python.git - run: pip install -ve ./mne-python - - run: pip install -v ${{ matrix.qt }} "PyQt6-Qt6!=6.6.1" + - run: pip install -v ${{ matrix.qt }}!=6.6.1 "PyQt6-Qt6!=6.6.1" - run: pip install -ve .[tests] - run: mne sys_info - run: | From 76ce34d5e7d8f8dc46062107a68cc078260e0557 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 13:59:37 -0800 Subject: [PATCH 17/45] cruft --- mne_gui_addons/_segment.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index 8eef8b8..351a650 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -103,7 +103,6 @@ def __init__( base_image=base_image, subject=subject, subjects_dir=subjects_dir, - check_aligned=False, ) self._vol_img = np.zeros(self._base_data.shape) * np.nan From a6272176689502db2f101831e2fb2057664dc1c5 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 14:20:10 -0800 Subject: [PATCH 18/45] update index, don't plot brain if not aligned --- README.rst | 96 ++++++++++++++++++++++++++++++++++++++++- doc/index.rst | 9 +++- mne_gui_addons/_core.py | 4 +- 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 76ab385..9a3b408 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,5 @@ +.. -*- mode: rst -*- + MNE-GUI-Addons -------------- @@ -10,4 +12,96 @@ for them to be accomplished. For example: magnetic resonance (MR) scan - Viewing time-frequency source estimate data -Please see the documentation: https://mne.tools/mne-gui-addons/. +Documentation +^^^^^^^^^^^^^ + +Please see https://mne.tools/mne-gui-addons/ for installation instructions, tutorials, +and examples for a wide variety of topics, contributing guidelines, and an API +reference. + + +Forum +^^^^^^ + +The `user forum`_ is the best place to ask questions about MNE-Python usage or +the contribution process. The forum also features job opportunities and other +announcements. + +If you find a bug or have an idea for a new feature that should be added, +please use the +`issue tracker `__ of +our GitHub repository. + + +Installation +^^^^^^^^^^^^ + +To install the latest stable version of MNE-GUI-Addons with minimal dependencies +only, use pip in a terminal: + +.. code-block:: console + + $ pip install --upgrade mne-gui-addons + + +Get the development version +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To install the latest development version of MNE-Python using pip, open a +terminal and type: + +.. code-block:: console + + $ pip install --upgrade git+https://github.com/mne-tools/mne-gui-addons@main + +To clone the repository with `git `__, open a terminal +and type: + +.. code-block:: console + + $ git clone https://github.com/mne-tools/mne-gui-addons.git + + +Contributing +^^^^^^^^^^^^ + +Please see the `contributing guidelines `__ on our documentation website. + + +License +^^^^^^^ + +MNE-GUI-Addons is **BSD-licensed** (BSD-3-Clause): + + This software is OSI Certified Open Source Software. + OSI Certified is a certification mark of the Open Source Initiative. + + Copyright (c) 2011-2023, authors of MNE-Python. + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the names of MNE-Python authors nor the names of any + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + **This software is provided by the copyright holders and contributors + "as is" and any express or implied warranties, including, but not + limited to, the implied warranties of merchantability and fitness for + a particular purpose are disclaimed. In no event shall the copyright + owner or contributors be liable for any direct, indirect, incidental, + special, exemplary, or consequential damages (including, but not + limited to, procurement of substitute goods or services; loss of use, + data, or profits; or business interruption) however caused and on any + theory of liability, whether in contract, strict liability, or tort + (including negligence or otherwise) arising in any way out of the use + of this software, even if advised of the possibility of such + damage.** diff --git a/doc/index.rst b/doc/index.rst index a23d5db..0e95cfa 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,7 +1,14 @@ MNE-GUI-Addons -------------- -MNE-Python GUI addons. +This project contains graphical user interface (GUI) addons that complement the +scripting functionality in ``mne-python``, ``mne-bids`` and other ``mne`` projects +for tasks that require a user to interact with data, usually that is 3D, in order +for them to be accomplished. For example: + +- Locating intracranial electrode contacts from a computed tomography (CT) or + magnetic resonance (MR) scan +- Viewing time-frequency source estimate data .. toctree:: :maxdepth: 1 diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index b79bb9d..55d47de 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -336,7 +336,7 @@ def _plot_images(self): "button_release_event", partial(self._on_click, axis=axis) ) # add head and brain in mm (convert from m) - if self._head is None: + if self._head is None or not self._base_mr_aligned: logger.debug( "Using marching cubes on the base image for the " "3D visualization panel" @@ -367,7 +367,7 @@ def _plot_images(self): reset_camera=False, render=False, ) - if self._lh is not None and self._rh is not None: + if self._lh is not None and self._rh is not None and self._base_mr_aligned: self._renderer.mesh( *self._lh["rr"].T * 1000, triangles=self._lh["tris"], From a0f0c42b800aac3ea57858a5a0acab924c04c619 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 14:21:42 -0800 Subject: [PATCH 19/45] don't show brain if not aligned --- mne_gui_addons/_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index 5a97464..579a7b5 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -400,7 +400,7 @@ def _plot_images(self): "button_release_event", partial(self._on_click, axis=axis) ) # add head and brain in mm (convert from m) - if self._head is None: + if self._head is None or not self._base_mr_aligned: logger.debug( "Using marching cubes on the base image for the " "3D visualization panel" @@ -431,7 +431,7 @@ def _plot_images(self): reset_camera=False, render=False, ) - if self._lh is not None and self._rh is not 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 * 1000, triangles=self._lh["tris"], From 739afa75a5b178dc513cd6bd4fddae7812342b12 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 14:23:27 -0800 Subject: [PATCH 20/45] fix broken link --- README.rst | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 9a3b408..e4ae2f8 100644 --- a/README.rst +++ b/README.rst @@ -20,12 +20,8 @@ and examples for a wide variety of topics, contributing guidelines, and an API reference. -Forum -^^^^^^ - -The `user forum`_ is the best place to ask questions about MNE-Python usage or -the contribution process. The forum also features job opportunities and other -announcements. +Reporting +^^^^^^^^^ If you find a bug or have an idea for a new feature that should be added, please use the From 3992f9132980402d66cf1713d32b60db1051096f Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 14:35:28 -0800 Subject: [PATCH 21/45] add tests start --- mne_gui_addons/tests/test_segment.py | 72 ++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 mne_gui_addons/tests/test_segment.py diff --git a/mne_gui_addons/tests/test_segment.py b/mne_gui_addons/tests/test_segment.py new file mode 100644 index 0000000..5dc1f0a --- /dev/null +++ b/mne_gui_addons/tests/test_segment.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# Authors: Alex Rockhill +# +# License: BSD-3-clause + +import numpy as np +from numpy.testing import assert_allclose + +import pytest + +from mne.datasets import testing +from mne.utils import catch_logging, use_log_level +from mne.viz.utils import _fake_click + +data_path = testing.data_path(download=False) +subject = "sample" +subjects_dir = data_path / "subjects" + + +@testing.requires_testing_data +def test_segment_io(renderer_interactive_pyvistaqt): + """Test the input/output of the slice browser GUI.""" + nib = pytest.importorskip("nibabel") + from mne_gui_addons._segment import VolumeSegmenter + + with pytest.warns(match="`pial` surface not found"): + VolumeSegmenter( + nib.MGHImage(np.ones((96, 96, 96), dtype=np.float32), np.eye(4)), + subject=subject, + subjects_dir=subjects_dir, + ) + + +# TODO: For some reason this leaves some stuff un-closed, we should fix it +@pytest.mark.allow_unclosed +@testing.requires_testing_data +def test_segment_display(renderer_interactive_pyvistaqt): + """Test that the slice browser GUI displays properly.""" + pytest.importorskip("nibabel") + from mne_gui_addons._segment import VolumeSegmenter + + # test no seghead, fsaverage doesn't have seghead + with pytest.warns(RuntimeWarning, match="`seghead` not found"): + with catch_logging() as log: + gui = VolumeSegmenter( + subject="fsaverage", subjects_dir=subjects_dir, verbose=True + ) + log = log.getvalue() + assert "using marching cubes" in log + gui.close() + + # test functions + with pytest.warns(RuntimeWarning, match="`pial` surface not found"): + gui = SliceBrowser(subject=subject, subjects_dir=subjects_dir) + + # test RAS + gui._RAS_textbox.setText("10 10 10") + gui._RAS_textbox.focusOutEvent(event=None) + assert_allclose(gui._ras, [10, 10, 10]) + + # test vox + gui._VOX_textbox.setText("150, 150, 150") + gui._VOX_textbox.focusOutEvent(event=None) + assert_allclose(gui._ras, [23, 22, 23]) + + # test click + with use_log_level("debug"): + _fake_click( + gui._figs[2], gui._figs[2].axes[0], [137, 140], xform="data", kind="release" + ) + assert_allclose(gui._ras, [10, 12, 23]) + gui.close() From 49a253e3ba0fa9331ae7feb7ed20e2474b0c1965 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 14:58:33 -0800 Subject: [PATCH 22/45] fix all tests are skipped --- mne_gui_addons/_core.py | 5 ++++- mne_gui_addons/_vol_stc.py | 2 +- mne_gui_addons/tests/test_core.py | 13 ++++++++----- mne_gui_addons/tests/test_vol_stc.py | 4 ++-- pyproject.toml | 1 + 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index 55d47de..a68d16c 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -342,8 +342,11 @@ def _plot_images(self): "3D visualization panel" ) # in this case, leave in voxel coordinates + thresh = np.quantile(self._base_data, 0.95) + if not (self._base_data < thresh).any(): + thresh = self._base_data.min() rr, tris = _marching_cubes( - np.where(self._base_data < np.quantile(self._base_data, 0.95), 0, 1), + np.where(self._base_data <= thresh, 0, 1), [1], )[0] rr = apply_trans(self._vox_scan_ras_t, rr) # base image vox -> RAS diff --git a/mne_gui_addons/_vol_stc.py b/mne_gui_addons/_vol_stc.py index fa69320..a8289f2 100644 --- a/mne_gui_addons/_vol_stc.py +++ b/mne_gui_addons/_vol_stc.py @@ -830,7 +830,7 @@ def get_inst_data(inst): if isinstance(inst, EpochsTFR): inst_data = inst.data elif isinstance(inst, BaseEpochs): - inst_data = inst.get_data() + inst_data = inst.get_data(copy=True) else: inst_data = inst.data[None] # new axis for single epoch # convert to power or ITC for group diff --git a/mne_gui_addons/tests/test_core.py b/mne_gui_addons/tests/test_core.py index 9e36d5f..bf50cd7 100644 --- a/mne_gui_addons/tests/test_core.py +++ b/mne_gui_addons/tests/test_core.py @@ -23,9 +23,12 @@ def test_slice_browser_io(renderer_interactive_pyvistaqt): nib = pytest.importorskip("nibabel") from mne_gui_addons._core import SliceBrowser - with pytest.raises(ValueError, match="Base image is not aligned to MRI"): + data = np.ones((96, 96, 96), dtype=np.float32) + data[30:50, 30:50, 30:50] = 2 + + with pytest.warns(match="`pial` surface not found"): SliceBrowser( - nib.MGHImage(np.ones((96, 96, 96), dtype=np.float32), np.eye(4)), + nib.MGHImage(data, np.eye(4)), subject=subject, subjects_dir=subjects_dir, ) @@ -56,17 +59,17 @@ def test_slice_browser_display(renderer_interactive_pyvistaqt): # test RAS gui._RAS_textbox.setText("10 10 10") gui._RAS_textbox.focusOutEvent(event=None) - assert_allclose(gui._ras, [10, 10, 10]) + assert_allclose(gui._ras, [10, 10, 10], atol=1e-5) # test vox gui._VOX_textbox.setText("150, 150, 150") gui._VOX_textbox.focusOutEvent(event=None) - assert_allclose(gui._ras, [23, 22, 23]) + assert_allclose(gui._ras, [-27.27362 , 31.039082, -49.287958], atol=0.1) # test click with use_log_level("debug"): _fake_click( gui._figs[2], gui._figs[2].axes[0], [137, 140], xform="data", kind="release" ) - assert_allclose(gui._ras, [10, 12, 23]) + assert_allclose(gui._ras, [4.726382, 21.039079, -49.287956], atol=0.1) gui.close() diff --git a/mne_gui_addons/tests/test_vol_stc.py b/mne_gui_addons/tests/test_vol_stc.py index 54d1e5c..044a437 100644 --- a/mne_gui_addons/tests/test_vol_stc.py +++ b/mne_gui_addons/tests/test_vol_stc.py @@ -145,7 +145,7 @@ def test_stc_viewer_display(renderer_interactive_pyvistaqt): ) # test go to max viewer._go_to_extreme_button.click() - assert_allclose(viewer._ras, [-20, -60, -20], atol=0.01) + assert_allclose(viewer._ras, [-25.273603, -50.960915, -47.287962], atol=0.01) src_coord = viewer._get_src_coord() stc_idx = viewer._src_lut[src_coord] @@ -190,7 +190,7 @@ def test_stc_viewer_display(renderer_interactive_pyvistaqt): # test go to max viewer._go_to_extreme_button.click() - assert_allclose(viewer._ras, [-20, -60, -20], atol=0.01) + assert_allclose(viewer._ras, [-25.273603, -50.960915, -47.287962], atol=0.01) src_coord = viewer._get_src_coord() stc_idx = viewer._src_lut[src_coord] diff --git a/pyproject.toml b/pyproject.toml index 9ce44fb..babc9c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "dipy>=1.4", "traitlets", "setuptools >=65", + "imageio-ffmpeg>=0.4.1", ] dynamic = ["version"] From a5609498feea14870a5ec4d5c8598ebdb1bc306f Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 14:59:19 -0800 Subject: [PATCH 23/45] style --- mne_gui_addons/tests/test_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mne_gui_addons/tests/test_core.py b/mne_gui_addons/tests/test_core.py index bf50cd7..e61d631 100644 --- a/mne_gui_addons/tests/test_core.py +++ b/mne_gui_addons/tests/test_core.py @@ -64,12 +64,12 @@ def test_slice_browser_display(renderer_interactive_pyvistaqt): # test vox gui._VOX_textbox.setText("150, 150, 150") gui._VOX_textbox.focusOutEvent(event=None) - assert_allclose(gui._ras, [-27.27362 , 31.039082, -49.287958], atol=0.1) + assert_allclose(gui._ras, [-27.27362, 31.039082, -49.287958], atol=0.1) # test click with use_log_level("debug"): _fake_click( gui._figs[2], gui._figs[2].axes[0], [137, 140], xform="data", kind="release" ) - assert_allclose(gui._ras, [4.726382, 21.039079, -49.287956], atol=0.1) + assert_allclose(gui._ras, [4.726382, 21.039079, -49.287956], atol=0.1) gui.close() From 37c66f59f758213c46fe659f43476eb465190c66 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 15:10:31 -0800 Subject: [PATCH 24/45] fix dep, tests --- doc/conf.py | 6 +++++- pyproject.toml | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/doc/conf.py b/doc/conf.py index 8d42361..4fd689a 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,8 +1,8 @@ import faulthandler import os import sys +import warnings -import pyvista import mne_gui_addons faulthandler.enable() @@ -94,6 +94,10 @@ numpydoc_validation_exclude = { # set of regex r"mne\.utils\.deprecated", } + +with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + import pyvista pyvista.OFF_SCREEN = False pyvista.BUILDING_GALLERY = True sphinx_gallery_conf = { diff --git a/pyproject.toml b/pyproject.toml index babc9c8..36d8bb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ tests = [ "pytest", "pytest-cov", "black", # function signature formatting + "sphinx-gallery", ] [project.urls] From b44c4d706f142a87f647846829a64c6d87d8fc85 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 15:17:32 -0800 Subject: [PATCH 25/45] try again ignore warning --- doc/conf.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 4fd689a..8010a98 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -3,6 +3,7 @@ import sys import warnings +import pyvista import mne_gui_addons faulthandler.enable() @@ -94,10 +95,6 @@ numpydoc_validation_exclude = { # set of regex r"mne\.utils\.deprecated", } - -with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - import pyvista pyvista.OFF_SCREEN = False pyvista.BUILDING_GALLERY = True sphinx_gallery_conf = { @@ -167,3 +164,9 @@ bibtex_bibfiles = ["./references.bib"] bibtex_style = "unsrt" bibtex_footbibliography_header = "" + +# ignore warnings +warnings.filterwarnings( + "ignore", + message="The `pyvista.plotting.plotting` module has been deprecated.*", +) From 77d88534305fe1d45849041749712bb14154a7f7 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 15:22:03 -0800 Subject: [PATCH 26/45] ignore in wrong place --- doc/conf.py | 7 ------- mne_gui_addons/conftest.py | 8 ++++++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 8010a98..8d42361 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,7 +1,6 @@ import faulthandler import os import sys -import warnings import pyvista import mne_gui_addons @@ -164,9 +163,3 @@ bibtex_bibfiles = ["./references.bib"] bibtex_style = "unsrt" bibtex_footbibliography_header = "" - -# ignore warnings -warnings.filterwarnings( - "ignore", - message="The `pyvista.plotting.plotting` module has been deprecated.*", -) diff --git a/mne_gui_addons/conftest.py b/mne_gui_addons/conftest.py index 25b7ed1..92cedaa 100644 --- a/mne_gui_addons/conftest.py +++ b/mne_gui_addons/conftest.py @@ -1,2 +1,10 @@ # get all MNE fixtures and settings from mne.conftest import * # noqa: F403 + +import warnings + +# ignore warnings +warnings.filterwarnings( + "ignore", + message="The `pyvista.plotting.plotting` module has been deprecated.*", +) From 4c2db14a09008c7312b597c805329423e83499fc Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 15:32:03 -0800 Subject: [PATCH 27/45] wrong still --- mne_gui_addons/conftest.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/mne_gui_addons/conftest.py b/mne_gui_addons/conftest.py index 92cedaa..1b156c7 100644 --- a/mne_gui_addons/conftest.py +++ b/mne_gui_addons/conftest.py @@ -3,8 +3,10 @@ import warnings -# ignore warnings -warnings.filterwarnings( - "ignore", - message="The `pyvista.plotting.plotting` module has been deprecated.*", -) + +def pytest_configure(config): + """Configure pytest options.""" + config.addinivalue_line( + "filterwarnings", + "ignore:*The `pyvista.plotting.plotting` module has been deprecated.*", + ) From aef86070e1bbefb5928af9b372ea6fc49bb39e27 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 15:33:00 -0800 Subject: [PATCH 28/45] cruft --- mne_gui_addons/conftest.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mne_gui_addons/conftest.py b/mne_gui_addons/conftest.py index 1b156c7..a9f395e 100644 --- a/mne_gui_addons/conftest.py +++ b/mne_gui_addons/conftest.py @@ -1,9 +1,6 @@ # get all MNE fixtures and settings from mne.conftest import * # noqa: F403 -import warnings - - def pytest_configure(config): """Configure pytest options.""" config.addinivalue_line( From 290298f835da110a3215a700da62498c38a58a7c Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 15:33:51 -0800 Subject: [PATCH 29/45] space --- mne_gui_addons/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mne_gui_addons/conftest.py b/mne_gui_addons/conftest.py index a9f395e..5a88adf 100644 --- a/mne_gui_addons/conftest.py +++ b/mne_gui_addons/conftest.py @@ -1,6 +1,7 @@ # get all MNE fixtures and settings from mne.conftest import * # noqa: F403 + def pytest_configure(config): """Configure pytest options.""" config.addinivalue_line( From ea08203c8b4370f3903e0780a902e401fa5bbd55 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 15:49:58 -0800 Subject: [PATCH 30/45] finish adding tests --- mne_gui_addons/_segment.py | 12 +++++++- mne_gui_addons/tests/test_segment.py | 41 ++++++++++------------------ 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index 351a650..b4dd0d7 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -304,6 +304,16 @@ def set_clim(self, vmin=None, vmax=None): if vmax is not None: self._img_max_slider.setValue(vmax) + def set_smooth(self, smooth): + """Set the smoothness of the 3D renderering of the segmented volume. + + Parameters + ---------- + smooth : float [0, 1] + The smoothness of the 3D rendering. + """ + self._smooth_slider.setValue(int(round(smooth * 100))) + def _update_img_scale(self): """Update base image slider values.""" new_min = self._img_min_slider.value() @@ -448,7 +458,7 @@ def _plot_3d(self, render=False): verts, tris = _marching_cubes(self._vol_img, [1], smooth=smooth)[0] verts = apply_trans(self._vox_scan_ras_t, verts) # vox -> scanner RAS verts = apply_trans( - self._mri_scan_ras_vox_t, verts + self._mr_scan_ras_vox_t, verts ) # scanner RAS -> mri vox verts = apply_trans(self._mr_vox_mri_t, verts) # mr voxels -> surface RAS self._vol_actor = self._renderer.mesh( diff --git a/mne_gui_addons/tests/test_segment.py b/mne_gui_addons/tests/test_segment.py index 5dc1f0a..3acc614 100644 --- a/mne_gui_addons/tests/test_segment.py +++ b/mne_gui_addons/tests/test_segment.py @@ -9,8 +9,6 @@ import pytest from mne.datasets import testing -from mne.utils import catch_logging, use_log_level -from mne.viz.utils import _fake_click data_path = testing.data_path(download=False) subject = "sample" @@ -41,32 +39,23 @@ def test_segment_display(renderer_interactive_pyvistaqt): # test no seghead, fsaverage doesn't have seghead with pytest.warns(RuntimeWarning, match="`seghead` not found"): - with catch_logging() as log: - gui = VolumeSegmenter( - subject="fsaverage", subjects_dir=subjects_dir, verbose=True - ) - log = log.getvalue() - assert "using marching cubes" in log - gui.close() + gui = VolumeSegmenter( + subject="fsaverage", subjects_dir=subjects_dir, verbose=True + ) # test functions - with pytest.warns(RuntimeWarning, match="`pial` surface not found"): - gui = SliceBrowser(subject=subject, subjects_dir=subjects_dir) + gui.set_RAS([25.37, 0.00, 34.18]) - # test RAS - gui._RAS_textbox.setText("10 10 10") - gui._RAS_textbox.focusOutEvent(event=None) - assert_allclose(gui._ras, [10, 10, 10]) + # test mark + gui._mark() + assert abs(np.nansum(gui._vol_img) - 250) < 3 - # test vox - gui._VOX_textbox.setText("150, 150, 150") - gui._VOX_textbox.focusOutEvent(event=None) - assert_allclose(gui._ras, [23, 22, 23]) + # increase tolerance + gui.set_tolerance(0.5) - # test click - with use_log_level("debug"): - _fake_click( - gui._figs[2], gui._figs[2].axes[0], [137, 140], xform="data", kind="release" - ) - assert_allclose(gui._ras, [10, 12, 23]) - gui.close() + # check more voxels marked + gui._mark() + assert np.nansum(gui._vol_img) > 253 + + # check smooth + gui.set_smooth(0.7) From b8e2c81cd846279c37d3031c2b6716d789ca857b Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 15:50:55 -0800 Subject: [PATCH 31/45] fix re --- mne_gui_addons/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne_gui_addons/conftest.py b/mne_gui_addons/conftest.py index 5a88adf..c044c2e 100644 --- a/mne_gui_addons/conftest.py +++ b/mne_gui_addons/conftest.py @@ -6,5 +6,5 @@ def pytest_configure(config): """Configure pytest options.""" config.addinivalue_line( "filterwarnings", - "ignore:*The `pyvista.plotting.plotting` module has been deprecated.*", + "ignore:.*The `pyvista.plotting.plotting` module has been deprecated.*", ) From 2632c631a6a521629cbd4df37bdb505f28daee49 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Mon, 4 Dec 2023 16:35:06 -0800 Subject: [PATCH 32/45] mne-stable errors --- .github/workflows/tests.yml | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7b378d7..21c4256 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,12 +27,12 @@ jobs: include: - os: macos-latest python-version: "3.10" - mne-version: mne-stable + mne-version: mne-main # pin to main for import functions until 1.6 release qt: PyQt6 # Old (and PyQt5) - os: ubuntu-latest python-version: "3.9" - mne-version: mne-stable + mne-version: mne-main # pin to main for import functions until 1.6 release qt: PyQt5 # PySide2 - os: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index 36d8bb0..9f96d1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,6 @@ dependencies = [ "dipy>=1.4", "traitlets", "setuptools >=65", - "imageio-ffmpeg>=0.4.1", ] dynamic = ["version"] @@ -33,6 +32,7 @@ tests = [ "pytest-cov", "black", # function signature formatting "sphinx-gallery", + "imageio-ffmpeg>=0.4.1", ] [project.urls] From eca96148c5e95045d75d738d9217372625cab2ce Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Tue, 5 Dec 2023 17:05:25 -0800 Subject: [PATCH 33/45] working --- examples/ieeg_locate.py | 6 +++ mne_gui_addons/_core.py | 53 +++++++++--------- mne_gui_addons/_ieeg_locate.py | 68 ++++++++++-------------- mne_gui_addons/_vol_stc.py | 7 ++- mne_gui_addons/tests/test_ieeg_locate.py | 53 +++++++++++++----- 5 files changed, 105 insertions(+), 82 deletions(-) diff --git a/examples/ieeg_locate.py b/examples/ieeg_locate.py index fd3df9e..d5ff076 100644 --- a/examples/ieeg_locate.py +++ b/examples/ieeg_locate.py @@ -495,6 +495,9 @@ def plot_overlay(image, compare, title, thresh=None): # :ref:`mne watershed_bem` or :ref:`mne flash_bem`. # First, let's plot the localized sensor positions without modification. +# reload original found positions +raw_ecog = mne.io.read_raw(misc_path / "ecog" / "sample_ecog_ieeg.fif") + # plot projected sensors brain_kwargs = dict(cortex="low_contrast", alpha=0.2, background="white") brain = mne.viz.Brain( @@ -537,6 +540,9 @@ def plot_overlay(image, compare, title, thresh=None): # identify their location. The estimated head->mri ``trans`` was used # when the electrode contacts were localized so we need to use it again here. +# reload original found positions +raw = mne.io.read_raw(misc_path / "ecog" / "sample_ecog_ieeg.fif") + # plot the alignment brain = mne.viz.Brain("sample_seeg", subjects_dir=misc_path / "seeg", **brain_kwargs) brain.add_sensors(raw.info, trans=subj_trans) diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index a68d16c..0283ffd 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -172,9 +172,9 @@ def _load_image_data(self, base_image=None): ) ( self._mr_data, - self._mr_vox_mri_t, - self._mr_vox_scan_ras_t, - self._mr_ras_vox_scan_ras_t, + mr_vox_mri_t, + mr_vox_scan_ras_t, + mr_ras_vox_scan_ras_t, ) = _load_image(op.join(self._subject_dir, "mri", f"{mri_img}.mgz")) # ready alternate base image if provided, otherwise use brain/T1 @@ -182,9 +182,9 @@ def _load_image_data(self, base_image=None): if base_image is None: assert self._mr_data is not None self._base_data = self._mr_data - self._vox_mri_t = self._mr_vox_mri_t - self._vox_scan_ras_t = self._mr_vox_scan_ras_t - self._ras_vox_scan_ras_t = self._mr_ras_vox_scan_ras_t + self._vox_mri_t = mr_vox_mri_t + self._vox_scan_ras_t = mr_vox_scan_ras_t + self._ras_vox_scan_ras_t = mr_ras_vox_scan_ras_t else: ( self._base_data, @@ -192,24 +192,15 @@ def _load_image_data(self, base_image=None): self._vox_scan_ras_t, self._ras_vox_scan_ras_t, ) = _load_image(base_image) - if self._mr_data is None: - # if no Freesurfer subjects directory provided, send 3D - # renderings to surface RAS of the base image - self._mr_vox_mri_t = self._vox_mri_t - self._mr_vox_scan_ras_t = self._vox_scan_ras_t - self._mr_ras_vox_scan_ras_t = self._ras_vox_scan_ras_t - else: + if self._mr_data is not None: if self._mr_data.shape != self._base_data.shape or not np.allclose( - self._vox_scan_ras_t, self._mr_vox_scan_ras_t, rtol=1e-6 + self._vox_scan_ras_t, mr_vox_scan_ras_t, rtol=1e-6 ): self._base_mr_aligned = False self._mri_vox_t = np.linalg.inv(self._vox_mri_t) - self._mr_mri_vox_t = np.linalg.inv(self._mr_vox_mri_t) self._scan_ras_vox_t = np.linalg.inv(self._vox_scan_ras_t) - self._mr_scan_ras_vox_t = np.linalg.inv(self._mr_vox_scan_ras_t) self._scan_ras_ras_vox_t = np.linalg.inv(self._ras_vox_scan_ras_t) - self._mr_scan_ras_ras_vox_t = np.linalg.inv(self._mr_ras_vox_scan_ras_t) self._scan_ras_mri_t = np.dot(self._vox_mri_t, self._scan_ras_vox_t) self._mri_scan_ras_t = np.dot(self._vox_scan_ras_t, self._mri_vox_t) @@ -236,6 +227,10 @@ def _load_image_data(self, base_image=None): op.join(self._subject_dir, "surf", "lh.seghead") ) assert _frame_to_str[self._head["coord_frame"]] == "mri" + # transform to scanner RAS + self._head["rr"] = apply_trans( + self._mri_scan_ras_t, self._head["rr"] * 1000 + ) else: warn( "`seghead` not found, using marching cubes on base image " @@ -256,8 +251,16 @@ def _load_image_data(self, base_image=None): if op.exists(surf_fname.format(hemi="lh")): self._lh = _read_mri_surface(surf_fname.format(hemi="lh")) assert _frame_to_str[self._lh["coord_frame"]] == "mri" + # convert to scanner RAS + self._lh["rr"] = apply_trans( + self._mri_scan_ras_t, self._lh["rr"] * 1000 + ) self._rh = _read_mri_surface(surf_fname.format(hemi="rh")) assert _frame_to_str[self._rh["coord_frame"]] == "mri" + # convert to scanner RAS + self._rh["rr"] = apply_trans( + self._mri_scan_ras_t, self._rh["rr"] * 1000 + ) else: warn( "`pial` surface not found, skipping adding to 3D " @@ -349,9 +352,7 @@ def _plot_images(self): np.where(self._base_data <= thresh, 0, 1), [1], )[0] - rr = apply_trans(self._vox_scan_ras_t, rr) # base image vox -> RAS - rr = apply_trans(self._mr_scan_ras_vox_t, rr) # RAS -> MR voxels - rr = apply_trans(self._mr_vox_mri_t, rr) # MR voxels -> MR surface RAS + rr = apply_trans(self._ras_vox_scan_ras_t, rr) # base image vox -> RAS self._renderer.mesh( *rr.T, triangles=tris, @@ -360,10 +361,9 @@ def _plot_images(self): reset_camera=False, render=False, ) - self._renderer.set_camera(focalpoint=rr.mean(axis=0)) else: self._renderer.mesh( - *self._head["rr"].T * 1000, + *self._head["rr"].T, triangles=self._head["tris"], color="gray", opacity=0.2, @@ -372,7 +372,7 @@ def _plot_images(self): ) if self._lh is not None and self._rh is not None and self._base_mr_aligned: self._renderer.mesh( - *self._lh["rr"].T * 1000, + *self._lh["rr"].T, triangles=self._lh["tris"], color="white", opacity=0.2, @@ -380,7 +380,7 @@ def _plot_images(self): render=False, ) self._renderer.mesh( - *self._rh["rr"].T * 1000, + *self._rh["rr"].T, triangles=self._rh["tris"], color="white", opacity=0.2, @@ -421,10 +421,7 @@ def _configure_status_bar(self, hbox=None): def _update_camera(self, render=False): """Update the camera position.""" - self._renderer.set_camera( - focalpoint=tuple(apply_trans(self._scan_ras_mri_t, self._ras)), - distance="auto", - ) + self._renderer.set_camera(focalpoint=tuple(self._ras), distance="auto") if render: self._renderer._update() diff --git a/mne_gui_addons/_ieeg_locate.py b/mne_gui_addons/_ieeg_locate.py index f110d46..8b1d1e5 100644 --- a/mne_gui_addons/_ieeg_locate.py +++ b/mne_gui_addons/_ieeg_locate.py @@ -131,12 +131,11 @@ def __init__( 'be in the "head" coordinate frame.' ) - # load channels, convert from m to mm + self._ch_names = info.ch_names + # load channels, leave in "head" coordinate frame until transforms are loaded in super self._chs = { - name: apply_trans(self._head_mri_t, ch["loc"][:3]) * 1000 - for name, ch in zip(info.ch_names, info["chs"]) + name: ch["loc"][:3] for name, ch in zip(info.ch_names, info["chs"]) } - self._ch_names = list(self._chs.keys()) self._group_channels(groups) # Initialize GUI @@ -144,6 +143,12 @@ def __init__( base_image=base_image, subject=subject, subjects_dir=subjects_dir ) + # convert channel positions to scanner RAS + for name, pos in self._chs.items(): + self._chs[name] = apply_trans( + self._mri_scan_ras_t, apply_trans(self._head_mri_t, pos) * 1000 + ) + if targets: self.auto_find_contacts(targets) @@ -167,9 +172,7 @@ def __init__( # set current position as current contact location if exists else: self._set_ras( - apply_trans( - self._mri_scan_ras_t, self._chs[self._ch_names[self._ch_index]] - ), + self._chs[self._ch_names[self._ch_index]], update_plots=False, ) self._ch_list.setFocus() # always focus on list @@ -206,7 +209,7 @@ def _configure_channel_sidebar(self): """Configure the sidebar to select channels/contacts.""" ch_list = QListView() ch_list.setSelectionMode(QAbstractItemView.SingleSelection) - max_ch_name_len = max([len(name) for name in self._chs]) + max_ch_name_len = max([len(name) for name in self._ch_names]) ch_list.setMinimumWidth(max_ch_name_len * _CH_MENU_WIDTH) ch_list.setMaximumWidth(max_ch_name_len * _CH_MENU_WIDTH) self._ch_list_model = QtGui.QStandardItemModel(ch_list) @@ -237,14 +240,12 @@ def color_ch_radius(ch_image, xf, yf, group, radius): ch_image[-(ey + ii[idx[1]]), ex + ii[idx[0]]] = group return ch_image - for name, surf_ras in self._chs.items(): + for name, ras in self._chs.items(): # move from middle-centered (half coords positive, half negative) # to bottom-left corner centered (all coords positive). - if np.isnan(surf_ras).any(): + if np.isnan(ras).any(): continue - xyz = apply_trans( - self._scan_ras_ras_vox_t, apply_trans(self._mri_scan_ras_t, surf_ras) - ) + xyz = apply_trans(self._scan_ras_ras_vox_t, ras) # check if closest to that voxel dist = np.linalg.norm(xyz - self._current_slice) if proj or dist <= self._radius: @@ -272,10 +273,11 @@ def _save_ch_coords(self, info=None, verbose=None): if montage else dict(ch_pos=dict(), coord_frame="head") ) - for ch in info["chs"]: + for ch in self._ch_names: # surface RAS-> head and mm->m - montage_kwargs["ch_pos"][ch["ch_name"]] = apply_trans( - self._mri_head_t, self._chs[ch["ch_name"]].copy() / 1000 + montage_kwargs["ch_pos"][ch] = apply_trans( + self._mri_head_t, + apply_trans(self._scan_ras_mri_t, self._chs[ch].copy()) / 1000, ) info.set_montage(make_dig_montage(**montage_kwargs)) @@ -777,10 +779,8 @@ def auto_find_contacts( # assign locations for name, loc in zip(names, locs): if not np.isnan(loc).any(): - vox = apply_trans(self._ras_vox_scan_ras_t, loc) - self._chs[name][:] = apply_trans( - self._scan_ras_mri_t, vox - ) # to surface RAS + # convert to scanner RAS + self._chs[name][:] = apply_trans(self._ras_vox_scan_ras_t, loc) self._color_list_item(name) self._save_ch_coords() @@ -804,10 +804,8 @@ def _auto_mark_group(self): locs = self._auto_find_line(locs[0], locs[1] - locs[0]) # assign locations for name, loc in zip(names, locs): - vox = apply_trans(self._ras_vox_scan_ras_t, loc) - self._chs[name][:] = apply_trans( - self._scan_ras_mri_t, vox - ) # to surface RAS + # convert to scanner RAS + self._chs[name][:] = apply_trans(self._ras_vox_scan_ras_t, loc) self._color_list_item(name) self._save_ch_coords() else: @@ -856,15 +854,9 @@ def _update_lines(self, group, only_2D=False): )[0] if self._toggle_show_mip_button.text() == "Hide Max Intensity Proj": # add 2D lines on each slice plot if in max intensity projection - target_vox = apply_trans( - self._mri_scan_ras_t, - apply_trans(self._scan_ras_ras_vox_t, pos[target_idx]), - ) + target_vox = apply_trans(self._scan_ras_ras_vox_t, pos[target_idx]) insert_vox = apply_trans( - self._mri_scan_ras_t, - apply_trans( - self._scan_ras_ras_vox_t, pos[insert_idx] + elec_v * _BOLT_SCALAR - ), + self._scan_ras_ras_vox_t, pos[insert_idx] + elec_v * _BOLT_SCALAR ) lines_2D = list() for axis in range(3): @@ -906,7 +898,7 @@ def _update_ch_selection(self): self._group_selector.setCurrentIndex(self._groups[name]) self._update_group() if not np.isnan(self._chs[name]).any(): - self._set_ras(apply_trans(self._mri_scan_ras_t, self._chs[name])) + self._set_ras(self._chs[name]) self._zoom(sign=0, draw=True) self._update_camera(render=True) @@ -971,9 +963,7 @@ def mark_channel(self, ch=None): self._ch_index if ch is None else self._ch_names.index(ch) ] if self._snap_button.text() == "Off": - self._chs[name][:] = apply_trans( - self._scan_ras_mri_t, self._ras - ) # stored as surface RAS + self._chs[name][:] = self._ras else: neighbors = _voxel_neighbors( self._vox, @@ -983,7 +973,7 @@ def mark_channel(self, ch=None): use_relative=True, ) self._chs[name][:] = apply_trans( # to surface RAS - self._vox_mri_t, np.array(list(neighbors)).mean(axis=0) + self._ras_vox_scan_ras_t, np.array(list(neighbors)).mean(axis=0) ) self._color_list_item() self._update_lines(self._groups[name]) @@ -1118,7 +1108,7 @@ def _update_ct_maxima(self, ct_thresh=0.95): maximum_filter(self._ct_data, (self._radius,) * 3) == self._ct_data ) self._ct_maxima[self._ct_data <= self._ct_data.max() * ct_thresh] = False - if self._mr_data is not None: + if self._base_mr_aligned and self._mr_data is not None: self._ct_maxima[self._mr_data == 0] = False self._ct_maxima = np.where(self._ct_maxima, 1, np.nan) # transparent @@ -1146,7 +1136,7 @@ def _toggle_show_mip(self): # add circles for each channel xs, ys, colors = list(), list(), list() for name, ras in self._chs.items(): - xyz = self._vox + xyz = apply_trans(self._scan_ras_vox_t, ras) xs.append(xyz[self._xy_idx[axis][0]]) ys.append(xyz[self._xy_idx[axis][1]]) colors.append(_CMAP(self._groups[name])) diff --git a/mne_gui_addons/_vol_stc.py b/mne_gui_addons/_vol_stc.py index a8289f2..c5c9dfb 100644 --- a/mne_gui_addons/_vol_stc.py +++ b/mne_gui_addons/_vol_stc.py @@ -284,6 +284,9 @@ def __init__( subject=subject, subjects_dir=subjects_dir ) + # convert to RAS now that super call has loaded transforms + self._src_rr = apply_trans(self._mri_scan_ras_t, self._src_rr) + if src._subject != op.basename(self._subject_dir): raise RuntimeError( f"Source space subject ({src._subject})-freesurfer subject" @@ -350,7 +353,9 @@ def __init__( if any([this_src["type"] == "vol" for this_src in self._src]): scalars = np.array(np.where(np.isnan(self._stc_img), 0, 1.0)) spacing = np.diag(self._src_vox_mri_t)[:3] - origin = self._src_vox_mri_t[:3, 3] - spacing / 2.0 + origin = apply_trans( + self._mri_scan_ras_t, self._src_vox_mri_t[:3, 3] - spacing / 2.0 + ) center = 0.5 * self._stc_range - self._stc_min ( self._grid, diff --git a/mne_gui_addons/tests/test_ieeg_locate.py b/mne_gui_addons/tests/test_ieeg_locate.py index 5b590fb..201ee5f 100644 --- a/mne_gui_addons/tests/test_ieeg_locate.py +++ b/mne_gui_addons/tests/test_ieeg_locate.py @@ -154,9 +154,9 @@ def test_ieeg_elec_locate_display(renderer_interactive_pyvistaqt, _fake_CT_coord ) with pytest.raises(ValueError, match="read-only"): - gui._ras[:] = apply_trans( - gui._mri_scan_ras_t, coords[0] - ) # start in the right position + gui._ras[:] = coords[0] + + # start in the right position gui.set_RAS(apply_trans(gui._mri_scan_ras_t, coords[0])) gui.mark_channel() @@ -177,14 +177,14 @@ def test_ieeg_elec_locate_display(renderer_interactive_pyvistaqt, _fake_CT_coord kind="release", ) assert_allclose( - coord[:2], - apply_trans(gui._scan_ras_mri_t, gui._ras)[:2], + apply_trans(gui._mri_scan_ras_t, coord)[:2], + gui._ras[:2], atol=0.1, err_msg=f"coords[{ci}][:2]", ) assert_allclose( - coord[2], - apply_trans(gui._scan_ras_mri_t, gui._ras)[2], + apply_trans(gui._mri_scan_ras_t, coord)[2], + gui._ras[2], atol=2, err_msg=f"coords[{ci}][2]", ) @@ -197,14 +197,27 @@ def test_ieeg_elec_locate_display(renderer_interactive_pyvistaqt, _fake_CT_coord gui._ch_index = 0 gui.set_RAS(apply_trans(gui._mri_scan_ras_t, coords[0])) # move to first position gui.mark_channel() - assert np.linalg.norm(coords[0] - gui._chs["LAMY 1"]) < 1.1 + assert ( + abs( + np.linalg.norm( + apply_trans(gui._mri_scan_ras_t, coords[0]) - gui._chs["LAMY 1"] + ) + - 1.03 + ) + < 1e-3 + ) gui._snap_button.click() assert gui._snap_button.text() == "Off" # now make sure no snap happens gui._ch_index = 0 gui.set_RAS(apply_trans(gui._mri_scan_ras_t, coords[1] + 1)) gui.mark_channel() - assert np.linalg.norm(coords[1] + 1 - gui._chs["LAMY 1"]) < 1e-3 + assert ( + np.linalg.norm( + apply_trans(gui._mri_scan_ras_t, coords[1] + 1) - gui._chs["LAMY 1"] + ) + < 1e-3 + ) # check that it turns back on gui._snap_button.click() assert gui._snap_button.text() == "On" @@ -246,7 +259,7 @@ def test_ieeg_elec_locate_display(renderer_interactive_pyvistaqt, _fake_CT_coord assert montage is not None assert_allclose( montage.get_positions()["ch_pos"]["LAMY 1"], - [0.00726235, 0.01713514, 0.04167233], + [5.276672, -9.030582, 27.302032], atol=0.01, ) @@ -259,15 +272,27 @@ def test_ieeg_elec_locate_display(renderer_interactive_pyvistaqt, _fake_CT_coord # test just target gui.auto_find_contacts(targets={"LAMY ": target}) - assert np.linalg.norm(coords[0] - gui._chs["LAMY 1"]) < 1e-3 - assert np.linalg.norm(coords[1] - gui._chs["LAMY 2"]) < 1e-3 + assert ( + np.linalg.norm(apply_trans(gui._mri_scan_ras_t, coords[0]) - gui._chs["LAMY 1"]) + < 1e-3 + ) + assert ( + np.linalg.norm(apply_trans(gui._mri_scan_ras_t, coords[1]) - gui._chs["LAMY 2"]) + < 1e-3 + ) gui.remove_channel("LAMY 1") gui.remove_channel("LAMY 2") # test with target and entry gui.auto_find_contacts(targets={"LAMY ": (target, entry)}) - assert np.linalg.norm(coords[0] - gui._chs["LAMY 1"]) < 1e-3 - assert np.linalg.norm(coords[1] - gui._chs["LAMY 2"]) < 1e-3 + assert ( + np.linalg.norm(apply_trans(gui._mri_scan_ras_t, coords[0]) - gui._chs["LAMY 1"]) + < 1e-3 + ) + assert ( + np.linalg.norm(apply_trans(gui._mri_scan_ras_t, coords[1]) - gui._chs["LAMY 2"]) + < 1e-3 + ) gui.close() From 1ab565c859e7a429f32ffb6a6c54883b15990d76 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Tue, 5 Dec 2023 17:06:43 -0800 Subject: [PATCH 34/45] 1.6 release --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 21c4256..7b378d7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,12 +27,12 @@ jobs: include: - os: macos-latest python-version: "3.10" - mne-version: mne-main # pin to main for import functions until 1.6 release + mne-version: mne-stable qt: PyQt6 # Old (and PyQt5) - os: ubuntu-latest python-version: "3.9" - mne-version: mne-main # pin to main for import functions until 1.6 release + mne-version: mne-stable qt: PyQt5 # PySide2 - os: ubuntu-latest From dfdc49253cdaa350af380ab5a1f9098ebaf70834 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Tue, 5 Dec 2023 17:10:08 -0800 Subject: [PATCH 35/45] still fails on stable --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7b378d7..30477ee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,12 +27,12 @@ jobs: include: - os: macos-latest python-version: "3.10" - mne-version: mne-stable + mne-version: mne-main # pin to main for import functions until 1.7 release qt: PyQt6 # Old (and PyQt5) - os: ubuntu-latest python-version: "3.9" - mne-version: mne-stable + mne-version: mne-main # pin to main for import functions until 1.7 release qt: PyQt5 # PySide2 - os: ubuntu-latest From 5e3804db7e85890166f8ec95abb965093bbe996a Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Tue, 5 Dec 2023 17:27:50 -0800 Subject: [PATCH 36/45] fix tests after bug fixes in scan RAS conversion --- mne_gui_addons/_ieeg_locate.py | 4 +- mne_gui_addons/tests/test_ieeg_locate.py | 59 +++++++----------------- 2 files changed, 18 insertions(+), 45 deletions(-) diff --git a/mne_gui_addons/_ieeg_locate.py b/mne_gui_addons/_ieeg_locate.py index 8b1d1e5..0d570d2 100644 --- a/mne_gui_addons/_ieeg_locate.py +++ b/mne_gui_addons/_ieeg_locate.py @@ -966,13 +966,13 @@ def mark_channel(self, ch=None): self._chs[name][:] = self._ras else: neighbors = _voxel_neighbors( - self._vox, + apply_trans(self._scan_ras_ras_vox_t, self._ras), self._ct_data, thresh=_VOXEL_NEIGHBORS_THRESH, voxels_max=self._radius**3, use_relative=True, ) - self._chs[name][:] = apply_trans( # to surface RAS + self._chs[name][:] = apply_trans( self._ras_vox_scan_ras_t, np.array(list(neighbors)).mean(axis=0) ) self._color_list_item() diff --git a/mne_gui_addons/tests/test_ieeg_locate.py b/mne_gui_addons/tests/test_ieeg_locate.py index 201ee5f..86cbfd7 100644 --- a/mne_gui_addons/tests/test_ieeg_locate.py +++ b/mne_gui_addons/tests/test_ieeg_locate.py @@ -61,7 +61,7 @@ def _fake_CT_coords(skull_size=5, contact_size=2): np.array(np.meshgrid(*[range(-contact_size, contact_size + 1)] * 3)), axis=0 ) ct = nib.MGHImage(ct_data, brain.affine) - coords = apply_trans(ct.header.get_vox2ras_tkr(), np.array(coords)) + coords = apply_trans(ct.header.get_vox2ras(), np.array(coords)) return ct, coords @@ -157,7 +157,7 @@ def test_ieeg_elec_locate_display(renderer_interactive_pyvistaqt, _fake_CT_coord gui._ras[:] = coords[0] # start in the right position - gui.set_RAS(apply_trans(gui._mri_scan_ras_t, coords[0])) + gui.set_RAS(coords[0]) gui.mark_channel() with pytest.raises(ValueError, match="not found"): @@ -165,9 +165,7 @@ def test_ieeg_elec_locate_display(renderer_interactive_pyvistaqt, _fake_CT_coord assert not gui._lines and not gui._lines_2D # no lines for one contact for ci, coord in enumerate(coords[1:], 1): - coord_vox = apply_trans( - gui._scan_ras_ras_vox_t, apply_trans(gui._mri_scan_ras_t, coord) - ) + coord_vox = apply_trans(gui._scan_ras_ras_vox_t, coord) with use_log_level("debug"): _fake_click( gui._figs[2], @@ -177,13 +175,13 @@ def test_ieeg_elec_locate_display(renderer_interactive_pyvistaqt, _fake_CT_coord kind="release", ) assert_allclose( - apply_trans(gui._mri_scan_ras_t, coord)[:2], + coord[:2], gui._ras[:2], atol=0.1, err_msg=f"coords[{ci}][:2]", ) assert_allclose( - apply_trans(gui._mri_scan_ras_t, coord)[2], + coord[2], gui._ras[2], atol=2, err_msg=f"coords[{ci}][2]", @@ -195,29 +193,16 @@ def test_ieeg_elec_locate_display(renderer_interactive_pyvistaqt, _fake_CT_coord # test snap to center gui._ch_index = 0 - gui.set_RAS(apply_trans(gui._mri_scan_ras_t, coords[0])) # move to first position + gui.set_RAS(coords[0]) # move to first position gui.mark_channel() - assert ( - abs( - np.linalg.norm( - apply_trans(gui._mri_scan_ras_t, coords[0]) - gui._chs["LAMY 1"] - ) - - 1.03 - ) - < 1e-3 - ) + assert 0 < np.linalg.norm(coords[0] - gui._chs["LAMY 1"]) < 1 gui._snap_button.click() assert gui._snap_button.text() == "Off" # now make sure no snap happens gui._ch_index = 0 - gui.set_RAS(apply_trans(gui._mri_scan_ras_t, coords[1] + 1)) + gui.set_RAS(coords[1] + 1) gui.mark_channel() - assert ( - np.linalg.norm( - apply_trans(gui._mri_scan_ras_t, coords[1] + 1) - gui._chs["LAMY 1"] - ) - < 1e-3 - ) + assert np.linalg.norm(coords[1] + 1 - gui._chs["LAMY 1"]) < 1e-3 # check that it turns back on gui._snap_button.click() assert gui._snap_button.text() == "On" @@ -259,40 +244,28 @@ def test_ieeg_elec_locate_display(renderer_interactive_pyvistaqt, _fake_CT_coord assert montage is not None assert_allclose( montage.get_positions()["ch_pos"]["LAMY 1"], - [5.276672, -9.030582, 27.302032], + [0.007262, 0.017135, 0.041672], atol=0.01, ) # check auto find targets gui.remove_channel("LAMY 1") target, entry = ( - apply_trans(gui._mri_scan_ras_t, coords[0]) / 1000, - apply_trans(gui._mri_scan_ras_t, coords[1]) / 1000, + coords[0] / 1000, + coords[1] / 1000, ) # test just target gui.auto_find_contacts(targets={"LAMY ": target}) - assert ( - np.linalg.norm(apply_trans(gui._mri_scan_ras_t, coords[0]) - gui._chs["LAMY 1"]) - < 1e-3 - ) - assert ( - np.linalg.norm(apply_trans(gui._mri_scan_ras_t, coords[1]) - gui._chs["LAMY 2"]) - < 1e-3 - ) + assert np.linalg.norm(coords[0] - gui._chs["LAMY 1"]) < 1e-3 + assert np.linalg.norm(coords[1] - gui._chs["LAMY 2"]) < 1e-3 gui.remove_channel("LAMY 1") gui.remove_channel("LAMY 2") # test with target and entry gui.auto_find_contacts(targets={"LAMY ": (target, entry)}) - assert ( - np.linalg.norm(apply_trans(gui._mri_scan_ras_t, coords[0]) - gui._chs["LAMY 1"]) - < 1e-3 - ) - assert ( - np.linalg.norm(apply_trans(gui._mri_scan_ras_t, coords[1]) - gui._chs["LAMY 2"]) - < 1e-3 - ) + assert np.linalg.norm(coords[0] - gui._chs["LAMY 1"]) < 1e-3 + assert np.linalg.norm(coords[1] - gui._chs["LAMY 2"]) < 1e-3 gui.close() From bd63afc041bcd3673d0a54d2d484e8a1f5dac653 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Tue, 5 Dec 2023 17:50:53 -0800 Subject: [PATCH 37/45] fix new changes --- mne_gui_addons/_segment.py | 20 +++++--------------- mne_gui_addons/_vol_stc.py | 1 - 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index b4dd0d7..646b12e 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -250,21 +250,15 @@ def _apply_brainmask(self): "Applying Brainmask", "Applying the brainmask, this will take ~30 seconds", ) - img_data, _, vox_scan_ras_t, _ = _load_image( + img_data, _, _, ras_vox_scan_ras_t, _ = _load_image( op.join(self._subject_dir, "mri", "brainmask.mgz") ) - idxs = np.meshgrid( - np.arange(self._base_data.shape[0]), - np.arange(self._base_data.shape[1]), - np.arange(self._base_data.shape[2]), - indexing="ij", - ) - idxs = np.array(idxs) # (3, *image_data.shape) + idxs = np.indices(self._base_data.shape) idxs = np.transpose(idxs, [1, 2, 3, 0]) # (*image_data.shape, 3) idxs = idxs.reshape(-1, 3) # (n_voxels, 3) - idxs = apply_trans(self._vox_scan_ras_t, idxs) # vox -> scanner RAS + idxs = apply_trans(self._ras_vox_scan_ras_t, idxs) # vox -> scanner RAS idxs = apply_trans( - np.linalg.inv(vox_scan_ras_t), idxs + np.linalg.inv(ras_vox_scan_ras_t), idxs ) # scanner RAS -> mri vox idxs = idxs.round().astype(int) # round to nearest voxel brain = set([(x, y, z) for x, y, z in np.array(np.where(img_data > 0)).T]) @@ -456,11 +450,7 @@ def _plot_3d(self, render=False): if self._vol_coords: smooth = self._smooth_slider.value() / 100 verts, tris = _marching_cubes(self._vol_img, [1], smooth=smooth)[0] - verts = apply_trans(self._vox_scan_ras_t, verts) # vox -> scanner RAS - verts = apply_trans( - self._mr_scan_ras_vox_t, verts - ) # scanner RAS -> mri vox - verts = apply_trans(self._mr_vox_mri_t, verts) # mr voxels -> surface RAS + verts = apply_trans(self._ras_vox_scan_ras_t, verts) # vox -> scanner RAS self._vol_actor = self._renderer.mesh( *verts.T, tris, diff --git a/mne_gui_addons/_vol_stc.py b/mne_gui_addons/_vol_stc.py index c5c9dfb..5a704b3 100644 --- a/mne_gui_addons/_vol_stc.py +++ b/mne_gui_addons/_vol_stc.py @@ -1348,7 +1348,6 @@ def set_3d_view( azimuth=azimuth, elevation=elevation, focalpoint=focalpoint, - reset_camera=False, ) self._renderer._update() From db48a70cf67a98f8769d36fe89f8938ffa0f4110 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Tue, 5 Dec 2023 20:53:57 -0800 Subject: [PATCH 38/45] fix test --- examples/ieeg_locate.py | 2 +- mne_gui_addons/tests/test_segment.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/ieeg_locate.py b/examples/ieeg_locate.py index d5ff076..04bbfb7 100644 --- a/examples/ieeg_locate.py +++ b/examples/ieeg_locate.py @@ -541,7 +541,7 @@ def plot_overlay(image, compare, title, thresh=None): # when the electrode contacts were localized so we need to use it again here. # reload original found positions -raw = mne.io.read_raw(misc_path / "ecog" / "sample_ecog_ieeg.fif") +raw = mne.io.read_raw(misc_path / "seeg" / "sample_seeg_ieeg.fif") # plot the alignment brain = mne.viz.Brain("sample_seeg", subjects_dir=misc_path / "seeg", **brain_kwargs) diff --git a/mne_gui_addons/tests/test_segment.py b/mne_gui_addons/tests/test_segment.py index 3acc614..f99f151 100644 --- a/mne_gui_addons/tests/test_segment.py +++ b/mne_gui_addons/tests/test_segment.py @@ -23,7 +23,6 @@ def test_segment_io(renderer_interactive_pyvistaqt): with pytest.warns(match="`pial` surface not found"): VolumeSegmenter( - nib.MGHImage(np.ones((96, 96, 96), dtype=np.float32), np.eye(4)), subject=subject, subjects_dir=subjects_dir, ) From 09378281d3240f942fc5d797a1cf932b07631e5c Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Tue, 5 Dec 2023 20:55:38 -0800 Subject: [PATCH 39/45] style --- mne_gui_addons/tests/test_segment.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mne_gui_addons/tests/test_segment.py b/mne_gui_addons/tests/test_segment.py index f99f151..f8a3c7c 100644 --- a/mne_gui_addons/tests/test_segment.py +++ b/mne_gui_addons/tests/test_segment.py @@ -4,7 +4,6 @@ # License: BSD-3-clause import numpy as np -from numpy.testing import assert_allclose import pytest @@ -18,7 +17,6 @@ @testing.requires_testing_data def test_segment_io(renderer_interactive_pyvistaqt): """Test the input/output of the slice browser GUI.""" - nib = pytest.importorskip("nibabel") from mne_gui_addons._segment import VolumeSegmenter with pytest.warns(match="`pial` surface not found"): From a3ef6d52d862995fa9835cc3ae0dbdd1b4267ca6 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Tue, 5 Dec 2023 20:58:01 -0800 Subject: [PATCH 40/45] spelling --- mne_gui_addons/_segment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne_gui_addons/_segment.py b/mne_gui_addons/_segment.py index 646b12e..0a79761 100644 --- a/mne_gui_addons/_segment.py +++ b/mne_gui_addons/_segment.py @@ -299,7 +299,7 @@ def set_clim(self, vmin=None, vmax=None): self._img_max_slider.setValue(vmax) def set_smooth(self, smooth): - """Set the smoothness of the 3D renderering of the segmented volume. + """Set the smoothness of the 3D rendering of the segmented volume. Parameters ---------- From 6fd4845007c66bf51e14aa44c1bc536a52887f23 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Tue, 5 Dec 2023 21:07:42 -0800 Subject: [PATCH 41/45] close gui, add qdarkstyle --- .circleci/config.yml | 2 +- mne_gui_addons/tests/test_segment.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 44e538a..ff46169 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -42,7 +42,7 @@ jobs: - run: name: Get Python running command: | - pip install --upgrade PyQt6!=6.6.1 "PyQt6-Qt6!=6.6.1" sphinx-gallery pydata-sphinx-theme numpydoc scikit-learn nilearn mne-bids autoreject git+https://github.com/pyvista/pyvista@main memory_profiler sphinxcontrib.bibtex sphinxcontrib.youtube + pip install --upgrade PyQt6!=6.6.1 "PyQt6-Qt6!=6.6.1" sphinx-gallery pydata-sphinx-theme numpydoc scikit-learn nilearn mne-bids autoreject git+https://github.com/pyvista/pyvista@main memory_profiler sphinxcontrib.bibtex sphinxcontrib.youtube qdarkstyle pip install -ve ./mne-python . - run: name: Check Qt diff --git a/mne_gui_addons/tests/test_segment.py b/mne_gui_addons/tests/test_segment.py index f8a3c7c..6be5a16 100644 --- a/mne_gui_addons/tests/test_segment.py +++ b/mne_gui_addons/tests/test_segment.py @@ -56,3 +56,5 @@ def test_segment_display(renderer_interactive_pyvistaqt): # check smooth gui.set_smooth(0.7) + + gui.close() From f3fea043f5aa2b3974014595a982bb775fde6252 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Tue, 5 Dec 2023 21:13:16 -0800 Subject: [PATCH 42/45] allow unclosed --- .circleci/config.yml | 2 +- mne_gui_addons/tests/test_segment.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ff46169..d8115c6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -42,7 +42,7 @@ jobs: - run: name: Get Python running command: | - pip install --upgrade PyQt6!=6.6.1 "PyQt6-Qt6!=6.6.1" sphinx-gallery pydata-sphinx-theme numpydoc scikit-learn nilearn mne-bids autoreject git+https://github.com/pyvista/pyvista@main memory_profiler sphinxcontrib.bibtex sphinxcontrib.youtube qdarkstyle + pip install --upgrade PyQt6!=6.6.1 "PyQt6-Qt6!=6.6.1" sphinx-gallery pydata-sphinx-theme numpydoc scikit-learn nilearn mne-bids autoreject git+https://github.com/pyvista/pyvista@main memory_profiler sphinxcontrib.bibtex sphinxcontrib.youtube darkdetect qdarkstyle pip install -ve ./mne-python . - run: name: Check Qt diff --git a/mne_gui_addons/tests/test_segment.py b/mne_gui_addons/tests/test_segment.py index 6be5a16..32c8836 100644 --- a/mne_gui_addons/tests/test_segment.py +++ b/mne_gui_addons/tests/test_segment.py @@ -14,9 +14,10 @@ subjects_dir = data_path / "subjects" +@pytest.mark.allow_unclosed @testing.requires_testing_data def test_segment_io(renderer_interactive_pyvistaqt): - """Test the input/output of the slice browser GUI.""" + """Test the input/output of the volume segmenter GUI.""" from mne_gui_addons._segment import VolumeSegmenter with pytest.warns(match="`pial` surface not found"): @@ -30,7 +31,7 @@ def test_segment_io(renderer_interactive_pyvistaqt): @pytest.mark.allow_unclosed @testing.requires_testing_data def test_segment_display(renderer_interactive_pyvistaqt): - """Test that the slice browser GUI displays properly.""" + """Test that the volume segmenter GUI displays properly.""" pytest.importorskip("nibabel") from mne_gui_addons._segment import VolumeSegmenter From 83ec391a58620e2cefbbbd12e4fcc04e86304ccb Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Wed, 6 Dec 2023 09:56:32 -0800 Subject: [PATCH 43/45] rerun cis From b1140cd148a52fba72ace96dbd71bc27ca9fb068 Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Wed, 6 Dec 2023 10:02:46 -0800 Subject: [PATCH 44/45] doubled line --- mne_gui_addons/_core.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mne_gui_addons/_core.py b/mne_gui_addons/_core.py index 64c9efd..6dd5cfe 100644 --- a/mne_gui_addons/_core.py +++ b/mne_gui_addons/_core.py @@ -442,7 +442,6 @@ def _plot_images(self): reset_camera=False, render=False, ) - self._rh_actor, _ = self._renderer.mesh( self._rh_actor, _ = self._renderer.mesh( *self._rh["rr"].T, triangles=self._rh["tris"], From c3f5609c2606f5f3f45e70149a7abaf8770dc5ab Mon Sep 17 00:00:00 2001 From: Alex Rockhill Date: Wed, 6 Dec 2023 10:30:01 -0800 Subject: [PATCH 45/45] increase timeout --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index d8115c6..0558510 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -70,6 +70,7 @@ jobs: name: make html command: | make -C doc html + no_output_timeout: 30m - store_test_results: path: doc/_build/test-results - store_artifacts: