diff --git a/README.rst b/README.rst index 6674c82f..2f0f34c8 100644 --- a/README.rst +++ b/README.rst @@ -14,6 +14,56 @@ brain-derived features, together with tools to generate spatial null models. For installation instructions, examples and documentation of BrainSpace see our `documentation `_. +Display-independent surface reports +----------------------------------- + +BrainSpace can export browser-readable HTML surface reports without opening a +VTK render window. This is useful on remote servers, CI runners, notebooks, and +containers where interactive VTK rendering is unavailable. + +.. raw:: html + + + +If the video player is not shown, open the +`BrainSpace threemica bridge preview video `_. +The preview is generated from BrainSpace's bundled Conte69/fsLR-32k reference +surfaces and maps; it does not contain participant data. + +The native BrainSpace HTML writer keeps the dependency surface small: + +.. code-block:: python + + from brainspace.plotting import write_surface_report + + report = write_surface_report( + surfaces={"lh": surf_lh, "rh": surf_rh}, + arrays={"Gradient 1": gradient_1}, + filename="surface_report.html", + title="BrainSpace surface report", + ) + +The optional threemica bridge preserves Zhengchen Cai's original viewer and +converts BrainSpace surfaces/maps into the BIDS-style derivative layout that +threemica reads: + +.. code-block:: python + + from brainspace.plotting import write_brainspace_threemica_report + + reports = write_brainspace_threemica_report( + "brainspace_threemica_report", + maps=["thickness", "fc_gradient1"], + custom_maps={ + "aligned_gradient": { + "values": aligned_gradients, + "label": "Aligned gradient", + "unit": "score", + "cmap": "diverging", + }, + }, + ) + Happy gradient analysis! License @@ -40,4 +90,3 @@ Core development team * Reinder Vos de Wael, MICA Lab - Montreal Neurological Institute * Oualid Benkarim, MICA Lab - Montreal Neurological Institute * Boris Bernhardt, MICA Lab - Montreal Neurological Institute - diff --git a/brainspace/plotting/__init__.py b/brainspace/plotting/__init__.py index 9f5e5604..660387ed 100644 --- a/brainspace/plotting/__init__.py +++ b/brainspace/plotting/__init__.py @@ -1,6 +1,25 @@ -from .surface_plotting import build_plotter, plot_surf, plot_hemispheres +from .surface_report import write_surface_report +from .threemica_report import (available_threemica_surface_maps, + write_brainspace_threemica_report, + write_threemica_report) __all__ = ['build_plotter', 'plot_surf', - 'plot_hemispheres'] + 'plot_hemispheres', + 'write_surface_report', + 'available_threemica_surface_maps', + 'write_brainspace_threemica_report', + 'write_threemica_report'] + + +def __getattr__(name): + if name in {'build_plotter', 'plot_surf', 'plot_hemispheres'}: + from .surface_plotting import build_plotter, plot_surf, plot_hemispheres + globals().update({ + 'build_plotter': build_plotter, + 'plot_surf': plot_surf, + 'plot_hemispheres': plot_hemispheres, + }) + return globals()[name] + raise AttributeError("module {!r} has no attribute {!r}".format(__name__, name)) diff --git a/brainspace/plotting/surface_plotting.py b/brainspace/plotting/surface_plotting.py index b56b5931..b6f7044d 100644 --- a/brainspace/plotting/surface_plotting.py +++ b/brainspace/plotting/surface_plotting.py @@ -495,7 +495,8 @@ def plot_hemispheres(surf_lh, surf_rh, array_name=None, color_bar=False, cmap='viridis', nan_color=(0, 0, 0, 1), zoom=1, background=(1, 1, 1), size=(400, 400), interactive=True, embed_nb=False, screenshot=False, filename=None, - scale=(1, 1), transparent_bg=True, **kwargs): + scale=(1, 1), transparent_bg=True, backend='vtk', + title='BrainSpace Surface Report', **kwargs): """Plot left and right hemispheres in lateral and medial views. Parameters @@ -548,6 +549,12 @@ def plot_hemispheres(surf_lh, surf_rh, array_name=None, color_bar=False, scale : tuple, optional Scale (magnification). Only used if ``screenshot==True``. Default is None. + backend : {'vtk', 'html'}, optional + Rendering backend. The default ``'vtk'`` preserves the existing VTK + behavior. ``'html'`` writes a display-independent HTML report to + ``filename`` and does not initialize VTK rendering. + title : str, optional + Title used when ``backend='html'``. kwargs : keyword-valued args Additional arguments passed to the plotter. @@ -563,6 +570,18 @@ def plot_hemispheres(surf_lh, surf_rh, array_name=None, color_bar=False, :func:`plot_surf` """ + if backend not in {'vtk', 'html'}: + raise ValueError("backend must be either 'vtk' or 'html'.") + if backend == 'html': + if filename is None: + raise ValueError("filename is required when backend='html'.") + from .surface_report import write_surface_report + return write_surface_report({'lh': surf_lh, 'rh': surf_rh}, + arrays=array_name, filename=filename, + title=title, cmap=cmap, + color_range=color_range, + nan_color=nan_color) + if color_bar is True: color_bar = 'right' diff --git a/brainspace/plotting/surface_report.py b/brainspace/plotting/surface_report.py new file mode 100644 index 00000000..57bcc614 --- /dev/null +++ b/brainspace/plotting/surface_report.py @@ -0,0 +1,585 @@ +"""Display-independent HTML reports for surface maps.""" + +# Author: The BrainSpace developers +# License: BSD 3 clause + +import json +from html import escape +from pathlib import Path + +import numpy as np + +from .colormaps import colormaps + + +def _rgba255(color): + arr = np.asarray(color, dtype=float) + if arr.size == 3: + arr = np.append(arr, 1.0) + if arr.size != 4: + raise ValueError("Colors must have 3 or 4 channels.") + if np.nanmax(arr) <= 1.0: + arr = arr * 255 + return np.clip(np.round(arr), 0, 255).astype(int).tolist() + + +def _json_list(arr): + arr = np.asarray(arr) + if np.issubdtype(arr.dtype, np.floating): + out = arr.astype(object) + out[~np.isfinite(arr)] = None + return out.tolist() + return arr.tolist() + + +def _n_points(surf): + if isinstance(surf, dict): + return np.asarray(surf["points"]).shape[0] + return surf.n_points + + +def _surface_payload(surf, name): + if isinstance(surf, dict): + points = np.asarray(surf["points"], dtype=np.float32) + cells = np.asarray(surf["cells"], dtype=np.int64) + else: + points = np.asarray(surf.Points, dtype=np.float32) + cells = np.asarray(surf.GetCells2D(), dtype=np.int64) + + if cells.ndim != 2 or cells.shape[1] != 3: + raise ValueError( + "HTML surface reports currently require triangular surfaces; " + "{} has cells with shape {}.".format(name, cells.shape) + ) + if points.ndim != 2 or points.shape[1] != 3: + raise ValueError( + "HTML surface reports require point coordinates with shape " + "(n_points, 3); {} has points with shape {}.".format(name, points.shape) + ) + + return { + "points": points.round(6).tolist(), + "cells": cells.tolist(), + } + + +def _as_surface_dict(surfaces): + if isinstance(surfaces, dict): + if "lh" not in surfaces or "rh" not in surfaces: + raise ValueError("Surface dictionaries must contain 'lh' and 'rh'.") + return {"lh": surfaces["lh"], "rh": surfaces["rh"]} + if isinstance(surfaces, (list, tuple)) and len(surfaces) == 2: + return {"lh": surfaces[0], "rh": surfaces[1]} + raise TypeError("Surfaces must be a {'lh': surf_lh, 'rh': surf_rh} dict or a pair.") + + +def _point_data_values(surfaces, name): + try: + return { + "lh": np.asarray(surfaces["lh"].PointData[name], dtype=float), + "rh": np.asarray(surfaces["rh"].PointData[name], dtype=float), + } + except KeyError as exc: + raise KeyError("Point-data array {!r} is missing from a surface.".format(name)) from exc + + +def _split_values(surfaces, values): + if isinstance(values, dict): + return { + "lh": np.asarray(values["lh"], dtype=float).ravel(), + "rh": np.asarray(values["rh"], dtype=float).ravel(), + } + + arr = np.asarray(values, dtype=float).ravel() + n_lh = _n_points(surfaces["lh"]) + n_rh = _n_points(surfaces["rh"]) + if arr.size != n_lh + n_rh: + raise ValueError( + "Surface map has {} values; expected {} ({} left + {} right).".format( + arr.size, n_lh + n_rh, n_lh, n_rh + ) + ) + return {"lh": arr[:n_lh], "rh": arr[n_lh:]} + + +def _map_spec(values): + if isinstance(values, dict) and "values" in values: + opts = {k: v for k, v in values.items() if k != "values"} + return values["values"], opts + return values, {} + + +def _iter_maps(surfaces, arrays): + n_lh = _n_points(surfaces["lh"]) + n_rh = _n_points(surfaces["rh"]) + n_total = n_lh + n_rh + if arrays is None: + yield "Surface", {"lh": np.zeros(n_lh), "rh": np.zeros(n_rh)}, {} + return + + if isinstance(arrays, str): + yield arrays, _point_data_values(surfaces, arrays), {} + return + + if isinstance(arrays, dict): + for name, values in arrays.items(): + values, opts = _map_spec(values) + if isinstance(values, str): + yield str(name), _point_data_values(surfaces, values), opts + else: + yield str(name), _split_values(surfaces, values), opts + return + + arr = np.asarray(arrays, dtype=object) + if arr.ndim == 1 and arr.size == n_total: + yield "Map 1", _split_values(surfaces, arrays), {} + return + if arr.ndim == 2 and arr.shape[1] == n_total: + for i, values in enumerate(arr): + yield "Map {}".format(i + 1), _split_values(surfaces, values), {} + return + + if isinstance(arrays, (list, tuple)): + for i, values in enumerate(arrays): + name = values if isinstance(values, str) else "Map {}".format(i + 1) + if isinstance(values, str): + yield name, _point_data_values(surfaces, values), {} + else: + yield name, _split_values(surfaces, values), {} + return + + yield "Map 1", _split_values(surfaces, arrays), {} + + +def _resolve_cmap(cmap): + if isinstance(cmap, str) and cmap in colormaps: + lut = np.asarray(colormaps[cmap], dtype=np.uint8) + else: + if not callable(cmap): + import matplotlib as mpl + cmap = mpl.colormaps[cmap] + lut = np.asarray(cmap(np.linspace(0, 1, 256)) * 255, dtype=np.uint8) + if lut.shape[1] == 3: + lut = np.column_stack([lut, np.full(lut.shape[0], 255, dtype=np.uint8)]) + return lut[:, :4].astype(int).tolist() + + +def _map_range(values, color_range): + finite = np.concatenate([ + np.asarray(values["lh"], dtype=float), + np.asarray(values["rh"], dtype=float), + ]) + finite = finite[np.isfinite(finite)] + if color_range == "sym": + vmax = float(np.max(np.abs(finite))) if finite.size else 1.0 + return [-vmax, vmax] + if color_range is not None: + lo, hi = color_range + return [float(lo), float(hi)] + if not finite.size: + return [0.0, 1.0] + lo, hi = float(np.min(finite)), float(np.max(finite)) + if lo == hi: + pad = 1.0 if lo == 0 else abs(lo) * 0.01 + lo, hi = lo - pad, hi + pad + return [lo, hi] + + +def _metadata_html(metadata): + if not metadata: + return "" + rows = "\n".join( + "{}{}".format(escape(str(k)), escape(str(v))) + for k, v in metadata.items() + ) + return "
Report metadata{}
".format(rows) + + +def _html_template(payload): + title = escape(payload["title"]) + data = json.dumps(payload, allow_nan=False, separators=(",", ":")).replace("<", "\\u003c") + metadata = _metadata_html(payload.get("metadata")) + html = """ + + + + +{title} + + + +
+

