Source code for habit.viz.diagrams

# Copyright (c) 2024-2026 Li Chao, Dong Mengshi and HABIT Contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Teaching diagrams: how data ENTERS HABIT and how features are chained.

Gallery pages that teach an ingestion route (directory tree, SimpleITK
objects, NumPy arrays, NIfTI file pairs) or the feature-preprocessing
chains get a thumbnail that shows the ROUTE, not another bare slice: a
stylised badge on the left, an arrow, and a real cropped data panel on the
right. The data panel reuses the habitat-overlay rendering internals, so
colours and orientation match :func:`~habit.viz.plot_habitat_overlay`
exactly.

The badges are HABIT-drawn schematics. They are NOT official SimpleITK,
NumPy, or NIfTI logos (trademark); they only evoke a voxel cube / a matrix
/ a file pair so the ingestion route is recognisable at thumbnail size.

Pure functions: contract objects or arrays in, a matplotlib ``Figure`` out,
no filesystem and no ``show``. All text is ASCII (journal-safe).
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Optional, Sequence, Tuple

import numpy as np

from habit.exceptions import HABITAPIError
from habit.utils.optional_deps import require
from habit.viz._crop import bbox_slices
from habit.viz.habitat_overlay import (
    _blend_overlay,
    _draw_label_contour,
    _habitat_color_lookup,
    _normalize_grey,
    _positive_habitat_ids,
    _slice_index,
    _take_slice,
)
from habit.viz.labels import sanitize_label
from habit.viz.orientation import (
    DEFAULT_DISPLAY_CONVENTION,
    direction_matrix as _parse_direction_matrix,
    imshow_physical_extent,
    orient_slice_for_display,
    resolve_display_geometry,
)
from habit.viz.style import use_style

if TYPE_CHECKING:
    from matplotlib.axes import Axes
    from matplotlib.figure import Figure

__all__ = [
    "plot_directory_ingest",
    "plot_simpleitk_ingest",
    "plot_numpy_ingest",
    "plot_nifti_ingest",
    "plot_feature_preprocessing_chain",
]

#: What habit.viz needs matplotlib for.
_VIZ_PURPOSE = "ingestion / preprocessing teaching diagrams"

#: ROI fill colour, matching the cyan ROI contour used across habit.viz.
_ROI_FILL = "#00E5FF"

#: Directory-tree entry kinds mapped to colours (Okabe-Ito hues).
_TREE_KIND_COLORS = {
    "folder": "#0072B2",  # blue
    "image": "#009E73",  # bluish green
    "mask": "#E69F00",  # orange
}

#: Soft categorical ramp for the preprocessing-chain boxes (Tableau-like).
_CHAIN_COLORS = (
    "#4E79A7",
    "#F28E2B",
    "#59A14F",
    "#E15759",
    "#76B7B2",
    "#B07AA1",
)

#: Default directory tree drawn by :func:`plot_directory_ingest`. Each entry
#: is ``(depth, name, kind)`` with kind in ``_TREE_KIND_COLORS``.
_DEFAULT_TREE: Tuple[Tuple[int, str, str], ...] = (
    (0, "DATA/", "folder"),
    (1, "images/", "folder"),
    (2, "subj001/", "folder"),
    (3, "LAP/", "folder"),
    (4, "image.nrrd", "image"),
    (1, "masks/", "folder"),
    (2, "subj001/", "folder"),
    (3, "LAP/", "folder"),
    (4, "mask.nrrd", "mask"),
)


def _plt():
    """
    Return the pyplot module with the Agg canvas guaranteed headless.

    Returns:
        The ``matplotlib.pyplot`` module, with a non-interactive backend
        already active.

    Raises:
        OptionalDependencyError: When matplotlib is not installed.
    """
    matplotlib = require("matplotlib", extra="viz", purpose=_VIZ_PURPOSE)
    if matplotlib.get_backend().lower() not in (
        "agg",
        "module://matplotlib_inline.backend_inline",
    ):
        matplotlib.use("Agg")
    return require("matplotlib.pyplot", extra="viz", purpose=_VIZ_PURPOSE)


