Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
eb78548
ENH: group triaxial OPM topomaps by orientation
PragnyaKhandelwal Apr 23, 2026
be12b9e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 24, 2026
d88772f
DOC: add changelog for #13866
PragnyaKhandelwal Apr 24, 2026
676c4c5
FIX: remove dead mask assignment in topomap
PragnyaKhandelwal Apr 24, 2026
8b9c58d
FIX: restrict OPM orientation grouping to triaxial overlaps
PragnyaKhandelwal Apr 24, 2026
d695e87
FIX: avoid stale pick indexing in OPM modality check
PragnyaKhandelwal Apr 24, 2026
a02b69d
Merge branch 'main' into enh-opm-grouping-final-fix
PragnyaKhandelwal Apr 25, 2026
c24a2ea
DOC: add example showing grouped triaxial OPM topomaps
PragnyaKhandelwal Apr 28, 2026
02b2553
Merge branch 'enh-opm-grouping-final-fix' of https://github.com/Pragn…
PragnyaKhandelwal Apr 28, 2026
0ae8b4b
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 28, 2026
c6d31a9
FIX: use synthetic data in OPM topomap example to avoid dataset downl…
PragnyaKhandelwal Apr 28, 2026
d491efc
DOC: show grouped OPM topomaps in tutorial
PragnyaKhandelwal Apr 29, 2026
da17857
FIX: avoid over-allocating axes for OPM joint plots
PragnyaKhandelwal Apr 29, 2026
c4e8b39
DOC: move grouped OPM demo to kernel_phantom example
PragnyaKhandelwal Apr 29, 2026
953cac8
DOC: fix kernel phantom grouped topomap example
PragnyaKhandelwal Apr 29, 2026
4044692
DOC: remove non-functional grouped OPM demo from kernel_phantom example
PragnyaKhandelwal Apr 29, 2026
7468d76
FIX: Add type check for merge_channels in OPM grouping gate
PragnyaKhandelwal Apr 30, 2026
a650b25
DOC: fix kernel phantom grouped topomap API usage
PragnyaKhandelwal May 1, 2026
8e81f8d
DOC: remove unsupported merge_channels from kernel phantom joint topomap
PragnyaKhandelwal May 1, 2026
8fd2600
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 1, 2026
f23049f
FIX: Support biaxial OPM sensor grouping for visualization
PragnyaKhandelwal May 1, 2026
37536ee
TEST: Update OPM test assertions for grouped biaxial rendering
PragnyaKhandelwal May 1, 2026
bdaba0b
Merge branch 'main' into enh-opm-grouping-final-fix
larsoner May 2, 2026
c5b056a
SCOPE: Remove plot_joint OPM grouping to narrow PR focus
PragnyaKhandelwal May 4, 2026
5edd947
Minor: Format evoked.py line wrapping
PragnyaKhandelwal May 4, 2026
3a354cf
DOC: Update changelog entry as per the scope
PragnyaKhandelwal May 4, 2026
3f8bd1d
TEST: Remove plot_joint OPM grouping test (feature removed from scope)
PragnyaKhandelwal May 4, 2026
e2b784e
DOC: Fix OPM tutorial link in kernel phantom example
PragnyaKhandelwal May 4, 2026
f97c89f
DOC: Add grouped OPM topomap in kernel phantom example
PragnyaKhandelwal May 5, 2026
b9caa67
Merge branch 'main' into enh-opm-grouping-final-fix
larsoner May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/dev/13866.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Completed triaxial OPM topomap grouping by rendering separate radial and tangential maps in evoked topomap, joint plot, and ICA component plotting paths, by `Pragnya Khandelwal`_.
10 changes: 10 additions & 0 deletions examples/datasets/kernel_phantom.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,13 @@
)
mne.viz.plot_dipole_locations(dipoles=dip, mode="arrow", color=(0.2, 1.0, 0.5), fig=fig)
mne.viz.set_3d_view(figure=fig, azimuth=30, elevation=70, distance=0.4)

# %%
# Grouped OPM topomap visualization
# ==================================
#
# Since Kernel OPMs are triaxial sensors (measuring Bx, By, Bz directions),
# we can visualize them as grouped topomaps showing radial and tangential
# components side-by-side when multiple colocated channels are detected:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


fig = evoked.plot_joint(times=[t_peak], topomap_args=dict(sphere=sphere))
79 changes: 62 additions & 17 deletions mne/viz/evoked.py
Original file line number Diff line number Diff line change
Expand Up @@ -1868,6 +1868,7 @@ def plot_evoked_joint(
ts_args.get("time_unit", "s"), evoked.times
)
topomap_args = dict() if topomap_args is None else topomap_args.copy()
opm_group_factor = 1

