Skip to content

Visualization

The viz module provides plotting utilities for displaying source estimates and comparing inverse solutions.

Overview

Visualization options include:

  • Glass brain plots: 2D projections showing source activity overlaid on a transparent brain
  • 3D glass brain: Interactive 3D visualization of source estimates
  • Surface plots: Activity mapped onto the cortical surface

Quick Start

from invert.viz import plot_glass_brain, plot_surface

# Plot source estimate on glass brain
fig = plot_glass_brain(stc, forward)

# Plot on cortical surface
fig = plot_surface(stc, subject='fsaverage', subjects_dir=subjects_dir)

API Reference

Glass Brain Plots

invert.viz.plot_glass_brain

plot_glass_brain(
    stc,
    src,
    time_idx=None,
    threshold=0.2,
    cmap="magma",
    alpha=0.8,
    marker_size=15,
    brain_alpha=0.1,
    brain_color="0.7",
    figsize=(12, 4),
    title=None,
    ax=None,
)

Plot a glass-brain projection of a SourceEstimate.

Shows a faint outline of all source positions (the brain shape) with supra-threshold activations overlaid in colour.

Parameters:

Name Type Description Default
stc SourceEstimate

Source estimate returned by a solver.

required
src SourceSpaces

Source space, typically forward['src'].

required
time_idx int | None

Time sample index to plot. If None, plots the time point with maximum activation. If the data is a single time point, uses that.

None
threshold float

Fraction of the peak value below which sources are hidden. 0.0 shows all sources, 0.5 hides the bottom 50 %.

0.2
cmap str

Matplotlib colormap name.

'magma'
alpha float

Marker transparency for active sources.

0.8
marker_size float

Scatter marker size for active sources.

15
brain_alpha float

Transparency for the brain outline.

0.1
brain_color str

Color for the brain outline.

'0.7'
figsize tuple

Figure size (width, height) in inches.

(12, 4)
title str | None

Figure title.

None
ax array of Axes | None

Three matplotlib Axes to draw into. If None, a new figure is created.

None

Returns:

Name Type Description
fig Figure

The figure.