def _volume_array(obj: object, *, name: str) -> np.ndarray:
    """
    Coerce a contract object or plain array to a 2D/3D NumPy volume.

    Accepts objects with ``.label_array`` (``HabitatMap`` / supervoxel maps),
    objects with ``.data`` (``ImageVolume`` / ``MaskVolume``), or bare arrays.

    Args:
        obj: Candidate volume-like object.
        name: Name used in error messages.

    Returns:
        Array with ndim in ``{2, 3}``.

    Raises:
        HABITAPIError: When the object cannot be read as a 2D/3D volume.
    """
    if hasattr(obj, "label_array"):
        arr = getattr(obj, "label_array")
    elif hasattr(obj, "data"):
        arr = getattr(obj, "data")
    else:
        arr = obj
    volume = np.asarray(arr)
    while volume.ndim > 3 and volume.shape[0] == 1:
        volume = np.squeeze(volume, axis=0)
    if volume.ndim == 4:
        # Multi-channel volumes: average channels for display only.
        volume = np.mean(volume, axis=-1) if volume.shape[-1] <= 4 else volume[0]
    if volume.ndim not in (2, 3):
        raise HABITAPIError(
            f"{name} must be a 2D or 3D volume; got shape {tuple(volume.shape)}."
        )
    if volume.size == 0:
        raise HABITAPIError(f"{name} must not be empty.")
    return volume


def _draw_arrow(ax: "Axes") -> None:
    """Draw a single bold right arrow centred in a spacer axes."""
    ax.axis("off")
    ax.annotate(
        "",
        xy=(0.82, 0.5),
        xytext=(0.18, 0.5),
        xycoords="axes fraction",
        arrowprops={
            "arrowstyle": "-|>",
            "lw": 3.0,
            "color": "#555555",
            "mutation_scale": 32,
        },
    )


def _draw_data_panel(
    ax: "Axes",
    image: object,
    *,
    labels: Optional[object] = None,
    roi_mask: Optional[object] = None,
    pad: int = 8,
    panel_title: Optional[str] = None,
) -> None:
    """
    Draw one cropped, display-oriented axial data slice on ``ax``.

    With ``labels`` the panel is an opaque habitat-colour overlay identical
    in palette and orientation to :func:`plot_habitat_overlay`; with only
    ``roi_mask`` the ROI is painted as a cyan fill. The view is zoomed to
    the label / ROI bounding box so the tumour fills the panel.

    Args:
        ax: Target axes.
        image: Anatomy volume (array or ``ImageVolume``).
        labels: Optional habitat label map (array or ``HabitatMap``).
        roi_mask: Optional ROI mask (array or ``MaskVolume``).
        pad: Context voxels around the bounding box.
        panel_title: Optional panel title (ASCII-sanitised).

    Raises:
        HABITAPIError: When neither labels nor roi_mask is given, or shapes
            disagree.
    """
    if labels is None and roi_mask is None:
        raise HABITAPIError(
            "diagrams: a data panel needs labels or roi_mask; got neither."
        )
    image_vol = _volume_array(image, name="image")
    label_vol = (
        _volume_array(labels, name="labels").astype(np.int32)
        if labels is not None
        else None
    )
    roi_vol = (
        _volume_array(roi_mask, name="roi_mask") if roi_mask is not None else None
    )
    target = label_vol if label_vol is not None else roi_vol
    if target is None or image_vol.shape != target.shape:
        raise HABITAPIError(
            "diagrams: image and labels/roi_mask must share the same shape; "
            f"got image {image_vol.shape} vs {None if target is None else target.shape}."
        )

    # Zoom to the foreground bounding box, then pick the densest axial slice
    # inside the cropped volume (same rule as the main overlay plotter).
    crop = bbox_slices(target, pad, caller="diagrams", mask_name="labels/roi_mask")
    image_vol = image_vol[crop]
    if label_vol is not None:
        label_vol = label_vol[crop]
    if roi_vol is not None:
        roi_vol = roi_vol[crop]
    target = label_vol if label_vol is not None else roi_vol

    resolved_direction, resolved_spacing = resolve_display_geometry(
        image, labels if labels is not None else roi_mask
    )
    direction = _parse_direction_matrix(resolved_direction, ndim=image_vol.ndim)
    spacing_xyz = (
        tuple(1.0 for _ in range(image_vol.ndim))
        if resolved_spacing is None
        else tuple(float(v) for v in resolved_spacing)
    )

    axis_id = 0
    slice_index = _slice_index(target, axis_id, None)
    grey = _normalize_grey(_take_slice(image_vol, axis_id, slice_index))
    mask_2d = _take_slice(target, axis_id, slice_index)
    grey = orient_slice_for_display(
        grey, slice_axis=axis_id, direction=direction
    )
    mask_2d = orient_slice_for_display(
        mask_2d, slice_axis=axis_id, direction=direction
    )

    if label_vol is not None:
        # Volume-level ID->colour so the panel matches multi-panel overlays.
        habitat_ids = _positive_habitat_ids(label_vol)
        id_to_color = _habitat_color_lookup(habitat_ids)
        rgb = _blend_overlay(
            grey,
            mask_2d,
            alpha=1.0,
            colors=(),
            id_to_color=id_to_color,
        )
    else:
        rgb = np.stack([grey, grey, grey], axis=-1)
        fill = np.array(
            (
                int(_ROI_FILL[1:3], 16) / 255.0,
                int(_ROI_FILL[3:5], 16) / 255.0,
                int(_ROI_FILL[5:7], 16) / 255.0,
            )
        )
        inside = mask_2d > 0
        for channel in range(3):
            plane = rgb[..., channel]
            plane[inside] = 0.2 * plane[inside] + 0.8 * fill[channel]
            rgb[..., channel] = plane

    extent = imshow_physical_extent(
        (int(rgb.shape[0]), int(rgb.shape[1])),
        spacing_xyz,
        slice_axis=axis_id,
        ndim=image_vol.ndim,
        direction=direction,
        convention=DEFAULT_DISPLAY_CONVENTION,
    )
    ax.imshow(
        rgb,
        interpolation="nearest",
        origin="upper",
        extent=extent,
        aspect="equal",
    )
    _draw_label_contour(ax, mask_2d, extent=extent)
    ax.set_aspect("equal", adjustable="box")
    if panel_title:
        ax.set_title(sanitize_label(panel_title))
    ax.axis("off")


