diff --git a/tests/test_smoke.py b/tests/test_smoke.py index fdd744e..505e905 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,4 +1,5 @@ import pytest +import numpy as np import yabplot as yab import pyvista as pv @@ -35,4 +36,14 @@ def test_plot_tracts(): """ Integration test: Downloads 'xtract_tiny' and plots it. """ - yab.plot_tracts(atlas='xtract_tiny', display_type=None) \ No newline at end of file + yab.plot_tracts(atlas='xtract_tiny', display_type=None) + +def test_plot_vertexwise(): + """ + Integration test: plot_vertexwise with synthetic sphere meshes. + """ + lh = pv.Sphere() + rh = pv.Sphere() + lh['Data'] = np.random.rand(lh.n_points) + rh['Data'] = np.random.rand(rh.n_points) + yab.plot_vertexwise(lh, rh, display_type=None) \ No newline at end of file diff --git a/yabplot/__init__.py b/yabplot/__init__.py index 2e81c76..f738989 100644 --- a/yabplot/__init__.py +++ b/yabplot/__init__.py @@ -1,6 +1,6 @@ from importlib.metadata import version, PackageNotFoundError -from .plotting import plot_cortical, plot_subcortical, plot_tracts, clear_tract_cache +from .plotting import plot_cortical, plot_subcortical, plot_tracts, clear_tract_cache, plot_vertexwise from .data import get_available_resources, get_atlas_regions, get_surface_paths from .atlas_builder import build_cortical_atlas, build_subcortical_atlas diff --git a/yabplot/plotting.py b/yabplot/plotting.py index 0d8a7c2..ece8e74 100644 --- a/yabplot/plotting.py +++ b/yabplot/plotting.py @@ -190,6 +190,142 @@ def plot_cortical(data=None, atlas=None, custom_atlas_path=None, views=None, lay return finalize_plot(plotter, export_path, display_type) +# --- plot for arbitrary per-vertex data --- + +def plot_vertexwise(lh, rh, views=None, layout=None, figsize=(1000, 600), + cmap='coolwarm', vminmax=[None, None], + nan_color=(1.0, 1.0, 1.0), style='default', zoom=1.2, + proc_vertices=None, display_type='static', export_path=None): + """ + Visualize arbitrary per-vertex scalar data on a user-supplied brain mesh. + + Unlike `plot_cortical`, this function requires no atlas. The user provides + PyVista PolyData meshes (e.g. from `make_cortical_mesh`) with per-vertex + scalar data stored under the key ``'Data'``. + + Parameters + ---------- + lh : pyvista.PolyData + Left hemisphere mesh with ``mesh['Data']`` as a (N,) float array. + rh : pyvista.PolyData + Right hemisphere mesh with ``mesh['Data']`` as a (N,) float array. + views : list of str, optional + Can be a list of presets ('left_lateral', 'right_medial', etc.) + or a dictionary of camera configurations. Defaults to all views. + layout : tuple (rows, cols), optional + Grid layout for subplots. If None, auto-calculated. + figsize : tuple (width, height), optional + Window size in pixels. Default is (1000, 600). + cmap : str or matplotlib.colors.Colormap, optional + Colormap. Default is 'coolwarm'. + vminmax : list [min, max], optional + Colormap bounds. If [None, None], inferred from data range. + nan_color : tuple or str, optional + Color for NaN vertices. Default is white. + style : str, optional + Lighting preset ('default', 'matte', 'glossy', 'sculpted', 'flat'). + zoom : float, optional + Camera zoom level. Default is 1.2. + proc_vertices : str or None, optional + Vertex processing mode: None, 'blur', or 'sharp'. + display_type : {'static', 'interactive', 'none'}, optional + Rendering mode. + export_path : str, optional + If provided, saves the figure to this path. + + Returns + ------- + pyvista.Plotter + The plotter instance used for rendering. + + See Also + -------- + yabplot.utils.load_vertexwise_mesh + + Examples + -------- + >>> from yabplot.utils import load_vertexwise_mesh + >>> lh, rh = load_vertexwise_mesh( + ... fsaverage.pial_left, fsaverage.pial_right, + ... d_values_lh, d_values_rh + ... ) + >>> plot_vertexwise(lh, rh, views=['left_lateral', 'right_lateral']) + """ + # extract v, f, raw from PyVista meshes + lh_v = lh.points + lh_f = lh.faces.reshape(-1, 4)[:, 1:] + lh_vals_raw = lh['Data'] + + rh_v = rh.points + rh_f = rh.faces.reshape(-1, 4)[:, 1:] + rh_vals_raw = rh['Data'] + + # compute vmin/vmax across both hemispheres + all_vals = np.concatenate([lh_vals_raw, rh_vals_raw]) + vmin = vminmax[0] if vminmax[0] is not None else np.nanmin(all_vals) + vmax = vminmax[1] if vminmax[1] is not None else np.nanmax(all_vals) + + # vertices processing + results = [] + for v, f, raw in [(lh_v, lh_f, lh_vals_raw), (rh_v, rh_f, rh_vals_raw)]: + if proc_vertices == 'sharp': + base, pieces = get_puzzle_pieces(v, f, raw) + results.append((base, pieces)) + else: + v_proc = apply_internal_blur(f, raw, iterations=3, weight=0.3) if proc_vertices == 'blur' else raw + dilated = apply_dilation(f, v_proc, iterations=4) + o_guide = get_smooth_mask(f, np.where(np.isnan(raw), 0.0, 1.0), iterations=4) + + mesh = make_cortical_mesh(v, f, dilated) + mesh['Slice_Mask'] = o_guide + data_p = mesh.clip_scalar(scalars='Slice_Mask', value=0.5, invert=False) + base_p = mesh.clip_scalar(scalars='Slice_Mask', value=0.5, invert=True) + if base_p.n_points > 0: base_p['Data'] = np.full(base_p.n_points, np.nan) + results.append((base_p, [data_p])) + (lh_base, lh_parts), (rh_base, rh_parts) = results + + # plotter setup + sel_views = get_view_configs(views) + plotter, ncols, nrows = setup_plotter(sel_views, layout, figsize, display_type) + shading_params = get_shading_preset(style) + scalar_bar_mapper = None + + for i, (name, cfg) in enumerate(sel_views.items()): + plotter.subplot(i // ncols, i % ncols) + + view_bases = [] + view_pieces = [] + if cfg['side'] in ['L', 'both']: + if lh_base.n_points > 0: view_bases.append(lh_base) + view_pieces.extend(lh_parts) + if cfg['side'] in ['R', 'both']: + if rh_base.n_points > 0: view_bases.append(rh_base) + view_pieces.extend(rh_parts) + + for b_mesh in view_bases: + plotter.add_mesh(b_mesh, color=nan_color, smooth_shading=True, **shading_params) + + for p_mesh in view_pieces: + if p_mesh.n_points == 0: continue + interp = (proc_vertices == 'blur') + + actor = plotter.add_mesh( + p_mesh, scalars='Data', cmap=cmap, clim=(vmin, vmax), + n_colors=256, nan_color=nan_color, show_scalar_bar=False, + smooth_shading=True, interpolate_before_map=interp, **shading_params + ) + if scalar_bar_mapper is None: scalar_bar_mapper = actor.mapper + + set_camera(plotter, cfg, zoom=zoom) + plotter.hide_axes() + + if scalar_bar_mapper: + plotter.subplot(nrows - 1, 0) + plotter.add_scalar_bar(mapper=scalar_bar_mapper, vertical=False, position_x=0.3, position_y=0.25, height=0.5, width=0.4) + + return finalize_plot(plotter, export_path, display_type) + + # --- plot for subcortical structures --- def plot_subcortical(data=None, atlas=None, custom_atlas_path=None, views=None, layout=None, diff --git a/yabplot/utils.py b/yabplot/utils.py index 27d2feb..8bcc7d5 100644 --- a/yabplot/utils.py +++ b/yabplot/utils.py @@ -40,6 +40,12 @@ def load_gii2pv(gii_path, smooth_i=0, smooth_f=0.1): return mesh +def load_vertexwise_mesh(mesh_lh, mesh_rh, data_lh, data_rh): + """Helper to load arbitrary user-supplied mesh (e.g., fsaverage5).""" + lh = make_cortical_mesh(*load_gii(mesh_lh), data_lh) + rh = make_cortical_mesh(*load_gii(mesh_rh), data_rh) + return lh, rh + def make_cortical_mesh(verts, faces, scalars): """Helper to create a PyVista mesh from raw buffers.""" faces_pv = np.hstack([np.full((faces.shape[0], 1), 3), faces]).flatten().astype(int)