From eb78548a202d1019ddc71c4b88f80ff769379f69 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Thu, 23 Apr 2026 23:17:21 +0530 Subject: [PATCH 01/34] ENH: group triaxial OPM topomaps by orientation --- mne/viz/evoked.py | 78 ++++++++--- mne/viz/tests/test_ica.py | 5 +- mne/viz/tests/test_topo.py | 5 +- mne/viz/tests/test_topomap.py | 16 +++ mne/viz/topomap.py | 245 +++++++++++++++++++++++----------- 5 files changed, 250 insertions(+), 99 deletions(-) diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py index a62d2379f03..57a657dff5a 100644 --- a/mne/viz/evoked.py +++ b/mne/viz/evoked.py @@ -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"} @@ -1954,9 +1955,32 @@ 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 + from .topomap import _opm_coils + + ( + _, + _, + merge_channels, + _, + _, + _, + _, + ) = _prepare_topomap_plot( + evoked, + "mag", + sphere=topomap_args.get("sphere", None), + ) + is_opm = any(ch["coil_type"] in _opm_coils for ch in evoked.info["chs"]) + if is_opm and bool(merge_channels): + 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"] @@ -2044,22 +2068,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: diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py index 46d1145e851..34575e2eddc 100644 --- a/mne/viz/tests/test_ica.py +++ b/mne/viz/tests/test_ica.py @@ -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) diff --git a/mne/viz/tests/test_topo.py b/mne/viz/tests/test_topo.py index fe6c938d244..1ffe3188b32 100644 --- a/mne/viz/tests/test_topo.py +++ b/mne/viz/tests/test_topo.py @@ -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(): diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index d364d196f18..e1ddef78780 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -851,6 +851,22 @@ def test_split_opm_overlaps(triaxial_evoked): assert tangential == ["OPM002", "OPM003", "OPM005", "OPM006"] +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() diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index fcbd213ce78..a78ef9ab43b 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -329,6 +329,36 @@ def _split_opm_overlaps(overlapping_channels): return radial, tangential +def _compute_opm_orientation_topomap_data(data, ch_names, pos, overlapping_channels): + """Compute radial and tangential OPM topomap data from overlap sets.""" + from ..channels.layout import _merge_ch_data + + # Radial data matches the existing OPM merge behavior and position layout. + radial_data, radial_names = _merge_ch_data( + data.copy(), "mag", copy.copy(ch_names), modality="opm" + ) + radial_pos = pos + + name_lookup = [name.removesuffix("_MERGE-REMOVE") for name in ch_names] + tangential_data = [] + tangential_names = [] + tangential_pos = [] + for overlap_set in overlapping_channels: + idx = [name_lookup.index(ch_name) for ch_name in overlap_set[1:]] + # Collapse multiple tangential channels at one location using RMS. + tangential_data.append(np.sqrt(np.mean(data[idx] ** 2, axis=0))) + tangential_names.append(f"{overlap_set[0]}t") + tangential_pos.append(radial_pos[radial_names.index(overlap_set[0])]) + + tangential_data = np.array(tangential_data) + tangential_pos = np.array(tangential_pos) + + return [ + ("radial", radial_data, radial_pos, radial_names), + ("tangential", tangential_data, tangential_pos, tangential_names), + ] + + def _plot_update_evoked_topomap(params, bools): """Update topomaps.""" from ..channels.layout import _merge_ch_data @@ -1729,7 +1759,6 @@ def plot_ica_components( clip_origin, ) = _prepare_topomap_plot(ica, ch_type, sphere=sphere) cmap = _setup_cmap(cmap, n_axes=len(picks)) - disp_names = _prepare_sensor_names(names, show_names) outlines = _make_head_outlines(sphere, pos, outlines, clip_origin) data = np.dot( @@ -1738,64 +1767,93 @@ def plot_ica_components( data = np.atleast_2d(data) data = data[:, data_picks] + use_opm_orientation_groups = ( + ch_type == "mag" + and any(ch["coil_type"] in _opm_coils for ch in ica.info["chs"]) + and bool(merge_channels) + ) + n_group_axes = 2 if use_opm_orientation_groups else 1 + if title is None: title = "ICA components" user_passed_axes = _axes is not None if not user_passed_axes: - fig, _axes, _, _ = _prepare_trellis(len(data), ncols=ncols, nrows=nrows) + fig, _axes, _, _ = _prepare_trellis( + len(data) * n_group_axes, ncols=ncols, nrows=nrows + ) fig.suptitle(title) else: _axes = [_axes] if isinstance(_axes, Axes) else _axes + if len(_axes) != len(data) * n_group_axes: + raise RuntimeError( + "You must provide one axis per component and orientation " + "group for colocated OPM data." + ) fig = _axes[0].get_figure() subplot_titles = list() - for ii, data_, ax in zip(picks, data, _axes): + for comp_offset, (ii, data_) in enumerate(zip(picks, data)): kwargs = dict(color="gray") if ii in ica.exclude else dict() comp_title = ica._ica_names[ii] if len(set(ica.get_channel_types())) > 1: comp_title += f" ({ch_type})" - subplot_titles.append(ax.set_title(comp_title, fontsize=12, **kwargs)) - if merge_channels: - data_, names_ = _merge_ch_data(data_, ch_type, copy.copy(names)) - # ↓↓↓ NOTE: we intentionally use the default norm=False here, so that - # ↓↓↓ we get vlims that are symmetric-about-zero, even if the data for - # ↓↓↓ a given component happens to be one-sided. - _vlim = _setup_vmin_vmax(data_, *vlim) - im = plot_topomap( - data_.flatten(), - pos, - ch_type=ch_type, - sensors=sensors, - names=disp_names, - contours=contours, - outlines=outlines, - sphere=sphere, - image_interp=image_interp, - extrapolate=extrapolate, - border=border, - res=res, - size=size, - cmap=cmap[0], - vlim=_vlim, - cnorm=cnorm, - axes=ax, - show=False, - )[0] - - im.axes.set_label(ica._ica_names[ii]) - if colorbar: - cbar, cax = _add_colorbar( - ax, - im, - cmap, - title="AU", - format_=cbar_fmt, - kind="ica_comp_topomap", - ch_type=ch_type, + + if use_opm_orientation_groups: + grouped_data = _compute_opm_orientation_topomap_data( + data_[:, np.newaxis], names, pos, merge_channels ) - cbar.ax.tick_params(labelsize=12) - cbar.set_ticks(_vlim) - _hide_frame(ax) + else: + if merge_channels: + data_, names_ = _merge_ch_data(data_, ch_type, copy.copy(names)) + grouped_data = [(None, data_[:, np.newaxis], pos, names_)] + else: + grouped_data = [(None, data_[:, np.newaxis], pos, names)] + + for group_idx, (group_label, group_data, group_pos, group_names) in enumerate( + grouped_data + ): + ax_idx = comp_offset * n_group_axes + group_idx + ax = _axes[ax_idx] + plot_title = comp_title + if group_label is not None: + plot_title += f" [{group_label}]" + subplot_titles.append(ax.set_title(plot_title, fontsize=12, **kwargs)) + _vlim = _setup_vmin_vmax(group_data[:, 0], *vlim) + im = plot_topomap( + group_data[:, 0].flatten(), + group_pos, + ch_type=ch_type, + sensors=sensors, + names=_prepare_sensor_names(group_names, show_names), + contours=contours, + outlines=outlines, + sphere=sphere, + image_interp=image_interp, + extrapolate=extrapolate, + border=border, + res=res, + size=size, + cmap=cmap[0], + vlim=_vlim, + cnorm=cnorm, + axes=ax, + show=False, + )[0] + + im.axes.set_label(ica._ica_names[ii]) + if colorbar: + cbar, cax = _add_colorbar( + ax, + im, + cmap, + title="AU", + format_=cbar_fmt, + kind="ica_comp_topomap", + ch_type=ch_type, + ) + cbar.ax.tick_params(labelsize=12) + cbar.set_ticks(_vlim) + _hide_frame(ax) del pos fig.canvas.draw() @@ -2259,11 +2317,17 @@ def plot_evoked_topomap( clip_origin, ) = _prepare_topomap_plot(evoked, ch_type, sphere=sphere) outlines = _make_head_outlines(sphere, pos, outlines, clip_origin) + is_opm = any(ch["coil_type"] in _opm_coils for ch in evoked.info["chs"]) + use_opm_orientation_groups = ch_type == "mag" and is_opm and bool(merge_channels) # check interactive axes_given = axes is not None interactive = isinstance(times, str) and times == "interactive" if interactive and axes_given: raise ValueError("User-provided axes not allowed when times='interactive'.") + if interactive and use_opm_orientation_groups: + raise NotImplementedError( + "times='interactive' is not supported for grouped OPM topomaps." + ) # units, scalings key = "grad" if ch_type.startswith("planar") else ch_type default_scaling = _handle_default("scalings", None)[key] @@ -2273,7 +2337,6 @@ def plot_evoked_topomap( unit = _handle_default("units", units)[key] # ch_names (required for NIRS) ch_names = names - names = _prepare_sensor_names(names, show_names) # apply projections before picking. NOTE: the `if proj is True` # anti-pattern is needed here to exclude proj='interactive' _check_option("proj", proj, (True, False, "interactive", "reconstruct")) @@ -2299,7 +2362,8 @@ def plot_evoked_topomap( f"Times should be between {evoked.times[0]:0.3} and {evoked.times[-1]:0.3}." ) # create axes - want_axes = n_times + int(colorbar) + n_groups = 2 if use_opm_orientation_groups else 1 + want_axes = n_times * n_groups + int(colorbar) if interactive: height_ratios = [5, 1] nrows = 2 @@ -2313,7 +2377,7 @@ def plot_evoked_topomap( axes.append(plt.subplot(gs[0, ax_idx])) elif axes is None: fig, axes, ncols, nrows = _prepare_trellis( - n_times, ncols=ncols, nrows=nrows, size=size + n_times * n_groups, ncols=ncols, nrows=nrows, size=size ) else: nrows, ncols = None, None # Deactivate ncols when axes were passed @@ -2386,19 +2450,26 @@ def plot_evoked_topomap( # apply scalings and merge channels data *= scaling + grouped_data = None if merge_channels: # check modality - if any(ch["coil_type"] in _opm_coils for ch in evoked.info["chs"]): + if is_opm: modality = "opm" elif ch_type in _fnirs_types: modality = "fnirs" else: modality = "other" - # merge data - data, ch_names = _merge_ch_data(data, ch_type, ch_names, modality=modality) - # if ch_type in _fnirs_types: - if modality != "other": + if modality == "opm" and use_opm_orientation_groups: + grouped_data = _compute_opm_orientation_topomap_data( + data, ch_names, pos, merge_channels + ) merge_channels = False + else: + # merge data + data, ch_names = _merge_ch_data(data, ch_type, ch_names, modality=modality) + # if ch_type in _fnirs_types: + if modality != "other": + merge_channels = False # apply mask if requested if mask is not None: mask = mask.astype(bool, copy=False) @@ -2409,8 +2480,14 @@ def plot_evoked_topomap( else: # mag, eeg, planar1, planar2 mask_ = mask[np.ix_(picks, time_idx)] # set up colormap + if grouped_data is None: + all_data = [data] + else: + all_data = [group_[1] for group_ in grouped_data] _vlim = [ - _setup_vmin_vmax(data[:, i], *vlim, norm=merge_channels) for i in range(n_times) + _setup_vmin_vmax(group_data[:, i], *vlim, norm=merge_channels) + for group_data in all_data + for i in range(n_times) ] _vlim = [np.min(_vlim), np.max(_vlim)] cmap = _setup_cmap(cmap, n_axes=n_times, norm=_vlim[0] >= 0) @@ -2427,7 +2504,6 @@ def plot_evoked_topomap( kwargs = dict( sensors=sensors, res=res, - names=names, cmap=cmap[0], cnorm=cnorm, mask_params=mask_params, @@ -2441,33 +2517,42 @@ def plot_evoked_topomap( ch_type=ch_type, ) images, contours_ = [], [] - # loop over times - for average_idx, (time, this_average) in enumerate(zip(times, average)): - tp, cn, interp = _plot_topomap( - data[:, average_idx], - pos, - axes=axes[average_idx], - mask=mask_[:, average_idx] if mask is not None else None, - vmin=_vlim[0], - vmax=_vlim[1], - **kwargs, - ) + if grouped_data is None: + grouped_data = [(None, data, pos, ch_names)] - images.append(tp) - if cn is not None: - contours_.append(cn) - if time_format != "": - if this_average is None: - axes_title = time_format % (time * scaling_time) - else: - tmin_ = averaged_times[average_idx][0] - tmax_ = averaged_times[average_idx][-1] - from_time = time_format % (tmin_ * scaling_time) - from_time = from_time.split(" ")[0] # Remove unit - to_time = time_format % (tmax_ * scaling_time) - axes_title = f"{from_time} – {to_time}" - del from_time, to_time, tmin_, tmax_ - axes[average_idx].set_title(axes_title) + for group_idx, (group_label, group_data, group_pos, group_names) in enumerate( + grouped_data + ): + kwargs["names"] = _prepare_sensor_names(group_names, show_names) + for average_idx, (time, this_average) in enumerate(zip(times, average)): + ax_idx = group_idx * n_times + average_idx + tp, cn, interp = _plot_topomap( + group_data[:, average_idx], + group_pos, + axes=axes[ax_idx], + mask=None, + vmin=_vlim[0], + vmax=_vlim[1], + **kwargs, + ) + + images.append(tp) + if cn is not None: + contours_.append(cn) + if time_format != "": + if this_average is None: + axes_title = time_format % (time * scaling_time) + else: + tmin_ = averaged_times[average_idx][0] + tmax_ = averaged_times[average_idx][-1] + from_time = time_format % (tmin_ * scaling_time) + from_time = from_time.split(" ")[0] # Remove unit + to_time = time_format % (tmax_ * scaling_time) + axes_title = f"{from_time} – {to_time}" + del from_time, to_time, tmin_, tmax_ + if group_label is not None: + axes_title = f"{group_label}\n{axes_title}" + axes[ax_idx].set_title(axes_title) if interactive: # Add a slider to the figure and start publishing and subscribing to time_change From be12b9e981e4960ced2731dc279abcf50188903c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:23:59 +0000 Subject: [PATCH 02/34] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/viz/evoked.py | 3 +-- mne/viz/topomap.py | 9 ++++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py index 57a657dff5a..ca079a6bcfe 100644 --- a/mne/viz/evoked.py +++ b/mne/viz/evoked.py @@ -1956,8 +1956,7 @@ def plot_evoked_joint( _, 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 - from .topomap import _opm_coils + from .topomap import _opm_coils, _prepare_topomap_plot ( _, diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index a78ef9ab43b..8a626379837 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -1809,9 +1809,12 @@ def plot_ica_components( else: grouped_data = [(None, data_[:, np.newaxis], pos, names)] - for group_idx, (group_label, group_data, group_pos, group_names) in enumerate( - grouped_data - ): + for group_idx, ( + group_label, + group_data, + group_pos, + group_names, + ) in enumerate(grouped_data): ax_idx = comp_offset * n_group_axes + group_idx ax = _axes[ax_idx] plot_title = comp_title From d88772f4aa543e066717d5022c5ae816e7910d9b Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Fri, 24 Apr 2026 19:57:16 +0530 Subject: [PATCH 03/34] DOC: add changelog for #13866 --- doc/changes/dev/13866.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/13866.bugfix.rst diff --git a/doc/changes/dev/13866.bugfix.rst b/doc/changes/dev/13866.bugfix.rst new file mode 100644 index 00000000000..7c2e617e644 --- /dev/null +++ b/doc/changes/dev/13866.bugfix.rst @@ -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`_. From 676c4c5e693af2d24a94dc4d9ab755237eff51e3 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Fri, 24 Apr 2026 20:10:40 +0530 Subject: [PATCH 04/34] FIX: remove dead mask assignment in topomap --- mne/viz/topomap.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index 8a626379837..0998dd7f914 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -2473,15 +2473,6 @@ def plot_evoked_topomap( # if ch_type in _fnirs_types: if modality != "other": merge_channels = False - # apply mask if requested - if mask is not None: - mask = mask.astype(bool, copy=False) - if ch_type == "grad": - mask_ = ( - mask[np.ix_(picks[::2], time_idx)] | mask[np.ix_(picks[1::2], time_idx)] - ) - else: # mag, eeg, planar1, planar2 - mask_ = mask[np.ix_(picks, time_idx)] # set up colormap if grouped_data is None: all_data = [data] From 8b9c58d0a82bd71803af76ba647d7edd5794c914 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Fri, 24 Apr 2026 22:16:54 +0530 Subject: [PATCH 05/34] FIX: restrict OPM orientation grouping to triaxial overlaps --- mne/viz/tests/test_topomap.py | 26 ++++++++++++++++++++++++ mne/viz/topomap.py | 38 ++++++++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index e1ddef78780..4b92d326dbd 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -851,6 +851,32 @@ 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( diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index 0998dd7f914..c29f6affbc6 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -359,6 +359,22 @@ def _compute_opm_orientation_topomap_data(data, ch_names, pos, overlapping_chann ] +def _should_use_opm_orientation_groups(info, picks, merge_channels, ch_type): + """Return whether OPM orientation grouping should be enabled. + + Grouping is only used for OPM magnetometer channels with overlap sets that + include at least 3 colocated channels (triaxial-style sensors). + """ + if ch_type != "mag" or not merge_channels: + return False + + pick_chs = [info["chs"][pick] for pick in picks] + if not pick_chs or not all(ch["coil_type"] in _opm_coils for ch in pick_chs): + return False + + return any(len(overlap_set) >= 3 for overlap_set in merge_channels) + + def _plot_update_evoked_topomap(params, bools): """Update topomaps.""" from ..channels.layout import _merge_ch_data @@ -1744,9 +1760,9 @@ def plot_ica_components( axes = axes.flatten() if isinstance(axes, np.ndarray) else axes for k, picks in enumerate(pick_groups): - try: # either an iterable, 1D numpy array or others - _axes = axes[k * max_subplots : (k + 1) * max_subplots] - except TypeError: # None or Axes + if axes is None: + _axes = None + else: _axes = axes ( @@ -1767,10 +1783,8 @@ def plot_ica_components( data = np.atleast_2d(data) data = data[:, data_picks] - use_opm_orientation_groups = ( - ch_type == "mag" - and any(ch["coil_type"] in _opm_coils for ch in ica.info["chs"]) - and bool(merge_channels) + use_opm_orientation_groups = _should_use_opm_orientation_groups( + ica.info, data_picks, merge_channels, ch_type ) n_group_axes = 2 if use_opm_orientation_groups else 1 @@ -2320,8 +2334,9 @@ def plot_evoked_topomap( clip_origin, ) = _prepare_topomap_plot(evoked, ch_type, sphere=sphere) outlines = _make_head_outlines(sphere, pos, outlines, clip_origin) - is_opm = any(ch["coil_type"] in _opm_coils for ch in evoked.info["chs"]) - use_opm_orientation_groups = ch_type == "mag" and is_opm and bool(merge_channels) + use_opm_orientation_groups = _should_use_opm_orientation_groups( + evoked.info, picks, merge_channels, ch_type + ) # check interactive axes_given = axes is not None interactive = isinstance(times, str) and times == "interactive" @@ -2456,7 +2471,10 @@ def plot_evoked_topomap( grouped_data = None if merge_channels: # check modality - if is_opm: + is_opm_picks = len(picks) > 0 and all( + evoked.info["chs"][pick]["coil_type"] in _opm_coils for pick in picks + ) + if is_opm_picks: modality = "opm" elif ch_type in _fnirs_types: modality = "fnirs" From d695e8758a18858c8bf6ad6d1f82251756a37c03 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Fri, 24 Apr 2026 23:12:09 +0530 Subject: [PATCH 06/34] FIX: avoid stale pick indexing in OPM modality check --- mne/viz/topomap.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index c29f6affbc6..5b31a1e08d4 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -2471,8 +2471,8 @@ def plot_evoked_topomap( grouped_data = None if merge_channels: # check modality - is_opm_picks = len(picks) > 0 and all( - evoked.info["chs"][pick]["coil_type"] in _opm_coils for pick in picks + is_opm_picks = len(evoked.info["chs"]) > 0 and all( + ch["coil_type"] in _opm_coils for ch in evoked.info["chs"] ) if is_opm_picks: modality = "opm" From c24a2ea68ccef7480cb68e9639cd45256bb43f8f Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Wed, 29 Apr 2026 01:00:47 +0530 Subject: [PATCH 07/34] DOC: add example showing grouped triaxial OPM topomaps --- .../plot_evoked_topomap_opm_grouped.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 examples/visualization/plot_evoked_topomap_opm_grouped.py diff --git a/examples/visualization/plot_evoked_topomap_opm_grouped.py b/examples/visualization/plot_evoked_topomap_opm_grouped.py new file mode 100644 index 00000000000..c577bfb80c8 --- /dev/null +++ b/examples/visualization/plot_evoked_topomap_opm_grouped.py @@ -0,0 +1,41 @@ +""" +===================================== +Plot grouped triaxial OPM topomaps +===================================== + +This example demonstrates grouped radial/tangential topomap rendering for +colocated triaxial OPM sensors using a small segment of the UCL OPM auditory +dataset. The grouped rendering places radial maps alongside tangential maps +so orientation information is explicit. + +""" +# Authors: MNE contributors +# License: BSD-3-Clause + +import matplotlib.pyplot as plt +import numpy as np + +import mne + +# Load a small segment of the UCL OPM dataset +subject = "sub-002" +data_path = mne.datasets.ucl_opm_auditory.data_path() +opm_file = ( + data_path / subject / "ses-001" / "meg" / "sub-002_ses-001_task-aef_run-001_meg.bin" +) + +# Read and crop for speed +raw = mne.io.read_raw_fil(opm_file, verbose="error") +raw.crop(120, 210).load_data() + +# Create epochs and average to get evoked +events = mne.find_events(raw, min_duration=0.1) +epochs = mne.Epochs( + raw, events, tmin=-0.1, tmax=0.4, baseline=(None, 0), verbose="error" +) +evoked = epochs.average() + +# Find a peak time and plot grouped topomap +t_peak = evoked.times[np.argmax(np.std(evoked.copy().pick("meg").data, axis=0))] +fig = evoked.plot_topomap(times=[float(t_peak)], ch_type="mag") +plt.show() From 0ae8b4b6cfc98ef42fcf8b3f4322ea51d4249182 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:36:01 +0000 Subject: [PATCH 08/34] [autofix.ci] apply automated fixes --- examples/visualization/plot_evoked_topomap_opm_grouped.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/visualization/plot_evoked_topomap_opm_grouped.py b/examples/visualization/plot_evoked_topomap_opm_grouped.py index c577bfb80c8..bf02b629002 100644 --- a/examples/visualization/plot_evoked_topomap_opm_grouped.py +++ b/examples/visualization/plot_evoked_topomap_opm_grouped.py @@ -11,6 +11,7 @@ """ # Authors: MNE contributors # License: BSD-3-Clause +# Copyright the MNE-Python contributors. import matplotlib.pyplot as plt import numpy as np From c6d31a97504de083808aa1ead27674243d76ee3a Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Wed, 29 Apr 2026 01:25:37 +0530 Subject: [PATCH 09/34] FIX: use synthetic data in OPM topomap example to avoid dataset download timeout in CI --- .../plot_evoked_topomap_opm_grouped.py | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/examples/visualization/plot_evoked_topomap_opm_grouped.py b/examples/visualization/plot_evoked_topomap_opm_grouped.py index bf02b629002..c79703706fe 100644 --- a/examples/visualization/plot_evoked_topomap_opm_grouped.py +++ b/examples/visualization/plot_evoked_topomap_opm_grouped.py @@ -4,39 +4,44 @@ ===================================== This example demonstrates grouped radial/tangential topomap rendering for -colocated triaxial OPM sensors using a small segment of the UCL OPM auditory -dataset. The grouped rendering places radial maps alongside tangential maps -so orientation information is explicit. - +colocated triaxial OPM sensors. The grouped rendering places radial maps +alongside tangential maps so orientation information is explicit. """ # Authors: MNE contributors # License: BSD-3-Clause # Copyright the MNE-Python contributors. -import matplotlib.pyplot as plt import numpy as np import mne -# Load a small segment of the UCL OPM dataset -subject = "sub-002" -data_path = mne.datasets.ucl_opm_auditory.data_path() -opm_file = ( - data_path / subject / "ses-001" / "meg" / "sub-002_ses-001_task-aef_run-001_meg.bin" +# Create synthetic triaxial OPM data +# Simulate three colocated OPM sensors with radial and tangential orientations +n_channels = 6 # 3 locations × 2 orientations (radial + tangential) +n_samples = 500 +sfreq = 100.0 + +# Create synthetic channel info with triaxial OPM layout +ch_names = [f"G{i // 2}-{['RAD', 'TAN'][i % 2]}" for i in range(n_channels)] +ch_types = ["mag"] * n_channels +info = mne.create_info(ch_names, sfreq, ch_types) + +# Set channel locations in a line to simulate colocated triplets +locs = np.array([[0, i // 2 * 0.01, 0] for i in range(n_channels)]) +info.set_montage( + mne.channels.make_dig_montage( + ch_pos={name: loc for name, loc in zip(ch_names, locs)} + ) ) -# Read and crop for speed -raw = mne.io.read_raw_fil(opm_file, verbose="error") -raw.crop(120, 210).load_data() +# Create synthetic data +data = np.random.randn(n_channels, n_samples) * 1e-12 # Tesla +data[::2] += ( + np.sin(2 * np.pi * 10 * np.arange(n_samples) / sfreq) * 1e-12 +) # 10 Hz signal -# Create epochs and average to get evoked -events = mne.find_events(raw, min_duration=0.1) -epochs = mne.Epochs( - raw, events, tmin=-0.1, tmax=0.4, baseline=(None, 0), verbose="error" -) -evoked = epochs.average() +# Create evoked object +evoked = mne.EvokedArray(data, info, tmin=-0.5) -# Find a peak time and plot grouped topomap -t_peak = evoked.times[np.argmax(np.std(evoked.copy().pick("meg").data, axis=0))] -fig = evoked.plot_topomap(times=[float(t_peak)], ch_type="mag") -plt.show() +# Plot grouped topomap showing radial and tangential maps side-by-side +fig = evoked.plot_topomap(times=[0.0], ch_type="mag") From d491efc8acb7135774a6fc848a43d05fdb620f17 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Wed, 29 Apr 2026 17:27:04 +0530 Subject: [PATCH 10/34] DOC: show grouped OPM topomaps in tutorial --- .../plot_evoked_topomap_opm_grouped.py | 47 ------------------- tutorials/preprocessing/80_opm_processing.py | 7 +++ 2 files changed, 7 insertions(+), 47 deletions(-) delete mode 100644 examples/visualization/plot_evoked_topomap_opm_grouped.py diff --git a/examples/visualization/plot_evoked_topomap_opm_grouped.py b/examples/visualization/plot_evoked_topomap_opm_grouped.py deleted file mode 100644 index c79703706fe..00000000000 --- a/examples/visualization/plot_evoked_topomap_opm_grouped.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -===================================== -Plot grouped triaxial OPM topomaps -===================================== - -This example demonstrates grouped radial/tangential topomap rendering for -colocated triaxial OPM sensors. The grouped rendering places radial maps -alongside tangential maps so orientation information is explicit. -""" -# Authors: MNE contributors -# License: BSD-3-Clause -# Copyright the MNE-Python contributors. - -import numpy as np - -import mne - -# Create synthetic triaxial OPM data -# Simulate three colocated OPM sensors with radial and tangential orientations -n_channels = 6 # 3 locations × 2 orientations (radial + tangential) -n_samples = 500 -sfreq = 100.0 - -# Create synthetic channel info with triaxial OPM layout -ch_names = [f"G{i // 2}-{['RAD', 'TAN'][i % 2]}" for i in range(n_channels)] -ch_types = ["mag"] * n_channels -info = mne.create_info(ch_names, sfreq, ch_types) - -# Set channel locations in a line to simulate colocated triplets -locs = np.array([[0, i // 2 * 0.01, 0] for i in range(n_channels)]) -info.set_montage( - mne.channels.make_dig_montage( - ch_pos={name: loc for name, loc in zip(ch_names, locs)} - ) -) - -# Create synthetic data -data = np.random.randn(n_channels, n_samples) * 1e-12 # Tesla -data[::2] += ( - np.sin(2 * np.pi * 10 * np.arange(n_samples) / sfreq) * 1e-12 -) # 10 Hz signal - -# Create evoked object -evoked = mne.EvokedArray(data, info, tmin=-0.5) - -# Plot grouped topomap showing radial and tangential maps side-by-side -fig = evoked.plot_topomap(times=[0.0], ch_type="mag") diff --git a/tutorials/preprocessing/80_opm_processing.py b/tutorials/preprocessing/80_opm_processing.py index ba44a797a51..cc7180a1fab 100644 --- a/tutorials/preprocessing/80_opm_processing.py +++ b/tutorials/preprocessing/80_opm_processing.py @@ -245,6 +245,13 @@ t_peak = evoked.times[np.argmax(np.std(evoked.copy().pick("meg").data, axis=0))] fig = evoked.plot_joint(picks="mag") +# %% +# Demonstrating grouped OPM topomaps +# ---------------------------------- +# The UCL OPM auditory dataset has already been loaded above, so this extra +# figure reuses the same real evoked object without any additional downloads. +fig = evoked.plot_topomap(times=[t_peak], ch_type="mag") + # %% # Visualizing coregistration # -------------------------- From da17857d8f93d5a3652e71e2b8b4e594c40098e1 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Wed, 29 Apr 2026 18:01:50 +0530 Subject: [PATCH 11/34] FIX: avoid over-allocating axes for OPM joint plots --- mne/viz/evoked.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py index ca079a6bcfe..5edfed8e756 100644 --- a/mne/viz/evoked.py +++ b/mne/viz/evoked.py @@ -1956,10 +1956,11 @@ def plot_evoked_joint( _, times_ts = _check_time_unit(ts_args["time_unit"], times_sec) if len(ch_types) == 1 and set(ch_types) == {"mag"}: - from .topomap import _opm_coils, _prepare_topomap_plot + from .topomap import _prepare_topomap_plot, _should_use_opm_orientation_groups + picks_topomap = None ( - _, + picks_topomap, _, merge_channels, _, @@ -1971,8 +1972,9 @@ def plot_evoked_joint( "mag", sphere=topomap_args.get("sphere", None), ) - is_opm = any(ch["coil_type"] in _opm_coils for ch in evoked.info["chs"]) - if is_opm and bool(merge_channels): + if _should_use_opm_orientation_groups( + evoked.info, picks_topomap, merge_channels, "mag" + ): opm_group_factor = 2 # prepare axes for topomap From c4e8b39a3ef217d0d0344af9b55201ac60d04fc0 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Wed, 29 Apr 2026 19:41:29 +0530 Subject: [PATCH 12/34] DOC: move grouped OPM demo to kernel_phantom example --- examples/datasets/kernel_phantom.py | 8 ++++++++ tutorials/preprocessing/80_opm_processing.py | 8 -------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/datasets/kernel_phantom.py b/examples/datasets/kernel_phantom.py index da17f708454..9f6c7f23ee1 100644 --- a/examples/datasets/kernel_phantom.py +++ b/examples/datasets/kernel_phantom.py @@ -51,6 +51,14 @@ t_peak = 0.016 # based on visual inspection of evoked fig.axes[0].axvline(t_peak, color="k", ls=":", lw=3, zorder=2) +# %% +# Demonstrating grouped OPM topomaps +# The Kernel OPM phantom data is triaxial, so we can demonstrate the grouped +# radial and tangential topomap rendering: + +fig = evoked.plot_topomap(times=[t_peak], ch_type="mag", merge_channels=True) +fig = evoked.plot_joint(times=[t_peak], picks="mag") + # %% # The data covariance has an interesting structure because of densely packed sensors: diff --git a/tutorials/preprocessing/80_opm_processing.py b/tutorials/preprocessing/80_opm_processing.py index cc7180a1fab..b650e8db7de 100644 --- a/tutorials/preprocessing/80_opm_processing.py +++ b/tutorials/preprocessing/80_opm_processing.py @@ -243,14 +243,6 @@ ) evoked = epochs.average() t_peak = evoked.times[np.argmax(np.std(evoked.copy().pick("meg").data, axis=0))] -fig = evoked.plot_joint(picks="mag") - -# %% -# Demonstrating grouped OPM topomaps -# ---------------------------------- -# The UCL OPM auditory dataset has already been loaded above, so this extra -# figure reuses the same real evoked object without any additional downloads. -fig = evoked.plot_topomap(times=[t_peak], ch_type="mag") # %% # Visualizing coregistration From 953cac8c1af10602c76662728891c4e162f9c909 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Wed, 29 Apr 2026 19:58:07 +0530 Subject: [PATCH 13/34] DOC: fix kernel phantom grouped topomap example --- examples/datasets/kernel_phantom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/datasets/kernel_phantom.py b/examples/datasets/kernel_phantom.py index 9f6c7f23ee1..23d8aaf6ee7 100644 --- a/examples/datasets/kernel_phantom.py +++ b/examples/datasets/kernel_phantom.py @@ -56,7 +56,7 @@ # The Kernel OPM phantom data is triaxial, so we can demonstrate the grouped # radial and tangential topomap rendering: -fig = evoked.plot_topomap(times=[t_peak], ch_type="mag", merge_channels=True) +fig = evoked.plot_topomap(times=[t_peak], ch_type="mag") fig = evoked.plot_joint(times=[t_peak], picks="mag") # %% From 40446920aeb13896cf4c3bc03b90c27088348b40 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Wed, 29 Apr 2026 20:59:43 +0530 Subject: [PATCH 14/34] DOC: remove non-functional grouped OPM demo from kernel_phantom example --- examples/datasets/kernel_phantom.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/examples/datasets/kernel_phantom.py b/examples/datasets/kernel_phantom.py index 23d8aaf6ee7..da17f708454 100644 --- a/examples/datasets/kernel_phantom.py +++ b/examples/datasets/kernel_phantom.py @@ -51,14 +51,6 @@ t_peak = 0.016 # based on visual inspection of evoked fig.axes[0].axvline(t_peak, color="k", ls=":", lw=3, zorder=2) -# %% -# Demonstrating grouped OPM topomaps -# The Kernel OPM phantom data is triaxial, so we can demonstrate the grouped -# radial and tangential topomap rendering: - -fig = evoked.plot_topomap(times=[t_peak], ch_type="mag") -fig = evoked.plot_joint(times=[t_peak], picks="mag") - # %% # The data covariance has an interesting structure because of densely packed sensors: From 7468d765474df686b5a1e1d3472326614ef6381c Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Thu, 30 Apr 2026 23:25:15 +0530 Subject: [PATCH 15/34] FIX: Add type check for merge_channels in OPM grouping gate --- examples/datasets/kernel_phantom.py | 11 +++++++++++ mne/viz/topomap.py | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/examples/datasets/kernel_phantom.py b/examples/datasets/kernel_phantom.py index da17f708454..db40b09632f 100644 --- a/examples/datasets/kernel_phantom.py +++ b/examples/datasets/kernel_phantom.py @@ -106,3 +106,14 @@ ) 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. This is only done when merge_channels=True +# and there are multiple colocated channels at each location: + +fig = evoked.plot_topomap(times=[t_peak], merge_channels=True, sphere=sphere) diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index 5b31a1e08d4..bb0f023c6ba 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -372,6 +372,10 @@ def _should_use_opm_orientation_groups(info, picks, merge_channels, ch_type): if not pick_chs or not all(ch["coil_type"] in _opm_coils for ch in pick_chs): return False + # merge_channels should be a list of overlap sets, not a boolean + if not isinstance(merge_channels, (list, tuple)): + return False + return any(len(overlap_set) >= 3 for overlap_set in merge_channels) From a650b2503dc4fdcf9e8ca9adcb1c068ef4548b55 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Fri, 1 May 2026 17:53:10 +0530 Subject: [PATCH 16/34] DOC: fix kernel phantom grouped topomap API usage --- examples/datasets/kernel_phantom.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/datasets/kernel_phantom.py b/examples/datasets/kernel_phantom.py index db40b09632f..1c749a2574d 100644 --- a/examples/datasets/kernel_phantom.py +++ b/examples/datasets/kernel_phantom.py @@ -116,4 +116,6 @@ # components side-by-side. This is only done when merge_channels=True # and there are multiple colocated channels at each location: -fig = evoked.plot_topomap(times=[t_peak], merge_channels=True, sphere=sphere) +fig = evoked.plot_joint( + times=[t_peak], topomap_args=dict(merge_channels=True, sphere=sphere) +) From 8e81f8ddf4ac4074c0be17a2d58c72bede476661 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Fri, 1 May 2026 18:17:41 +0530 Subject: [PATCH 17/34] DOC: remove unsupported merge_channels from kernel phantom joint topomap --- examples/datasets/kernel_phantom.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/datasets/kernel_phantom.py b/examples/datasets/kernel_phantom.py index 1c749a2574d..e0e786db560 100644 --- a/examples/datasets/kernel_phantom.py +++ b/examples/datasets/kernel_phantom.py @@ -113,9 +113,8 @@ # # 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. This is only done when merge_channels=True -# and there are multiple colocated channels at each location: +# components side-by-side when multiple colocated channels are detected: fig = evoked.plot_joint( - times=[t_peak], topomap_args=dict(merge_channels=True, sphere=sphere) + times=[t_peak], topomap_args=dict(sphere=sphere) ) From 8fd2600277b89e0e29b5dfbd414b18c67015e9c9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 12:48:03 +0000 Subject: [PATCH 18/34] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- examples/datasets/kernel_phantom.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/datasets/kernel_phantom.py b/examples/datasets/kernel_phantom.py index e0e786db560..80d43f7fc34 100644 --- a/examples/datasets/kernel_phantom.py +++ b/examples/datasets/kernel_phantom.py @@ -115,6 +115,4 @@ # we can visualize them as grouped topomaps showing radial and tangential # components side-by-side when multiple colocated channels are detected: -fig = evoked.plot_joint( - times=[t_peak], topomap_args=dict(sphere=sphere) -) +fig = evoked.plot_joint(times=[t_peak], topomap_args=dict(sphere=sphere)) From f23049f69112d26f5d8a2bdbe60d0d6d932970e9 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Fri, 1 May 2026 22:52:08 +0530 Subject: [PATCH 19/34] FIX: Support biaxial OPM sensor grouping for visualization Fixed two critical bugs in OPM grouped topomap rendering: 1. Use symmetric distance matrix (not upper triangle) to properly detect all colocated channel overlaps bidirectionally. 2. Lower grouping threshold from >=3 to >=2 channels to support both biaxial (e.g., bx+by or by+bz) and triaxial (bx+by+bz) OPM sensors. Real OPM hardware like Kernel phantom has biaxial pairs instead of perfect triaxial arrays. This fix enables grouped radial/tangential visualization for real datasets. Changes: - mne/viz/topomap.py: Fixed distance matrix and threshold - mne/viz/tests/test_topomap.py: Updated test expectations - examples/datasets/kernel_phantom.py: Updated docstring - tutorials/preprocessing/80_opm_processing.py: Restored plot_joint call Closes the rendering issue where only 1 plot showed instead of 2. --- examples/datasets/kernel_phantom.py | 6 +++--- mne/viz/tests/test_topomap.py | 7 +++---- mne/viz/topomap.py | 9 +++++---- tutorials/preprocessing/80_opm_processing.py | 1 + 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/examples/datasets/kernel_phantom.py b/examples/datasets/kernel_phantom.py index 80d43f7fc34..f08a2b36755 100644 --- a/examples/datasets/kernel_phantom.py +++ b/examples/datasets/kernel_phantom.py @@ -111,8 +111,8 @@ # 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: +# Kernel OPMs measure multiple magnetic field directions (Bx, By, Bz). +# We can visualize colocated channels as grouped topomaps showing radial and +# tangential components side-by-side for clearer interpretation of the data: fig = evoked.plot_joint(times=[t_peak], topomap_args=dict(sphere=sphere)) diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index 4b92d326dbd..fb402b686a0 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -852,7 +852,7 @@ def test_split_opm_overlaps(triaxial_evoked): def test_should_use_opm_orientation_groups_only_for_triaxial(): - """Test that OPM orientation grouping is restricted to triaxial overlaps.""" + """Test that OPM orientation grouping works for biaxial and 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(): @@ -869,9 +869,8 @@ def test_should_use_opm_orientation_groups_only_for_triaxial(): np.array(["OPM004", "OPM005", "OPM006"]), ] - assert not topomap._should_use_opm_orientation_groups( - info, picks, pair_overlaps, "mag" - ) + # Both biaxial and triaxial overlaps should trigger grouping + assert topomap._should_use_opm_orientation_groups(info, picks, pair_overlaps, "mag") assert topomap._should_use_opm_orientation_groups( info, picks, triax_overlaps, "mag" ) diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index bb0f023c6ba..8d491589588 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -226,7 +226,8 @@ def _find_overlaps(info, ch_type, sphere, modality="fnirs"): channels_to_exclude = list() if len(locs3d) > 1 and np.min(dist) < 1e-10: - overlapping_mask = np.triu(squareform(dist < 1e-10)) + # Use symmetric distance matrix to find all colocated channel groups + overlapping_mask = squareform(dist < 1e-10) for chan_idx in range(overlapping_mask.shape[0]): already_overlapped = list( itertools.chain.from_iterable(overlapping_channels) @@ -362,8 +363,8 @@ def _compute_opm_orientation_topomap_data(data, ch_names, pos, overlapping_chann def _should_use_opm_orientation_groups(info, picks, merge_channels, ch_type): """Return whether OPM orientation grouping should be enabled. - Grouping is only used for OPM magnetometer channels with overlap sets that - include at least 3 colocated channels (triaxial-style sensors). + Grouping is used for OPM magnetometer channels with overlap sets that + include at least 2 colocated channels (biaxial or triaxial sensors). """ if ch_type != "mag" or not merge_channels: return False @@ -376,7 +377,7 @@ def _should_use_opm_orientation_groups(info, picks, merge_channels, ch_type): if not isinstance(merge_channels, (list, tuple)): return False - return any(len(overlap_set) >= 3 for overlap_set in merge_channels) + return any(len(overlap_set) >= 2 for overlap_set in merge_channels) def _plot_update_evoked_topomap(params, bools): diff --git a/tutorials/preprocessing/80_opm_processing.py b/tutorials/preprocessing/80_opm_processing.py index b650e8db7de..ba44a797a51 100644 --- a/tutorials/preprocessing/80_opm_processing.py +++ b/tutorials/preprocessing/80_opm_processing.py @@ -243,6 +243,7 @@ ) evoked = epochs.average() t_peak = evoked.times[np.argmax(np.std(evoked.copy().pick("meg").data, axis=0))] +fig = evoked.plot_joint(picks="mag") # %% # Visualizing coregistration From 37536ee44244602ad2bd4a37ade91501343c5967 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Sat, 2 May 2026 00:09:23 +0530 Subject: [PATCH 20/34] TEST: Update OPM test assertions for grouped biaxial rendering Tests now expect doubled axis counts due to radial+tangential grouping for biaxial OPM pairs. Kernel phantom data now correctly triggers grouped visualization with 20 axes for 10 ICA components and 9 axes for 4-point topomap (4 radial + 4 tangential + 1 colorbar). --- mne/viz/tests/test_ica.py | 3 ++- mne/viz/tests/test_topomap.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py index 34575e2eddc..c1b50340d17 100644 --- a/mne/viz/tests/test_ica.py +++ b/mne/viz/tests/test_ica.py @@ -573,7 +573,8 @@ def test_plot_components_opm(): ica = ICA(max_iter=1, random_state=0, n_components=10) ica.fit(RawArray(evoked.data, evoked.info), picks="mag", verbose="error") fig = ica.plot_components() - assert len(fig.axes) == 10 + # Biaxial OPM pairs trigger grouped rendering (radial + tangential axes) + assert len(fig.axes) == 20 @pytest.mark.slowtest diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index fb402b686a0..3fbb31ce668 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -794,7 +794,8 @@ def test_plot_topomap_opm(): fig_evoked = evoked.plot_topomap( times=[-0.1, 0, 0.1, 0.2], ch_type="mag", show=False ) - assert len(fig_evoked.axes) == 5 + # Biaxial OPM pairs trigger grouped rendering (4 radial + 4 tangential + 1 colorbar) + assert len(fig_evoked.axes) == 9 def test_prepare_topomap_plot_opm_non_quspin_coils(): From c5b056a486ccf30653190c4c575a6669d980f4ad Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Mon, 4 May 2026 20:23:42 +0530 Subject: [PATCH 21/34] SCOPE: Remove plot_joint OPM grouping to narrow PR focus --- examples/datasets/kernel_phantom.py | 10 ++-- mne/viz/evoked.py | 77 +++++++---------------------- 2 files changed, 20 insertions(+), 67 deletions(-) diff --git a/examples/datasets/kernel_phantom.py b/examples/datasets/kernel_phantom.py index f08a2b36755..eaa954fa9ff 100644 --- a/examples/datasets/kernel_phantom.py +++ b/examples/datasets/kernel_phantom.py @@ -108,11 +108,7 @@ mne.viz.set_3d_view(figure=fig, azimuth=30, elevation=70, distance=0.4) # %% -# Grouped OPM topomap visualization -# ================================== +# For more information on OPM data visualization, see the OPM preprocessing +# tutorial: # -# Kernel OPMs measure multiple magnetic field directions (Bx, By, Bz). -# We can visualize colocated channels as grouped topomaps showing radial and -# tangential components side-by-side for clearer interpretation of the data: - -fig = evoked.plot_joint(times=[t_peak], topomap_args=dict(sphere=sphere)) +# - :ref:`ex-opm-processing` diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py index 5edfed8e756..1dffe2ddabd 100644 --- a/mne/viz/evoked.py +++ b/mne/viz/evoked.py @@ -1868,7 +1868,6 @@ 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"} @@ -1955,32 +1954,10 @@ 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) * opm_group_factor, figsize=(8.0, 4.2) + len(times_sec), figsize=(8.0, 4.2) ) cbar_ax = None else: @@ -2069,42 +2046,22 @@ def plot_evoked_joint( # connection lines # draw the connection lines between time series and topoplots - 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) + 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) # mark times in time series plot for timepoint in times_ts: From 5edd9471ce00f635ad32d491df96fee95e132e53 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Mon, 4 May 2026 20:35:31 +0530 Subject: [PATCH 22/34] Minor: Format evoked.py line wrapping --- mne/viz/evoked.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py index 1dffe2ddabd..923f80f8eda 100644 --- a/mne/viz/evoked.py +++ b/mne/viz/evoked.py @@ -1956,9 +1956,7 @@ def plot_evoked_joint( # 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), figsize=(8.0, 4.2)) cbar_ax = None else: ts_ax = ts_args["axes"] From 3a354cf20bc55e967c03028947802e66d6bd045a Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Mon, 4 May 2026 21:00:44 +0530 Subject: [PATCH 23/34] DOC: Update changelog entry as per the scope --- doc/changes/dev/13866.bugfix.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changes/dev/13866.bugfix.rst b/doc/changes/dev/13866.bugfix.rst index 7c2e617e644..9e0eff077d1 100644 --- a/doc/changes/dev/13866.bugfix.rst +++ b/doc/changes/dev/13866.bugfix.rst @@ -1 +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`_. +Completed triaxial OPM topomap grouping by rendering separate radial and tangential maps in evoked topomap and ICA component plotting paths, by `Pragnya Khandelwal`_. From 3f8bd1d814e1129bb2cd41e825d2da751b31facd Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Mon, 4 May 2026 21:25:06 +0530 Subject: [PATCH 24/34] TEST: Remove plot_joint OPM grouping test (feature removed from scope) --- mne/viz/tests/test_topo.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/mne/viz/tests/test_topo.py b/mne/viz/tests/test_topo.py index 1ffe3188b32..0cb01c03a4f 100644 --- a/mne/viz/tests/test_topo.py +++ b/mne/viz/tests/test_topo.py @@ -136,21 +136,6 @@ def return_inds(d): # to test function kwarg to zorder arg of evoked.plot plt.close("all") -def test_plot_joint_opm_triaxial(triaxial_evoked): - """Test joint plot with triaxial colocated OPM channels.""" - fig = triaxial_evoked.plot_joint( - times=[0.0], - picks="mag", - show=False, - ts_args=dict(time_unit="s"), - topomap_args=dict(time_unit="s", contours=0, res=8, sensors=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_topo(): """Test plotting of ERP topography.""" # Show topography From e2b784e6c1b10946d5962f654c14958b2a96801c Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Mon, 4 May 2026 22:15:44 +0530 Subject: [PATCH 25/34] DOC: Fix OPM tutorial link in kernel phantom example --- examples/datasets/kernel_phantom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/datasets/kernel_phantom.py b/examples/datasets/kernel_phantom.py index eaa954fa9ff..c516087e6d5 100644 --- a/examples/datasets/kernel_phantom.py +++ b/examples/datasets/kernel_phantom.py @@ -111,4 +111,4 @@ # For more information on OPM data visualization, see the OPM preprocessing # tutorial: # -# - :ref:`ex-opm-processing` +# - :ref:`tut-opm-processing` From f97c89fe01ebb23a4eb20a2834fb49e8b3abf337 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Tue, 5 May 2026 10:03:12 +0530 Subject: [PATCH 26/34] DOC: Add grouped OPM topomap in kernel phantom example --- examples/datasets/kernel_phantom.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/datasets/kernel_phantom.py b/examples/datasets/kernel_phantom.py index c516087e6d5..da2297ba88f 100644 --- a/examples/datasets/kernel_phantom.py +++ b/examples/datasets/kernel_phantom.py @@ -51,6 +51,11 @@ t_peak = 0.016 # based on visual inspection of evoked fig.axes[0].axvline(t_peak, color="k", ls=":", lw=3, zorder=2) +# %% +# Because these OPM sensors are colocated in biaxial pairs, topomaps are +# grouped into radial and tangential components. +evoked.plot_topomap(times=[t_peak], ch_type="mag", show=True) + # %% # The data covariance has an interesting structure because of densely packed sensors: From be0eaa15d67b63befc82eff1a9b542ac70c06903 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Sat, 23 May 2026 16:53:25 +0530 Subject: [PATCH 27/34] Refactor OPM orientation grouping --- mne/viz/tests/test_topomap.py | 56 ++++- mne/viz/topomap.py | 399 ++++++++++++++++++++++++---------- 2 files changed, 334 insertions(+), 121 deletions(-) diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index c0c976b484f..41b4dbc3cb1 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -794,7 +794,8 @@ def test_plot_topomap_opm(): fig_evoked = evoked.plot_topomap( times=[-0.1, 0, 0.1, 0.2], ch_type="mag", show=False ) - assert len(fig_evoked.axes) == 5 + # Biaxial OPM pairs trigger grouped rendering (4 radial + 4 tangential + 2 colorbars) + assert len(fig_evoked.axes) == 10 def test_prepare_topomap_plot_opm_non_quspin_coils(): @@ -851,6 +852,59 @@ def test_split_opm_overlaps(triaxial_evoked): assert tangential == ["OPM002", "OPM003", "OPM005", "OPM006"] +def test_opm_tangential_rms_unsigned(triaxial_evoked): + """Test that tangential OPM data is RMS magnitude and unsigned.""" + picks, pos, merge_channels, names, *_ = topomap._prepare_topomap_plot( + triaxial_evoked, "mag" + ) + data = triaxial_evoked.data[picks] + grouped = topomap._compute_opm_orientation_topomap_data( + data, names, pos, merge_channels + ) + tangential = [group for group in grouped if group[0] == "tangential"][0] + assert np.all(tangential[1] >= 0) + assert tangential[4] + + +def test_should_use_opm_orientation_groups_only_for_triaxial(): + """Test that OPM orientation grouping works for biaxial and 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"]), + ] + + # Both biaxial and triaxial overlaps should trigger grouping + assert 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) == 4 + 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() diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index fcbd213ce78..f158e151ad4 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -329,6 +329,85 @@ def _split_opm_overlaps(overlapping_channels): return radial, tangential +def _rms(data, axis=0): + """Compute root-mean-square magnitude along an axis.""" + return np.sqrt(np.mean(data**2, axis=axis)) + + +def _compute_opm_orientation_topomap_data(data, ch_names, pos, overlapping_channels): + """Compute radial and tangential OPM topomap data from overlap sets.""" + from ..channels.layout import _merge_ch_data + + # Radial data matches the existing OPM merge behavior and position layout. + radial_data, radial_names = _merge_ch_data( + data.copy(), "mag", copy.copy(ch_names), modality="opm" + ) + radial_pos = pos + + name_lookup = [name.removesuffix("_MERGE-REMOVE") for name in ch_names] + tangential_data = [] + tangential_names = [] + tangential_pos = [] + for overlap_set in overlapping_channels: + idx = [name_lookup.index(ch_name) for ch_name in overlap_set[1:]] + # Collapse multiple tangential channels at one location using RMS. + tangential_data.append(_rms(data[idx], axis=0)) + tangential_names.append(f"{overlap_set[0]}t") + tangential_pos.append(radial_pos[radial_names.index(overlap_set[0])]) + + tangential_data = np.array(tangential_data) + tangential_pos = np.array(tangential_pos) + + return [ + ("radial", radial_data, radial_pos, radial_names, False), + ("tangential", tangential_data, tangential_pos, tangential_names, True), + ] + + +def _compute_orientation_group_data( + data, + ch_names, + pos, + *, + ch_type, + modality, + merge_channels, + use_opm_orientation_groups, +): + """Compute grouped topomap data for OPM/Neuromag-like orientations.""" + from ..channels.layout import _merge_ch_data + + if merge_channels: + if modality == "opm" and use_opm_orientation_groups: + return _compute_opm_orientation_topomap_data( + data, ch_names, pos, merge_channels + ) + + data, ch_names = _merge_ch_data(data, ch_type, ch_names, modality=modality) + group_norm = ch_type == "grad" + return [(None, data, pos, ch_names, group_norm)] + + return [(None, data, pos, ch_names, False)] + + +def _should_use_opm_orientation_groups(info, picks, merge_channels, ch_type): + """Return whether OPM orientation grouping should be enabled. + + Grouping is used for OPM magnetometer channels with overlap sets that + include at least 2 colocated channels (biaxial or triaxial sensors). + """ + if ch_type != "mag" or not merge_channels: + return False + + pick_chs = [info["chs"][pick] for pick in picks] + if not pick_chs or not all(ch["coil_type"] in _opm_coils for ch in pick_chs): + return False + + # merge_channels should be a list of overlap sets, not a boolean + if not isinstance(merge_channels, (list, tuple)): + return False + + return any(len(overlap_set) >= 2 for overlap_set in merge_channels) def _plot_update_evoked_topomap(params, bools): """Update topomaps.""" from ..channels.layout import _merge_ch_data @@ -1738,64 +1817,96 @@ def plot_ica_components( data = np.atleast_2d(data) data = data[:, data_picks] + use_opm_orientation_groups = _should_use_opm_orientation_groups( + ica.info, data_picks, merge_channels, ch_type + ) + n_group_axes = 2 if use_opm_orientation_groups else 1 + if title is None: title = "ICA components" user_passed_axes = _axes is not None if not user_passed_axes: - fig, _axes, _, _ = _prepare_trellis(len(data), ncols=ncols, nrows=nrows) + fig, _axes, _, _ = _prepare_trellis( + len(data) * n_group_axes, ncols=ncols, nrows=nrows + ) fig.suptitle(title) else: _axes = [_axes] if isinstance(_axes, Axes) else _axes + if len(_axes) != len(data) * n_group_axes: + raise RuntimeError( + "You must provide one axis per component and orientation " + "group for colocated OPM data." + ) fig = _axes[0].get_figure() subplot_titles = list() - for ii, data_, ax in zip(picks, data, _axes): + for comp_offset, (ii, data_) in enumerate(zip(picks, data)): kwargs = dict(color="gray") if ii in ica.exclude else dict() comp_title = ica._ica_names[ii] if len(set(ica.get_channel_types())) > 1: comp_title += f" ({ch_type})" - subplot_titles.append(ax.set_title(comp_title, fontsize=12, **kwargs)) - if merge_channels: - data_, names_ = _merge_ch_data(data_, ch_type, copy.copy(names)) - # ↓↓↓ NOTE: we intentionally use the default norm=False here, so that - # ↓↓↓ we get vlims that are symmetric-about-zero, even if the data for - # ↓↓↓ a given component happens to be one-sided. - _vlim = _setup_vmin_vmax(data_, *vlim) - im = plot_topomap( - data_.flatten(), + + modality = "opm" if use_opm_orientation_groups else "other" + grouped_data = _compute_orientation_group_data( + data_[:, np.newaxis], + copy.copy(names), pos, ch_type=ch_type, - sensors=sensors, - names=disp_names, - contours=contours, - outlines=outlines, - sphere=sphere, - image_interp=image_interp, - extrapolate=extrapolate, - border=border, - res=res, - size=size, - cmap=cmap[0], - vlim=_vlim, - cnorm=cnorm, - axes=ax, - show=False, - )[0] - - im.axes.set_label(ica._ica_names[ii]) - if colorbar: - cbar, cax = _add_colorbar( - ax, - im, - cmap, - title="AU", - format_=cbar_fmt, - kind="ica_comp_topomap", + modality=modality, + merge_channels=merge_channels, + use_opm_orientation_groups=use_opm_orientation_groups, + ) + + for group_idx, ( + group_label, + group_data, + group_pos, + group_names, + group_norm, + ) in enumerate(grouped_data): + ax_idx = comp_offset * n_group_axes + group_idx + ax = _axes[ax_idx] + plot_title = comp_title + if group_label is not None: + plot_title += f" [{group_label}]" + subplot_titles.append(ax.set_title(plot_title, fontsize=12, **kwargs)) + _vlim = _setup_vmin_vmax(group_data[:, 0], *vlim, norm=group_norm) + group_cmap = _setup_cmap(cmap, n_axes=len(picks), norm=group_norm) + im = plot_topomap( + group_data[:, 0].flatten(), + group_pos, ch_type=ch_type, - ) - cbar.ax.tick_params(labelsize=12) - cbar.set_ticks(_vlim) - _hide_frame(ax) + sensors=sensors, + names=_prepare_sensor_names(group_names, show_names), + contours=contours, + outlines=outlines, + sphere=sphere, + image_interp=image_interp, + extrapolate=extrapolate, + border=border, + res=res, + size=size, + cmap=group_cmap[0], + vlim=_vlim, + cnorm=cnorm, + axes=ax, + show=False, + )[0] + + im.axes.set_label(ica._ica_names[ii]) + if colorbar: + cbar, cax = _add_colorbar( + ax, + im, + group_cmap, + title="AU", + format_=cbar_fmt, + kind="ica_comp_topomap", + ch_type=ch_type, + ) + cbar.ax.tick_params(labelsize=12) + cbar.set_ticks(_vlim) + _hide_frame(ax) del pos fig.canvas.draw() @@ -2259,11 +2370,18 @@ def plot_evoked_topomap( clip_origin, ) = _prepare_topomap_plot(evoked, ch_type, sphere=sphere) outlines = _make_head_outlines(sphere, pos, outlines, clip_origin) + use_opm_orientation_groups = _should_use_opm_orientation_groups( + evoked.info, picks, merge_channels, ch_type + ) # check interactive axes_given = axes is not None interactive = isinstance(times, str) and times == "interactive" if interactive and axes_given: raise ValueError("User-provided axes not allowed when times='interactive'.") + if interactive and use_opm_orientation_groups: + raise NotImplementedError( + "times='interactive' is not supported for grouped OPM topomaps." + ) # units, scalings key = "grad" if ch_type.startswith("planar") else ch_type default_scaling = _handle_default("scalings", None)[key] @@ -2273,7 +2391,6 @@ def plot_evoked_topomap( unit = _handle_default("units", units)[key] # ch_names (required for NIRS) ch_names = names - names = _prepare_sensor_names(names, show_names) # apply projections before picking. NOTE: the `if proj is True` # anti-pattern is needed here to exclude proj='interactive' _check_option("proj", proj, (True, False, "interactive", "reconstruct")) @@ -2299,7 +2416,9 @@ def plot_evoked_topomap( f"Times should be between {evoked.times[0]:0.3} and {evoked.times[-1]:0.3}." ) # create axes - want_axes = n_times + int(colorbar) + n_groups = 2 if use_opm_orientation_groups else 1 + n_cbar = int(colorbar) * n_groups + want_axes = n_times * n_groups + n_cbar if interactive: height_ratios = [5, 1] nrows = 2 @@ -2313,7 +2432,7 @@ def plot_evoked_topomap( axes.append(plt.subplot(gs[0, ax_idx])) elif axes is None: fig, axes, ncols, nrows = _prepare_trellis( - n_times, ncols=ncols, nrows=nrows, size=size + n_times * n_groups, ncols=ncols, nrows=nrows, size=size ) else: nrows, ncols = None, None # Deactivate ncols when axes were passed @@ -2326,6 +2445,12 @@ def plot_evoked_topomap( f"each time{cbar_err}), got {len(axes)}." ) del want_axes + if axes_given and colorbar: + plot_axes = axes[:-n_cbar] + cbar_axes = axes[-n_cbar:] + else: + plot_axes = axes + cbar_axes = [] # find first index that's >= (to rounding error) to each time point time_idx = [ np.where( @@ -2386,53 +2511,61 @@ def plot_evoked_topomap( # apply scalings and merge channels data *= scaling - if merge_channels: - # check modality - if any(ch["coil_type"] in _opm_coils for ch in evoked.info["chs"]): - modality = "opm" - elif ch_type in _fnirs_types: - modality = "fnirs" - else: - modality = "other" - # merge data - data, ch_names = _merge_ch_data(data, ch_type, ch_names, modality=modality) - # if ch_type in _fnirs_types: - if modality != "other": - merge_channels = False - # apply mask if requested - if mask is not None: - mask = mask.astype(bool, copy=False) - if ch_type == "grad": - mask_ = ( - mask[np.ix_(picks[::2], time_idx)] | mask[np.ix_(picks[1::2], time_idx)] - ) - else: # mag, eeg, planar1, planar2 - mask_ = mask[np.ix_(picks, time_idx)] - # set up colormap - _vlim = [ - _setup_vmin_vmax(data[:, i], *vlim, norm=merge_channels) for i in range(n_times) - ] - _vlim = [np.min(_vlim), np.max(_vlim)] - cmap = _setup_cmap(cmap, n_axes=n_times, norm=_vlim[0] >= 0) - # set up contours - if not isinstance(contours, list | np.ndarray): - _, contours = _set_contour_locator(*_vlim, contours) + # check modality + is_opm_picks = len(evoked.info["chs"]) > 0 and all( + ch["coil_type"] in _opm_coils for ch in evoked.info["chs"] + ) + if is_opm_picks: + modality = "opm" + elif ch_type in _fnirs_types: + modality = "fnirs" else: - if vlim[0] is None and np.any(contours < _vlim[0]): - _vlim[0] = contours[0] - if vlim[1] is None and np.any(contours > _vlim[1]): - _vlim[1] = contours[-1] + modality = "other" + + grouped_data = _compute_orientation_group_data( + data, + ch_names, + pos, + ch_type=ch_type, + modality=modality, + merge_channels=merge_channels, + use_opm_orientation_groups=use_opm_orientation_groups, + ) + + if modality != "other" or use_opm_orientation_groups: + merge_channels = False + + # set up colormaps, vlims, and contours per group + group_vlims = [] + group_cmaps = [] + group_contours = [] + for group_label, group_data, group_pos, group_names, group_norm in grouped_data: + group_vlim = [ + _setup_vmin_vmax(group_data[:, i], *vlim, norm=group_norm) + for i in range(n_times) + ] + group_vlim = [np.min(group_vlim), np.max(group_vlim)] + if not isinstance(contours, list | np.ndarray): + _, group_contour = _set_contour_locator(*group_vlim, contours) + else: + group_contour = contours + if vlim[0] is None and np.any(group_contour < group_vlim[0]): + group_vlim[0] = group_contour[0] + if vlim[1] is None and np.any(group_contour > group_vlim[1]): + group_vlim[1] = group_contour[-1] + group_vlims.append(group_vlim) + group_cmaps.append( + _setup_cmap(cmap, n_axes=n_times, norm=group_vlim[0] >= 0)[0] + ) + group_contours.append(group_contour) # prepare for main loop over times kwargs = dict( sensors=sensors, res=res, - names=names, - cmap=cmap[0], cnorm=cnorm, mask_params=mask_params, outlines=outlines, - contours=contours, image_interp=image_interp, show=False, extrapolate=extrapolate, @@ -2441,38 +2574,51 @@ def plot_evoked_topomap( ch_type=ch_type, ) images, contours_ = [], [] - # loop over times - for average_idx, (time, this_average) in enumerate(zip(times, average)): - tp, cn, interp = _plot_topomap( - data[:, average_idx], - pos, - axes=axes[average_idx], - mask=mask_[:, average_idx] if mask is not None else None, - vmin=_vlim[0], - vmax=_vlim[1], - **kwargs, - ) + for group_idx, ( + group_label, + group_data, + group_pos, + group_names, + _group_norm, + ) in enumerate(grouped_data): + kwargs["names"] = _prepare_sensor_names(group_names, show_names) + kwargs["cmap"] = group_cmaps[group_idx] + kwargs["contours"] = group_contours[group_idx] + group_vlim = group_vlims[group_idx] + for average_idx, (time, this_average) in enumerate(zip(times, average)): + ax_idx = group_idx * n_times + average_idx + tp, cn, interp = _plot_topomap( + group_data[:, average_idx], + group_pos, + axes=plot_axes[ax_idx], + mask=None, + vmin=group_vlim[0], + vmax=group_vlim[1], + **kwargs, + ) - images.append(tp) - if cn is not None: - contours_.append(cn) - if time_format != "": - if this_average is None: - axes_title = time_format % (time * scaling_time) - else: - tmin_ = averaged_times[average_idx][0] - tmax_ = averaged_times[average_idx][-1] - from_time = time_format % (tmin_ * scaling_time) - from_time = from_time.split(" ")[0] # Remove unit - to_time = time_format % (tmax_ * scaling_time) - axes_title = f"{from_time} – {to_time}" - del from_time, to_time, tmin_, tmax_ - axes[average_idx].set_title(axes_title) + images.append(tp) + if cn is not None: + contours_.append(cn) + if time_format != "": + if this_average is None: + axes_title = time_format % (time * scaling_time) + else: + tmin_ = averaged_times[average_idx][0] + tmax_ = averaged_times[average_idx][-1] + from_time = time_format % (tmin_ * scaling_time) + from_time = from_time.split(" ")[0] # Remove unit + to_time = time_format % (tmax_ * scaling_time) + axes_title = f"{from_time} – {to_time}" + del from_time, to_time, tmin_, tmax_ + if group_label is not None: + axes_title = f"{group_label}\n{axes_title}" + plot_axes[ax_idx].set_title(axes_title) if interactive: # Add a slider to the figure and start publishing and subscribing to time_change # events. - kwargs.update(vlim=_vlim) + kwargs.update(vlim=group_vlims[0], cmap=group_cmaps[0]) axes.append(fig.add_subplot(gs[1])) slider = Slider( axes[-1], @@ -2523,17 +2669,30 @@ def _slider_changed(val): else: # use the default behavior cax = None - cbar = fig.colorbar(images[-1], ax=axes, cax=cax, format=cbar_fmt, shrink=0.6) - if unit is not None: - cbar.ax.set_title(unit) - if cn is not None: - cbar.set_ticks(contours) - cbar.ax.tick_params(labelsize=7) - if cmap[1]: - for im in images: - im.axes.CB = DraggableColorbar( - cbar, im, kind="evoked_topomap", ch_type=ch_type - ) + for group_idx in range(n_groups): + group_images = images[group_idx * n_times : (group_idx + 1) * n_times] + group_axes = plot_axes[group_idx * n_times : (group_idx + 1) * n_times] + if axes_given and colorbar: + cax = cbar_axes[group_idx] + else: + cax = None + cbar = fig.colorbar( + group_images[-1], + ax=group_axes, + cax=cax, + format=cbar_fmt, + shrink=0.6, + ) + if unit is not None: + cbar.ax.set_title(unit) + if cn is not None: + cbar.set_ticks(group_contours[group_idx]) + cbar.ax.tick_params(labelsize=7) + if cmap[1]: + for im in group_images: + im.axes.CB = DraggableColorbar( + cbar, im, kind="evoked_topomap", ch_type=ch_type + ) if proj == "interactive": _check_delayed_ssp(evoked) From 4127c037f66b4c1e8b1191a89f3ffd1161d46460 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Sun, 24 May 2026 16:09:54 +0530 Subject: [PATCH 28/34] Use per-group colormaps for OPM topomaps --- mne/viz/topomap.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index f158e151ad4..2a0419595c5 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -1807,8 +1807,7 @@ def plot_ica_components( sphere, clip_origin, ) = _prepare_topomap_plot(ica, ch_type, sphere=sphere) - cmap = _setup_cmap(cmap, n_axes=len(picks)) - disp_names = _prepare_sensor_names(names, show_names) + _setup_cmap(cmap, n_axes=len(picks)) outlines = _make_head_outlines(sphere, pos, outlines, clip_origin) data = np.dot( From 9a615d2458be3ed7c75f166237de8c2218fd743e Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Tue, 26 May 2026 12:46:18 +0530 Subject: [PATCH 29/34] Unify orientation grouping helper --- mne/viz/topomap.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index 2a0419595c5..e539b021bb6 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -1760,7 +1760,6 @@ def plot_ica_components( """ # noqa E501 from matplotlib.pyplot import Axes - from ..channels.layout import _merge_ch_data from ..epochs import BaseEpochs from ..io import BaseRaw From 816d7beca4d37ad27f5625988a98716034dab5ff Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Tue, 26 May 2026 13:08:05 +0530 Subject: [PATCH 30/34] Fix refactor cherry-pick resolution --- mne/viz/tests/test_topomap.py | 2 ++ mne/viz/topomap.py | 10 ++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index 41b4dbc3cb1..ebaff372cc2 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -905,6 +905,8 @@ def test_plot_evoked_topomap_opm_triaxial_groups(triaxial_evoked): 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() diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index e539b021bb6..d544c1784b9 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -408,6 +408,8 @@ def _should_use_opm_orientation_groups(info, picks, merge_channels, ch_type): return False return any(len(overlap_set) >= 2 for overlap_set in merge_channels) + + def _plot_update_evoked_topomap(params, bools): """Update topomaps.""" from ..channels.layout import _merge_ch_data @@ -1792,9 +1794,9 @@ def plot_ica_components( axes = axes.flatten() if isinstance(axes, np.ndarray) else axes for k, picks in enumerate(pick_groups): - try: # either an iterable, 1D numpy array or others - _axes = axes[k * max_subplots : (k + 1) * max_subplots] - except TypeError: # None or Axes + if axes is None: + _axes = None + else: _axes = axes ( @@ -1806,7 +1808,7 @@ def plot_ica_components( sphere, clip_origin, ) = _prepare_topomap_plot(ica, ch_type, sphere=sphere) - _setup_cmap(cmap, n_axes=len(picks)) + _setup_cmap(cmap, n_axes=len(picks)) outlines = _make_head_outlines(sphere, pos, outlines, clip_origin) data = np.dot( From e8105196f77b6c732b6f74fbaa23b980814e558d Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Tue, 26 May 2026 13:20:54 +0530 Subject: [PATCH 31/34] Add changelog entry for refactor --- doc/changes/dev/13921.bugfix.rst | 1 + mne/viz/tests/test_topomap.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 doc/changes/dev/13921.bugfix.rst diff --git a/doc/changes/dev/13921.bugfix.rst b/doc/changes/dev/13921.bugfix.rst new file mode 100644 index 00000000000..9179860b0b2 --- /dev/null +++ b/doc/changes/dev/13921.bugfix.rst @@ -0,0 +1 @@ +Ensure grouped OPM tangential topomaps use unsigned RMS magnitude and per-group colormap scaling, aligning behavior with Neuromag-style grouping, by `Pragnya Khandelwal`_. diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index ebaff372cc2..a9fc3353c89 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -794,7 +794,8 @@ def test_plot_topomap_opm(): fig_evoked = evoked.plot_topomap( times=[-0.1, 0, 0.1, 0.2], ch_type="mag", show=False ) - # Biaxial OPM pairs trigger grouped rendering (4 radial + 4 tangential + 2 colorbars) + # Biaxial OPM pairs trigger grouped rendering + # (4 radial + 4 tangential + 2 colorbars) assert len(fig_evoked.axes) == 10 From c8633d38efcaf6753ddf30ddb44691efc589b1f9 Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Wed, 27 May 2026 19:48:12 +0530 Subject: [PATCH 32/34] FIX: align grouped OPM topomaps with Neuromag semantics --- mne/viz/evoked.py | 21 ++++++++++++++++++--- mne/viz/tests/test_ica.py | 2 +- mne/viz/topomap.py | 4 ++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py index a62d2379f03..d98d0b1c695 100644 --- a/mne/viz/evoked.py +++ b/mne/viz/evoked.py @@ -1954,9 +1954,24 @@ def plot_evoked_joint( del times _, times_ts = _check_time_unit(ts_args["time_unit"], times_sec) - # prepare axes for topomap + ch_type = ch_types.pop() # set should only contain one element + use_opm_orientation_groups = False + if ch_type == "mag": + from .topomap import _should_use_opm_orientation_groups + + picks_topo, _, merge_channels, *_ = _prepare_topomap_plot( + evoked, ch_type, sphere=topomap_args.get("sphere") + ) + use_opm_orientation_groups = _should_use_opm_orientation_groups( + evoked.info, picks_topo, merge_channels, ch_type + ) + n_group_axes = 2 if use_opm_orientation_groups else 1 + + # prepare axes for topomap and butterfly plots 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) * n_group_axes, figsize=(8.0, 4.2) + ) cbar_ax = None else: ts_ax = ts_args["axes"] @@ -2002,7 +2017,7 @@ def plot_evoked_joint( # topomap contours = topomap_args.get("contours", 6) - ch_type = ch_types.pop() # set should only contain one element + # Since the data has all the ch_types, we get the limits from the plot. vmin, vmax = (None, None) norm = ch_type == "grad" diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py index 0aaabb418e2..8e3e160421b 100644 --- a/mne/viz/tests/test_ica.py +++ b/mne/viz/tests/test_ica.py @@ -628,4 +628,4 @@ 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 diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index d544c1784b9..f95f8d7cd26 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -1808,7 +1808,7 @@ def plot_ica_components( sphere, clip_origin, ) = _prepare_topomap_plot(ica, ch_type, sphere=sphere) - _setup_cmap(cmap, n_axes=len(picks)) + cmap = _setup_cmap(cmap, n_axes=len(picks)) outlines = _make_head_outlines(sphere, pos, outlines, clip_origin) data = np.dot( @@ -2688,7 +2688,7 @@ def _slider_changed(val): if cn is not None: cbar.set_ticks(group_contours[group_idx]) cbar.ax.tick_params(labelsize=7) - if cmap[1]: + if group_cmaps[group_idx][1]: for im in group_images: im.axes.CB = DraggableColorbar( cbar, im, kind="evoked_topomap", ch_type=ch_type From 0a9afd9878fcd982339acec58866df2b69150a7a Mon Sep 17 00:00:00 2001 From: PragnyaKhandelwal Date: Wed, 27 May 2026 20:46:48 +0530 Subject: [PATCH 33/34] TEST: update OPM ICA grouped-axis expectation --- mne/viz/tests/test_ica.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mne/viz/tests/test_ica.py b/mne/viz/tests/test_ica.py index 8e3e160421b..b9802e2d22f 100644 --- a/mne/viz/tests/test_ica.py +++ b/mne/viz/tests/test_ica.py @@ -616,7 +616,8 @@ def test_plot_components_opm(): ica = ICA(max_iter=1, random_state=0, n_components=10) ica.fit(RawArray(evoked.data, evoked.info), picks="mag", verbose="error") fig = ica.plot_components() - assert len(fig.axes) == 10 + # Biaxial OPM overlaps render grouped radial+tangential maps. + assert len(fig.axes) == 20 @pytest.mark.slowtest From 5f877f0040164f63fff5411f23787f3adc77b3c7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:57:53 +0000 Subject: [PATCH 34/34] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mne/viz/tests/test_topomap.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index 287f5bad07e..0d9f1817ad1 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -872,6 +872,7 @@ def test_opm_tangential_rms_unsigned(triaxial_evoked): assert np.all(tangential[1] >= 0) assert tangential[4] + def test_should_use_opm_orientation_groups_only_for_triaxial(): """Test that OPM orientation grouping works for biaxial and triaxial overlaps.""" ch_names = [f"OPM{k:03}" for k in range(1, 7)]