Source code in invert/viz/glass_brain.py
def plot_glass_brain(
    stc,
    src,
    time_idx=None,
    threshold=0.2,
    cmap="magma",
    alpha=0.8,
    marker_size=15,
    brain_alpha=0.1,
    brain_color="0.7",
    figsize=(12, 4),
    title=None,
    ax=None,
):
    """Plot a glass-brain projection of a SourceEstimate.

    Shows a faint outline of all source positions (the brain shape) with
    supra-threshold activations overlaid in colour.

    Parameters
    ----------
    stc : mne.SourceEstimate
        Source estimate returned by a solver.
    src : mne.SourceSpaces
        Source space, typically ``forward['src']``.
    time_idx : int | None
        Time sample index to plot. If None, plots the time point with
        maximum activation. If the data is a single time point, uses that.
    threshold : float
        Fraction of the peak value below which sources are hidden.
        0.0 shows all sources, 0.5 hides the bottom 50 %.
    cmap : str
        Matplotlib colormap name.
    alpha : float
        Marker transparency for active sources.
    marker_size : float
        Scatter marker size for active sources.
    brain_alpha : float
        Transparency for the brain outline.
    brain_color : str
        Color for the brain outline.
    figsize : tuple
        Figure size (width, height) in inches.
    title : str | None
        Figure title.
    ax : array of Axes | None
        Three matplotlib Axes to draw into. If None, a new figure is created.

    Returns
    -------
    fig : matplotlib.figure.Figure
        The figure.
    """
    all_pos = _get_source_positions(src)  # all source space vertices
    all_pos_mm = all_pos * 1e3

    # Select time point
    data = stc.data
    if data.ndim == 1:
        values = data
    else:
        if time_idx is None:
            time_idx = np.argmax(np.max(np.abs(data), axis=0))
        values = data[:, time_idx]

    values = np.abs(values)

    # Active source positions (same ordering as stc.data rows)
    active_pos = []
    for i, hemi in enumerate(src):
        active_pos.append(hemi["rr"][stc.vertices[i]])
    active_pos = np.concatenate(active_pos, axis=0) * 1e3

    # Apply threshold
    peak = values.max()
    if peak > 0:
        mask = values >= threshold * peak
    else:
        mask = np.ones(len(values), dtype=bool)

    active_pos = active_pos[mask]
    values = values[mask]

    # Sort for MIP effect (higher values on top)
    idx = np.argsort(values)
    active_pos = active_pos[idx]
    values = values[idx]

    # Normalise for colormap
    norm = Normalize(vmin=0, vmax=peak if peak > 0 else 1)

    # Projection planes: (sagittal=YZ, coronal=XZ, axial=XY)
    planes = [
        ("Sagittal", 1, 2, "Y (mm)", "Z (mm)"),
        ("Coronal", 0, 2, "X (mm)", "Z (mm)"),
        ("Axial", 0, 1, "X (mm)", "Y (mm)"),
    ]

    if ax is None:
        fig, axes = plt.subplots(1, 3, figsize=figsize, constrained_layout=True)
    else:
        axes = np.asarray(ax).ravel()
        fig = axes[0].figure

    for ax_i, (label, xi, yi, _xlabel, _ylabel) in zip(axes, planes):
        # 1. Draw brain outline using ConvexHull of projected points
        points_2d = all_pos_mm[:, [xi, yi]]
        try:
            hull = ConvexHull(points_2d)
            for simplex in hull.simplices:
                ax_i.plot(
                    points_2d[simplex, 0],
                    points_2d[simplex, 1],
                    color=brain_color,
                    alpha=0.3,
                    linewidth=1.5,
                )
        except Exception:
            # Fallback to scatter if ConvexHull fails
            ax_i.scatter(
                points_2d[:, 0],
                points_2d[:, 1],
                c=brain_color,
                s=1,
                alpha=0.05,
                edgecolors="none",
            )

        # 2. Draw faint cloud of all sources for depth
        ax_i.scatter(
            points_2d[:, 0],
            points_2d[:, 1],
            c=brain_color,
            s=1,
            alpha=brain_alpha,
            edgecolors="none",
        )

        # 3. Overlay active sources
        sc = ax_i.scatter(
            active_pos[:, xi],
            active_pos[:, yi],
            c=values,
            cmap=cmap,
            norm=norm,
            s=marker_size,
            alpha=alpha,
            edgecolors="none",
            zorder=10,
        )

        # 4. Styling
        ax_i.set_aspect("equal")
        ax_i.axis("off")

        # Add anatomical labels
        xlim = ax_i.get_xlim()
        ylim = ax_i.get_ylim()
        pad_x = (xlim[1] - xlim[0]) * 0.05
        pad_y = (ylim[1] - ylim[0]) * 0.05

        if label == "Sagittal":  # Y, Z
            ax_i.text(xlim[1] + pad_x, (ylim[0] + ylim[1]) / 2, "A", va="center")
            ax_i.text(xlim[0] - pad_x, (ylim[0] + ylim[1]) / 2, "P", va="center")
            ax_i.text((xlim[0] + xlim[1]) / 2, ylim[1] + pad_y, "S", ha="center")
        elif label == "Coronal":  # X, Z
            ax_i.text(xlim[1] + pad_x, (ylim[0] + ylim[1]) / 2, "R", va="center")
            ax_i.text(xlim[0] - pad_x, (ylim[0] + ylim[1]) / 2, "L", va="center")
            ax_i.text((xlim[0] + xlim[1]) / 2, ylim[1] + pad_y, "S", ha="center")
        elif label == "Axial":  # X, Y
            ax_i.text(xlim[1] + pad_x, (ylim[0] + ylim[1]) / 2, "R", va="center")
            ax_i.text(xlim[0] - pad_x, (ylim[0] + ylim[1]) / 2, "L", va="center")
            ax_i.text((xlim[0] + xlim[1]) / 2, ylim[1] + pad_y, "A", ha="center")

        ax_i.set_title(label, fontweight="bold", pad=15)

    # Single colorbar for all subplots
    cbar = fig.colorbar(
        sc, ax=axes, orientation="vertical", fraction=0.02, pad=0.04, aspect=30
    )
    cbar.set_label("Activation", fontweight="bold")
    cbar.outline.set_visible(False)

    if title is not None:
        fig.suptitle(title, fontsize=16, fontweight="bold")

    return fig

3D Glass Brain

