Skip to content
4 changes: 4 additions & 0 deletions docs/source/user_guide/preprocessing_pipeline.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion src/eegprep/functions/popfunc/pop_erpimage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -165,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"),
Expand Down Expand Up @@ -322,6 +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()
# 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,
Expand Down Expand Up @@ -403,6 +414,7 @@ def _raise_for_unsupported_kwargs(kwargs: dict[str, Any]) -> None:
"erp",
"vert",
"projchan",
"plotmap",
"sortingeventfield",
"sortingtype",
"sortingwin",
Expand All @@ -421,6 +433,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:
Expand Down
112 changes: 96 additions & 16 deletions src/eegprep/functions/sigprocfunc/erpimage.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
import matplotlib.pyplot as plt
import numpy as np

from eegprep.functions.sigprocfunc.topoplot import topoplot

_ZERO_LINEWIDTH = 2.5 # solid time-zero line, clearly thicker than dotted vert lines (EEGLAB ZEROWIDTH=3.0)


def erpimage(
data: Any,
Expand All @@ -20,8 +24,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")
Expand All @@ -37,38 +48,75 @@ 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))
# 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 if cbar else 1,
width_ratios=[20, 1] if cbar else [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 topo_ax is not None:
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]]
im = image_ax.imshow(image, aspect="auto", origin="lower", extent=extent, cmap="RdBu_r")
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 cbar:
fig.colorbar(im, ax=image_ax, shrink=0.85)
if draw_zero:
image_ax.axvline(0, color="black", linewidth=_ZERO_LINEWIDTH)
if cax is not None:
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("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


Expand Down Expand Up @@ -109,4 +157,36 @@ 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="")
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"]
7 changes: 6 additions & 1 deletion src/eegprep/resources/help/pop_erpimage.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ 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.

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:

Expand Down
Loading
Loading