got_axes = False
illegal_args = {"show", "times", "exclude"}
Expand Down Expand Up @@ -1954,9 +1955,33 @@ def plot_evoked_joint(
del times
_, times_ts = _check_time_unit(ts_args["time_unit"], times_sec)

if len(ch_types) == 1 and set(ch_types) == {"mag"}:
from .topomap import _prepare_topomap_plot, _should_use_opm_orientation_groups

picks_topomap = None
(
picks_topomap,
_,
merge_channels,
_,
_,
_,
_,
) = _prepare_topomap_plot(
evoked,
"mag",
sphere=topomap_args.get("sphere", None),
)
if _should_use_opm_orientation_groups(
evoked.info, picks_topomap, merge_channels, "mag"
):
opm_group_factor = 2

# prepare axes for topomap
if not got_axes:
fig, ts_ax, map_ax = _prepare_joint_axes(len(times_sec), figsize=(8.0, 4.2))
fig, ts_ax, map_ax = _prepare_joint_axes(
len(times_sec) * opm_group_factor, figsize=(8.0, 4.2)
)
cbar_ax = None
else:
ts_ax = ts_args["axes"]
Expand Down Expand Up @@ -2044,22 +2069,42 @@ def plot_evoked_joint(

# connection lines
# draw the connection lines between time series and topoplots
for timepoint, map_ax_ in zip(times_ts, map_ax):
con = ConnectionPatch(
xyA=[timepoint, ts_ax.get_ylim()[1]],
xyB=[0.5, 0],
coordsA="data",
coordsB="axes fraction",
axesA=ts_ax,
axesB=map_ax_,
color="grey",
linestyle="-",
linewidth=1.5,
alpha=0.66,
zorder=1,
clip_on=False,
)
fig.add_artist(con)
if opm_group_factor == 1:
for timepoint, map_ax_ in zip(times_ts, map_ax):
con = ConnectionPatch(
xyA=[timepoint, ts_ax.get_ylim()[1]],
xyB=[0.5, 0],
coordsA="data",
coordsB="axes fraction",
axesA=ts_ax,
axesB=map_ax_,
color="grey",
linestyle="-",
linewidth=1.5,
alpha=0.66,
zorder=1,
clip_on=False,
)
ts_ax.add_artist(con)
else:
for time_idx, timepoint in enumerate(times_ts):
for group_idx in range(opm_group_factor):
map_ax_ = map_ax[time_idx + group_idx * len(times_ts)]
con = ConnectionPatch(
xyA=[timepoint, ts_ax.get_ylim()[1]],
xyB=[0.5, 0],
coordsA="data",
coordsB="axes fraction",
axesA=ts_ax,
axesB=map_ax_,
color="grey",
linestyle="-",
linewidth=1.0,
alpha=0.5,
zorder=1,
clip_on=False,
)
ts_ax.add_artist(con)

# mark times in time series plot
for timepoint in times_ts:
Expand Down
5 changes: 4 additions & 1 deletion mne/viz/tests/test_ica.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,4 +585,7 @@ def test_plot_components_opm_triaxial(triaxial_raw):
ica = ICA(max_iter=1, random_state=0, n_components=3)
ica.fit(triaxial_raw, picks="mag", verbose="error")
fig = ica.plot_components()
assert len(fig.axes) == 3
assert len(fig.axes) == 6
titles = [ax.get_title() for ax in fig.axes]
assert any("[radial]" in title for title in titles)
assert any("[tangential]" in title for title in titles)
5 changes: 4 additions & 1 deletion mne/viz/tests/test_topo.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ def test_plot_joint_opm_triaxial(triaxial_evoked):
ts_args=dict(time_unit="s"),
topomap_args=dict(time_unit="s", contours=0, res=8, sensors=False),
)
assert len(fig.axes) >= 2
assert len(fig.axes) >= 3
titles = [ax.get_title() for ax in fig.axes]
assert any("radial" in title for title in titles)
assert any("tangential" in title for title in titles)


def test_plot_topo():
Expand Down
42 changes: 42 additions & 0 deletions mne/viz/tests/test_topomap.py
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,48 @@ def test_split_opm_overlaps(triaxial_evoked):
assert tangential == ["OPM002", "OPM003", "OPM005", "OPM006"]


def test_should_use_opm_orientation_groups_only_for_triaxial():
"""Test that OPM orientation grouping is restricted to triaxial overlaps."""
ch_names = [f"OPM{k:03}" for k in range(1, 7)]
info = create_info(ch_names, 1000.0, ch_types="mag")
with info._unlock():
for ch in info["chs"]:
ch["coil_type"] = FIFF.FIFFV_COIL_FIELDLINE_OPM_MAG_GEN1

picks = np.arange(len(ch_names))
pair_overlaps = [
np.array(["OPM001", "OPM002"]),
np.array(["OPM003", "OPM004"]),
]
triax_overlaps = [
np.array(["OPM001", "OPM002", "OPM003"]),
np.array(["OPM004", "OPM005", "OPM006"]),
]

assert not topomap._should_use_opm_orientation_groups(
info, picks, pair_overlaps, "mag"
)
assert topomap._should_use_opm_orientation_groups(
info, picks, triax_overlaps, "mag"
)


def test_plot_evoked_topomap_opm_triaxial_groups(triaxial_evoked):
"""Test grouped radial/tangential topomap rendering for triaxial OPM."""
fig = triaxial_evoked.plot_topomap(
times=[0.0],
ch_type="mag",
contours=0,
res=8,
sensors=False,
show=False,
)
assert len(fig.axes) == 3
titles = [ax.get_title() for ax in fig.axes]
assert any("radial" in title for title in titles)
assert any("tangential" in title for title in titles)


def test_plot_topomap_nirs_overlap(fnirs_epochs):
"""Test plotting nirs topomap with overlapping channels (gh-7414)."""
fig = fnirs_epochs["A"].average(picks="hbo").plot_topomap()
Expand Down
Loading
Loading