invert.viz.plot_3d_glass_brain

plot_3d_glass_brain(
    stc,
    src,
    time_idx=None,
    threshold=0.2,
    cmap="magma",
    alpha=0.9,
    marker_size=30,
    brain_alpha=0.05,
    brain_color="0.7",
    figsize=(8, 8),
    title=None,
    ax=None,
    view=(90, 10),
    depth_scale=True,
)

Plot a 3D-like glass brain visualization.

Parameters:

Name Type Description Default
stc SourceEstimate

Source estimate.

required
src SourceSpaces

Source space.

required
time_idx int | None

Time index to plot.

None
threshold float

Threshold fraction (0.0 to 1.0) of peak activation.

0.2
cmap str

Colormap.

'magma'
alpha float

Opacity of active sources.

0.9
marker_size float

Base marker size.

30
brain_alpha float

Opacity of the inactive source cloud.

0.05
brain_color str

Color of the brain outline/cloud.

'0.7'
figsize tuple

Figure size.

(8, 8)
title str | None

Title.

None
ax Axes | None

Axes to plot on.

None
view tuple

(azimuth, elevation) in degrees.

(90, 10)
depth_scale bool

Whether to scale marker size by depth.

True

Returns:

Name Type Description
fig Figure

The figure.

Source code in invert/viz/glass_brain_3d.py
def plot_3d_glass_brain(
    stc,
    src,
    time_idx=None,
    threshold=0.2,
    cmap="magma",
    alpha=0.9,
    marker_size=30,
    brain_alpha=0.05,
    brain_color="0.7",
    figsize=(8, 8),
    title=None,
    ax=None,
    view=(90, 10),  # Azimuth, Elevation
    depth_scale=True,
):
    """Plot a 3D-like glass brain visualization.

    Parameters
    ----------
    stc : mne.SourceEstimate
        Source estimate.
    src : mne.SourceSpaces
        Source space.
    time_idx : int | None
        Time index to plot.
    threshold : float
        Threshold fraction (0.0 to 1.0) of peak activation.
    cmap : str
        Colormap.
    alpha : float
        Opacity of active sources.
    marker_size : float
        Base marker size.
    brain_alpha : float
        Opacity of the inactive source cloud.
    brain_color : str
        Color of the brain outline/cloud.
    figsize : tuple
        Figure size.
    title : str | None
        Title.
    ax : matplotlib.axes.Axes | None
        Axes to plot on.
    view : tuple
        (azimuth, elevation) in degrees.
    depth_scale : bool
        Whether to scale marker size by depth.

    Returns
    -------
    fig : matplotlib.figure.Figure
        The figure.
    """
    all_pos = _get_source_positions(src)
    all_pos_mm = all_pos * 1e3

    # Rotate all points
    az, el = view
    rotated_pos = _rotate_points(all_pos_mm, az, el)

    # Extract data
    data = stc.data
    if data.ndim == 1:
        values = data
    else:
        if time_idx is None:
            time_idx = np.argmax(np.max(np.abs(data), axis=0))
        values = data[:, time_idx]
    values = np.abs(values)

    # Get active positions
    active_pos_list = []
    for i, hemi in enumerate(src):
        active_pos_list.append(hemi["rr"][stc.vertices[i]])
    active_pos = np.concatenate(active_pos_list, axis=0) * 1e3

    # Rotate active positions using same rotation
    active_pos_rot = _rotate_points(active_pos, az, el)

    # Threshold
    peak = values.max()
    if peak > 0:
        mask = values >= threshold * peak
    else:
        mask = np.ones(len(values), dtype=bool)

    active_pos_rot = active_pos_rot[mask]
    values = values[mask]

    # Sort by depth (Y coordinate after rotation? Or Z?
    # In matplotlib 2D, we plot X vs Y (or Z).
    # Let's project to X-Z plane (coronal-like) or X-Y (axial-like).
    # If we rotate such that the viewer is looking down the Y axis (or Z axis).
    # Let's assume we plot X (horizontal) and Z (vertical) of the rotated points.
    # The depth is Y (going into the screen).
    # Standard: X=Right, Y=Anterior, Z=Superior.
    # After rotation, we plot X' and Z'. Y' is depth.
    # If Y' is depth, larger Y' means closer or further depending on convention.
    # Let's assume positive Y is "into screen" or "away".
    # Actually, in right-handed system:
    # If we look from +Y towards origin, then X is right, Z is up.
    # So Y is depth. Larger Y = closer to viewer if we look from +inf Y.

    # Let's define projection:
    x_proj = rotated_pos[:, 0]
    y_proj = rotated_pos[:, 2]  # Plot Z on vertical axis
    depth = rotated_pos[:, 1]  # Y is depth

    # Active sources projection
    act_x = active_pos_rot[:, 0]
    act_y = active_pos_rot[:, 2]
    act_depth = active_pos_rot[:, 1]

    # Sort active sources by depth (furthest first -> smallest Y first if looking from +Y)
    # If looking from +Y, larger Y is closer. We want to plot furthest (small Y) first.
    sort_idx = np.argsort(act_depth)
    act_x = act_x[sort_idx]
    act_y = act_y[sort_idx]
    act_depth = act_depth[sort_idx]
    values = values[sort_idx]

    # Setup plot
    if ax is None:
        fig, ax = plt.subplots(figsize=figsize)
    else:
        fig = ax.figure

    # 1. Draw brain outline (Convex Hull of projected points)
    points_2d = np.column_stack((x_proj, y_proj))
    try:
        hull = ConvexHull(points_2d)
        for simplex in hull.simplices:
            ax.plot(
                points_2d[simplex, 0],
                points_2d[simplex, 1],
                color=brain_color,
                alpha=0.5,
                linewidth=1.5,
            )
    except Exception:
        pass

    # 2. Draw faint cloud of all sources
    ax.scatter(
        x_proj,
        y_proj,
        c=brain_color,
        s=1,
        alpha=brain_alpha,
        edgecolors="none",
        zorder=1,
    )

    # 3. Draw active sources
    if len(values) > 0:
        norm = Normalize(vmin=0, vmax=peak if peak > 0 else 1)

        sizes = marker_size
        if depth_scale:
            # Scale size by depth.
            # Normalize depth to [0, 1] or similar.
            # Larger depth (closer) -> larger size.
            # act_depth range:
            d_min, d_max = depth.min(), depth.max()
            if d_max > d_min:
                d_norm = (act_depth - d_min) / (d_max - d_min)  # 0 to 1
                # Scale factor: e.g. 0.5 to 2.0
                scale = 0.5 + 2.0 * d_norm
                sizes = marker_size * (scale**2)  # Area scales with square

        ax.scatter(
            act_x,
            act_y,
            c=values,
            cmap=cmap,
            norm=norm,
            s=sizes,
            alpha=alpha,
            edgecolors="none",
            zorder=10,
        )

        # Add colorbar if this is the only axis or requested
        # (Handling colorbar is tricky with subplots, usually done outside)

    # Styling
    ax.set_aspect("equal")
    ax.axis("off")
    if title:
        ax.set_title(title, fontweight="bold")

    return fig

