"""Visualization theme helpers."""
from __future__ import annotations
import contextlib
from collections.abc import Mapping
from pathlib import Path
from types import MappingProxyType
import matplotlib as mpl
import matplotlib.pyplot as plt
# =====================================================================
# Colorblind-safe Wong palette (Nature Methods, 2011)
# =====================================================================
_BASE_COLORS = {
"blue": "#0072B2",
"orange": "#E69F00",
"green": "#009E73",
"red": "#D55E00",
"purple": "#CC79A7",
"cyan": "#56B4E9",
"yellow": "#F0E442",
"gray": "#BBBBBB",
"light_gray": "#DDDDDD",
"dark": "#333333",
}
_SEMANTIC_COLORS = {
"primary": _BASE_COLORS["blue"],
"secondary": _BASE_COLORS["orange"],
"accent": _BASE_COLORS["red"],
"success": _BASE_COLORS["green"],
"muted": _BASE_COLORS["gray"],
"text": _BASE_COLORS["dark"],
"before": _BASE_COLORS["dark"], # PSD / signal before cleaning
"after": _BASE_COLORS["green"], # PSD / signal after cleaning
"excluded": _BASE_COLORS["red"], # component excluded from the clean output
"kept": _BASE_COLORS["dark"], # component kept in the clean output
"line_marker": _BASE_COLORS["red"],
"no_artifact": _BASE_COLORS["gray"],
"edge": _BASE_COLORS["dark"], # bar / scatter edge color
"placeholder": "#999999", # "no data" text colour
"separator": "#e0e0e0", # subtle separator lines
"label_secondary": "#555555", # secondary stat labels
"highlight": _BASE_COLORS["yellow"], # best-method star etc.
"stat_mean": _BASE_COLORS["blue"], # group mean trend/marker
"stat_subject": _BASE_COLORS["gray"], # paired subject trajectories
"stat_reference": _BASE_COLORS["dark"], # reference thresholds / baselines
"stat_ci": _BASE_COLORS["light_gray"], # confidence / interval shading
"stat_highlight": _BASE_COLORS["yellow"], # best metric marker
}
BASE_COLORS = MappingProxyType(_BASE_COLORS)
SEMANTIC_COLORS = MappingProxyType(_SEMANTIC_COLORS)
COLORS = MappingProxyType({**_BASE_COLORS, **_SEMANTIC_COLORS})
# `METHOD_COLORS` names broad denoising concepts used across the package.
METHOD_COLORS = MappingProxyType(
{
"original": COLORS["dark"],
"before": COLORS["dark"],
"after": COLORS["green"],
"dss": COLORS["blue"],
"zapline": COLORS["orange"],
"dss_smooth": COLORS["cyan"],
"dss_segment": COLORS["purple"],
"clean": COLORS["green"],
}
)
# Generic palette and colormaps for reusable spectral series/time-frequency plots.
SERIES_COLORS = (
COLORS["primary"],
COLORS["secondary"],
COLORS["accent"],
COLORS["purple"],
COLORS["success"],
COLORS["cyan"],
)
SEQUENTIAL_CMAP = "viridis"
DIVERGING_CMAP = "RdBu_r"
AXIS_COLOR = "#666666"
TICK_COLOR = "#999999"
GRID_COLOR = "#888888"
GRID_ALPHA = 0.12
GRID_LINEWIDTH = 0.4
LEGEND_EDGE_COLOR = "#cccccc"
SAVEFIG_DPI = 300
SAVEFIG_BBOX = "tight"
SAVEFIG_PAD_INCHES = 0.05
[docs]
def get_color(key, fallback=None):
"""Return a color from the shared viz palettes by key.
Parameters
----------
key : str
A key into :data:`COLORS` or :data:`METHOD_COLORS`.
fallback : str | None
Returned if *key* is not found in any shared palette.
Defaults to ``COLORS["dark"]``.
Returns
-------
str
Hex color string.
"""
if key in COLORS:
return COLORS[key]
if key in METHOD_COLORS:
return METHOD_COLORS[key]
return fallback if fallback is not None else COLORS["dark"]
[docs]
def get_series_color(index, colors=None):
"""Return a color from the shared spectral series palette.
Parameters
----------
index : int
Series index in plotting order.
colors : sequence of str | None
Optional palette override. If provided, this sequence is cycled
instead of :data:`SERIES_COLORS`.
Returns
-------
str
Hex color string.
"""
palette = SERIES_COLORS if colors is None else tuple(colors)
if len(palette) == 0:
raise ValueError("colors must contain at least one color.")
return palette[index % len(palette)]
# =====================================================================
# Font sizes (pt) — tuned for single-column journal figures (~3.5 in)
# and two-column figures (~7 in).
# =====================================================================
FONTS = {
"suptitle": 13,
"title": 10,
"label": 9,
"tick": 8,
"legend": 7.5,
"annotation": 7.5,
}
# =====================================================================
# Default figure parameters
# =====================================================================
DEFAULT_DPI = 200
DEFAULT_FIGSIZE = (11, 8.5) # landscape letter
# =====================================================================
# Axes styling
# =====================================================================
[docs]
def style_axes(ax, grid=False):
"""Apply per-axes theme overrides.
Parameters
----------
ax : matplotlib.axes.Axes
The axes to style.
grid : bool, default=False
If True, add a subtle background grid.
"""
for sp in ("top", "right"):
ax.spines[sp].set_visible(False)
for sp in ("bottom", "left"):
ax.spines[sp].set_linewidth(0.5)
ax.spines[sp].set_color(AXIS_COLOR)
if grid:
ax.grid(True, alpha=GRID_ALPHA, linewidth=GRID_LINEWIDTH, color=GRID_COLOR)
ax.set_axisbelow(True)
# =====================================================================
# Figure factory
# =====================================================================
# =====================================================================
# Legend helper
# =====================================================================
[docs]
def themed_legend(ax, **kwargs):
"""Add a clean, minimal legend.
Parameters
----------
ax : matplotlib.axes.Axes
**kwargs
Overrides forwarded to ``ax.legend()``.
Returns
-------
legend : Legend
The legend instance returned by ``ax.legend()``.
"""
defaults = {
"fontsize": FONTS["legend"],
"frameon": True,
"fancybox": False,
"edgecolor": LEGEND_EDGE_COLOR,
}
defaults.update(kwargs)
return ax.legend(**defaults)
def _finalize_fig(fig, show=True, fname=None, tight=True):
"""Finalize a figure by applying layout, saving, and/or showing it."""
if tight:
with contextlib.suppress(Exception):
fig.tight_layout()
if fname is not None:
fname = Path(fname)
fname.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(
fname,
dpi=SAVEFIG_DPI,
bbox_inches=SAVEFIG_BBOX,
pad_inches=SAVEFIG_PAD_INCHES,
)
if show:
plt.show()
elif fname is not None:
# Figure was saved to disk; close to free memory.
plt.close(fig)
# If show=False and fname=None the caller owns the figure.
return fig
# =====================================================================
# rcParams dict (shared between set_theme and use_theme)
# =====================================================================
_THEME_RC = {
# Font sizes
"font.size": FONTS["label"],
"axes.titlesize": FONTS["title"],
"axes.labelsize": FONTS["label"],
"xtick.labelsize": FONTS["tick"],
"ytick.labelsize": FONTS["tick"],
"legend.fontsize": FONTS["legend"],
"figure.titlesize": FONTS["suptitle"],
# Spines
"axes.spines.top": False,
"axes.spines.right": False,
"axes.linewidth": 0.5,
"axes.edgecolor": AXIS_COLOR,
# Ticks
"xtick.direction": "out",
"ytick.direction": "out",
"xtick.major.width": 0.5,
"ytick.major.width": 0.5,
"xtick.major.size": 3,
"ytick.major.size": 3,
"xtick.color": TICK_COLOR,
"ytick.color": TICK_COLOR,
# Grid
"axes.grid": False,
"grid.alpha": GRID_ALPHA,
"grid.linewidth": GRID_LINEWIDTH,
"grid.color": GRID_COLOR,
# Figure
"figure.facecolor": "white",
"figure.dpi": DEFAULT_DPI,
"savefig.dpi": SAVEFIG_DPI,
"savefig.bbox": SAVEFIG_BBOX,
"savefig.pad_inches": SAVEFIG_PAD_INCHES,
# Legend
"legend.frameon": True,
"legend.fancybox": False,
"legend.edgecolor": LEGEND_EDGE_COLOR,
# Lines
"lines.linewidth": 1.0,
"lines.markersize": 3,
}
# =====================================================================
# rcParams helpers
# =====================================================================
[docs]
def get_theme_rc(rc: Mapping[str, object] | None = None) -> dict[str, object]:
"""Return the theme rcParams, optionally merged with overrides.
Parameters
----------
rc : mapping | None
Optional matplotlib rcParams overrides. Only the provided keys
are changed; all other theme defaults are preserved.
Returns
-------
rc_out : dict
Copy of the theme rcParams with overrides applied.
"""
rc_out = dict(_THEME_RC)
if rc is not None:
rc_out.update(rc)
return rc_out
# =====================================================================
# Context-manager style application (recommended for library use)
# =====================================================================
[docs]
@contextlib.contextmanager
def use_theme(name="default", rc: Mapping[str, object] | None = None):
"""Context manager that temporarily applies the mne-denoise theme.
Parameters
----------
name : str, default='default'
Currently only ``"default"`` is supported.
rc : mapping | None
Optional matplotlib rcParams overrides merged into the shared
theme defaults for the duration of the context.
"""
if name != "default":
raise ValueError(f"Unknown theme {name!r}; only 'default' is supported.")
with mpl.rc_context(rc=get_theme_rc(rc)):
yield
# =====================================================================
# Global rcParams setter (opt-in, for notebooks only)
# =====================================================================
[docs]
def set_theme(rc: Mapping[str, object] | None = None):
"""Apply the mne-denoise plotting theme to matplotlib rcParams.
Parameters
----------
rc : mapping | None
Optional matplotlib rcParams overrides merged into the shared
theme defaults before applying them globally.
"""
plt.rcParams.update(get_theme_rc(rc))
__all__ = [
"COLORS",
"METHOD_COLORS",
"SERIES_COLORS",
"SEQUENTIAL_CMAP",
"DIVERGING_CMAP",
"FONTS",
"DEFAULT_DPI",
"DEFAULT_FIGSIZE",
"get_color",
"get_series_color",
"style_axes",
"themed_figure",
"themed_legend",
"get_theme_rc",
"use_theme",
"set_theme",
]