Plot sensors on realistic surfaces

Sometimes you may want to plot sensor positions on a realistic surface, like an actual head. The fsaverage standard brain template shipped with the Freesurfer package provides surfaces of the head, so we are going to use them below.

For more information, check out these MNE resources:

We start by importing what we need.

This example furthermore assumes that you have installed eeg_positions via pip install eeg_positions[docs].

import matplotlib.pyplot as plt
import mne
import numpy as np

from eeg_positions import get_elec_coords

Get fsaverage data via MNE-Python, this will download some data.

Using default location ~/mne_data for montage coregistration...
Attempting to create new mne-python configuration file:
/home/docs/.mne/mne-python.json
Could not read the /home/docs/.mne/mne-python.json json file during the writing. Assuming it is empty. Got: Expecting value: line 1 column 1 (char 0)
0 files missing from root.txt in /home/docs/mne_data/MNE-fsaverage-data
0 files missing from bem.txt in /home/docs/mne_data/MNE-fsaverage-data/fsaverage

Get idealized sensor positions that were computed on a sphere and export as mne.channels.DigMontage.

montage = get_elec_coords(as_mne_montage=True)

For the code below we need to provide an mne.Info object. The sampling frequency does not matter so we are setting it to 1.

info = mne.create_info(ch_names=montage.ch_names, sfreq=1, ch_types="eeg")
info.set_montage(montage)
General
MNE object type Info
Measurement date Unknown
Participant Unknown
Experimenter Unknown
Acquisition
Sampling frequency 1.00 Hz
Channels
EEG
Head & sensor digitization 348 points
Filters
Highpass 0.00 Hz
Lowpass 0.50 Hz


As a sanity check, let’s create a spherical head model first. We expect a perfect fit with our idealized electrode positions, because they were also computed on a sphere.

sphere = mne.make_sphere_model(r0="auto", head_radius="auto", info=info)

# We need a transform from the coordinates of our montage to the surface.
# Given that both are spheres, we do not need to actually transform, and
# can just use the identity transform.
trans = mne.Transform("head", "mri", trans=np.eye(4))

fig = mne.viz.plot_alignment(
    info=info,
    trans=trans,
    bem=sphere,  # our spherical head model
    surfaces={"head": 0.8},  # alpha value
    coord_frame="head",
    eeg=["original", "projected"],
    dig="fiducials",
    show_axes=True,
    mri_fiducials=False,
)

mne.viz.set_3d_view(figure=fig, azimuth=65, elevation=75)
plot on surface
Fitted sphere radius:         95.0 mm
Origin head coordinates:      0.0 0.0 0.0 mm
Origin device coordinates:    0.0 0.0 0.0 mm

Equiv. model fitting -> RV = 0.0034764 %%
mu1 = 0.944832    lambda1 = 0.13687
mu2 = 0.667751    lambda2 = 0.683677
mu3 = -0.292609    lambda3 = -0.0101772
Set up EEG sphere model with scalp radius    95.0 mm

Using pyvistaqt 3d backend.
Channel types:: eeg: 345
Projecting sensors to the head surface

As can be seen above, the fit of our idealized sensor positions with a spherical head model is perfect.

Now let’s proceed to plot our positions on the fsaverage head. We expect this to be potentially problematic, because we computed our positions on a sphere … and a sphere is usually a poor approximation of a human head.

# instead of the identity transform as above, we are using the inbuilt transformation
# from MNE-Python that can transform a Montage to fit fsaverage.
# Note that this only works for "fsaverage" and not other surfaces.
trans = "fsaverage"

fig = mne.viz.plot_alignment(
    info=info,
    trans=trans,
    subject="fsaverage",  # this is fsaverage
    subjects_dir=subjects_dir,  # directory of fsaverage
    surfaces={"head": 0.8},  # alpha value
    coord_frame="head",
    eeg=["original", "projected"],
    dig="fiducials",
    show_axes=True,
    mri_fiducials=True,
)

mne.viz.set_3d_view(figure=fig, azimuth=135, elevation=80)
plot on surface
Using outer_skin.surf for head surface.
Channel types:: eeg: 345
Projecting sensors to the head surface

As expected, the fit above is very poor.

The electrode positions that were computed on a sphere are not easily projected to the fsaverage head.

However, we can try to “scale” fsaverage in three dimensions to make the fit slightly better.

Below we will try to scale fsaverage to minimize the distance of sensors to the scalp surface, while still trying to have the landmarks (LPA, RPA, NAS) aligned between MRI and montage.

fiducials = "estimated"  # taken from fsaverage
subject = "fsaverage"

coreg = mne.coreg.Coregistration(info, subject, subjects_dir, fiducials=fiducials)

coreg.set_scale_mode("3-axis")  # can also be "uniform", but the fit would be worse

coreg.fit_fiducials(verbose=True)

coreg.fit_icp(
    n_iterations=40,
    lpa_weight=1.0,
    nasion_weight=1.0,
    rpa_weight=1.0,
    hsp_weight=0,
    eeg_weight=1.0,
    hpi_weight=0,
    verbose=True,
)
trans = coreg.trans