def _draw_directory_tree(
    ax: "Axes", tree: Sequence[Tuple[int, str, str]]
) -> None:
    """
    Draw a colour-coded directory tree on ``ax`` (monospace, ASCII only).

    Args:
        ax: Target axes.
        tree: ``(depth, name, kind)`` entries; kind is one of
            ``_TREE_KIND_COLORS`` (folder / image / mask).
    """
    ax.axis("off")
    ax.set_xlim(0.0, 1.0)
    ax.set_ylim(0.0, 1.0)
    n_lines = len(tree)
    line_height = 0.86 / max(n_lines, 1)
    y_top = 0.94
    for row, (depth, name, kind) in enumerate(tree):
        y = y_top - row * line_height
        x = 0.04 + 0.09 * depth
        color = _TREE_KIND_COLORS.get(kind, "#666666")
        # Colour swatch in front of every entry so kinds survive greyscale
        # printing and colour-vision deficiency.
        ax.add_patch(
            _rectangle((x, y - 0.028), 0.035, 0.05, color)
        )
        ax.text(
            x + 0.055,
            y,
            sanitize_label(name),
            family="monospace",
            fontsize=10.5,
            color="#222222",
            va="center",
            ha="left",
        )


def _rectangle(xy: Tuple[float, float], width: float, height: float, color: str):
    """Build one filled Rectangle patch (kept small for tree swatches)."""
    from matplotlib.patches import Rectangle

    return Rectangle(xy, width, height, facecolor=color, edgecolor="none")


