Skip to content
58 changes: 39 additions & 19 deletions src/eegprep/functions/popfunc/pop_topoplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,24 @@ def pop_topoplot(
else:
raise ValueError("typeplot must be 1 for ERP maps or 0 for component maps")

# EEGLAB scaling: ERP maps share one symmetric absmax scale; component maps each use their own.
plot_options = dict(options)
plot_options.setdefault("maplimits", _default_maplimits(maps) if typeplot == 1 else "absmax")
figures = _plot_map_pages(
maps,
labels,
plot_chanlocs,
topotitle=topotitle,
rowcols=rowcols_array,
options=dict(options),
options=plot_options,
component=typeplot == 0,
)
command = _history_command(typeplot, items_array, topotitle, rowcols_array, int(bool(plotdip)), options)

# EEGLAB displays the figure whether called from the GUI or the command line.
if _backend_can_display():
for figure in figures:
figure.show()
return (figures, command) if return_com else figures


Expand Down Expand Up @@ -133,12 +142,13 @@ def pop_topoplot_dialog_spec(EEG: dict[str, Any], *, typeplot: int = 1) -> Dialo
ControlSpec("edit", tag="rowcols", value="[]"),
]
if not int(typeplot):
# The dipole row is text + checkbox + one filler cell (EEGLAB uigeom [1.55 0.2 0.8]);
# the standalone spacer before the options edit is added by the shared block below.
controls.extend(
[
ControlSpec("text", "Plot associated dipole(s) (if present)"),
ControlSpec("checkbox", tag="plotdip", value=False),
ControlSpec("spacer"),
ControlSpec("spacer"),
]
)
controls.extend(
Expand Down Expand Up @@ -195,6 +205,9 @@ def plot_channel_locations(EEG: dict[str, Any], *, mode: str = "labels", return_
electrodes = "numpoint" if mode == "numbers" else "labelpoint"
fig, *_ = topoplot([], chanlocs, style="blank", electrodes=electrodes, title="Channel locations")
command = f"topoplot([], EEG['chanlocs'], style='blank', electrodes={electrodes!r})"

if _backend_can_display():
fig.show()
return (fig, command) if return_com else fig


Expand All @@ -221,48 +234,50 @@ def _plot_map_pages(
topotitle: str,
rowcols: tuple[int, int],
options: dict[str, Any],
component: bool = False,
) -> list[Any]:
rows, cols = rowcols
per_page = rows * cols
figures = []
colorbar = _is_on(options.pop("colorbar", "on"))
maplimits = options.pop("maplimits", None)
if maplimits is None or _is_absmax_maplimits(maplimits):
maplimits = _default_maplimits(maps)
for page_start in range(0, len(maps), per_page):
page_maps = maps[page_start : page_start + per_page]
page_labels = labels[page_start : page_start + per_page]
plotted_map_count = sum(values is not None for values in page_maps)
fig, axes = plt.subplots(rows, cols, squeeze=False, figsize=(cols * 2.1, rows * 2.0))
colorbar_image = None
plotted_axes = []
for ax, values, label in zip(axes.ravel(), page_maps, page_labels):
if values is None:
ax.axis("off")
continue
topoplot(
values,
chanlocs,
axes=ax,
colorbar=colorbar and plotted_map_count == 1,
maplimits=maplimits,
**options,
)
# pop_topoplot owns the colorbar so it can label component maps by polarity.
topoplot(values, chanlocs, axes=ax, colorbar=False, maplimits=maplimits, **options)
if ax.images:
colorbar_image = ax.images[-1]
plotted_axes.append(ax)
ax.set_title(label)
for ax in axes.ravel()[len(page_maps) :]:
ax.axis("off")
# EEGLAB prints the figure title at the bottom (textsc at y=0.05), not the top.
if topotitle:
fig.suptitle(topotitle, fontweight="bold")
fig.tight_layout()
if colorbar and plotted_map_count > 1 and colorbar_image is not None:
fig.colorbar(colorbar_image, ax=plotted_axes, shrink=0.7)
fig.text(0.5, 0.02, topotitle, ha="center", va="bottom", fontweight="bold")
fig.tight_layout(rect=(0, 0.05, 1, 1) if topotitle else (0, 0, 1, 1))
if colorbar and colorbar_image is not None:
_add_map_colorbar(fig, colorbar_image, plotted_axes, component=component)
figures.append(fig)
return figures


def _add_map_colorbar(fig: Any, image: Any, axes: list[Any], *, component: bool) -> None:
"""Draw the shared scalp-map colorbar, marking component maps with -/0/+ polarity labels."""
cbar = fig.colorbar(image, ax=axes, shrink=0.7)
if component:
low, high = image.get_clim()
cbar.set_ticks([low, 0.0, high])
cbar.set_ticklabels(["-", "0", "+"])


def _erp_maps(EEG: dict[str, Any], latencies_ms: np.ndarray) -> tuple[list[np.ndarray | None], list[str]]:
_require_chanlocs(EEG)
data = np.asarray(EEG.get("data"))
Expand Down Expand Up @@ -415,8 +430,13 @@ def _validate_topoplot_inputs(EEG: dict[str, Any], typeplot: int) -> None:
_require_ica(EEG)


def _is_absmax_maplimits(value: Any) -> bool:
return isinstance(value, str) and value.lower() == "absmax"
# matplotlib's file-output backends (Agg, PDF, SVG, ...) cannot open a window.
_NONINTERACTIVE_BACKENDS = frozenset({"agg", "cairo", "pdf", "pgf", "ps", "svg", "template"})


def _backend_can_display() -> bool:
"""True when the active matplotlib backend can show a figure window."""
return plt.get_backend().lower() not in _NONINTERACTIVE_BACKENDS


def _is_plotdip_value(value: Any) -> bool:
Expand Down
13 changes: 11 additions & 2 deletions src/eegprep/functions/sigprocfunc/topoplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ def _channel_location_points(chan_locs):
_EAR_X = np.array([0.492, 0.510, 0.518, 0.5299, 0.5419, 0.540, 0.547, 0.532, 0.510, 0.484])
_EAR_Y = np.array([0.0955, 0.1175, 0.1183, 0.1146, 0.0955, -0.0055, -0.0932, -0.1313, -0.1384, -0.1199])
_HEAD_LINEWIDTH = 2.5
_CLIM_MARGIN = 0.05 # EEGLAB expands the color axis by 5% beyond the data limits (topoplot caxis)


def _draw_ears(ax, scale=1.0):
Expand Down Expand Up @@ -421,10 +422,18 @@ def _maplimits_kwargs(maplimits, data):
if isinstance(maplimits, str):
if maplimits.lower() == 'absmax':
limit = np.nanmax(np.abs(data))
return {"vmin": -limit, "vmax": limit} if np.isfinite(limit) and limit > 0 else {}
return _clim_kwargs(-limit, limit) if np.isfinite(limit) and limit > 0 else {}
if maplimits.lower() == 'maxmin':
return {}
values = np.asarray(maplimits, dtype=float).ravel()
if values.size >= 2 and np.all(np.isfinite(values[:2])):
return {"vmin": float(values[0]), "vmax": float(values[1])}
return _clim_kwargs(float(values[0]), float(values[1]))
return {}


def _clim_kwargs(amin, amax):
# Scale each limit to 1.05*v away from zero, matching EEGLAB topoplot's caxis 5% margin.
return {
"vmin": amin + np.sign(amin) * _CLIM_MARGIN * abs(amin),
"vmax": amax + np.sign(amax) * _CLIM_MARGIN * abs(amax),
}
50 changes: 47 additions & 3 deletions tests/test_pop_topoplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@
from tests.fixtures import SAMPLE_DATASET_PATH, create_test_eeg_with_ica


def _is_numeric_label(text: str) -> bool:
try:
float(text.replace("−", "-")) # matplotlib renders minus as U+2212
return True
except ValueError:
return False


def test_topoplot_blank_channel_locations_by_label_and_number():
chanlocs = [
{"labels": "Fz", "theta": 0, "radius": 0.3},
Expand Down Expand Up @@ -94,9 +102,9 @@ def test_pop_topoplot_multi_map_pages_include_shared_colorbar_by_default():
plt.close(figures[0])


def test_pop_topoplot_component_pages_use_shared_default_scale():
def test_pop_topoplot_component_pages_scale_each_map_to_own_absmax():
eeg = create_test_eeg_with_ica(n_channels=6, n_samples=30, n_components=2)
eeg["icawinv"] = np.column_stack([np.ones(6), np.arange(1, 7) * 10.0])
eeg["icawinv"] = np.column_stack([np.arange(1, 7) * 1.0, np.arange(1, 7) * 10.0])

figures = pop_topoplot(
eeg,
Expand All @@ -107,12 +115,36 @@ def test_pop_topoplot_component_pages_use_shared_default_scale():
electrodes="off",
)

expected = []
for index in range(2):
_, zi, *_ = topoplot(eeg["icawinv"][:, index], eeg["chanlocs"], noplot="on")
limit = float(np.nanmax(np.abs(zi))) * 1.05 # topoplot widens the color axis by EEGLAB's 5% margin
expected.append((-limit, limit))
clims = [axis.images[0].get_clim() for axis in figures[0].axes[:2]]
assert clims[0] == clims[1] == (-60.0, 60.0)
np.testing.assert_allclose(clims[0], expected[0], rtol=1e-6)
np.testing.assert_allclose(clims[1], expected[1], rtol=1e-6)
assert not np.allclose(clims[0], clims[1])
assert len(figures[0].axes) == 3
plt.close(figures[0])


def test_pop_topoplot_component_colorbar_uses_polarity_labels():
eeg = create_test_eeg_with_ica(n_channels=6, n_samples=30, n_components=3)

comp_figs = pop_topoplot(eeg, typeplot=0, items=[1, 2], topotitle="ic", rowcols=[1, 2], electrodes="off")
comp_figs[0].canvas.draw()
comp_labels = [text.get_text() for text in comp_figs[0].axes[-1].get_yticklabels()]
assert comp_labels == ["-", "0", "+"]
plt.close(comp_figs[0])

erp_figs = pop_topoplot(eeg, typeplot=1, items=[0, 20], topotitle="erp", rowcols=[1, 2], electrodes="off")
erp_figs[0].canvas.draw()
erp_labels = [text.get_text() for text in erp_figs[0].axes[-1].get_yticklabels() if text.get_text().strip()]
assert len(erp_labels) >= 2
assert all(_is_numeric_label(label) for label in erp_labels)
plt.close(erp_figs[0])


def test_pop_topoplot_plots_component_maps_with_inverted_and_blank_items():
eeg = create_test_eeg_with_ica(n_channels=6, n_samples=50, n_components=3)

Expand Down Expand Up @@ -232,6 +264,18 @@ def run(self, spec, initial_values=None):
plt.close(figures[0])


def test_pop_topoplot_dialog_geometry_lays_out_every_control():
eeg = create_test_eeg_with_ica(n_channels=4, n_samples=20, n_components=4)
for typeplot in (1, 0):
spec = pop_topoplot_dialog_spec(eeg, typeplot=typeplot)
slots = sum(len(row) if isinstance(row, tuple) else 1 for row in spec.geometry)
assert slots == len(spec.controls), (
f"typeplot={typeplot}: geometry lays out {slots} controls but spec has {len(spec.controls)}"
)
assert len(spec.geomvert) == len(spec.geometry)
assert any(control.tag == "options" for control in spec.controls)


def test_pop_topoplot_rejects_missing_ica_or_chanlocs():
eeg = create_test_eeg_with_ica(n_channels=4, n_samples=20, n_components=4)
eeg["icawinv"] = np.array([])
Expand Down
Loading