From 45382b73dbd256bf5e3f5568de1a4bdad2272354 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:37:31 +0000 Subject: [PATCH 1/7] pop_erpimage: turbo cmap, aligned ERP plot, scalp inset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel ERP-image now matches EEGLAB defaults: turbo colormap, image and ERP axes share the same x-range (the colorbar has its own gridspec column), the ERP y-axis is labeled µV, and a small scalp map sits above the image with the plotted channel marked (opt out with plotmap=False). Component plots keep the existing 2-row layout. Fixes #300 --- src/eegprep/functions/popfunc/pop_erpimage.py | 21 ++++++ src/eegprep/functions/sigprocfunc/erpimage.py | 74 +++++++++++++++---- src/eegprep/resources/help/pop_erpimage.md | 3 +- tests/test_phase4_plot_wrappers.py | 42 +++++++++++ 4 files changed, 123 insertions(+), 17 deletions(-) diff --git a/src/eegprep/functions/popfunc/pop_erpimage.py b/src/eegprep/functions/popfunc/pop_erpimage.py index ec516edf..0855979c 100644 --- a/src/eegprep/functions/popfunc/pop_erpimage.py +++ b/src/eegprep/functions/popfunc/pop_erpimage.py @@ -8,6 +8,7 @@ from eegprep.functions.guifunc.inputgui import inputgui from eegprep.functions.guifunc.spec import CallbackSpec, ControlSpec, DialogSpec +from eegprep.functions.popfunc._chanutils import chanlocs_as_list from eegprep.functions.popfunc.plot_utils import ( channel_labels, component_activations, @@ -54,6 +55,7 @@ def pop_erpimage( _raise_for_unsupported_kwargs(kwargs) command_kwargs = dict(kwargs) projchan = kwargs.pop("projchan", None) + plotmap = bool(kwargs.pop("plotmap", True)) values = _erpimage_values(EEG, typeplot, int(index), projchan=projchan) times = eeg_times_ms(EEG) sort_values = kwargs.pop("sort_values", None) @@ -76,6 +78,7 @@ def pop_erpimage( raise ValueError("limits do not contain any samples") values = values[mask, :] times = times[mask] + chan_locs, channel_index = _scalp_map_arguments(EEG, typeplot, int(index), plotmap=plotmap) figure, image = erpimage( values, times=times, @@ -87,6 +90,8 @@ def pop_erpimage( cbar=bool(kwargs.pop("cbar", True)), plot_erp=bool(kwargs.pop("erp", True)), vert=kwargs.pop("vert", None), + chan_locs=chan_locs, + channel_index=channel_index, ) command = history_command("pop_erpimage", typeplot, int(index), **command_kwargs) show_figures(figure, plot=plot) @@ -322,6 +327,7 @@ def _run_gui(EEG: dict[str, Any], *, typeplot: int, renderer: Any | None = None) projchan = numeric_vector(result.get("projchan", []), dtype=int) if projchan.size: options["projchan"] = projchan.tolist() + options["plotmap"] = bool(result.get("plotmap", True)) return { "index": int(values[0]) if values.size else 1, "options": options, @@ -403,6 +409,7 @@ def _raise_for_unsupported_kwargs(kwargs: dict[str, Any]) -> None: "erp", "vert", "projchan", + "plotmap", "sortingeventfield", "sortingtype", "sortingwin", @@ -421,6 +428,20 @@ def _raise_for_unsupported_kwargs(kwargs: dict[str, Any]) -> None: ) +def _scalp_map_arguments(EEG: dict[str, Any], typeplot: int, index: int, *, plotmap: bool) -> tuple[Any, int | None]: + """Return ``(chan_locs, channel_index)`` for the scalp inset, or ``(None, None)``. + + EEGLAB only draws the small ERP-image scalp map for channel plots; component + scalp maps are shown by other pop-functions and are outside this dialog's default. + """ + if not plotmap or not typeplot: + return None, None + chanlocs = chanlocs_as_list(EEG.get("chanlocs")) + if not chanlocs: + return None, None + return chanlocs, index + + def _event_sort_values(EEG: dict[str, Any], field: Any, event_types: Any, eventrange: Any, renorm: Any) -> np.ndarray: field_name = str(field).strip() if not field_name: diff --git a/src/eegprep/functions/sigprocfunc/erpimage.py b/src/eegprep/functions/sigprocfunc/erpimage.py index 6ac63e62..ea5e0fbc 100644 --- a/src/eegprep/functions/sigprocfunc/erpimage.py +++ b/src/eegprep/functions/sigprocfunc/erpimage.py @@ -7,6 +7,8 @@ import matplotlib.pyplot as plt import numpy as np +from eegprep.functions.sigprocfunc.topoplot import topoplot + def erpimage( data: Any, @@ -20,8 +22,15 @@ def erpimage( cbar: bool = True, plot_erp: bool = True, vert: Any = None, + chan_locs: Any = None, + channel_index: int | None = None, ): - """Plot trials as an EEGLAB-style ERP image plus the average ERP.""" + """Plot trials as an EEGLAB-style ERP image plus the average ERP. + + Pass ``chan_locs`` and ``channel_index`` (1-based) to draw a small scalp + map above the image with the plotted channel marked, matching EEGLAB's + default ERP-image layout for channels. + """ values = np.asarray(data, dtype=float) if values.ndim != 2: raise ValueError("erpimage data must be points x trials") @@ -37,19 +46,33 @@ def erpimage( image = values[:, order].T image = _decimate_trials(image, decimate) image = _smooth_trials(image, smooth) + show_topo = chan_locs is not None and channel_index is not None + height_ratios: list[float] = [] + if show_topo: + height_ratios.append(1.0) + height_ratios.append(3.0) if plot_erp: - fig, (image_ax, erp_ax) = plt.subplots( - 2, - 1, - figsize=(7.5, 5.0), - gridspec_kw={"height_ratios": [3, 1]}, - sharex=True, - ) - else: - fig, image_ax = plt.subplots(figsize=(7.5, 4.2)) - erp_ax = None + height_ratios.append(1.0) + fig_height = 1.2 * sum(height_ratios) + 0.6 + fig = plt.figure(figsize=(7.5, fig_height)) + gs = fig.add_gridspec( + nrows=len(height_ratios), + ncols=2, + width_ratios=[20, 1], + height_ratios=height_ratios, + hspace=0.15, + wspace=0.04, + ) + row = 0 + topo_ax = fig.add_subplot(gs[row, 0]) if show_topo else None + if show_topo: + row += 1 + image_ax = fig.add_subplot(gs[row, 0]) + cax = fig.add_subplot(gs[row, 1]) if cbar else None + row += 1 + erp_ax = fig.add_subplot(gs[row, 0], sharex=image_ax) if plot_erp else None extent = [float(x_values[0]), float(x_values[-1]), 1, image.shape[0]] - im = image_ax.imshow(image, aspect="auto", origin="lower", extent=extent, cmap="RdBu_r") + im = image_ax.imshow(image, aspect="auto", origin="lower", extent=extent, cmap="turbo") limits = _limits(caxis) if limits is not None: im.set_clim(*limits) @@ -57,18 +80,19 @@ def erpimage( image_ax.set_title(title or "ERP image") for latency in _numeric_values(vert): image_ax.axvline(latency, color="black", linestyle=":", linewidth=0.8) - if cbar: - fig.colorbar(im, ax=image_ax, shrink=0.85) + if cax is not None: + fig.colorbar(im, cax=cax) if erp_ax is not None: erp_ax.plot(x_values, np.nanmean(values, axis=1), color="black") erp_ax.axhline(0, color="0.7", linewidth=0.6) for latency in _numeric_values(vert): erp_ax.axvline(latency, color="black", linestyle=":", linewidth=0.8) erp_ax.set_xlabel("Time (ms)") - erp_ax.set_ylabel("ERP") + erp_ax.set_ylabel("µV") else: image_ax.set_xlabel("Time (ms)") - fig.tight_layout() + if topo_ax is not None: + _draw_channel_topo(topo_ax, chan_locs, int(channel_index)) return fig, image @@ -109,4 +133,22 @@ def _numeric_values(value: Any) -> np.ndarray: return values[np.isfinite(values)] +def _draw_channel_topo(ax: Any, chan_locs: Any, channel_index: int) -> None: + """Render a small scalp map with the plotted channel marked.""" + topoplot([], chan_locs, style="blank", electrodes="off", axes=ax, title="") + if not 1 <= channel_index <= len(chan_locs): + return + loc = chan_locs[channel_index - 1] + try: + theta_rad = np.deg2rad(float(loc.get("theta"))) + radius_value = float(loc.get("radius")) + except (TypeError, ValueError): + return + if not (np.isfinite(theta_rad) and np.isfinite(radius_value)): + return + x = np.cos(theta_rad) * radius_value + y = np.sin(theta_rad) * radius_value + ax.scatter(-y, x, c="k", s=24, zorder=6) + + __all__ = ["erpimage"] diff --git a/src/eegprep/resources/help/pop_erpimage.md b/src/eegprep/resources/help/pop_erpimage.md index b6977617..05401036 100644 --- a/src/eegprep/resources/help/pop_erpimage.md +++ b/src/eegprep/resources/help/pop_erpimage.md @@ -11,7 +11,8 @@ dataset must be epoched. Supported plot options include `title`, `limits`, `caxis`, `cbar`, `erp`, `vert`, `smooth`, `decimate`, `sort_values`, and component projection through -`projchan`. +`projchan`. Channel plots draw a small scalp map above the image with the +plotted electrode marked; pass `plotmap=False` to suppress it. Event-field sorting is available with EEGLAB-style names: diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index 1262e40b..b44d9679 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -1590,6 +1590,48 @@ def test_pop_erpimage_sorts_by_epoch_event_field_and_limits(sample_epoch): pop_erpimage(eeg, typeplot=1, index=1, align=[0]) +def test_pop_erpimage_uses_turbo_and_aligns_image_with_erp(sample_epoch): + """Match EEGLAB defaults: turbo colormap, ERP axis flush with the image column.""" + result, _ = pop_erpimage(sample_epoch, typeplot=1, index=1, return_com=True) + figure = result["figure"] + + image_ax = next(ax for ax in figure.axes if ax.images) + erp_ax = next( + ax for ax in figure.axes if ax is not image_ax and ax.get_xlabel() == "Time (ms)" and ax.get_ylabel() == "µV" + ) + + assert image_ax.images[0].get_cmap().name == "turbo" + assert image_ax.get_position().x1 == pytest.approx(erp_ax.get_position().x1, abs=1e-6) + assert image_ax.get_position().x0 == pytest.approx(erp_ax.get_position().x0, abs=1e-6) + plt.close(figure) + + +def test_pop_erpimage_channel_adds_scalp_map_axis(sample_epoch): + """Channel ERP images draw a small scalp inset above the image with a marker at the channel.""" + result, _ = pop_erpimage(sample_epoch, typeplot=1, index=1, return_com=True) + figure = result["figure"] + image_ax = next(ax for ax in figure.axes if ax.images) + topo_axes = [ + ax + for ax in figure.axes + if ax is not image_ax and not ax.images and ax.get_position().y0 > image_ax.get_position().y1 + ] + assert topo_axes, "expected a scalp topo axis above the image axis" + marker_axes = [ax for ax in topo_axes if any(coll.get_offsets().size > 0 for coll in ax.collections)] + assert marker_axes, "scalp topo axis should mark the plotted channel" + plt.close(figure) + + +def test_pop_erpimage_component_omits_scalp_map_axis(ica_epoch): + """Component ERP images stay in the 2-row layout (no scalp inset).""" + result, _ = pop_erpimage(ica_epoch, typeplot=0, index=1, return_com=True) + figure = result["figure"] + image_ax = next(ax for ax in figure.axes if ax.images) + above = [ax for ax in figure.axes if ax is not image_ax and ax.get_position().y0 > image_ax.get_position().y1] + assert not above + plt.close(figure) + + def test_plot_history_preserves_effective_options(sample_epoch, ica_epoch): timtopo_fig, timtopo_command = pop_timtopo( sample_epoch, From bf187a0910bd37356aea9cd9b427ee210d9b0998 Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Mon, 17 Aug 2026 08:43:11 -0700 Subject: [PATCH 2/7] pop_erpimage: symmetric color axis, time-zero line, upper-left scalp map, EEGLAB colorbar ticks --- src/eegprep/functions/sigprocfunc/erpimage.py | 39 ++++++++++++- src/eegprep/resources/help/pop_erpimage.md | 4 ++ tests/test_phase4_plot_wrappers.py | 56 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/eegprep/functions/sigprocfunc/erpimage.py b/src/eegprep/functions/sigprocfunc/erpimage.py index ea5e0fbc..12e37e93 100644 --- a/src/eegprep/functions/sigprocfunc/erpimage.py +++ b/src/eegprep/functions/sigprocfunc/erpimage.py @@ -9,6 +9,8 @@ from eegprep.functions.sigprocfunc.topoplot import topoplot +_ZERO_LINEWIDTH = 1.5 # EEGLAB erpimage draws a solid time-zero line (ZEROWIDTH), thicker than vert lines + def erpimage( data: Any, @@ -67,26 +69,47 @@ def erpimage( topo_ax = fig.add_subplot(gs[row, 0]) if show_topo else None if show_topo: row += 1 + cell = topo_ax.get_position() + fw, fh = fig.get_size_inches() + side_h = cell.height + side_w = side_h * fh / fw # keep the scalp map square in display coordinates + # EEGLAB draws the scalp map as a small square at the upper left (erpimage.m). + topo_ax.set_position((cell.x0 + 0.10 * cell.width, cell.y0, side_w, side_h)) image_ax = fig.add_subplot(gs[row, 0]) cax = fig.add_subplot(gs[row, 1]) if cbar else None row += 1 erp_ax = fig.add_subplot(gs[row, 0], sharex=image_ax) if plot_erp else None extent = [float(x_values[0]), float(x_values[-1]), 1, image.shape[0]] + draw_zero = float(x_values[0]) <= 0.0 <= float(x_values[-1]) im = image_ax.imshow(image, aspect="auto", origin="lower", extent=extent, cmap="turbo") limits = _limits(caxis) + if limits is None: + # EEGLAB erpimage default: color axis symmetric about 0 (erpimage.m). + cmax = float(np.nanmax(np.abs(image))) if image.size else 0.0 + limits = (-cmax, cmax) if np.isfinite(cmax) and cmax > 0 else None if limits is not None: im.set_clim(*limits) image_ax.set_ylabel("Trials") image_ax.set_title(title or "ERP image") for latency in _numeric_values(vert): image_ax.axvline(latency, color="black", linestyle=":", linewidth=0.8) + if draw_zero: + image_ax.axvline(0, color="black", linewidth=_ZERO_LINEWIDTH) if cax is not None: - fig.colorbar(im, cax=cax) + colorbar = fig.colorbar(im, cax=cax) + vmin, vmax = im.get_clim() + if vmax > vmin: + # EEGLAB cbar: 5 ticks across the color range, decade-rounded labels (cbar.m). + ticks, tick_labels = _colorbar_ticks(vmin, vmax) + colorbar.set_ticks(ticks) + colorbar.set_ticklabels([f"{value:g}" for value in tick_labels]) if erp_ax is not None: erp_ax.plot(x_values, np.nanmean(values, axis=1), color="black") erp_ax.axhline(0, color="0.7", linewidth=0.6) for latency in _numeric_values(vert): erp_ax.axvline(latency, color="black", linestyle=":", linewidth=0.8) + if draw_zero: + erp_ax.axvline(0, color="black", linewidth=_ZERO_LINEWIDTH) erp_ax.set_xlabel("Time (ms)") erp_ax.set_ylabel("µV") else: @@ -133,6 +156,20 @@ def _numeric_values(value: Any) -> np.ndarray: return values[np.isfinite(values)] +def _colorbar_ticks(vmin: float, vmax: float) -> tuple[np.ndarray, np.ndarray]: + """Five evenly spaced ticks with EEGLAB cbar's decade-based label rounding (cbar.m).""" + ticks = np.linspace(vmin, vmax, 5) + scale = max(abs(vmin), abs(vmax)) + dec = int(np.floor(np.log10(scale))) + if dec < 1: + labels = np.round(ticks * 10.0 ** (1 - dec)) * 10.0 ** (dec - 1) + elif dec == 1: + labels = np.round(ticks * 10.0 ** (2 - dec)) * 10.0 ** (dec - 2) + else: + labels = np.round(ticks) + return ticks, labels + + def _draw_channel_topo(ax: Any, chan_locs: Any, channel_index: int) -> None: """Render a small scalp map with the plotted channel marked.""" topoplot([], chan_locs, style="blank", electrodes="off", axes=ax, title="") diff --git a/src/eegprep/resources/help/pop_erpimage.md b/src/eegprep/resources/help/pop_erpimage.md index 05401036..dc641cb6 100644 --- a/src/eegprep/resources/help/pop_erpimage.md +++ b/src/eegprep/resources/help/pop_erpimage.md @@ -14,6 +14,10 @@ Supported plot options include `title`, `limits`, `caxis`, `cbar`, `erp`, `projchan`. Channel plots draw a small scalp map above the image with the plotted electrode marked; pass `plotmap=False` to suppress it. +By default the color axis is symmetric about zero (as in EEGLAB); pass `caxis` +to set explicit limits. A solid vertical line marks time zero on both the image +and the ERP trace. + Event-field sorting is available with EEGLAB-style names: ```python diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index b44d9679..6da0c65f 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -1543,6 +1543,62 @@ def test_pop_erpimage_applies_time_limits_and_decimation(sample_epoch): plt.close(result["figure"]) +def test_pop_erpimage_default_caxis_is_symmetric(sample_epoch): + """With no caxis, the color axis is symmetric about 0 (EEGLAB erpimage default).""" + result, _command = pop_erpimage(sample_epoch, typeplot=1, index=1, return_com=True) + image_ax = next(ax for ax in result["figure"].axes if ax.images) + vmin, vmax = image_ax.images[0].get_clim() + assert vmax > 0 + assert vmin == pytest.approx(-vmax) + assert vmax == pytest.approx(float(np.nanmax(np.abs(result["image"])))) + plt.close(result["figure"]) + + +def test_pop_erpimage_draws_solid_time_zero_line(sample_epoch): + """The ERP image and the ERP trace both mark time zero with a solid line.""" + result, _command = pop_erpimage(sample_epoch, typeplot=1, index=1, return_com=True) + fig = result["figure"] + image_ax = next(ax for ax in fig.axes if ax.images) + erp_ax = next(ax for ax in fig.axes if ax.get_xlabel() == "Time (ms)") + + def has_solid_zero_line(ax): + return any( + np.allclose(line.get_xdata(), 0.0) and line.get_linestyle() == "-" for line in ax.get_lines() + ) + + assert has_solid_zero_line(image_ax) + assert has_solid_zero_line(erp_ax) + plt.close(fig) + + +def test_pop_erpimage_colorbar_matches_eeglab_ticks(sample_epoch): + """Colorbar shows 5 ticks across the range with EEGLAB cbar's decade rounding (cbar.m).""" + result, _command = pop_erpimage(sample_epoch, typeplot=1, index=1, caxis=[-50.24, 50.24], return_com=True) + fig = result["figure"] + image_ax = next(ax for ax in fig.axes if ax.get_ylabel() == "Trials") + erp_ax = next(ax for ax in fig.axes if ax.get_xlabel() == "Time (ms)") + topo_ax = next(ax for ax in fig.axes if ax is not image_ax and ax.get_aspect() == 1.0) + cax = next(ax for ax in fig.axes if ax not in {image_ax, erp_ax, topo_ax}) + fig.canvas.draw() + assert np.allclose(sorted(cax.get_yticks()), np.linspace(-50.24, 50.24, 5)) + assert {t.get_text() for t in cax.get_yticklabels()} >= {"-50.2", "-25.1", "0", "25.1", "50.2"} + plt.close(fig) + + +def test_pop_erpimage_scalp_map_is_small_and_upper_left(sample_epoch): + """The channel scalp map is a small square at the upper left (EEGLAB layout).""" + result, _command = pop_erpimage(sample_epoch, typeplot=1, index=1, return_com=True) + fig = result["figure"] + image_ax = next(ax for ax in fig.axes if ax.get_ylabel() == "Trials") + topo_ax = next(ax for ax in fig.axes if ax is not image_ax and ax.get_aspect() == 1.0) + img = image_ax.get_position() + topo = topo_ax.get_position() + assert topo.width < 0.5 * img.width # small, not full width + assert (topo.x0 + topo.width / 2) < (img.x0 + img.width / 2) # left of the image center + assert topo.y0 >= img.y1 - 1e-6 # above the image + plt.close(fig) + + def test_pop_erpimage_sorts_by_epoch_event_field_and_limits(sample_epoch): eeg = deepcopy(sample_epoch) eeg["data"] = np.asarray( From 0c36282bcb3300f20855a09e0644f7d8d5487bb8 Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Mon, 17 Aug 2026 10:58:28 -0700 Subject: [PATCH 3/7] pop_erpimage: draw scalp-inset channel marker in EEGLAB left-right orientation --- src/eegprep/functions/sigprocfunc/erpimage.py | 3 ++- tests/test_phase4_plot_wrappers.py | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/eegprep/functions/sigprocfunc/erpimage.py b/src/eegprep/functions/sigprocfunc/erpimage.py index 12e37e93..ab2218a3 100644 --- a/src/eegprep/functions/sigprocfunc/erpimage.py +++ b/src/eegprep/functions/sigprocfunc/erpimage.py @@ -185,7 +185,8 @@ def _draw_channel_topo(ax: Any, chan_locs: Any, channel_index: int) -> None: return x = np.cos(theta_rad) * radius_value y = np.sin(theta_rad) * radius_value - ax.scatter(-y, x, c="k", s=24, zorder=6) + # EEGLAB draws the scalp map with screen X = sin(theta)*radius (left hemisphere on the left). + ax.scatter(y, x, c="k", s=24, zorder=6) __all__ = ["erpimage"] diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index 6da0c65f..d2cb39c2 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -1585,6 +1585,31 @@ def test_pop_erpimage_colorbar_matches_eeglab_ticks(sample_epoch): plt.close(fig) +def test_pop_erpimage_scalp_marker_on_eeglab_side(sample_epoch): + """The scalp-inset channel marker uses screen X = +sin(theta)*radius (EEGLAB side, not mirrored).""" + + def screen_xy(loc): + try: + theta = np.deg2rad(float(np.asarray(loc["theta"]).ravel()[0])) + radius = float(np.asarray(loc["radius"]).ravel()[0]) + except (KeyError, IndexError, TypeError, ValueError): + return 0.0, 0.0 + return np.sin(theta) * radius, np.cos(theta) * radius + + chanlocs = sample_epoch["chanlocs"] + index = next(i for i, loc in enumerate(chanlocs, start=1) if abs(screen_xy(loc)[0]) > 0.1) + expected_x, expected_y = screen_xy(chanlocs[index - 1]) + result, _command = pop_erpimage(sample_epoch, typeplot=1, index=index, return_com=True) + fig = result["figure"] + image_ax = next(ax for ax in fig.axes if ax.get_ylabel() == "Trials") + topo_ax = next(ax for ax in fig.axes if ax is not image_ax and ax.get_aspect() == 1.0) + marker = next(c for c in topo_ax.collections if len(c.get_offsets()) == 1) + marker_x, marker_y = np.asarray(marker.get_offsets())[0] + assert marker_x == pytest.approx(expected_x) # +sin(theta)*r, not the mirrored -sin + assert marker_y == pytest.approx(expected_y) + plt.close(fig) + + def test_pop_erpimage_scalp_map_is_small_and_upper_left(sample_epoch): """The channel scalp map is a small square at the upper left (EEGLAB layout).""" result, _command = pop_erpimage(sample_epoch, typeplot=1, index=1, return_com=True) From d54bd8bdcd1294e1c025e2089454ce34d5a1254a Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Tue, 18 Aug 2026 11:12:59 -0700 Subject: [PATCH 4/7] pop_erpimage: fix ty axes narrowing, ruff format, and document ERP-image layout --- docs/source/user_guide/preprocessing_pipeline.rst | 4 ++++ src/eegprep/functions/sigprocfunc/erpimage.py | 2 +- tests/test_phase4_plot_wrappers.py | 4 +--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/source/user_guide/preprocessing_pipeline.rst b/docs/source/user_guide/preprocessing_pipeline.rst index aff23c9b..bdc6bc3d 100644 --- a/docs/source/user_guide/preprocessing_pipeline.rst +++ b/docs/source/user_guide/preprocessing_pipeline.rst @@ -248,6 +248,10 @@ Legacy ``pop_timef`` and ``pop_crossf`` calls route through the standalone image, com = pop_erpimage(EEG, typeplot=1, index=1, return_com=True) +Channel ERP images use a symmetric color axis, a solid line marking time zero on +both the image and the ERP trace, and a small scalp map at the upper left with +the plotted channel marked, matching EEGLAB's ERP-image layout. + GUI paths live under the ``Plot`` menu when a dataset is loaded. EEGPrep returns replayable Python history commands for these wrappers. Some MATLAB-only ``pop_erpimage`` event-alignment and advanced renormalization options are not diff --git a/src/eegprep/functions/sigprocfunc/erpimage.py b/src/eegprep/functions/sigprocfunc/erpimage.py index ab2218a3..22129463 100644 --- a/src/eegprep/functions/sigprocfunc/erpimage.py +++ b/src/eegprep/functions/sigprocfunc/erpimage.py @@ -67,7 +67,7 @@ def erpimage( ) row = 0 topo_ax = fig.add_subplot(gs[row, 0]) if show_topo else None - if show_topo: + if topo_ax is not None: row += 1 cell = topo_ax.get_position() fw, fh = fig.get_size_inches() diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index d2cb39c2..2c9da0fc 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -1562,9 +1562,7 @@ def test_pop_erpimage_draws_solid_time_zero_line(sample_epoch): erp_ax = next(ax for ax in fig.axes if ax.get_xlabel() == "Time (ms)") def has_solid_zero_line(ax): - return any( - np.allclose(line.get_xdata(), 0.0) and line.get_linestyle() == "-" for line in ax.get_lines() - ) + return any(np.allclose(line.get_xdata(), 0.0) and line.get_linestyle() == "-" for line in ax.get_lines()) assert has_solid_zero_line(image_ax) assert has_solid_zero_line(erp_ax) From 50a679cf65e36796a31ca480e4499af8a51bc0b7 Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Tue, 18 Aug 2026 12:32:53 -0700 Subject: [PATCH 5/7] Revert erpimage scalp-inset marker orientation (defer to cross-cutting mirror PR) --- src/eegprep/functions/sigprocfunc/erpimage.py | 3 +-- tests/test_phase4_plot_wrappers.py | 25 ------------------- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/src/eegprep/functions/sigprocfunc/erpimage.py b/src/eegprep/functions/sigprocfunc/erpimage.py index 22129463..b59f4b97 100644 --- a/src/eegprep/functions/sigprocfunc/erpimage.py +++ b/src/eegprep/functions/sigprocfunc/erpimage.py @@ -185,8 +185,7 @@ def _draw_channel_topo(ax: Any, chan_locs: Any, channel_index: int) -> None: return x = np.cos(theta_rad) * radius_value y = np.sin(theta_rad) * radius_value - # EEGLAB draws the scalp map with screen X = sin(theta)*radius (left hemisphere on the left). - ax.scatter(y, x, c="k", s=24, zorder=6) + ax.scatter(-y, x, c="k", s=24, zorder=6) __all__ = ["erpimage"] diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index 2c9da0fc..bc4cb3cc 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -1583,31 +1583,6 @@ def test_pop_erpimage_colorbar_matches_eeglab_ticks(sample_epoch): plt.close(fig) -def test_pop_erpimage_scalp_marker_on_eeglab_side(sample_epoch): - """The scalp-inset channel marker uses screen X = +sin(theta)*radius (EEGLAB side, not mirrored).""" - - def screen_xy(loc): - try: - theta = np.deg2rad(float(np.asarray(loc["theta"]).ravel()[0])) - radius = float(np.asarray(loc["radius"]).ravel()[0]) - except (KeyError, IndexError, TypeError, ValueError): - return 0.0, 0.0 - return np.sin(theta) * radius, np.cos(theta) * radius - - chanlocs = sample_epoch["chanlocs"] - index = next(i for i, loc in enumerate(chanlocs, start=1) if abs(screen_xy(loc)[0]) > 0.1) - expected_x, expected_y = screen_xy(chanlocs[index - 1]) - result, _command = pop_erpimage(sample_epoch, typeplot=1, index=index, return_com=True) - fig = result["figure"] - image_ax = next(ax for ax in fig.axes if ax.get_ylabel() == "Trials") - topo_ax = next(ax for ax in fig.axes if ax is not image_ax and ax.get_aspect() == 1.0) - marker = next(c for c in topo_ax.collections if len(c.get_offsets()) == 1) - marker_x, marker_y = np.asarray(marker.get_offsets())[0] - assert marker_x == pytest.approx(expected_x) # +sin(theta)*r, not the mirrored -sin - assert marker_y == pytest.approx(expected_y) - plt.close(fig) - - def test_pop_erpimage_scalp_map_is_small_and_upper_left(sample_epoch): """The channel scalp map is a small square at the upper left (EEGLAB layout).""" result, _command = pop_erpimage(sample_epoch, typeplot=1, index=1, return_com=True) From c6ce1d51ef7139b09c6972729f56948ec0425348 Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Tue, 18 Aug 2026 12:36:42 -0700 Subject: [PATCH 6/7] pop_erpimage: gate scalp-map checkbox to channels, drop colorbar column when cbar off, thicker zero line --- src/eegprep/functions/popfunc/pop_erpimage.py | 5 ++++- src/eegprep/functions/sigprocfunc/erpimage.py | 7 +++--- tests/test_phase4_plot_wrappers.py | 22 +++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/eegprep/functions/popfunc/pop_erpimage.py b/src/eegprep/functions/popfunc/pop_erpimage.py index 0855979c..02114135 100644 --- a/src/eegprep/functions/popfunc/pop_erpimage.py +++ b/src/eegprep/functions/popfunc/pop_erpimage.py @@ -170,7 +170,10 @@ def pop_erpimage_dialog_spec(EEG: dict[str, Any], *, typeplot: int = 1) -> Dialo [ ControlSpec("text", "Smoothing", font_weight="bold"), ControlSpec("edit", tag="smooth", value=str(smooth)), - ControlSpec("checkbox", "Plot scalp map", tag="plotmap", value=True), + # EEGLAB shows no scalp map for components, so only offer the checkbox for channels. + ControlSpec("checkbox", "Plot scalp map", tag="plotmap", value=True) + if is_channel + else ControlSpec("spacer"), ControlSpec("spacer"), ControlSpec("spacer"), ControlSpec("text", "Downsampling", font_weight="bold"), diff --git a/src/eegprep/functions/sigprocfunc/erpimage.py b/src/eegprep/functions/sigprocfunc/erpimage.py index b59f4b97..ed68aac3 100644 --- a/src/eegprep/functions/sigprocfunc/erpimage.py +++ b/src/eegprep/functions/sigprocfunc/erpimage.py @@ -9,7 +9,7 @@ from eegprep.functions.sigprocfunc.topoplot import topoplot -_ZERO_LINEWIDTH = 1.5 # EEGLAB erpimage draws a solid time-zero line (ZEROWIDTH), thicker than vert lines +_ZERO_LINEWIDTH = 2.5 # solid time-zero line, clearly thicker than dotted vert lines (EEGLAB ZEROWIDTH=3.0) def erpimage( @@ -57,10 +57,11 @@ def erpimage( height_ratios.append(1.0) fig_height = 1.2 * sum(height_ratios) + 0.6 fig = plt.figure(figsize=(7.5, fig_height)) + # Reserve a narrow colorbar column only when a colorbar is drawn, so cbar=False fills the width. gs = fig.add_gridspec( nrows=len(height_ratios), - ncols=2, - width_ratios=[20, 1], + ncols=2 if cbar else 1, + width_ratios=[20, 1] if cbar else [1], height_ratios=height_ratios, hspace=0.15, wspace=0.04, diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index bc4cb3cc..732fe4c7 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -1597,6 +1597,28 @@ def test_pop_erpimage_scalp_map_is_small_and_upper_left(sample_epoch): plt.close(fig) +def test_pop_erpimage_dialog_plotmap_checkbox_only_for_channels(sample_epoch, ica_epoch): + """The 'Plot scalp map' checkbox appears only in channel mode (ignored for components).""" + channel_tags = {control.tag for control in pop_erpimage_dialog_spec(sample_epoch, typeplot=1).controls} + component_tags = {control.tag for control in pop_erpimage_dialog_spec(ica_epoch, typeplot=0).controls} + assert "plotmap" in channel_tags + assert "plotmap" not in component_tags + + +def test_pop_erpimage_cbar_false_fills_width(sample_epoch): + """cbar=False drops the colorbar column and lets the image span the full width.""" + with_bar, _ = pop_erpimage(sample_epoch, typeplot=1, index=1, plotmap=False, return_com=True) + without_bar, _ = pop_erpimage(sample_epoch, typeplot=1, index=1, plotmap=False, cbar=False, return_com=True) + try: + img_with = next(ax for ax in with_bar["figure"].axes if ax.get_ylabel() == "Trials") + img_without = next(ax for ax in without_bar["figure"].axes if ax.get_ylabel() == "Trials") + assert len(without_bar["figure"].axes) == len(with_bar["figure"].axes) - 1 # no colorbar axes + assert img_without.get_position().x1 > img_with.get_position().x1 # image reclaims the width + finally: + plt.close(with_bar["figure"]) + plt.close(without_bar["figure"]) + + def test_pop_erpimage_sorts_by_epoch_event_field_and_limits(sample_epoch): eeg = deepcopy(sample_epoch) eeg["data"] = np.asarray( From 9ba0a913882b1f152d63e152ce167a4750db4af2 Mon Sep 17 00:00:00 2001 From: innaamogolonova Date: Tue, 18 Aug 2026 13:00:33 -0700 Subject: [PATCH 7/7] pop_erpimage: record plotmap in GUI history only for channels --- src/eegprep/functions/popfunc/pop_erpimage.py | 4 +++- tests/test_phase4_plot_wrappers.py | 13 ++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/eegprep/functions/popfunc/pop_erpimage.py b/src/eegprep/functions/popfunc/pop_erpimage.py index 02114135..da20bc21 100644 --- a/src/eegprep/functions/popfunc/pop_erpimage.py +++ b/src/eegprep/functions/popfunc/pop_erpimage.py @@ -330,7 +330,9 @@ def _run_gui(EEG: dict[str, Any], *, typeplot: int, renderer: Any | None = None) projchan = numeric_vector(result.get("projchan", []), dtype=int) if projchan.size: options["projchan"] = projchan.tolist() - options["plotmap"] = bool(result.get("plotmap", True)) + # Only components lack the scalp-map checkbox; recording plotmap for them is history noise. + if bool(int(typeplot)): + options["plotmap"] = bool(result.get("plotmap", True)) return { "index": int(values[0]) if values.size else 1, "options": options, diff --git a/tests/test_phase4_plot_wrappers.py b/tests/test_phase4_plot_wrappers.py index 732fe4c7..0ff1f0cb 100644 --- a/tests/test_phase4_plot_wrappers.py +++ b/tests/test_phase4_plot_wrappers.py @@ -8,6 +8,7 @@ from pathlib import Path from types import SimpleNamespace from typing import Any +from unittest.mock import patch import matplotlib @@ -24,7 +25,7 @@ from eegprep.functions.guifunc.spec import controls_by_tag from eegprep.functions.popfunc.pop_comperp import pop_comperp, pop_comperp_dialog_spec from eegprep.functions.popfunc.pop_envtopo import pop_envtopo -from eegprep.functions.popfunc.pop_erpimage import pop_erpimage, pop_erpimage_dialog_spec +from eegprep.functions.popfunc.pop_erpimage import _run_gui, pop_erpimage, pop_erpimage_dialog_spec from eegprep.functions.popfunc.pop_headplot import ( pop_headplot, pop_headplot_dialog_spec, @@ -1605,6 +1606,16 @@ def test_pop_erpimage_dialog_plotmap_checkbox_only_for_channels(sample_epoch, ic assert "plotmap" not in component_tags +def test_pop_erpimage_gui_records_plotmap_only_for_channels(sample_epoch, ica_epoch): + """_run_gui records plotmap in the replayable options only for channels, not components.""" + with patch("eegprep.functions.popfunc.pop_erpimage.inputgui", return_value={"index": 1, "plotmap": True}): + channel_options = _run_gui(sample_epoch, typeplot=1)["options"] + with patch("eegprep.functions.popfunc.pop_erpimage.inputgui", return_value={"index": 1}): + component_options = _run_gui(ica_epoch, typeplot=0)["options"] + assert "plotmap" in channel_options + assert "plotmap" not in component_options + + def test_pop_erpimage_cbar_false_fills_width(sample_epoch): """cbar=False drops the colorbar column and lets the image span the full width.""" with_bar, _ = pop_erpimage(sample_epoch, typeplot=1, index=1, plotmap=False, return_com=True)