def _draw_simpleitk_badge(ax: "Axes") -> None:
    """
    Draw a stylised voxel-cube badge with a ``SimpleITK`` caption on ``ax``.

    HABIT-drawn schematic (an isometric gridded cube evoking an ITK image
    object); NOT the official SimpleITK logo.
    """
    ax.axis("off")
    ax.set_xlim(0.0, 1.0)
    ax.set_ylim(0.0, 1.0)
    # Front face square and the depth offset that makes it a cube.
    x0, y0, side = 0.20, 0.30, 0.40
    dx, dy = 0.14, 0.14
    front = [(x0, y0), (x0 + side, y0), (x0 + side, y0 + side), (x0, y0 + side)]
    back = [(x + dx, y + dy) for x, y in front]
    faces = (
        # (polygon vertices, face colour) — paint back-to-front.
        ([front[1], back[1], back[2], front[2]], "#6BAED6"),  # right side
        ([front[2], back[2], back[3], front[3]], "#C6DBEF"),  # top
        (front, "#9ECAE1"),  # front
    )
    from matplotlib.patches import Polygon

    for vertices, color in faces:
        ax.add_patch(
            Polygon(
                vertices,
                closed=True,
                facecolor=color,
                edgecolor="#2171B5",
                linewidth=1.6,
            )
        )
    # Voxel lattice on the front face: 4x4 grid lines.
    for step in range(1, 4):
        frac = step / 4.0
        ax.plot(
            [x0 + side * frac, x0 + side * frac],
            [y0, y0 + side],
            color="#2171B5",
            linewidth=0.8,
            alpha=0.75,
        )
        ax.plot(
            [x0, x0 + side],
            [y0 + side * frac, y0 + side * frac],
            color="#2171B5",
            linewidth=0.8,
            alpha=0.75,
        )
    ax.text(
        0.5,
        0.16,
        "SimpleITK",
        fontsize=14,
        fontweight="bold",
        color="#08519C",
        ha="center",
        va="center",
    )
    ax.text(
        0.5,
        0.06,
        "sitk.Image",
        family="monospace",
        fontsize=9,
        color="#555555",
        ha="center",
        va="center",
    )


def _downsample_to_matrix(slice_2d: np.ndarray, cells: int = 10) -> np.ndarray:
    """
    Block-average a 2D slice into a small ``cells x cells`` display matrix.

    Args:
        slice_2d: Cropped 2D slice (already display-oriented).
        cells: Number of rows/columns of the badge matrix.

    Returns:
        ``(cells, cells)`` float array for ``imshow``.
    """
    data = np.asarray(slice_2d, dtype=np.float64)
    finite = data[np.isfinite(data)]
    if finite.size == 0:
        return np.zeros((cells, cells))
    data = np.nan_to_num(data, nan=float(np.min(finite)))
    # Pad to a whole number of blocks, then average each block.
    pad_rows = (-data.shape[0]) % cells
    pad_cols = (-data.shape[1]) % cells
    padded = np.pad(
        data,
        ((0, pad_rows), (0, pad_cols)),
        mode="edge",
    )
    block_rows = padded.shape[0] // cells
    block_cols = padded.shape[1] // cells
    return padded.reshape(cells, block_rows, cells, block_cols).mean(axis=(1, 3))


def _draw_numpy_badge(ax: "Axes", matrix: np.ndarray) -> None:
    """
    Draw the NumPy badge on ``ax``: the data itself as a small colour matrix.

    Args:
        ax: Target axes.
        matrix: Small 2D array (e.g. from :func:`_downsample_to_matrix`).
    """
    ax.set_title("")
    ax.imshow(matrix, cmap="viridis", interpolation="nearest")
    # White cell borders make the matrix read as an array, not an image.
    n_rows, n_cols = matrix.shape
    for row in range(n_rows + 1):
        ax.axhline(row - 0.5, color="white", linewidth=0.6, alpha=0.7)
    for col in range(n_cols + 1):
        ax.axvline(col - 0.5, color="white", linewidth=0.6, alpha=0.7)
    ax.set_xticks([])
    ax.set_yticks([])
    for spine in ax.spines.values():
        spine.set_visible(False)
    ax.set_xlabel("NumPy ndarray  (z, y, x)", fontsize=11, fontweight="bold")


def _draw_file_card(
    ax: "Axes",
    *,
    x0: float,
    y0: float,
    width: float,
    height: float,
    facecolor: str,
    edgecolor: str,
    foldcolor: str,
    label: str,
    zorder: int,
) -> None:
    """
    Draw one document glyph (rounded body + dog-ear) labelled ``label``.

    Args:
        ax: Target axes in axes-fraction coordinates.
        x0: Left edge of the card.
        y0: Bottom edge of the card.
        width: Card width.
        height: Card height.
        facecolor: Fill of the card body.
        edgecolor: Stroke of the body and the dog-ear.
        foldcolor: Fill of the folded corner.
        label: ASCII filename drawn on the card (e.g. ``image.nii.gz``).
        zorder: Matplotlib z-order; the dog-ear and text sit above the body.
    """
    from matplotlib.patches import Polygon

    fold = min(width, height) * 0.22
    body = [
        (x0, y0),
        (x0 + width, y0),
        (x0 + width, y0 + height - fold),
        (x0 + width - fold, y0 + height),
        (x0, y0 + height),
    ]
    ax.add_patch(
        Polygon(
            body,
            closed=True,
            facecolor=facecolor,
            edgecolor=edgecolor,
            linewidth=1.6,
            zorder=zorder,
        )
    )
    ear = [
        (x0 + width - fold, y0 + height),
        (x0 + width, y0 + height - fold),
        (x0 + width - fold, y0 + height - fold),
    ]
    ax.add_patch(
        Polygon(
            ear,
            closed=True,
            facecolor=foldcolor,
            edgecolor=edgecolor,
            linewidth=1.2,
            zorder=zorder + 1,
        )
    )
    ax.text(
        x0 + width * 0.46,
        y0 + height * 0.42,
        sanitize_label(label),
        fontsize=10,
        fontweight="bold",
        color="#222222",
        ha="center",
        va="center",
        zorder=zorder + 2,
    )