Surface Plots

invert.viz.plot_surface

plot_surface(
    stc,
    src,
    time_idx=None,
    threshold=0.2,
    cmap="magma",
    background="white",
    views=None,
    figsize=(1200, 400),
    title=None,
    screenshot_path=None,
    show=True,
)

Plot source activations on the cortical surface mesh.

Parameters:

Name Type Description Default
stc SourceEstimate

Source estimate returned by a solver.

required
src SourceSpaces

Source space, typically forward['src'].

required
time_idx int | None

Time sample index to plot. If None, uses the time of peak activation.

None
threshold float

Fraction of peak below which activations are hidden (transparent).

0.2
cmap str

Colormap for activations.

'magma'
background str

Background color of the rendering window.

'white'
views list of str | None

Camera views to show. Defaults to ['lateral', 'medial', 'dorsal']. Options: 'lateral', 'medial', 'dorsal', 'ventral', 'anterior', 'posterior'.

None
figsize tuple

Window size (width, height) in pixels.

(1200, 400)
title str | None

Window title.

None
screenshot_path str | None

If provided, saves a screenshot to this path and returns the image array instead of showing the interactive window.

None
show bool

Whether to display the interactive window (ignored if screenshot_path is set).

True

Returns:

Name Type Description
plotter Plotter

The plotter instance.

