Skip to content

Commit 89ece45

Browse files
PragnyaKhandelwalpre-commit-ci[bot]autofix-ci[bot]
authored
ENH: group triaxial OPM topomaps by orientation (#13866)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 59ee9b9 commit 89ece45

7 files changed

Lines changed: 429 additions & 144 deletions

File tree

doc/changes/dev/13866.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Completed triaxial OPM topomap grouping by rendering separate radial and tangential maps in :func:`~mne.viz.plot_evoked_topomap` and :meth:`~mne.preprocessing.ICA.plot_components`, and fixed shape-mismatch crashes in :func:`~mne.viz.plot_projs_topomap` and :meth:`~mne.Evoked.animate_topomap` with colocated OPM channels, by `Pragnya Khandelwal`_.

examples/datasets/kernel_phantom.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@
5151
t_peak = 0.016 # based on visual inspection of evoked
5252
fig.axes[0].axvline(t_peak, color="k", ls=":", lw=3, zorder=2)
5353

54+
# %%
55+
# Because these OPM sensors are colocated in biaxial pairs, topomaps are
56+
# grouped into radial and tangential components.
57+
evoked.plot_topomap(times=[t_peak], ch_type="mag", show=True)
58+
5459
# %%
5560
# The data covariance has an interesting structure because of densely packed sensors:
5661

@@ -106,3 +111,9 @@
106111
)
107112
mne.viz.plot_dipole_locations(dipoles=dip, mode="arrow", color=(0.2, 1.0, 0.5), fig=fig)
108113
mne.viz.set_3d_view(figure=fig, azimuth=30, elevation=70, distance=0.4)
114+
115+
# %%
116+
# For more information on OPM data visualization, see the OPM preprocessing
117+
# tutorial:
118+
#
119+
# - :ref:`tut-opm-processing`