def _draw_nifti_badge(ax: "Axes") -> None:
    """
    Draw two stacked file cards: ``image.nii.gz`` over ``mask.nii.gz``.

    Colours match :data:`_TREE_KIND_COLORS` (image green, mask orange) so
    the NIfTI badge and the directory-tree swatches read as one system.
    The cards are HABIT-drawn; they are not a NIfTI Consortium logo.
    """
    ax.axis("off")
    ax.set_xlim(0.0, 1.0)
    ax.set_ylim(0.0, 1.0)
    # Image card sits up-left; mask card is offset down-right. Vertical
    # gap keeps the upper filename clear of the overlapping card.
    _draw_file_card(
        ax,
        x0=0.08,
        y0=0.50,
        width=0.58,
        height=0.38,
        facecolor="#B8E0D2",
        edgecolor=_TREE_KIND_COLORS["image"],
        foldcolor="#7FCBB3",
        label="image.nii.gz",
        zorder=2,
    )
    _draw_file_card(
        ax,
        x0=0.32,
        y0=0.16,
        width=0.58,
        height=0.38,
        facecolor="#F6E2B3",
        edgecolor=_TREE_KIND_COLORS["mask"],
        foldcolor="#EED18A",
        label="mask.nii.gz",
        zorder=3,
    )
    ax.text(
        0.5,
        0.06,
        "read_image / read_mask",
        family="monospace",
        fontsize=9,
        color="#555555",
        ha="center",
        va="center",
    )


def _draw_chain(ax: "Axes", steps: Sequence[str]) -> None:
    """
    Draw the feature-preprocessing chain as coloured boxes joined by arrows.

    Args:
        ax: Target axes.
        steps: Step names in pipeline order (e.g. ``("raw", "winsorize",
            "minmax", "zscore", "binning")``).
    """
    from matplotlib.patches import FancyBboxPatch

    ax.axis("off")
    ax.set_xlim(0.0, 1.0)
    ax.set_ylim(0.0, 1.0)
    n_steps = len(steps)
    gap = 0.045
    box_width = (1.0 - gap * (n_steps - 1)) / n_steps
    box_height = 0.34
    y0 = 0.5 - box_height / 2.0
    fontsize = 10.0 if n_steps <= 4 else 8.5
    for index, step in enumerate(steps):
        x0 = index * (box_width + gap)
        color = _CHAIN_COLORS[index % len(_CHAIN_COLORS)]
        ax.add_patch(
            FancyBboxPatch(
                (x0, y0),
                box_width,
                box_height,
                boxstyle="round,pad=0.012",
                facecolor=color,
                edgecolor="#333333",
                linewidth=0.8,
            )
        )
        ax.text(
            x0 + box_width / 2.0,
            0.5,
            sanitize_label(str(step)),
            fontsize=fontsize,
            fontweight="bold",
            color="white",
            ha="center",
            va="center",
        )
        if index < n_steps - 1:
            ax.annotate(
                "",
                xy=(x0 + box_width + gap * 0.85, 0.5),
                xytext=(x0 + box_width + gap * 0.15, 0.5),
                xycoords="axes fraction",
                arrowprops={
                    "arrowstyle": "-|>",
                    "lw": 1.6,
                    "color": "#555555",
                },
            )