Source code in invert/viz/surface.py
def plot_surface(
    stc,
    src,
    time_idx=None,
    threshold=0.2,
    cmap="magma",
    background="white",
    views=None,
    figsize=(1200, 400),
    title=None,
    screenshot_path=None,
    show=True,
):
    """Plot source activations on the cortical surface mesh.

    Parameters
    ----------
    stc : mne.SourceEstimate
        Source estimate returned by a solver.
    src : mne.SourceSpaces
        Source space, typically ``forward['src']``.
    time_idx : int | None
        Time sample index to plot. If None, uses the time of peak activation.
    threshold : float
        Fraction of peak below which activations are hidden (transparent).
    cmap : str
        Colormap for activations.
    background : str
        Background color of the rendering window.
    views : list of str | None
        Camera views to show. Defaults to ``['lateral', 'medial', 'dorsal']``.
        Options: 'lateral', 'medial', 'dorsal', 'ventral', 'anterior',
        'posterior'.
    figsize : tuple
        Window size (width, height) in pixels.
    title : str | None
        Window title.
    screenshot_path : str | None
        If provided, saves a screenshot to this path and returns the image
        array instead of showing the interactive window.
    show : bool
        Whether to display the interactive window (ignored if
        screenshot_path is set).

    Returns
    -------
    plotter : pyvista.Plotter
        The plotter instance.
    """
    import pyvista as pv

    if views is None:
        views = ["lateral", "medial", "dorsal"]

    # Select time point
    data = stc.data
    if data.ndim == 2 and data.shape[1] > 1:
        if time_idx is None:
            time_idx = np.argmax(np.max(np.abs(data), axis=0))
    else:
        time_idx = 0

    meshes = _build_mesh(stc, src, time_idx=time_idx)

    # Camera presets
    camera_positions = {
        "lateral": [(400, 0, 0), (0, 0, 0), (0, 0, 1)],
        "medial": [(-400, 0, 0), (0, 0, 0), (0, 0, 1)],
        "dorsal": [(0, 0, 400), (0, 0, 0), (0, 1, 0)],
        "ventral": [(0, 0, -400), (0, 0, 0), (0, 1, 0)],
        "anterior": [(0, 400, 0), (0, 0, 0), (0, 0, 1)],
        "posterior": [(0, -400, 0), (0, 0, 0), (0, 0, 1)],
    }

    n_views = len(views)
    shape = (1, n_views)

    off_screen = screenshot_path is not None
    plotter = pv.Plotter(
        shape=shape,
        window_size=figsize,
        off_screen=off_screen,
    )
    plotter.set_background(background)

    # Determine global clim across hemispheres
    all_act = np.concatenate([m["activation"] for m in meshes])
    peak = all_act.max() if all_act.size > 0 else 0
    clim = [threshold * peak, peak] if peak > 0 else [0, 1]

    for vi, view_name in enumerate(views):
        plotter.subplot(0, vi)
        for mesh in meshes:
            # Add the base brain (grey)
            plotter.add_mesh(
                mesh,
                color="lightgrey",
                opacity=1.0,
                smooth_shading=True,
            )
            # Add the activation overlay
            # We use an opacity map: 0 below threshold, 1 above
            # This is a bit complex in PyVista, simpler to just use clim
            # and a colormap that starts with transparency if supported,
            # but standard way is to add mesh twice or use scalars.

            if peak > 0:
                plotter.add_mesh(
                    mesh,
                    scalars="activation",
                    cmap=cmap,
                    clim=clim,
                    show_scalar_bar=False,
                    smooth_shading=True,
                    opacity="linear",  # Fades out low values
                )

        # Add a final scalar bar to the last subplot
        if vi == n_views - 1 and peak > 0:
            plotter.add_scalar_bar(
                title="Activation",
                label_font_size=12,
                title_font_size=14,
                n_labels=5,
            )

        cam = camera_positions.get(view_name)
        if cam is not None:
            plotter.camera_position = cam

        plotter.add_text(
            view_name.capitalize(),
            font_size=12,
            position="upper_left",
            color="black" if background == "white" else "white",
        )

    if title:
        plotter.add_text(title, font_size=16, position="upper_edge")

    if screenshot_path is not None:
        plotter.screenshot(screenshot_path)
        plotter.close()
    elif show:
        plotter.show()

    return plotter