diff --git a/src/eegprep/functions/popfunc/pop_topoplot.py b/src/eegprep/functions/popfunc/pop_topoplot.py index 40585c80..7e08dc17 100644 --- a/src/eegprep/functions/popfunc/pop_topoplot.py +++ b/src/eegprep/functions/popfunc/pop_topoplot.py @@ -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 @@ -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( @@ -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 @@ -221,18 +234,16 @@ 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 = [] @@ -240,29 +251,33 @@ def _plot_map_pages( 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")) @@ -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: diff --git a/src/eegprep/functions/sigprocfunc/topoplot.py b/src/eegprep/functions/sigprocfunc/topoplot.py index f6bdb838..7f5c6909 100644 --- a/src/eegprep/functions/sigprocfunc/topoplot.py +++ b/src/eegprep/functions/sigprocfunc/topoplot.py @@ -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): @@ -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), + } diff --git a/tests/test_pop_topoplot.py b/tests/test_pop_topoplot.py index ddf0b254..1e0e4be7 100644 --- a/tests/test_pop_topoplot.py +++ b/tests/test_pop_topoplot.py @@ -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}, @@ -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, @@ -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) @@ -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([])