def _ingest_figure(
    badge,
    image: object,
    *,
    labels: Optional[object] = None,
    roi_mask: Optional[object] = None,
    title: str,
    panel_title: str,
    crop_pad: int,
    badge_ratio: float = 1.0,
) -> "Figure":
    """
    Shared three-column layout: badge | arrow | cropped data panel.

    Args:
        badge: Callable drawing the left-hand badge on an axes.
        image: Anatomy volume for the right-hand data panel.
        labels: Optional habitat map for the data panel.
        roi_mask: Optional ROI mask for the data panel.
        title: Figure title (ASCII-sanitised); drawn as a small caption
            under the badge so it cannot collide with the panel title.
        panel_title: Data-panel title (ASCII-sanitised).
        crop_pad: Context voxels around the foreground bounding box.
        badge_ratio: Width ratio of the badge column relative to the data
            column (which is fixed at ratio 1.15).

    Returns:
        A matplotlib ``Figure``.
    """
    plt = _plt()
    with use_style("radiology"):
        fig = plt.figure(figsize=(7.2, 3.5), constrained_layout=True)
        grid = fig.add_gridspec(
            1, 3, width_ratios=[badge_ratio, 0.18, 1.15]
        )
        ax_badge = fig.add_subplot(grid[0, 0])
        ax_arrow = fig.add_subplot(grid[0, 1])
        ax_data = fig.add_subplot(grid[0, 2])
        badge(ax_badge)
        # Caption under the badge doubles as the figure title: a suptitle
        # would sit on top of the data-panel title at this compact height.
        ax_badge.set_title(sanitize_label(title), fontsize=10, pad=10)
        _draw_arrow(ax_arrow)
        _draw_data_panel(
            ax_data,
            image,
            labels=labels,
            roi_mask=roi_mask,
            pad=crop_pad,
            panel_title=panel_title,
        )
    return fig


