|
12 | 12 | that generates known magnetic signals, |
13 | 13 | allowing validation and benchmarking of MEG system accuracy and analysis methods. |
14 | 14 |
|
15 | | -The aim of this tutorial is to learn how to use phantom recordings to |
16 | | -evaluate source localisation methods by comparing estimated vs real dipole positions. |
| 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 | 17 |
|
18 | 18 | For comparison, see :footcite:`TadelEtAl2011` and |
19 | 19 | `the original Brainstorm tutorial |
|
28 | 28 | # Copyright the MNE-Python contributors. |
29 | 29 |
|
30 | 30 | # %% |
31 | | - |
32 | 31 | import matplotlib.pyplot as plt |
33 | 32 | import numpy as np |
34 | 33 | from scipy.signal import find_peaks |
|
39 | 38 | from mne.datasets.brainstorm import bst_phantom_elekta |
40 | 39 | from mne.io import read_raw_fif |
41 | 40 |
|
42 | | -print(__doc__) |
43 | 41 | # %% |
44 | 42 | # Load and prepare the data |
45 | 43 | # ------------------------------- |
| 44 | + |
46 | 45 | # The data were collected with an Elekta Neuromag VectorView system |
47 | 46 | # at 1000 Hz and low-pass filtered at 330 Hz. |
48 | | -# The dataset has recordings at 3 different current amplitudes (20, 200, and 2000 nAm), |
49 | | -# here we will load the medium-amplitude current. |
| 47 | +# |
| 48 | +# The dataset contains recordings at three current amplitudes (20, 200, and 2000 nAm). |
| 49 | +# Here we load the medium-amplitude condition. |
50 | 50 | data_path = bst_phantom_elekta.data_path(verbose=True) |
51 | 51 | raw_fname = data_path / "kojak_all_200nAm_pp_no_chpi_no_ms_raw.fif" |
52 | 52 | raw = read_raw_fif(raw_fname) |
53 | | -raw.info["bads"] = ["MEG1933", "MEG2421"] # known bad channels |
| 53 | + |
| 54 | +# Mark known bad channels |
| 55 | +raw.info["bads"] = ["MEG1933", "MEG2421"] |
| 56 | + |
54 | 57 | events = find_events(raw, "STI201") |
55 | | -# The first 32 events are the dipole phantoms recorded. |
56 | | -# The remaining event IDs are not relevant for this tutorial. |
| 58 | + |
| 59 | +# The first 32 events correspond to dipole activations. |
| 60 | + |
57 | 61 | # %% |
58 | | -# Here we epoch the data around dipole events |
59 | | -# and baseline correct the epochs from -100 ms to -0.05 ms before stimulus onset. |
| 62 | +# Epoch the data and plot evokeds |
| 63 | +# --------------- |
| 64 | +# |
| 65 | +# We epoch the data around dipole events and apply baseline correction. |
| 66 | + |
60 | 67 | bmax = -0.05 |
61 | 68 | tmin, tmax = -0.1, 0.8 |
62 | 69 | event_id = list(range(1, 33)) |
| 70 | + |
63 | 71 | epochs = mne.Epochs( |
64 | 72 | raw, events, event_id, tmin, tmax, baseline=(None, bmax), preload=False |
65 | 73 | ) |
66 | | -# %% |
67 | | -# Dipole fitting on phantom data |
68 | | -# ------------------------------- |
69 | | -# Next, we fit dipoles based on the epoched phantom recordings and |
70 | | -# compare the estimated to the known dipole locations built into the phantom. |
71 | | - |
72 | | -# We use the first dipole event to plot the evoked signal |
73 | | -# and skip the first and the last epoch as they are corrupted. |
74 | | - |
| 74 | +# Plot evoked response for the first clean dipole |
75 | 75 | epochs["1"][1:-1].average().plot(time_unit="s") |
76 | | - |
77 | | -# We average over 18 simulated events for the first dipole. |
| 76 | +# %% |
78 | 77 | # In this data the phantom was set to produce 20 Hz sinusoidal bursts of current. |
79 | 78 | # You can see that the burst envelope repeats at approximately 3 Hz. |
80 | | -# %% |
81 | | -# First, we need to determine the timepoint of the peak amplitude. |
82 | | -# To find this timepoint, we compute Global Field Power (GFP), |
83 | | -# which is the standard deviation of all EEG electrode voltages at a |
84 | | -# specific timepoint. |
| 79 | +# |
| 80 | +# Determine peak activation using Global Field Power (GFP) |
| 81 | +# -------------------------------------------------------- |
| 82 | + |
| 83 | +# GFP is the standard deviation across sensors at each time |
| 84 | +# point, providing a reference-independent measure of signal strength. |
85 | 85 | evoked_tmp = epochs["1"][1:-1].average() |
86 | 86 | gfp = np.std(evoked_tmp.data, axis=0) |
87 | 87 | times = evoked_tmp.times |
88 | 88 |
|
89 | | -# We restrict the time window to find the peak |
90 | | -# for the first bursting event. |
| 89 | +# Restrict to first burst window |
91 | 90 | time_mask = (times > 0) & (times <= 0.1) |
| 91 | + |
92 | 92 | peaks, _ = find_peaks(gfp[time_mask]) |
93 | | -# Convert to original indices |
94 | 93 | peak_indices = np.where(time_mask)[0][peaks] |
95 | | -# Select the strongest peak (max GFP) |
| 94 | + |
| 95 | +# Select the strongest peak |
96 | 96 | strongest_peak_idx = peak_indices[np.argmax(gfp[peak_indices])] |
97 | 97 | t_peak = times[strongest_peak_idx] |
98 | 98 |
|
|
107 | 107 | # %% |
108 | 108 | # Next, we need to compute the noise covariance to capture the sensor noise structure. |
109 | 109 | # We use the baseline window to estimate covariance. |
110 | | -# You can explore the covariance tutorial for details :ref:`tut-compute-covariance`. |
| 110 | +# |
| 111 | +# You can explore the covariance tutorial for details: :ref:`tut-compute-covariance`. |
111 | 112 |
|
112 | 113 | cov = mne.compute_covariance(epochs, tmax=bmax) |
113 | 114 |
|
|
120 | 121 | sphere = mne.make_sphere_model(r0=(0.0, 0.0, 0.0), head_radius=0.08) |
121 | 122 |
|
122 | 123 | # %% |
123 | | -# We finally fit all 32 phantom dipoles and store them |
124 | | -# as well as the residuals in a list. |
| 124 | +# Fit dipoles |
| 125 | +# ----------- |
125 | 126 |
|
| 127 | +# We fit dipoles for each phantom and store them in a list. |
126 | 128 | dip_all, residuals_all = [], [] |
127 | 129 |
|
128 | 130 | for evoked in evokeds: |
129 | 131 | dip, residual = fit_dipole(evoked, cov, sphere, n_jobs=1) |
130 | 132 | dip_all.append(dip) |
131 | 133 | residuals_all.append(residual) |
132 | 134 | # %% |
133 | | -# Let's visualize the explained variance. |
134 | | -# The dipole object stores the goodness of fit (GOF) for each dipole. |
135 | | -gof = [dip.gof[0] for dip in dip_all] # array of GOF values |
| 135 | +# Evaluate goodness of fit |
| 136 | +# ----------------------- |
136 | 137 |
|
137 | | -# Define colorblind-friendly colors |
| 138 | +# The dipole object stores the goodness of fit (GOF) for each dipole. |
| 139 | +gof = [dip.gof[0] for dip in dip_all] |
138 | 140 | colors = ["#E69F00" if val < 60 else "#0072B2" for val in gof] |
139 | | -# orange = low, blue = high (from Okabe-Ito palette) |
140 | | - |
141 | 141 | plt.bar(event_id, gof, color=colors) |
142 | 142 | plt.xlabel("Phantom dipole estimation") |
143 | 143 | plt.ylabel("Goodness of fit (%)") |
144 | 144 | plt.show() |
| 145 | + |
145 | 146 | # %% |
146 | | -# We can see that GOF varies between 50 and more than 95 % |
147 | | -# variance explained for dipoles. |
| 147 | +# |
| 148 | +# We can see that GOF varies between 50 % and up to 95 %. |
| 149 | +# |
| 150 | +# Compare estimated and true dipoles |
| 151 | +# -------------------------------- |
148 | 152 |
|
149 | | -# Estimated vs true dipole locations |
150 | | -# ------------------------------- |
151 | | -# Finally, we compare the estimated to the true dipole locations. |
152 | 153 | actual_pos, actual_ori = mne.dipole.get_phantom_dipoles() |
153 | 154 | actual_amp = 200.0 # nAm |
| 155 | + |
154 | 156 | # estimated dipoles |
155 | 157 | dip_pos = [dip.pos[0] for dip in dip_all] |
156 | 158 | dip_ori = [dip.ori[0] for dip in dip_all] |
|
186 | 188 | # The dipole fits closely match the true phantom data, |
187 | 189 | # achieving sub-centimeter accuracy (mean position error 2.7mm). |
188 | 190 | # |
189 | | -# Finally, we can plot the positions and the orientations |
190 | | -# of the estimated and true dipoles. |
| 191 | +# Visualise estimated and true dipoles |
| 192 | +# ----------------------- |
| 193 | + |
191 | 194 | actual_amp = np.ones(len(dip)) # fake amp, needed to create Dipole instance |
192 | 195 | actual_gof = np.ones(len(dip)) # fake goodness-of-fit (GOF) |
193 | 196 | # setup dipole objects for true and estimated dipoles |
|
208 | 211 | show_axes=True, |
209 | 212 | subjects_dir=subjects_dir, |
210 | 213 | ) |
| 214 | + |
211 | 215 | # Plot the position and the orientation of the true dipole in black |
212 | 216 | fig = mne.viz.plot_dipole_locations( |
213 | 217 | dipoles=dip_true, mode="arrow", subject=subject, color=(0.0, 0.0, 0.0), fig=fig |
214 | 218 | ) |
| 219 | + |
215 | 220 | # Plot the position and the orientation of the estimated dipole in green |
216 | 221 | fig = mne.viz.plot_dipole_locations( |
217 | 222 | dipoles=dip_estimated, mode="arrow", subject=subject, color=(0.2, 1.0, 0.5), fig=fig |
|
0 commit comments