Skip to content

Commit 5ca0141

Browse files
authored
[ENH, MRG] Allow picking ieeg contacts on just CT (#11567)
1 parent c461bad commit 5ca0141

File tree

15 files changed

+437
-153
lines changed

15 files changed

+437
-153
lines changed

doc/changes/latest.inc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Enhancements
3737
- Add :func:`mne.count_events` to count unique event types in a given event array (:gh:`11430` by `Clemens Brunner`_)
3838
- Add a video to :ref:`tut-freesurfer-mne` of a brain inflating from the pial surface to aid in understanding the inflated brain (:gh:`11440` by `Alex Rockhill`_)
3939
- Add automatic projection of sEEG contact onto the inflated surface for :meth:`mne.viz.Brain.add_sensors` (:gh:`11436` by `Alex Rockhill`_)
40+
- Allow an image with intracranial electrode contacts (e.g. computed tomography) to be used without the freesurfer recon-all surfaces to locate contacts so that it doesn't have to be downsampled to freesurfer dimensions (for microelectrodes) and show an example :ref:`ex-ieeg-micro` with :func:`mne.transforms.apply_volume_registration_points` added to aid this transform (:gh:`11567` by `Alex Rockhill`_)
4041

4142
Bugs
4243
~~~~
@@ -65,3 +66,4 @@ Bugs
6566
API changes
6667
~~~~~~~~~~~
6768
- Deprecate arguments ``kind`` and ``path`` from :func:`mne.channels.read_layout` in favor of a common argument ``fname`` (:gh:`11500` by `Mathieu Scheltienne`_)
69+
- Change ``aligned_ct`` positional argument in :func:`mne.gui.locate_ieeg` to ``base_image`` to reflect that this can now be used with unaligned images (:gh:`11567` by `Alex Rockhill`_)

doc/mri.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Step by step instructions for using :func:`gui.coregistration`:
3232
scale_labels
3333
scale_source_space
3434
transforms.apply_volume_registration
35+
transforms.apply_volume_registration_points
3536
transforms.compute_volume_registration
3637
vertex_to_mni
3738
warp_montage_volume
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
.. _ex-ieeg-micro:
4+
5+
====================================================
6+
Locating micro-scale intracranial electrode contacts
7+
====================================================
8+
9+
When intracranial electrode contacts are very small, sometimes
10+
the computed tomography (CT) scan is higher resolution than the
11+
magnetic resonance (MR) image and so you want to find the contacts
12+
on the CT without downsampling to the MR resolution. This example
13+
shows how to do this.
14+
"""
15+
16+
# Authors: Alex Rockhill <aprockhill@mailbox.org>
17+
#
18+
# License: BSD-3-Clause
19+
20+
import numpy as np
21+
import nibabel as nib
22+
import mne
23+
24+
# path to sample sEEG
25+
misc_path = mne.datasets.misc.data_path()
26+
subjects_dir = misc_path / 'seeg'
27+
28+
# GUI requires pyvista backend
29+
mne.viz.set_3d_backend('pyvistaqt')
30+
31+
# we need three things:
32+
# 1) The electrophysiology file which contains the channels names
33+
# that we would like to associate with positions in the brain
34+
# 2) The CT where the electrode contacts show up with high intensity
35+
# 3) The MR where the brain is best visible (low contrast in CT)
36+
raw = mne.io.read_raw(misc_path / 'seeg' / 'sample_seeg_ieeg.fif')
37+
CT_orig = nib.load(misc_path / 'seeg' / 'sample_seeg_CT.mgz')
38+
T1 = nib.load(misc_path / 'seeg' / 'sample_seeg' / 'mri' / 'T1.mgz')
39+
40+
# we'll also need a head-CT surface RAS transform, this can be faked with an
41+
# identify matrix but we'll find the fiducials on the CT in freeview (be sure
42+
# to find them in surface RAS (TkReg RAS in freeview) and not scanner RAS
43+
# (RAS in freeview)) (also be sure to note left is generally on the right in
44+
# freeview) and reproduce them here:
45+
montage = mne.channels.make_dig_montage(
46+
nasion=[-28.97, -5.88, -76.40], lpa=[-96.35, -16.26, 17.63],
47+
rpa=[31.28, -52.95, -0.69], coord_frame='mri')
48+
raw.set_montage(montage, on_missing='ignore') # haven't located yet!
49+
head_ct_t = mne.transforms.invert_transform(
50+
mne.channels.compute_native_head_t(montage))
51+
52+
# note: coord_frame = 'mri' is a bit of a misnormer, it is a reference to
53+
# the surface RAS coordinate frame, here it is of the CT
54+
55+
56+
# launch the viewer with only the CT (note, we won't be able to use
57+
# the MR in this case to help determine which brain area the contact is
58+
# in), and use the user interface to find the locations of the contacts
59+
gui = mne.gui.locate_ieeg(raw.info, head_ct_t, CT_orig)
60+
61+
# we'll programmatically mark all the contacts on one electrode shaft
62+
for i, pos in enumerate([(-52.66, -40.84, -26.99), (-55.47, -38.03, -27.92),
63+
(-57.68, -36.27, -28.85), (-59.89, -33.81, -29.32),
64+
(-62.57, -31.35, -30.37), (-65.13, -29.07, -31.30),
65+
(-67.57, -26.26, -31.88)]):
66+
gui.set_RAS(pos)
67+
gui.mark_channel(f'LENT {i + 1}')
68+
69+
# finally, the coordinates will be in "head" (unless the trans was faked
70+
# as the identity, in which case they will be in surface RAS of the CT already)
71+
# so we need to convert them to scanner RAS of the CT, apply the alignment so
72+
# that they are in scanner RAS of the MRI and from there to surface RAS
73+
# of the MRI for viewing using freesurfer recon-all surfaces--fortunately
74+
# that is done for us in `mne.transforms.apply_volume_registration_points`
75+
76+
# note that since we didn't fake the head->CT surface RAS transform, we
77+
# could apply the head->mri transform directly but that relies of the
78+
# fiducial points being marked exactly the same on the CT as on the MRI--
79+
# the error from this is not precise enough for intracranial electrophysiology,
80+
# better is to rely on the precision of the CT-MR image registration
81+
82+
reg_affine = np.array([ # CT-MR registration
83+
[0.99270756, -0.03243313, 0.11610254, -133.094156],
84+
[0.04374389, 0.99439665, -0.09623816, -97.58320673],
85+
[-0.11233068, 0.10061512, 0.98856381, -84.45551601],
86+
[0., 0., 0., 1.]])
87+
88+
raw.info, head_mri_t = mne.transforms.apply_volume_registration_points(
89+
raw.info, head_ct_t, CT_orig, T1, reg_affine)
90+
91+
brain = mne.viz.Brain(subject='sample_seeg', subjects_dir=subjects_dir,
92+
alpha=0.5)
93+
brain.add_sensors(raw.info, head_mri_t)
94+
brain.show_view(azimuth=120, elevation=100)

mne/conftest.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@
7878
def pytest_configure(config):
7979
"""Configure pytest options."""
8080
# Markers
81-
for marker in ('slowtest', 'ultraslowtest', 'pgtest'):
81+
for marker in ('slowtest', 'ultraslowtest', 'pgtest', 'allow_unclosed',
82+
'allow_unclosed_pyside2'):
8283
config.addinivalue_line('markers', marker)
8384

8485
# Fixtures
@@ -969,18 +970,18 @@ def qt_windows_closed(request):
969970
"""Ensure that no new Qt windows are open after a test."""
970971
_check_skip_backend('pyvistaqt')
971972
app = _init_mne_qtapp()
973+
from qtpy import API_NAME
974+
app.processEvents()
972975
gc.collect()
973976
n_before = len(app.topLevelWidgets())
977+
marks = set(mark.name for mark in request.node.iter_markers())
974978
yield
979+
app.processEvents()
975980
gc.collect()
976-
if 'allow_unclosed' in request.fixturenames:
981+
if 'allow_unclosed' in marks:
982+
return
983+
if 'allow_unclosed_pyside2' in marks and API_NAME.lower() == 'pyside2':
977984
return
978985
widgets = app.topLevelWidgets()
979986
n_after = len(widgets)
980987
assert n_before == n_after, widgets[-4:]
981-
982-
983-
@pytest.fixture
984-
def allow_unclosed():
985-
"""Allow unclosed Qt Windows."""
986-
pass

mne/gui/__init__.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,17 +201,19 @@ def coregistration(tabbed=False, split=True, width=None, inst=None,
201201

202202

203203
@verbose
204-
def locate_ieeg(info, trans, aligned_ct, subject=None, subjects_dir=None,
204+
def locate_ieeg(info, trans, base_image, subject=None, subjects_dir=None,
205205
groups=None, show=True, block=False, verbose=None):
206206
"""Locate intracranial electrode contacts.
207207
208208
Parameters
209209
----------
210210
%(info_not_none)s
211211
%(trans_not_none)s
212-
aligned_ct : path-like | nibabel.spatialimages.SpatialImage
213-
The CT image that has been aligned to the Freesurfer T1. Path-like
214-
inputs and nibabel image objects are supported.
212+
base_image : path-like | nibabel.spatialimages.SpatialImage
213+
The CT or MR image on which the electrode contacts can located. It
214+
must be aligned to the Freesurfer T1 if ``subject`` and
215+
``subjects_dir`` are provided. Path-like inputs and nibabel image
216+
objects are supported.
215217
%(subject)s
216218
%(subjects_dir)s
217219
groups : dict | None
@@ -240,7 +242,7 @@ def locate_ieeg(info, trans, aligned_ct, subject=None, subjects_dir=None,
240242
if app is None:
241243
app = QApplication(["Intracranial Electrode Locator"])
242244
gui = IntracranialElectrodeLocator(
243-
info, trans, aligned_ct, subject=subject, subjects_dir=subjects_dir,
245+
info, trans, base_image, subject=subject, subjects_dir=subjects_dir,
244246
groups=groups, show=show, verbose=verbose)
245247
if block:
246248
_qt_app_exec(app)

0 commit comments

Comments
 (0)