fig = mne.viz.plot_alignment(
    info=info,
    trans=trans,  # this is the optimized transform based on scaling fsaverage
    subject="fsaverage",
    subjects_dir=subjects_dir,
    surfaces={"head": 0.8},  # alpha value
    coord_frame="head",
    eeg=["original", "projected"],
    dig="fiducials",
    show_axes=True,
    mri_fiducials=True,
)

mne.viz.set_3d_view(figure=fig, azimuth=135, elevation=80)
plot on surface
Using high resolution head model in /home/docs/mne_data/MNE-fsaverage-data/fsaverage/bem/fsaverage-head-dense.fif
    Triangle neighbors and vertex normals...
Estimating fiducials from fsaverage.
Aligning using fiducials
Start median distance:   8.99 mm
Enforcing 1 scaling parameter for fit with fiducials.
End   median distance:  12.84 mm
Aligning using ICP
Start     median distance:  12.84 mm
  ICP  1  median distance:   9.54 mm
  ICP  2  median distance:   6.57 mm
  ICP  3  median distance:   4.17 mm
  ICP  4  median distance:   3.34 mm
  ICP  5  median distance:   2.87 mm
  ICP  6  median distance:   2.69 mm
  ICP  7  median distance:   2.54 mm
  ICP  8  median distance:   2.54 mm
  ICP  9  median distance:   2.47 mm
  ICP 10  median distance:   2.36 mm
  ICP 11  median distance:   2.20 mm
  ICP 12  median distance:   2.15 mm
  ICP 13  median distance:   2.09 mm
  ICP 14  median distance:   2.05 mm
  ICP 15  median distance:   1.98 mm
  ICP 16  median distance:   1.93 mm
  ICP 17  median distance:   1.89 mm
  ICP 18  median distance:   1.80 mm
  ICP 19  median distance:   1.78 mm
  ICP 20  median distance:   1.79 mm
  ICP 21  median distance:   1.75 mm
  ICP 22  median distance:   1.75 mm
  ICP 23  median distance:   1.78 mm
  ICP 24  median distance:   1.79 mm
  ICP 25  median distance:   1.79 mm
  ICP 26  median distance:   1.80 mm
  ICP 27  median distance:   1.78 mm
  ICP 28  median distance:   1.80 mm
  ICP 29  median distance:   1.79 mm
  ICP 30  median distance:   1.75 mm
  ICP 31  median distance:   1.73 mm
  ICP 32  median distance:   1.71 mm
  ICP 33  median distance:   1.73 mm
  ICP 34  median distance:   1.74 mm
  ICP 35  median distance:   1.75 mm
  ICP 36  median distance:   1.75 mm
  ICP 37  median distance:   1.76 mm
  ICP 38  median distance:   1.77 mm
  ICP 39  median distance:   1.73 mm
  ICP 40  median distance:   1.71 mm
End       median distance:   1.71 mm
Using outer_skin.surf for head surface.
Channel types:: eeg: 345
Projecting sensors to the head surface

Luckily, the fit is now slightly better.

It is still not perfect, so for actual source reconstruction, you should use sensor positions that are measured (digitized), rather than computed on a sphere (idealized).

The 1005 system eeg electrode positions shipped with MNE-Python happen to not be calculated on a sphrere (idealized), so they should be a better fit on a realistic surface.

Let’s have a look, first in 2D, then in 3D

# Getting the MNE-Python inbuilt 1005 system positions
montage_mne = mne.channels.make_standard_montage(kind="standard_1005")
info_mne = mne.create_info(ch_names=montage_mne.ch_names, sfreq=1, ch_types="eeg")
info_mne.set_montage(montage_mne)

# Plot in 2D versus the eeg_positions montage
fig, axs = plt.subplots(1, 2)
fig.set_layout_engine("constrained")

for i, this_info in enumerate([info, info_mne]):
    ax = axs.flat[i]
    this_info.plot_sensors(axes=ax, show=False)
    ax.set_title(["eeg_positions", "mne inbuilt"][i])

fig
eeg_positions, mne inbuilt
<Figure size 640x480 with 2 Axes>

As is maybe apparent from the plot above, the inbuilt mne 1005 montage does not look as clear when projected to a spherical 2D head model. The inbuilt eeg_positions montage, on the other hand, can really shine in this situation (because that is what it was designed for).

Fortuntely, the inbuilt mne montage will give us a much better fit on a realistic surface, see below, and compare to the two plots above.

trans = "fsaverage"

fig = mne.viz.plot_alignment(
    info=info_mne,
    trans=trans,
    subject="fsaverage",  # this is fsaverage
    subjects_dir=subjects_dir,  # directory of fsaverage
    surfaces={"head": 0.8},  # alpha value
    coord_frame="head",
    eeg=["original", "projected"],
    dig="fiducials",
    show_axes=True,
    mri_fiducials=True,
)

mne.viz.set_3d_view(figure=fig, azimuth=135, elevation=80)
plot on surface
Using outer_skin.surf for head surface.
Channel types:: eeg: 343
Projecting sensors to the head surface