|
5 | 5 | Brainstorm Elekta phantom dataset tutorial |
6 | 6 | ========================================== |
7 | 7 |
|
8 | | -Here we compute the evoked from raw for the Brainstorm Elekta phantom |
9 | | -tutorial dataset. For comparison, see :footcite:`TadelEtAl2011` and |
| 8 | +This tutorial provides a step-by-step guide to |
| 9 | +importing and processing Elekta-Neuromag current phantom recordings. |
| 10 | +
|
| 11 | +A phantom recording is a measurement obtained using a device (phantom) |
| 12 | +that generates known magnetic signals, |
| 13 | +allowing validation and benchmarking of MEG system accuracy and analysis methods. |
| 14 | +
|
| 15 | +The aim of this tutorial is to demonstrate how phantom recordings can be used to |
| 16 | +evaluate source localisation methods by comparing estimated and true dipole positions. |
| 17 | +
|
| 18 | +For comparison, see :footcite:`TadelEtAl2011` and |
10 | 19 | `the original Brainstorm tutorial |
11 | 20 | <https://neuroimage.usc.edu/brainstorm/Tutorials/PhantomElekta>`__. |
12 | 21 | """ |
13 | 22 | # sphinx_gallery_thumbnail_number = 9 |
14 | 23 |
|
15 | 24 | # Authors: Eric Larson <larson.eric.d@gmail.com> |
| 25 | +# Carina Forster <carinaforster0611@gmail.com> |
16 | 26 | # |
17 | 27 | # License: BSD-3-Clause |
18 | 28 | # Copyright the MNE-Python contributors. |
19 | 29 |
|
20 | 30 | # %% |
21 | | - |
22 | 31 | import matplotlib.pyplot as plt |
23 | 32 | import numpy as np |
| 33 | +from scipy.signal import find_peaks |
24 | 34 |
|
25 | 35 | import mne |
26 | 36 | from mne import find_events, fit_dipole |
27 | 37 | from mne.datasets import fetch_phantom |
28 | 38 | from mne.datasets.brainstorm import bst_phantom_elekta |
29 | 39 | from mne.io import read_raw_fif |
30 | 40 |
|
31 | | -print(__doc__) |
32 | | - |
33 | 41 | # %% |
34 | | -# The data were collected with an Elekta Neuromag VectorView system at 1000 Hz |
35 | | -# and low-pass filtered at 330 Hz. Here the medium-amplitude (200 nAm) data |
36 | | -# are read to construct instances of :class:`mne.io.Raw`. |
| 42 | +# Load and prepare the data |
| 43 | +# ------------------------- |
| 44 | +# The data were collected with an Elekta Neuromag VectorView system |
| 45 | +# at 1000 Hz, low-pass filtered at 330 Hz and contain recordings |
| 46 | +# at three current amplitudes (20, 200, and 2000 nAm). |
| 47 | +# Here we load the medium-amplitude condition. |
| 48 | + |
| 49 | +# Load the data |
37 | 50 | data_path = bst_phantom_elekta.data_path(verbose=True) |
38 | | - |
39 | 51 | raw_fname = data_path / "kojak_all_200nAm_pp_no_chpi_no_ms_raw.fif" |
40 | 52 | raw = read_raw_fif(raw_fname) |
41 | 53 |
|
42 | | -# %% |
43 | | -# Data channel array consisted of 204 MEG planor gradiometers, |
44 | | -# 102 axial magnetometers, and 3 stimulus channels. Let's get the events |
45 | | -# for the phantom, where each dipole (1-32) gets its own event: |
| 54 | +# Mark known bad channels |
| 55 | +raw.info["bads"] = ["MEG1933", "MEG2421"] |
46 | 56 |
|
| 57 | +# The first 32 events correspond to dipole activations |
47 | 58 | events = find_events(raw, "STI201") |
48 | | -raw.plot(events=events) |
49 | | -raw.info["bads"] = ["MEG1933", "MEG2421"] # known bad channels |
| 59 | + |
50 | 60 |
|
51 | 61 | # %% |
52 | | -# The data has strong line frequency (60 Hz and harmonics) and cHPI coil |
53 | | -# noise (five peaks around 300 Hz). Here, we use only the first 30 seconds |
54 | | -# to save memory: |
| 62 | +# Epoch the data and plot evokeds |
| 63 | +# ------------------------------- |
| 64 | +# We epoch and baseline correct the data around the dipole events. |
55 | 65 |
|
56 | | -raw.compute_psd(tmax=30).plot( |
57 | | - average=False, amplitude=False, picks="data", exclude="bads" |
| 66 | +# Epoch the data |
| 67 | +bmax = -0.05 |
| 68 | +tmin, tmax = -0.1, 0.8 |
| 69 | +event_id = list(range(1, 33)) |
| 70 | +epochs = mne.Epochs( |
| 71 | + raw, events, event_id, tmin, tmax, baseline=(None, bmax), preload=False |
58 | 72 | ) |
59 | 73 |
|
| 74 | +# We drop the first and last event, it can contain dipole-switching artifacts |
| 75 | +epochs_clean = epochs[1:-1] |
| 76 | + |
| 77 | +# We select the first simulated dipole for visualisation purposes |
| 78 | +epochs_firstdip = epochs_clean["1"] |
| 79 | + |
60 | 80 | # %% |
61 | | -# Our phantom produces sinusoidal bursts at 20 Hz: |
| 81 | +# Let's look at the evoked response for the first clean dipole. |
| 82 | +# |
| 83 | +# We can see that the phantom was set to produce 20 Hz sinusoidal bursts of current |
| 84 | +# and the burst envelope repeats at approximately 3 Hz. |
62 | 85 |
|
63 | | -raw.plot(events=events) |
| 86 | +epochs_firstdip.average().plot(time_unit="s") |
64 | 87 |
|
65 | 88 | # %% |
66 | | -# Now we epoch our data, average it, and look at the first dipole response. |
67 | | -# The first peak appears around 3 ms. Because we low-passed at 40 Hz, |
68 | | -# we can also decimate our data to save memory. |
| 89 | +# Determine peak activation using Global Field Power (GFP) |
| 90 | +# -------------------------------------------------------- |
| 91 | +# GFP is the standard deviation across sensors at each time |
| 92 | +# point, providing a reference-independent measure of signal strength. |
69 | 93 |
|
70 | | -tmin, tmax = -0.1, 0.1 |
71 | | -bmax = -0.05 # Avoid capture filter ringing into baseline |
72 | | -event_id = list(range(1, 33)) |
73 | | -epochs = mne.Epochs( |
74 | | - raw, events, event_id, tmin, tmax, baseline=(None, bmax), preload=False |
75 | | -) |
76 | | -epochs["1"].average().plot(time_unit="s") |
| 94 | +# Get the evoked signal of the first dipole |
| 95 | +evoked_tmp = epochs_firstdip.average() |
| 96 | + |
| 97 | +# Calculate GFP |
| 98 | +gfp = np.std(evoked_tmp.data, axis=0) |
77 | 99 |
|
| 100 | +# Restrict to first burst window |
| 101 | +times = evoked_tmp.times |
| 102 | +time_mask = (times > 0) & (times <= 0.05) |
| 103 | + |
| 104 | +# Find the peak GFP indices |
| 105 | +peaks, _ = find_peaks(gfp[time_mask]) |
| 106 | +peak_indices = np.where(time_mask)[0][peaks] |
| 107 | + |
| 108 | +# Select the strongest peak |
| 109 | +strongest_peak_idx = peak_indices[np.argmax(gfp[peak_indices])] |
| 110 | +t_peak = times[strongest_peak_idx] |
| 111 | +print(f"Strongest peak at {t_peak * 1000:.1f} ms") |
78 | 112 | # %% |
79 | | -# .. _plt_brainstorm_phantom_elekta_eeg_sphere_geometry: |
80 | | -# |
81 | | -# Let's use a :ref:`sphere head geometry model <eeg_sphere_model>` |
82 | | -# and let's see the coordinate alignment and the sphere location. The phantom |
83 | | -# is properly modeled by a single-shell sphere with origin (0., 0., 0.). |
84 | | -# |
85 | | -# Even though this is a VectorView/TRIUX phantom, we can use the Otaniemi |
86 | | -# phantom subject as a surrogate because the "head" surface (hemisphere outer |
87 | | -# shell) has the same geometry for both phantoms, even though the internal |
88 | | -# dipole locations differ. The phantom_otaniemi scan was aligned to the |
89 | | -# phantom's head coordinate frame, so an identity ``trans`` is appropriate |
90 | | -# here. |
| 113 | +# Here we select the peak amplitude timepoint and store the evoked data for each dipole. |
| 114 | + |
| 115 | +evokeds = [] |
| 116 | +for ii in event_id: |
| 117 | + evoked = epochs_clean[str(ii)].average().crop(t_peak, t_peak) |
| 118 | + evoked = mne.EvokedArray(np.array(evoked.data), evoked.info, tmin=0.0) |
| 119 | + evokeds.append(evoked) |
| 120 | +# %% |
| 121 | +# Next, we need to compute the noise covariance in the baseline window |
| 122 | +# to capture the sensor noise structure (for details: :ref:`tut-compute-covariance`). |
| 123 | + |
| 124 | +cov = mne.compute_covariance(epochs_clean, tmax=bmax) |
| 125 | +del epochs # delete to save memory |
| 126 | +# %% |
| 127 | +# We use a :ref:`sphere head geometry model <eeg_sphere_model>` |
| 128 | +# because the Elekta phantom is designed to approximate a spherical |
| 129 | +# conductor with known dipole locations. |
91 | 130 |
|
92 | 131 | subjects_dir = data_path |
93 | 132 | fetch_phantom("otaniemi", subjects_dir=subjects_dir) |
94 | 133 | sphere = mne.make_sphere_model(r0=(0.0, 0.0, 0.0), head_radius=0.08) |
95 | | -subject = "phantom_otaniemi" |
96 | | -trans = mne.transforms.Transform("head", "mri", np.eye(4)) |
97 | | -mne.viz.plot_alignment( |
98 | | - epochs.info, |
99 | | - subject=subject, |
100 | | - show_axes=True, |
101 | | - bem=sphere, |
102 | | - dig=True, |
103 | | - surfaces=("head-dense", "inner_skull"), |
104 | | - trans=trans, |
105 | | - mri_fiducials=True, |
106 | | - subjects_dir=subjects_dir, |
107 | | -) |
108 | 134 |
|
109 | 135 | # %% |
110 | | -# Let's do some dipole fits. We first compute the noise covariance, |
111 | | -# then do the fits for each event_id taking the time instant that maximizes |
112 | | -# the global field power. |
| 136 | +# Dipole fitting |
| 137 | +# -------------- |
| 138 | +# Finally, we fit dipoles for each phantom and store them in a list. |
113 | 139 |
|
114 | | -# here we can get away with using method='oas' for speed (faster than "shrunk") |
115 | | -# but in general "shrunk" is usually better |
116 | | -cov = mne.compute_covariance(epochs, tmax=bmax) |
117 | | -mne.viz.plot_evoked_white(epochs["1"].average(), cov) |
| 140 | +dip_all = [] |
118 | 141 |
|
119 | | -data = [] |
120 | | -t_peak = 0.036 # true for Elekta phantom |
121 | | -for ii in event_id: |
122 | | - # Avoid the first and last trials -- can contain dipole-switching artifacts |
123 | | - evoked = epochs[str(ii)][1:-1].average().crop(t_peak, t_peak) |
124 | | - data.append(evoked.data[:, 0]) |
125 | | -evoked = mne.EvokedArray(np.array(data).T, evoked.info, tmin=0.0) |
126 | | -del epochs |
127 | | -dip, residual = fit_dipole(evoked, cov, sphere, n_jobs=None) |
| 142 | +for evoked in evokeds: |
| 143 | + dip, residual = fit_dipole(evoked, cov, sphere, n_jobs=1) |
| 144 | + dip_all.append(dip) |
| 145 | +# %% |
| 146 | +# Evaluate goodness of fit |
| 147 | +# ------------------------ |
| 148 | +# The dipole object stores the goodness of fit (GOF) for each dipole. |
| 149 | +# Some dipoles have a low GOF (< 60 %). |
| 150 | +gof = [dip.gof[0] for dip in dip_all] |
| 151 | +colors = ["#E69F00" if val < 60 else "#0072B2" for val in gof] |
| 152 | +plt.bar(event_id, gof, color=colors) |
| 153 | +plt.xlabel("Phantom dipole estimation") |
| 154 | +plt.ylabel("Goodness of fit (%)") |
| 155 | +plt.show() |
128 | 156 |
|
129 | 157 | # %% |
130 | | -# Do a quick visualization of how much variance we explained, putting the |
131 | | -# data and residuals on the same scale (here the "time points" are the |
132 | | -# 32 dipole peak values that we fit): |
133 | | - |
134 | | -fig, axes = plt.subplots(2, 1) |
135 | | -evoked.plot(axes=axes) |
136 | | -for ax in axes: |
137 | | - for text in list(ax.texts): |
138 | | - text.remove() |
139 | | - for line in ax.lines: |
140 | | - line.set_color("#98df81") |
141 | | -residual.plot(axes=axes) |
| 158 | +# Dipoles with low goodness of fit |
| 159 | +# -------------------------------- |
| 160 | +# Why do some dipoles have a low GOF? |
| 161 | +# Here we plot the dipole locations of the dipoles with low GOF. |
| 162 | +# |
| 163 | +# We can see that dipoles with low GOF are deep in the brain which might explain |
| 164 | +# the low GOF. |
| 165 | + |
| 166 | +# Get indices of low GOF dipoles |
| 167 | +low_idx = [i for i, g in enumerate(gof) if g < 60] |
| 168 | +low_event_ids = [event_id[i] for i in low_idx] |
| 169 | + |
| 170 | +print("Low GOF dipoles:", low_event_ids) |
| 171 | + |
| 172 | +# Let's plot the locations of the dipoles with low GOF. |
| 173 | +low_dips = [dip_all[i] for i in low_idx] |
| 174 | + |
| 175 | +subject = "phantom_otaniemi" |
| 176 | +trans = mne.transforms.Transform("head", "mri", np.eye(4)) |
| 177 | + |
| 178 | +# Plot the position and the orientation of the dipoles with low GOF |
| 179 | +fig = mne.viz.plot_alignment( |
| 180 | + evoked.info, |
| 181 | + trans, |
| 182 | + subject, |
| 183 | + bem=sphere, |
| 184 | + surfaces={"head-dense": 0.2}, |
| 185 | + coord_frame="head", |
| 186 | + meg="helmet", |
| 187 | + show_axes=True, |
| 188 | + subjects_dir=subjects_dir, |
| 189 | +) |
142 | 190 |
|
| 191 | +fig = mne.viz.plot_dipole_locations( |
| 192 | + dipoles=low_dips, mode="arrow", subject=subject, color=(1.0, 0.2, 0.2), fig=fig |
| 193 | +) |
143 | 194 | # %% |
144 | | -# Now we can compare to the actual locations, taking the difference in mm: |
| 195 | +# Compare estimated and true dipoles |
| 196 | +# ---------------------------------- |
| 197 | +# The dipole fits closely match the true phantom data, |
| 198 | +# achieving sub-centimeter accuracy (mean position error 2.4mm). |
145 | 199 |
|
| 200 | +# We get the true dipole positions from the phantoms |
146 | 201 | actual_pos, actual_ori = mne.dipole.get_phantom_dipoles() |
147 | 202 | actual_amp = 100.0 # nAm |
148 | 203 |
|
| 204 | +# Here we store the estimated dipoles |
| 205 | +dip_pos = [dip.pos[0] for dip in dip_all] |
| 206 | +dip_ori = [dip.ori[0] for dip in dip_all] |
| 207 | +dip_amplitude = [dip.amplitude[0] for dip in dip_all] |
| 208 | + |
149 | 209 | fig, (ax1, ax2, ax3) = plt.subplots( |
150 | 210 | nrows=3, ncols=1, figsize=(6, 7), layout="constrained" |
151 | 211 | ) |
152 | 212 |
|
153 | | -diffs = 1000 * np.sqrt(np.sum((dip.pos - actual_pos) ** 2, axis=-1)) |
| 213 | +# Here we calculate the euclidean distance between estimated and true positions. |
| 214 | +# We multiply by 1000 to convert from meter to millimeter. |
| 215 | +diffs = 1000 * np.sqrt(np.sum((dip_pos - actual_pos) ** 2, axis=-1)) |
154 | 216 | print(f"mean(position error) = {np.mean(diffs):0.1f} mm") |
155 | 217 | ax1.bar(event_id, diffs) |
156 | 218 | ax1.set_xlabel("Dipole index") |
157 | 219 | ax1.set_ylabel("Loc. error (mm)") |
158 | 220 |
|
159 | | -angles = np.rad2deg(np.arccos(np.abs(np.sum(dip.ori * actual_ori, axis=1)))) |
| 221 | +# Next we calculate the angle between estimated and true orientation. |
| 222 | +# We convert radians to degrees. |
| 223 | +angles = np.rad2deg(np.arccos(np.abs(np.sum(dip_ori * actual_ori, axis=1)))) |
160 | 224 | print(f"mean(angle error) = {np.mean(angles):0.1f}°") |
161 | 225 | ax2.bar(event_id, angles) |
162 | 226 | ax2.set_xlabel("Dipole index") |
163 | 227 | ax2.set_ylabel("Angle error (°)") |
164 | 228 |
|
165 | | -amps = actual_amp - dip.amplitude / 1e-9 |
| 229 | +# Here we compare amplitudes by subtracting estimated from true amplitude. |
| 230 | +amps = actual_amp - np.array(dip_amplitude) / 1e-9 |
166 | 231 | print(f"mean(abs amplitude error) = {np.mean(np.abs(amps)):0.1f} nAm") |
167 | 232 | ax3.bar(event_id, amps) |
168 | 233 | ax3.set_xlabel("Dipole index") |
169 | 234 | ax3.set_ylabel("Amplitude error (nAm)") |
170 | | - |
171 | 235 | # %% |
172 | | -# Let's plot the positions and the orientations of the actual and the estimated |
173 | | -# dipoles |
| 236 | +# Visualise estimated and true dipole locations |
| 237 | +# --------------------------------------------- |
| 238 | +# We can see that the dipoles overlap, have approximately the same magnitude |
| 239 | +# and point in the same direction. |
174 | 240 |
|
175 | 241 | actual_amp = np.ones(len(dip)) # fake amp, needed to create Dipole instance |
176 | | -actual_gof = np.ones(len(dip)) # fake GOF, needed to create Dipole instance |
| 242 | +actual_gof = np.ones(len(dip)) # fake goodness-of-fit (GOF) |
| 243 | +# setup dipole objects for true and estimated dipoles |
177 | 244 | dip_true = mne.Dipole(dip.times, actual_pos, actual_amp, actual_ori, actual_gof) |
| 245 | +dip_estimated = mne.Dipole(dip.times, dip_pos, dip_amplitude, dip_ori, actual_gof) |
178 | 246 |
|
179 | 247 | fig = mne.viz.plot_alignment( |
180 | 248 | evoked.info, |
|
188 | 256 | subjects_dir=subjects_dir, |
189 | 257 | ) |
190 | 258 |
|
191 | | -# Plot the position and the orientation of the actual dipole |
| 259 | +# Plot the position and the orientation of the true dipole in black |
192 | 260 | fig = mne.viz.plot_dipole_locations( |
193 | 261 | dipoles=dip_true, mode="arrow", subject=subject, color=(0.0, 0.0, 0.0), fig=fig |
194 | 262 | ) |
195 | 263 |
|
196 | | -# Plot the position and the orientation of the estimated dipole |
| 264 | +# Plot the position and the orientation of the estimated dipole in green |
197 | 265 | fig = mne.viz.plot_dipole_locations( |
198 | | - dipoles=dip, mode="arrow", subject=subject, color=(0.2, 1.0, 0.5), fig=fig |
| 266 | + dipoles=dip_estimated, mode="arrow", subject=subject, color=(0.2, 1.0, 0.5), fig=fig |
199 | 267 | ) |
200 | | - |
201 | 268 | mne.viz.set_3d_view(figure=fig, azimuth=70, elevation=80, distance=0.5) |
202 | 269 |
|
203 | 270 | # %% |
|
0 commit comments