mne/viz/evoked.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1954,9 +1954,24 @@ def plot_evoked_joint(
19541954
del times
19551955
_, times_ts = _check_time_unit(ts_args["time_unit"], times_sec)
19561956

1957-
# prepare axes for topomap
1957+
ch_type = ch_types.pop() # set should only contain one element
1958+
use_opm_orientation_groups = False
1959+
if ch_type == "mag":
1960+
from .topomap import _should_use_opm_orientation_groups
1961+
1962+
_, _, merge_channels, *_ = _prepare_topomap_plot(
1963+
evoked, ch_type, sphere=topomap_args.get("sphere")
1964+
)
1965+
use_opm_orientation_groups = _should_use_opm_orientation_groups(
1966+
merge_channels, ch_type
1967+
)
1968+
n_group_axes = 2 if use_opm_orientation_groups else 1
1969+
1970+
# prepare axes for topomap and butterfly plots
19581971
if not got_axes:
1959-
fig, ts_ax, map_ax = _prepare_joint_axes(len(times_sec), figsize=(8.0, 4.2))
1972+
fig, ts_ax, map_ax = _prepare_joint_axes(
1973+
len(times_sec) * n_group_axes, figsize=(8.0, 4.2)
1974+
)
19601975
cbar_ax = None
19611976
else:
19621977
ts_ax = ts_args["axes"]
@@ -2002,7 +2017,7 @@ def plot_evoked_joint(
20022017

20032018
# topomap
20042019
contours = topomap_args.get("contours", 6)
2005-
ch_type = ch_types.pop() # set should only contain one element
2020+
20062021
# Since the data has all the ch_types, we get the limits from the plot.
20072022
vmin, vmax = (None, None)
20082023
norm = ch_type == "grad"
@@ -2059,7 +2074,7 @@ def plot_evoked_joint(
20592074
zorder=1,
20602075
clip_on=False,
20612076
)
2062-
fig.add_artist(con)
2077+
ts_ax.add_artist(con)
20632078

20642079
# mark times in time series plot
20652080
for timepoint in times_ts:

mne/viz/tests/test_ica.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,8 @@ def test_plot_components_opm():
616616
ica = ICA(max_iter=1, random_state=0, n_components=10)
617617
ica.fit(RawArray(evoked.data, evoked.info), picks="mag", verbose="error")
618618
fig = ica.plot_components()
619-
assert len(fig.axes) == 10
619+
# Biaxial OPM overlaps render grouped radial+tangential maps.
620+
assert len(fig.axes) == 20
620621

621622

622623
@pytest.mark.slowtest
@@ -628,4 +629,4 @@ def test_plot_components_opm_triaxial(triaxial_raw):
628629
ica = ICA(max_iter=1, random_state=0, n_components=3)
629630
ica.fit(triaxial_raw, picks="mag", verbose="error")
630631
fig = ica.plot_components()
631-
assert len(fig.axes) == 3
632+
assert len(fig.axes) == 6

mne/viz/tests/test_topo.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -136,18 +136,6 @@ def return_inds(d): # to test function kwarg to zorder arg of evoked.plot
136136
plt.close("all")
137137

138138

139-
def test_plot_joint_opm_triaxial(triaxial_evoked):
140-
"""Test joint plot with triaxial colocated OPM channels."""
141-
fig = triaxial_evoked.plot_joint(
142-
times=[0.0],
143-
picks="mag",
144-
show=False,
145-
ts_args=dict(time_unit="s"),
146-
topomap_args=dict(time_unit="s", contours=0, res=8, sensors=False),
147-
)
148-
assert len(fig.axes) >= 2
149-
150-
151139
def test_plot_topo():
152140
"""Test plotting of ERP topography."""
153141
# Show topography

mne/viz/tests/test_topomap.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -812,7 +812,9 @@ def test_plot_topomap_opm():
812812
fig_evoked = evoked.plot_topomap(
813813
times=[-0.1, 0, 0.1, 0.2], ch_type="mag", show=False
814814
)
815-
assert len(fig_evoked.axes) == 5
815+
# Biaxial OPM pairs trigger grouped rendering
816+
# (4 radial + 4 tangential + 2 colorbars)
817+
assert len(fig_evoked.axes) == 10
816818

817819

818820
def test_prepare_topomap_plot_opm_non_quspin_coils():
@@ -869,6 +871,102 @@ def test_split_opm_overlaps(triaxial_evoked):
869871
assert tangential == ["OPM002", "OPM003", "OPM005", "OPM006"]
870872

871873

874+
def test_opm_tangential_rms_unsigned(triaxial_evoked):
875+
"""Test that tangential OPM data is RMS magnitude and unsigned."""
876+
picks, pos, merge_channels, names, *_ = topomap._prepare_topomap_plot(
877+
triaxial_evoked, "mag"
878+
)
879+
data = triaxial_evoked.data[picks]
880+
grouped = topomap._compute_orientation_group_data(
881+
data,
882+
names,
883+
pos,
884+
ch_type="mag",
885+
modality="opm",
886+
merge_channels=merge_channels,
887+
use_opm_orientation_groups=True,
888+
)
889+
tangential = [group for group in grouped if group[0] == "tangential"][0]
890+
assert np.all(tangential[1] >= 0)
891+
assert tangential[4]
892+
893+
894+
def test_should_use_opm_orientation_groups_only_for_triaxial():
895+
"""Test that OPM orientation grouping works for biaxial and triaxial overlaps."""
896+
ch_names = [f"OPM{k:03}" for k in range(1, 7)]
897+
info = create_info(ch_names, 1000.0, ch_types="mag")
898+
with info._unlock():
899+
for ch in info["chs"]:
900+
ch["coil_type"] = FIFF.FIFFV_COIL_FIELDLINE_OPM_MAG_GEN1
901+
902+
pair_overlaps = [
903+
np.array(["OPM001", "OPM002"]),
904+
np.array(["OPM003", "OPM004"]),
905+
]
906+
triax_overlaps = [
907+
np.array(["OPM001", "OPM002", "OPM003"]),
908+
np.array(["OPM004", "OPM005", "OPM006"]),
909+
]
910+
911+
# Both biaxial and triaxial overlaps should trigger grouping
912+
assert topomap._should_use_opm_orientation_groups(pair_overlaps, "mag")
913+
assert topomap._should_use_opm_orientation_groups(triax_overlaps, "mag")
914+
915+
916+
def test_plot_evoked_topomap_opm_triaxial_groups(triaxial_evoked):
917+
"""Test grouped radial/tangential topomap rendering for triaxial OPM."""
918+
fig = triaxial_evoked.plot_topomap(
919+
times=[0.0],
920+
ch_type="mag",
921+
contours=0,
922+
res=8,
923+
sensors=False,
924+
show=False,
925+
)
926+
assert len(fig.axes) == 4
927+
titles = [ax.get_title() for ax in fig.axes]
928+
assert any("radial" in title for title in titles)
929+
assert any("tangential" in title for title in titles)
930+
931+
932+
def test_plot_projs_topomap_opm(triaxial_evoked):
933+
"""Test plot_projs_topomap does not crash on colocated OPM channels (gh-13866)."""
934+
from mne import compute_proj_evoked
935+
936+
projs = compute_proj_evoked(triaxial_evoked, n_mag=2)
937+
# Should not raise a shape mismatch between data and pos
938+
fig = plot_projs_topomap(projs, triaxial_evoked.info, show=False)
939+
assert len(fig.axes) >= 1
940+
941+
942+
@pytest.mark.filterwarnings("ignore:.*No contour levels.*:UserWarning")
943+
def test_animate_topomap_opm(triaxial_evoked):
944+
"""Test animate_topomap does not crash on colocated OPM channels (gh-13866)."""
945+
fig, anim = triaxial_evoked.animate_topomap(ch_type="mag", times=[0.0], show=False)
946+
anim._func(0)
947+
assert len(fig.axes) >= 1
948+
949+
950+
def test_plot_arrowmap_opm():
951+
"""Test plot_arrowmap does not crash on colocated OPM channels (gh-13866)."""
952+
from mne.viz import plot_arrowmap
953+
954+
# Need at least 3 unique sensor locations for Delaunay triangulation
955+
ch_names = [f"OPM{k:03d}" for k in range(1, 10)]
956+
info = create_info(ch_names, 1000.0, ch_types="mag")
957+
positions = np.array(
958+
[[0.03, 0.0, 0.05]] * 3 + [[-0.03, 0.0, 0.05]] * 3 + [[0.0, 0.03, 0.05]] * 3
959+
)
960+
with info._unlock():
961+
for idx, ch in enumerate(info["chs"]):
962+
ch["coil_type"] = FIFF.FIFFV_COIL_FIELDLINE_OPM_MAG_GEN1
963+
ch["loc"][:3] = positions[idx]
964+
rng = np.random.default_rng(0)
965+
data_snap = rng.standard_normal(9)
966+
fig = plot_arrowmap(data_snap, info, show=False)
967+
assert len(fig.axes) == 1
968+
969+
872970
def test_plot_topomap_nirs_overlap(fnirs_epochs):
873971
"""Test plotting nirs topomap with overlapping channels (gh-7414)."""
874972
fig = fnirs_epochs["A"].average(picks="hbo").plot_topomap()

0 commit comments

Comments
 (0)