[docs] def plot_directory_ingest( image: object, roi_mask: object, *, tree: Optional[Sequence[Tuple[int, str, str]]] = None, title: str = "From directory to Subject", panel_title: str = "Subject anatomy + ROI", crop_pad: int = 8, ) -> "Figure": """ Diagram for the directory-ingestion route: tree -> Subject with ROI. Args: image: Anatomy volume (array or ``ImageVolume``) of one subject. roi_mask: ROI mask (array or ``MaskVolume``), same grid as ``image``. tree: Optional ``(depth, name, kind)`` entries overriding the default preprocessed-tree schematic; kind is ``"folder"`` / ``"image"`` / ``"mask"``. title: Figure title (ASCII-sanitised). panel_title: Title of the cropped data panel. crop_pad: Context voxels around the ROI bounding box (default ``8``). Returns: A matplotlib ``Figure`` with the directory tree on the left and the cropped anatomy + cyan ROI fill on the right. """ entries = _DEFAULT_TREE if tree is None else tuple(tree) return _ingest_figure( lambda ax: _draw_directory_tree(ax, entries), image, roi_mask=roi_mask, title=title, panel_title=panel_title, crop_pad=crop_pad, )
[docs] def plot_simpleitk_ingest( image: object, labels: object, *, title: str = "From SimpleITK to habitats", panel_title: str = "Habitats", crop_pad: int = 8, ) -> "Figure": """ Diagram for the SimpleITK route: voxel-cube badge -> habitat overlay. Args: image: Anatomy volume (array or ``ImageVolume``) of one subject. labels: Habitat label map (array or ``HabitatMap``) on the same grid. title: Figure title (ASCII-sanitised). panel_title: Title of the cropped data panel. crop_pad: Context voxels around the habitat bounding box (default ``8``). Returns: A matplotlib ``Figure`` with the stylised SimpleITK badge on the left and the cropped habitat overlay on the right. """ return _ingest_figure( _draw_simpleitk_badge, image, labels=labels, title=title, panel_title=panel_title, crop_pad=crop_pad, )
[docs] def plot_numpy_ingest( image: object, roi_mask: Optional[object] = None, *, labels: Optional[object] = None, title: str = "From NumPy arrays to HABIT", panel_title: str = "Subject anatomy + ROI", crop_pad: int = 8, matrix_cells: int = 10, ) -> "Figure": """ Diagram for the array route: the data itself drawn as a colour matrix. The badge matrix is a block-averaged miniature of the cropped ROI slice of ``image`` -- the badge IS the data, not a stock icon. Args: image: Anatomy volume (array or ``ImageVolume``) of one subject. roi_mask: Optional ROI mask (array or ``MaskVolume``); painted as a cyan fill on the data panel when ``labels`` is not given. labels: Optional habitat label map (array or ``HabitatMap``); when given, the data panel shows the habitat overlay instead. title: Figure title (ASCII-sanitised). panel_title: Title of the cropped data panel. crop_pad: Context voxels around the foreground bounding box (default ``8``). matrix_cells: Rows/columns of the badge matrix (default ``10``). Returns: A matplotlib ``Figure`` with the matrix badge on the left and the cropped data panel on the right. Raises: HABITAPIError: When neither ``roi_mask`` nor ``labels`` is given. """ if roi_mask is None and labels is None: raise HABITAPIError( "plot_numpy_ingest: pass roi_mask or labels so the data panel " "has a foreground to zoom to." ) # Build the badge matrix from the same cropped slice the panel shows. image_vol = _volume_array(image, name="image") target = _volume_array( labels if labels is not None else roi_mask, name="labels/roi_mask" ) crop = bbox_slices( target, crop_pad, caller="plot_numpy_ingest", mask_name="labels/roi_mask" ) cropped_image = image_vol[crop] cropped_target = target[crop] slice_index = _slice_index(cropped_target, 0, None) matrix = _downsample_to_matrix( _take_slice(cropped_image, 0, slice_index), cells=matrix_cells ) return _ingest_figure( lambda ax: _draw_numpy_badge(ax, matrix), image, labels=labels, roi_mask=roi_mask, title=title, panel_title=panel_title, crop_pad=crop_pad, )
[docs] def plot_nifti_ingest( image: object, roi_mask: object, *, title: str = "From NIfTI files to Subject", panel_title: str = "Subject anatomy + ROI", crop_pad: int = 8, ) -> "Figure": """ Diagram for the file-pair route: ``image.nii.gz`` + ``mask.nii.gz``. The right-hand panel is the cropped anatomy with a cyan ROI fill, the same view as :func:`plot_directory_ingest` / :func:`plot_numpy_ingest`. This page does not fit habitats; the story is files to ``Subject``. Args: image: Anatomy volume (array or ``ImageVolume``) of one subject. roi_mask: ROI mask (array or ``MaskVolume``), same grid as ``image``. title: Figure title (ASCII-sanitised). panel_title: Title of the cropped data panel. crop_pad: Context voxels around the ROI bounding box (default ``8``). Returns: A matplotlib ``Figure`` with the NIfTI file-pair badge on the left and the cropped anatomy + cyan ROI fill on the right. """ return _ingest_figure( _draw_nifti_badge, image, roi_mask=roi_mask, title=title, panel_title=panel_title, crop_pad=crop_pad, )
[docs] def plot_feature_preprocessing_chain( steps: Sequence[str], image: Optional[object] = None, labels: Optional[object] = None, *, title: str = "Feature preprocessing", panel_title: str = "Habitats after the chain", crop_pad: int = 8, ) -> "Figure": """ Diagram of the clustering-feature preprocessing chain. Args: steps: Step names in pipeline order, e.g. ``("raw", "winsorize", "minmax", "zscore", "binning")``. image: Optional anatomy volume; with ``labels`` the right-hand panel shows the cropped habitat map produced after the chain. labels: Optional habitat label map (array or ``HabitatMap``). title: Figure title (ASCII-sanitised). panel_title: Title of the cropped data panel. crop_pad: Context voxels around the habitat bounding box (default ``8``). Returns: A matplotlib ``Figure``: the chain of step boxes, an arrow, and -- when ``image`` and ``labels`` are given -- the resulting habitats. Raises: HABITAPIError: When ``steps`` is empty or only one of ``image`` / ``labels`` is given. """ if not steps: raise HABITAPIError( "plot_feature_preprocessing_chain: steps must not be empty." ) if (image is None) != (labels is None): raise HABITAPIError( "plot_feature_preprocessing_chain: image and labels must be " "given together (the data panel needs both)." ) plt = _plt() with use_style("radiology"): if image is None: # Chain-only figure: one wide axes, no data panel. fig, ax = plt.subplots( 1, 1, figsize=(7.2, 1.6), constrained_layout=True ) _draw_chain(ax, steps) fig.suptitle(sanitize_label(title)) return fig return _ingest_figure( lambda ax: _draw_chain(ax, steps), image, labels=labels, title=title, panel_title=panel_title, crop_pad=crop_pad, badge_ratio=1.6, )