{title}

+ + + + + + +
+{metadata} +
+ +
H help · drag rotate · wheel zoom · [ ] switch map · R reset
+
+ + + + + +""" + html = html.replace("{{", "{").replace("}}", "}") + return html.replace("{title}", title).replace("{data}", data).replace("{metadata}", metadata) + + +def write_surface_report(surfaces, arrays=None, filename="surface_report.html", + title="BrainSpace Surface Report", cmap="viridis", + color_range=None, nan_color=(0.7, 0.7, 0.7, 1), + metadata=None): + """Write a display-independent HTML report for left/right surface maps. + + Parameters + ---------- + surfaces : dict or sequence + Either ``{'lh': surf_lh, 'rh': surf_rh}`` or a two-item sequence of + BrainSpace surfaces. + arrays : str, ndarray, sequence or dict, optional + Surface maps to include. One-dimensional arrays must contain left and + right hemisphere values concatenated. Two-dimensional arrays are + interpreted row-wise. Strings refer to point-data arrays already + present on both surfaces. Dictionaries map display names to arrays. + filename : str or path-like + HTML file to write. + title : str, optional + Report title. + cmap : str, optional + Matplotlib or BrainSpace colormap name. + color_range : {'sym'}, tuple or None, optional + Color range. If None, each map uses its finite data range. + nan_color : tuple, optional + RGBA color for missing values. + metadata : dict, optional + Key/value metadata rendered above the viewer. + + Returns + ------- + pathlib.Path + Path to the written report. + """ + surfaces = _as_surface_dict(surfaces) + maps = [] + for name, values, opts in _iter_maps(surfaces, arrays): + map_cmap = opts.get("cmap", cmap) + map_color_range = opts.get("color_range", color_range) + maps.append({ + "name": str(name), + "values": { + "lh": _json_list(values["lh"]), + "rh": _json_list(values["rh"]), + }, + "range": _map_range(values, map_color_range), + "cmap": _resolve_cmap(map_cmap), + "unit": str(opts.get("unit", "")), + "description": str(opts.get("description", "")), + }) + + payload = { + "title": str(title), + "nanColor": _rgba255(nan_color), + "surfaces": { + "lh": _surface_payload(surfaces["lh"], "lh"), + "rh": _surface_payload(surfaces["rh"], "rh"), + }, + "maps": maps, + "metadata": metadata or {}, + } + + filename = Path(filename) + filename.write_text(_html_template(payload), encoding="utf-8") + return filename diff --git a/brainspace/plotting/threemica_report.py b/brainspace/plotting/threemica_report.py new file mode 100644 index 00000000..c3ca15ab --- /dev/null +++ b/brainspace/plotting/threemica_report.py @@ -0,0 +1,410 @@ +"""Optional bridge to Zhengchen Cai's threemica HTML viewer.""" + +# Author: The BrainSpace developers +# License: BSD 3 clause + +import shutil +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + + +_BRAINSPACE_MAPS = { + "curvature": { + "filename": "conte69_32k_curvature.csv", + "label": "Cortical Curvature", + "unit": "1/mm", + "cmap": "diverging", + "group": "markers", + }, + "thickness": { + "filename": "conte69_32k_thickness.csv", + "label": "Cortical Thickness", + "unit": "mm", + "cmap": "pos-only", + "group": "markers", + }, + "t1wt2w": { + "filename": "conte69_32k_t1wt2w.csv", + "label": "T1w/T2w Myelin Proxy", + "unit": "ratio", + "cmap": "pos-only", + "group": "markers", + }, + "fc_gradient1": { + "filename": "conte69_32k_fc_gradient0.csv", + "label": "Functional Connectivity Gradient 1", + "unit": "score", + "cmap": "diverging", + "group": "gradients", + }, + "fc_gradient2": { + "filename": "conte69_32k_fc_gradient1.csv", + "label": "Functional Connectivity Gradient 2", + "unit": "score", + "cmap": "diverging", + "group": "gradients", + }, + "mpc_gradient1": { + "filename": "conte69_32k_mpc_gradient0.csv", + "label": "MPC Gradient 1", + "unit": "score", + "cmap": "diverging", + "group": "gradients", + }, + "mpc_gradient2": { + "filename": "conte69_32k_mpc_gradient1.csv", + "label": "MPC Gradient 2", + "unit": "score", + "cmap": "diverging", + "group": "gradients", + }, +} + + +@dataclass(frozen=True) +class ThreemicaSurfaceMap: + """Surface map specification for threemica export.""" + + tag: str + label: str + values: np.ndarray + unit: str = "" + cmap: str = "auto" + scale: float = 1.0 + + +def available_threemica_surface_maps(): + """Return BrainSpace bundled surface maps available for threemica export. + + Returns + ------- + dict + Mapping from map tag to metadata. Tags can be passed to + :func:`write_brainspace_threemica_report` via ``maps=[...]``. + """ + return { + tag: { + "label": spec["label"], + "unit": spec["unit"], + "cmap": spec["cmap"], + "group": spec["group"], + } + for tag, spec in _BRAINSPACE_MAPS.items() + } + + +def _datasets_root(): + return Path(__file__).resolve().parents[1] / "datasets" + + +def _select_builtin_maps(selection): + if selection is None or selection == "all": + return list(_BRAINSPACE_MAPS) + if isinstance(selection, str): + if selection in {"markers", "gradients"}: + return [ + tag for tag, spec in _BRAINSPACE_MAPS.items() + if spec["group"] == selection + ] + selection = [selection] + + unknown = [tag for tag in selection if tag not in _BRAINSPACE_MAPS] + if unknown: + raise ValueError( + "Unknown BrainSpace threemica map(s): {}. Available maps are: {}.".format( + ", ".join(unknown), + ", ".join(sorted(_BRAINSPACE_MAPS)), + ) + ) + return list(selection) + + +def _builtin_surface_map(tag): + spec = _BRAINSPACE_MAPS[tag] + path = _datasets_root() / "matrices" / "main_group" / spec["filename"] + values = np.loadtxt(path, dtype=np.float32) + return ThreemicaSurfaceMap( + tag=tag, + label=spec["label"], + values=values, + unit=spec["unit"], + cmap=spec["cmap"], + ) + + +def _safe_tag(value): + tag = "".join(ch.lower() if ch.isalnum() else "_" for ch in str(value)) + tag = "_".join(part for part in tag.split("_") if part) + return tag or "map" + + +def _custom_surface_maps(custom_maps): + if not custom_maps: + return [] + + out = [] + for name, spec in custom_maps.items(): + if isinstance(spec, dict): + values = spec["values"] + label = spec.get("label", name) + unit = spec.get("unit", "") + cmap = spec.get("cmap", "auto") + scale = float(spec.get("scale", 1.0)) + else: + values = spec + label = name + unit = "" + cmap = "auto" + scale = 1.0 + + arr = np.asarray(values, dtype=np.float32) + if arr.ndim == 1: + out.append(ThreemicaSurfaceMap(_safe_tag(name), str(label), arr, unit, cmap, scale)) + continue + if arr.ndim == 2: + if arr.shape[0] < arr.shape[1]: + arr = arr.T + for i in range(arr.shape[1]): + out.append(ThreemicaSurfaceMap( + "{}_{}".format(_safe_tag(name), i + 1), + "{} {}".format(label, i + 1), + arr[:, i], + unit, + cmap, + scale, + )) + continue + raise ValueError("Custom map {!r} must be one- or two-dimensional.".format(name)) + return out + + +def _write_func_gii(path, values): + import nibabel as nib + from nibabel.gifti import GiftiDataArray, GiftiImage + + img = GiftiImage(darrays=[ + GiftiDataArray(np.asarray(values, dtype=np.float32), intent="NIFTI_INTENT_SHAPE") + ]) + nib.save(img, str(path)) + + +def _surface_arrays(surf): + if isinstance(surf, dict): + return ( + np.asarray(surf["points"], dtype=np.float32), + np.asarray(surf["cells"], dtype=np.int32), + ) + return ( + np.asarray(surf.Points, dtype=np.float32), + np.asarray(surf.GetCells2D(), dtype=np.int32), + ) + + +def _write_surface_gii(path, surf): + import nibabel as nib + from nibabel.gifti import GiftiDataArray, GiftiImage + + points, cells = _surface_arrays(surf) + img = GiftiImage(darrays=[ + GiftiDataArray(points, intent="NIFTI_INTENT_POINTSET"), + GiftiDataArray(cells, intent="NIFTI_INTENT_TRIANGLE"), + ]) + nib.save(img, str(path)) + + +def _prepare_surface_files(surfaces, surf_dir, subject, resolution): + if resolution != "fsLR-32k": + raise ValueError("BrainSpace bundled threemica export currently supports resolution='fsLR-32k'.") + + if surfaces is None: + root = _datasets_root() / "surfaces" + sources = { + "L": root / "conte69_32k_lh.gii", + "R": root / "conte69_32k_rh.gii", + } + n_lh = n_rh = 32492 + for hemi, src in sources.items(): + dst = surf_dir / "{}_hemi-{}_surf-{}_label-midthickness.surf.gii".format( + subject, hemi, resolution + ) + shutil.copy2(src, dst) + return n_lh, n_rh + + if isinstance(surfaces, dict): + surf_lh, surf_rh = surfaces["lh"], surfaces["rh"] + else: + surf_lh, surf_rh = surfaces + + for hemi, surf in [("L", surf_lh), ("R", surf_rh)]: + dst = surf_dir / "{}_hemi-{}_surf-{}_label-midthickness.surf.gii".format( + subject, hemi, resolution + ) + _write_surface_gii(dst, surf) + return _surface_arrays(surf_lh)[0].shape[0], _surface_arrays(surf_rh)[0].shape[0] + + +def write_brainspace_threemica_report(output_root, *, maps="all", + custom_maps=None, surfaces=None, + subject="sub-brainspaceref", + resolution="fsLR-32k", + smooth_mm=None, + clean_input=True): + """Export BrainSpace surface maps through threemica's original viewer. + + Parameters + ---------- + output_root : str or path-like + Directory where threemica writes ``derivatives/threemica``. + maps : {'all', 'markers', 'gradients'} or sequence of str, optional + BrainSpace bundled maps to include. Available tags are returned by + :func:`available_threemica_surface_maps`. + custom_maps : dict, optional + Additional surface maps. Values can be arrays, or dictionaries with + ``values``, ``label``, ``unit``, ``cmap`` and ``scale`` keys. Two- + dimensional arrays are exported column-wise. + surfaces : pair or dict, optional + Left/right surfaces. If omitted, BrainSpace's bundled Conte69 32k + surfaces are used. + subject : str, optional + Subject ID used in the temporary BIDS derivative. The default is a + synthetic reference label for BrainSpace's bundled Conte69 maps; pass a + real ``sub-*`` ID when exporting participant-specific maps. + resolution : {'fsLR-32k'}, optional + threemica resolution label. + smooth_mm : int, optional + Smoothing FWHM passed through to threemica. + clean_input : bool, optional + Remove the temporary input BIDS tree after the self-contained HTML is + written. + + Returns + ------- + list[pathlib.Path] + Paths returned by threemica. + """ + output_root = Path(output_root).resolve() + input_root = output_root / "_brainspace_threemica_input" + derivative = "brainspace" + base = input_root / "derivatives" / derivative / subject + surf_dir = base / "surf" + map_dir = base / "maps" + + if input_root.exists(): + shutil.rmtree(input_root) + surf_dir.mkdir(parents=True, exist_ok=True) + map_dir.mkdir(parents=True, exist_ok=True) + + n_lh, n_rh = _prepare_surface_files(surfaces, surf_dir, subject, resolution) + selected = [_builtin_surface_map(tag) for tag in _select_builtin_maps(maps)] + selected.extend(_custom_surface_maps(custom_maps)) + if not selected: + raise ValueError("No maps selected for threemica export.") + + scope_maps = [] + for item in selected: + values = np.asarray(item.values, dtype=np.float32).ravel() + if values.size != n_lh + n_rh: + raise ValueError( + "Map {!r} has {} values; expected {} ({} left + {} right).".format( + item.tag, values.size, n_lh + n_rh, n_lh, n_rh + ) + ) + for hemi, hemi_values in [("L", values[:n_lh]), ("R", values[n_lh:])]: + path = map_dir / "{}_hemi-{}_surf-{}_label-{}.func.gii".format( + subject, hemi, resolution, item.tag + ) + _write_func_gii(path, hemi_values) + scope_maps.append({ + "tag": item.tag, + "label": item.label, + "unit": item.unit, + "cmap": item.cmap, + "scale": item.scale, + }) + + scope = { + "surface": { + "derivative": derivative, + "subdir": "surf", + "label": "midthickness", + }, + derivative: {"maps": scope_maps}, + } + + try: + return write_threemica_report( + bids_root=input_root, + subjects=[subject], + maps=[m.tag for m in selected], + resolution=resolution, + output_root=output_root, + smooth_mm=smooth_mm, + interactive=False, + scope=scope, + ) + finally: + if clean_input: + shutil.rmtree(input_root, ignore_errors=True) + + +def write_threemica_report(bids_root=None, *, subjects=None, sessions=None, + maps=None, resolution=None, output_root=None, + smooth_mm=None, interactive=False, scope=None): + """Write threemica reports without modifying the threemica viewer. + + This is a thin optional-dependency adapter. BrainSpace passes arguments to + :func:`threemica.run` and leaves threemica's HTML template, JavaScript + runtime, atlas/query payload, controls, and output layout unchanged. + + Parameters + ---------- + bids_root : str or path-like, optional + BIDS root containing ``derivatives/``. If omitted, threemica resolves + from the current working directory. + subjects : sequence of str, optional + Subject IDs, for example ``["sub-001"]``. Required when + ``interactive=False``. + sessions : sequence of str, optional + Session IDs to include. + maps : sequence of str, optional + threemica scope tags to render. Required when ``interactive=False``. + resolution : str or sequence of str, optional + threemica-supported resolution, for example ``"fsLR-32k"``. Required + when ``interactive=False``. + output_root : str or path-like, optional + Root under which threemica writes ``derivatives/threemica``. + smooth_mm : int, optional + Optional smoothing FWHM passed through to threemica. + interactive : bool, optional + Whether threemica should prompt for missing choices. + scope : dict, optional + Normalized threemica scope dictionary. If omitted, threemica loads or + creates ``derivatives/threemica/threemica_scope.json``. + + Returns + ------- + list[pathlib.Path] + Paths returned by :func:`threemica.run`. + """ + try: + from threemica import run + except ImportError as exc: + raise ImportError( + "write_threemica_report requires the optional 'threemica' package. " + "Install threemica to use Zhengchen Cai's original viewer." + ) from exc + + out = run( + bids_root=bids_root, + subjects=list(subjects) if subjects is not None else None, + sessions=list(sessions) if sessions is not None else None, + maps=list(maps) if maps is not None else None, + resolution=resolution, + output_root=Path(output_root) if output_root is not None else None, + smooth_mm=smooth_mm, + interactive=interactive, + scope=scope, + ) + return [Path(p) for p in out] diff --git a/brainspace/tests/test_surface_report.py b/brainspace/tests/test_surface_report.py new file mode 100644 index 00000000..3f66916c --- /dev/null +++ b/brainspace/tests/test_surface_report.py @@ -0,0 +1,147 @@ +"""Tests for display-independent HTML surface reports.""" + +import json +import re +import subprocess +import sys + +import numpy as np + +from brainspace.mesh.mesh_creation import build_polydata +from brainspace.plotting import write_surface_report + + +def _surfaces(): + points_lh = np.array([ + [-1.0, 0.0, 0.0], + [-0.2, 0.0, 0.0], + [-0.6, 0.7, 0.0], + ]) + points_rh = np.array([ + [0.2, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.6, 0.7, 0.0], + ]) + cells = np.array([[0, 1, 2]]) + return build_polydata(points_lh, cells), build_polydata(points_rh, cells) + + +def _payload(path): + text = path.read_text(encoding="utf-8") + match = re.search( + r'', + text, + ) + assert match is not None + return json.loads(match.group(1)) + + +def test_write_surface_report_embeds_maps(tmp_path): + surf_lh, surf_rh = _surfaces() + values = np.array([0.0, 1.0, np.nan, 2.0, 3.0, 4.0]) + out = write_surface_report( + {"lh": surf_lh, "rh": surf_rh}, + {"thickness": values}, + tmp_path / "report.html", + color_range="sym", + nan_color=(0.5, 0.5, 0.5, 1), + ) + + payload = _payload(out) + assert payload["title"] == "BrainSpace Surface Report" + assert payload["maps"][0]["name"] == "thickness" + assert payload["maps"][0]["range"] == [-4.0, 4.0] + assert payload["maps"][0]["values"]["lh"] == [0.0, 1.0, None] + assert payload["nanColor"] == [128, 128, 128, 255] + assert payload["surfaces"]["lh"]["cells"] == [[0, 1, 2]] + + +def test_write_surface_report_embeds_map_metadata(tmp_path): + surf_lh, surf_rh = _surfaces() + out = write_surface_report( + {"lh": surf_lh, "rh": surf_rh}, + { + "Cortical thickness": { + "values": np.arange(6, dtype=float), + "unit": "mm", + "description": "Real-valued marker", + "cmap": "viridis", + "color_range": (0, 5), + } + }, + tmp_path / "report.html", + ) + + payload = _payload(out) + assert payload["maps"][0]["name"] == "Cortical thickness" + assert payload["maps"][0]["unit"] == "mm" + assert payload["maps"][0]["description"] == "Real-valued marker" + assert payload["maps"][0]["range"] == [0.0, 5.0] + + +def test_plot_hemispheres_html_backend(tmp_path): + from brainspace.plotting import plot_hemispheres + + surf_lh, surf_rh = _surfaces() + out = tmp_path / "plot.html" + result = plot_hemispheres( + surf_lh, + surf_rh, + np.arange(6, dtype=float), + backend="html", + filename=out, + title="Demo", + ) + + assert result == out + payload = _payload(out) + assert payload["title"] == "Demo" + assert payload["maps"][0]["values"]["rh"] == [3.0, 4.0, 5.0] + + +def test_report_import_does_not_load_vtk_renderer(): + code = ( + "import sys; " + "from brainspace.plotting import write_surface_report; " + "print('vtk' in sys.modules); " + "print('brainspace.plotting.base' in sys.modules)" + ) + proc = subprocess.run( + [sys.executable, "-c", code], + check=True, + capture_output=True, + text=True, + ) + assert proc.stdout.splitlines() == ["False", "False"] + + +def test_plain_dict_report_generation_does_not_load_vtk_renderer(tmp_path): + out = tmp_path / "plain.html" + code = """ +import sys +from pathlib import Path + +import numpy as np + +from brainspace.plotting import write_surface_report + +lh = { + "points": np.array([[-1.0, 0.0, 0.0], [-0.2, 0.0, 0.0], [-0.6, 0.7, 0.0]]), + "cells": np.array([[0, 1, 2]]), +} +rh = { + "points": np.array([[0.2, 0.0, 0.0], [1.0, 0.0, 0.0], [0.6, 0.7, 0.0]]), + "cells": np.array([[0, 1, 2]]), +} +write_surface_report({"lh": lh, "rh": rh}, np.arange(6, dtype=float), Path(%r)) +print(Path(%r).exists()) +print("vtk" in sys.modules) +print("brainspace.plotting.base" in sys.modules) +""" % (str(out), str(out)) + proc = subprocess.run( + [sys.executable, "-c", code], + check=True, + capture_output=True, + text=True, + ) + assert proc.stdout.splitlines() == ["True", "False", "False"] diff --git a/brainspace/tests/test_threemica_report.py b/brainspace/tests/test_threemica_report.py new file mode 100644 index 00000000..ddbdb986 --- /dev/null +++ b/brainspace/tests/test_threemica_report.py @@ -0,0 +1,136 @@ +"""Tests for the optional threemica bridge.""" + +import subprocess +import sys +import types + +import numpy as np + +import brainspace.plotting.threemica_report as threemica_report +from brainspace.plotting.threemica_report import ( + available_threemica_surface_maps, + write_brainspace_threemica_report, + write_threemica_report, +) + + +def test_available_threemica_surface_maps_lists_builtin_choices(): + maps = available_threemica_surface_maps() + assert "thickness" in maps + assert "fc_gradient1" in maps + assert "mpc_gradient2" in maps + assert maps["thickness"]["group"] == "markers" + assert maps["fc_gradient1"]["group"] == "gradients" + + +def test_write_threemica_report_delegates(monkeypatch, tmp_path): + calls = {} + + def run(**kwargs): + calls.update(kwargs) + return [tmp_path / "report.html"] + + fake = types.SimpleNamespace(run=run) + monkeypatch.setitem(sys.modules, "threemica", fake) + + out = write_threemica_report( + bids_root=tmp_path / "bids", + subjects=["sub-001"], + sessions=["ses-01"], + maps=["thickness"], + resolution="fsLR-32k", + output_root=tmp_path / "out", + smooth_mm=5, + scope={"surface": {}}, + ) + + assert out == [tmp_path / "report.html"] + assert calls["subjects"] == ["sub-001"] + assert calls["sessions"] == ["ses-01"] + assert calls["maps"] == ["thickness"] + assert calls["resolution"] == "fsLR-32k" + assert calls["output_root"] == tmp_path / "out" + assert calls["smooth_mm"] == 5 + assert calls["interactive"] is False + assert calls["scope"] == {"surface": {}} + + +def test_write_brainspace_threemica_report_selects_builtin_maps(monkeypatch, tmp_path): + calls = {} + + def fake_write(**kwargs): + calls.update(kwargs) + return [kwargs["output_root"] / "derivatives" / "threemica" / "report.html"] + + monkeypatch.setattr(threemica_report, "write_threemica_report", fake_write) + + out = write_brainspace_threemica_report( + tmp_path / "out", + maps=["thickness", "fc_gradient1"], + clean_input=False, + ) + + assert out == [tmp_path / "out" / "derivatives" / "threemica" / "report.html"] + assert calls["subjects"] == ["sub-brainspaceref"] + assert calls["maps"] == ["thickness", "fc_gradient1"] + assert calls["resolution"] == "fsLR-32k" + assert calls["interactive"] is False + assert [m["tag"] for m in calls["scope"]["brainspace"]["maps"]] == [ + "thickness", + "fc_gradient1", + ] + assert (calls["bids_root"] / "derivatives" / "brainspace" / + "sub-brainspaceref" / "surf" / + "sub-brainspaceref_hemi-L_surf-fsLR-32k_label-midthickness.surf.gii").exists() + assert (calls["bids_root"] / "derivatives" / "brainspace" / + "sub-brainspaceref" / "maps" / + "sub-brainspaceref_hemi-R_surf-fsLR-32k_label-fc_gradient1.func.gii").exists() + + +def test_write_brainspace_threemica_report_expands_custom_components(monkeypatch, tmp_path): + calls = {} + + def fake_write(**kwargs): + calls.update(kwargs) + return [kwargs["output_root"] / "report.html"] + + monkeypatch.setattr(threemica_report, "write_threemica_report", fake_write) + values = np.column_stack([ + np.linspace(-1, 1, 64984), + np.linspace(1, -1, 64984), + ]) + + write_brainspace_threemica_report( + tmp_path / "out", + maps=[], + custom_maps={ + "aligned_gradient": { + "values": values, + "label": "Aligned Gradient", + "unit": "score", + "cmap": "diverging", + } + }, + clean_input=False, + ) + + assert calls["maps"] == ["aligned_gradient_1", "aligned_gradient_2"] + scope_maps = calls["scope"]["brainspace"]["maps"] + assert [m["label"] for m in scope_maps] == ["Aligned Gradient 1", "Aligned Gradient 2"] + assert all(m["unit"] == "score" for m in scope_maps) + + +def test_threemica_report_import_does_not_load_vtk_renderer(): + code = ( + "import sys; " + "from brainspace.plotting import write_brainspace_threemica_report, write_threemica_report; " + "print('vtk' in sys.modules); " + "print('brainspace.plotting.base' in sys.modules)" + ) + proc = subprocess.run( + [sys.executable, "-c", code], + check=True, + capture_output=True, + text=True, + ) + assert proc.stdout.splitlines() == ["False", "False"] diff --git a/docs/_static/brainspace_threemica_bridge_preview.mp4 b/docs/_static/brainspace_threemica_bridge_preview.mp4 new file mode 100644 index 00000000..dfcd12fe Binary files /dev/null and b/docs/_static/brainspace_threemica_bridge_preview.mp4 differ diff --git a/docs/conf.py b/docs/conf.py index ec5eb296..30e7812a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,6 +21,13 @@ from brainspace.plotting.sphinx_gallery_scrapper import _get_sg_image_scraper +try: + from sphinx.application import Sphinx + if not hasattr(Sphinx, 'add_javascript'): + Sphinx.add_javascript = Sphinx.add_js_file +except ImportError: + pass + # -- Project information ----------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index 182a93f2..54f8a3f3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,6 +40,7 @@ null models. pages/install pages/getting_started + pages/html_surface_reports python_doc/index_python pages/matlab_doc/index pages/references diff --git a/docs/pages/html_surface_reports.rst b/docs/pages/html_surface_reports.rst new file mode 100644 index 00000000..2833c0bd --- /dev/null +++ b/docs/pages/html_surface_reports.rst @@ -0,0 +1,157 @@ +.. _html_surface_reports: + +HTML surface reports +==================== + +BrainSpace's existing plotting functions use VTK and remain the default path +for local interactive rendering, screenshots, and advanced VTK-based +visualization. For remote notebooks, containers, CI runners, and other +headless environments, BrainSpace also provides display-independent HTML +surface report writers that do not require a live VTK render window. + +This path is intended for report export and result inspection: write the +surface geometry and scalar maps once, then open the resulting HTML file in a +browser. + + +Use ``plot_hemispheres`` with the HTML backend +---------------------------------------------- + +The smallest change for existing code is to keep using +:func:`brainspace.plotting.plot_hemispheres` and select the ``html`` backend: + +.. code-block:: python + + from brainspace.datasets import load_conte69 + from brainspace.plotting import plot_hemispheres + + surf_lh, surf_rh = load_conte69() + + report = plot_hemispheres( + surf_lh, + surf_rh, + array_name=my_surface_values, + backend="html", + filename="surface_report.html", + title="My surface map", + cmap="viridis", + color_bar=True, + ) + +``backend="vtk"`` remains the default. The HTML backend writes a browser +report to ``filename`` and returns the output path. It is useful when the goal +is to export a portable report rather than create a VTK window or screenshot. + + +Write reports directly +---------------------- + +For scripts and reporting pipelines, call +:func:`brainspace.plotting.write_surface_report` directly: + +.. code-block:: python + + from brainspace.plotting import write_surface_report + + report = write_surface_report( + surfaces={"lh": surf_lh, "rh": surf_rh}, + arrays={ + "Gradient 1": gradient_1, + "Gradient 2": { + "values": gradient_2, + "color_range": "sym", + "cmap": "viridis", + }, + }, + filename="gradients.html", + title="Gradient report", + metadata={"Surface": "Conte69", "Space": "fsLR"}, + ) + +``surfaces`` can be a ``{"lh": surf_lh, "rh": surf_rh}`` dictionary or a +two-item left/right sequence. Map values can be a combined vector ordered as +left hemisphere followed by right hemisphere, or a dictionary with separate +``"lh"`` and ``"rh"`` arrays. + + +Use the threemica viewer as an optional bridge +---------------------------------------------- + +BrainSpace can also call Zhengchen Cai's ``threemica`` package as an optional +dependency. This preserves threemica's original HTML template, JavaScript +viewer, atlas/query payloads, controls, and output layout. BrainSpace only +prepares compatible inputs and delegates report writing. + +Install ``threemica`` separately, then call: + +.. code-block:: python + + from brainspace.plotting import ( + available_threemica_surface_maps, + write_brainspace_threemica_report, + ) + + print(available_threemica_surface_maps()) + + reports = write_brainspace_threemica_report( + "brainspace_threemica_report", + maps="all", + ) + +This example uses BrainSpace's bundled Conte69/fsLR-32k reference surfaces and +maps. The default ``subject="sub-brainspaceref"`` is only a synthetic label in +the temporary BIDS-style derivative; pass a real ``sub-*`` ID when exporting +participant-specific maps. + +Built-in map selections are: + +``"all"`` + Include the bundled cortical marker maps and gradients. + +``"markers"`` + Include curvature, cortical thickness, and T1w/T2w myelin proxy maps. + +``"gradients"`` + Include the bundled functional connectivity and MPC gradients. + +You can also pass explicit tags or additional arrays: + +.. code-block:: python + + write_brainspace_threemica_report( + "subject_report", + maps=["thickness", "fc_gradient1"], + custom_maps={ + "aligned_gradient": { + "values": aligned_gradients, + "label": "Aligned gradient", + "unit": "score", + "cmap": "diverging", + } + }, + ) + +The custom map array can be one-dimensional, or two-dimensional with one map +per component. BrainSpace writes a temporary BIDS-style derivatives tree for +threemica and removes it after report generation unless ``clean_input=False`` +is passed. + +The lower-level :func:`brainspace.plotting.write_threemica_report` function is +available when users already have threemica-compatible BIDS derivatives and a +scope definition. + + +Choosing a rendering path +------------------------- + +Use the VTK backend when you need BrainSpace's full local plotting behavior, +VTK actor customization, screenshots, or notebook embedding through the +existing plotting stack. + +Use HTML reports when you need portable browser inspection, documentation +outputs, remote notebook exports, or a workflow that must not depend on an X +server, Xvfb, OSMesa, or EGL. + +``threemica`` is an optional external package. BrainSpace does not vendor or +modify threemica; it only provides an adapter so users can call the original +viewer from BrainSpace-oriented workflows. diff --git a/docs/pages/install.rst b/docs/pages/install.rst index 7aac6b14..c85129a1 100644 --- a/docs/pages/install.rst +++ b/docs/pages/install.rst @@ -85,6 +85,18 @@ If you prefer ``conda``, equivalent OSMesa builds are published on After installing, set ``embed_nb=True`` (Jupyter) or pass ``offscreen=True`` to the plotting functions. +For report export on headless systems, BrainSpace also provides an HTML +surface-report path that does not initialize a VTK render window. Use +``plot_hemispheres(..., backend="html", filename="report.html")`` or +``brainspace.plotting.write_surface_report`` when the goal is to save a +portable browser report rather than render through VTK. See +:ref:`html_surface_reports` for details. + +Users who want to preserve Zhengchen Cai's original threemica viewer can +install ``threemica`` as an optional dependency and call +``brainspace.plotting.write_threemica_report`` or +``brainspace.plotting.write_brainspace_threemica_report``. + MATLAB installation diff --git a/docs/python_doc/api_doc/brainspace.plotting.rst b/docs/python_doc/api_doc/brainspace.plotting.rst index 67731fb9..2dc70f51 100644 --- a/docs/python_doc/api_doc/brainspace.plotting.rst +++ b/docs/python_doc/api_doc/brainspace.plotting.rst @@ -22,6 +22,21 @@ Plotting of brain surfaces plot_surf build_plotter +.. _pymod-plotting-html-reports: + +HTML surface reports +-------------------- + +.. currentmodule:: brainspace.plotting + +.. autosummary:: + :toctree: ../../generated/ + + write_surface_report + available_threemica_surface_maps + write_brainspace_threemica_report + write_threemica_report + .. _pymod-plotting-generic: Generic plotting