From 0e4429df876b6f5ce9bcc890e2e1f0db00d3a933 Mon Sep 17 00:00:00 2001 From: Ali Khan Date: Sun, 1 Dec 2024 12:37:02 -0500 Subject: [PATCH] added docs and testing --- .github/workflows/testing.yml | 29 ++++ hippunfold_plot/plotting.py | 97 +++++++++---- hippunfold_plot/tests/test_plotting.py | 39 ++++++ hippunfold_plot/utils.py | 181 ++++++++++++++++++++----- 4 files changed, 291 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/testing.yml create mode 100644 hippunfold_plot/tests/test_plotting.py diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..47fbb10 --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,29 @@ +name: CI + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' # Specify the Python version you want to use + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install . + + - name: Run tests + run: | + python -m unittest discover -s . -p "test_*.py" + + - name: Run doctests + run: | + python -m doctest utils.py \ No newline at end of file diff --git a/hippunfold_plot/plotting.py b/hippunfold_plot/plotting.py index f710295..75ea4d3 100644 --- a/hippunfold_plot/plotting.py +++ b/hippunfold_plot/plotting.py @@ -2,37 +2,88 @@ from nilearn.plotting import plot_surf from hippunfold_plot.utils import get_surf_limits, get_data_limits, get_resource_path, check_surf_map_is_label_gii, get_legend_elements_from_label_gii - - -def plot_hipp_surf(surf_map, density='0p5mm', hemi='left',space=None,figsize=(12,8), dpi=300, vmin=None, vmax=None,colorbar=False, colorbar_shrink=0.25,cmap=None,view='dorsal',avg_method='median',bg_on_data=True,alpha=0.1,darkness=2,**kwargs): - - """ Plots surf_map, which can be a label-hippdentate shape.gii or func.gii, or - a Vx1 array (where V is number of vertices in hipp + dentate. Any arguments - that can be supplied to nilearn's plot_surf() can also be applied here. These - would override the defaults set below. - - By default this function will plot one hemisphere (left by default), in both canonical and unfold space. - (This is since nilearn's plot_surf also only plots one hemisphere at a time) - +def plot_hipp_surf(surf_map, density='0p5mm', hemi='left', space=None, figsize=(12, 8), dpi=300, vmin=None, vmax=None, colorbar=False, colorbar_shrink=0.25, cmap=None, view='dorsal', avg_method='median', bg_on_data=True, alpha=0.1, darkness=2, **kwargs): + """Plot hippocampal surface map. + + This function plots a surface map of the hippocampus, which can be a label-hippdentate shape.gii, func.gii, or a Vx1 array + (where V is the number of vertices in the hippocampus and dentate). Any arguments that can be supplied to nilearn's plot_surf() + can also be applied here, overriding the defaults set below. + + Parameters + ---------- + surf_map : str or array-like + The surface map to plot. This can be a file path to a .gii file or a Vx1 array. + density : str, optional + The density of the surface map. Can be 'unfoldiso', '0p5mm', '1mm', or '2mm'. Default is '0p5mm'. + hemi : str, optional + The hemisphere to plot. Can be 'left', 'right', or None (in which case both are plotted). Default is 'left'. + space : str, optional + The space of the surface map. Can be 'canonical', 'unfold', or None (in which case both are plotted). Default is None. + figsize : tuple, optional + The size of the figure. Default is (12, 8). + dpi : int, optional + The resolution of the figure in dots per inch. Default is 300. + vmin : float, optional + The minimum value for the color scale. Default is None. + vmax : float, optional + The maximum value for the color scale. Default is None. + colorbar : bool, optional + Whether to display a colorbar. Default is False. + colorbar_shrink : float, optional + The shrink factor for the colorbar. Default is 0.25. + cmap : str or colormap, optional + The colormap to use. Default is None. + view : str, optional + The view of the surface plot. Default is 'dorsal'. + avg_method : str, optional + The method to average the data. Default is 'median'. + bg_on_data : bool, optional + Whether to display the background on the data. Default is True. + alpha : float, optional + The alpha transparency level. Default is 0.1. + darkness : float, optional + The darkness level of the background. Default is 2. + **kwargs : dict + Additional arguments to pass to nilearn's plot_surf(). + + Returns + ------- + fig : matplotlib.figure.Figure + The figure object. + mappable : matplotlib.cm.ScalarMappable, optional + The mappable object, if return_mappable is True. + + Notes + ----- + By default, this function will plot one hemisphere (left by default) in both canonical and unfolded space. Both surfaces can be plotted with hemi=None, but the same surf_map will be plotted on both. Use return_mappable=True if you want to make a colorbar afterwards, e.g.: - fig,mappable = plot_hipp_surf(... return_mappable=True) - plt.colorbar(mappable, shrink=0.5) #shrink makes it smaller which is recommended + fig, mappable = plot_hipp_surf(..., return_mappable=True) + plt.colorbar(mappable, shrink=0.5) # shrink makes it smaller which is recommended """ + # Validate inputs + valid_densities = ['unfoldiso', '0p5mm', '1mm', '2mm'] + valid_spaces = ['canonical', 'unfold', None] + if density not in valid_densities: + raise ValueError(f"Invalid value for 'density'. Expected one of {valid_densities}.") + if hemi not in ['left', 'right', None]: + raise ValueError("Invalid value for 'hemi'. Expected 'left', 'right', or None.") + if space not in valid_spaces: + raise ValueError(f"Invalid value for 'space'. Expected one of {valid_spaces}.") + surf_gii = get_resource_path('tpl-avg_hemi-{hemi}_space-{space}_label-hippdentate_density-{density}_midthickness.surf.gii') curv_gii = get_resource_path('tpl-avg_label-hippdentate_density-{density}_curvature.shape.gii') - plot_kwargs = {'surf_map': surf_map, - 'bg_map':curv_gii.format(density=density), - 'alpha': alpha, - 'bg_on_data': bg_on_data, - 'darkness': darkness, - 'avg_method':avg_method, - 'cmap': cmap, - 'view':view} + 'bg_map': curv_gii.format(density=density), + 'alpha': alpha, + 'bg_on_data': bg_on_data, + 'darkness': darkness, + 'avg_method': avg_method, + 'cmap': cmap, + 'view': view} #add any user arguments plot_kwargs.update(kwargs) @@ -84,8 +135,6 @@ def plot_hipp_surf(surf_map, density='0p5mm', hemi='left',space=None,figsize=(12 norm = mpl.colors.Normalize(vmin=vmin if vmin else datamin, vmax=vmax if vmax else datamax) # Match your data range sm = mpl.cm.ScalarMappable(cmap=cmap, norm=norm) sm.set_array([]) # Dummy array for ScalarMappable - - plt.colorbar(sm,ax=fig.axes,shrink=colorbar_shrink) return fig diff --git a/hippunfold_plot/tests/test_plotting.py b/hippunfold_plot/tests/test_plotting.py new file mode 100644 index 0000000..1f609a0 --- /dev/null +++ b/hippunfold_plot/tests/test_plotting.py @@ -0,0 +1,39 @@ +import unittest +import matplotlib.pyplot as plt +from plotting import plot_hipp_surf + +class TestPlotHippSurf(unittest.TestCase): + + def test_invalid_density(self): + with self.assertRaises(ValueError): + plot_hipp_surf(surf_map='dummy_path', density='invalid_density') + + def test_invalid_hemi(self): + with self.assertRaises(ValueError): + plot_hipp_surf(surf_map='dummy_path', hemi='invalid_hemi') + + def test_invalid_space(self): + with self.assertRaises(ValueError): + plot_hipp_surf(surf_map='dummy_path', space='invalid_space') + + def test_plot_creation(self): + fig = plot_hipp_surf(surf_map='dummy_path') + self.assertIsInstance(fig, plt.Figure) + + def test_colorbar(self): + fig = plot_hipp_surf(surf_map='dummy_path', colorbar=True) + self.assertIsInstance(fig, plt.Figure) + self.assertEqual(len(fig.axes), 6) # 5 plots + 1 colorbar + + def test_default_parameters(self): + fig = plot_hipp_surf(surf_map='dummy_path') + self.assertIsInstance(fig, plt.Figure) + self.assertEqual(len(fig.axes), 5) # 5 plots + + def test_custom_parameters(self): + fig = plot_hipp_surf(surf_map='dummy_path', density='1mm', hemi='right', space='canonical', figsize=(10, 6), dpi=200, vmin=0, vmax=1, colorbar=True, colorbar_shrink=0.5, cmap='viridis', view='ventral', avg_method='mean', bg_on_data=False, alpha=0.5, darkness=1) + self.assertIsInstance(fig, plt.Figure) + self.assertEqual(len(fig.axes), 6) # 5 plots + 1 colorbar + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/hippunfold_plot/utils.py b/hippunfold_plot/utils.py index 2255388..b8a111b 100644 --- a/hippunfold_plot/utils.py +++ b/hippunfold_plot/utils.py @@ -2,46 +2,151 @@ from pathlib import Path import nibabel as nib import numpy as np +from typing import Union, Tuple, List def get_resource_path(file_name: str) -> str: - """Get the path to a resource file.""" - return str(resources.files("hippunfold_plot") / 'resources'/ file_name ) - -def check_surf_map_is_label_gii(surf_map): - if isinstance(surf_map,str): - if surf_map[-9:] == 'label.gii': - return True - else: - return False - -def read_pointset_from_surf_mesh(surf_mesh): - if isinstance(surf_mesh,str): - if surf_mesh[-8:] == 'surf.gii': + """Get the path to a resource file. + + Parameters + ---------- + file_name : str + The name of the resource file. + + Returns + ------- + str + The path to the resource file. + + Examples + -------- + >>> get_resource_path('example.txt') + 'path/to/resources/example.txt' + """ + return str(resources.files("hippunfold_plot") / 'resources' / file_name) + +def check_surf_map_is_label_gii(surf_map: Union[str, np.ndarray]) -> bool: + """Check if the surface map is a label GIFTI file. + + Parameters + ---------- + surf_map : str or np.ndarray + The surface map to check. + + Returns + ------- + bool + True if the surface map is a label GIFTI file, False otherwise. + + Examples + -------- + >>> check_surf_map_is_label_gii('example.label.gii') + True + >>> check_surf_map_is_label_gii('example.func.gii') + False + """ + if isinstance(surf_map, str): + return surf_map.endswith('label.gii') + return False + +def read_pointset_from_surf_mesh(surf_mesh: Union[str, Tuple[np.ndarray, np.ndarray]]) -> np.ndarray: + """Read pointset from a surface mesh. + + Parameters + ---------- + surf_mesh : str or tuple + The surface mesh to read from. Can be a file path to a .gii file or a tuple of arrays. + + Returns + ------- + np.ndarray + The pointset data. + + Examples + -------- + >>> points = read_pointset_from_surf_mesh('example.surf.gii') + >>> points.shape + (1000, 3) + """ + if isinstance(surf_mesh, str): + if surf_mesh.endswith('surf.gii'): points = nib.load(surf_mesh).get_arrays_from_intent('NIFTI_INTENT_POINTSET')[0].data - else: + else: raise TypeError("surf_mesh string not recognized as surf.gii") - elif isinstance(surf_mesh,tuple): + elif isinstance(surf_mesh, tuple): if len(surf_mesh) == 2: points = surf_mesh[0] return points -def read_data_from_surf_map(surf_map): - if isinstance(surf_map,str): - if surf_map[-4:] == '.gii': +def read_data_from_surf_map(surf_map: Union[str, np.ndarray]) -> np.ndarray: + """Read data from a surface map. + + Parameters + ---------- + surf_map : str or np.ndarray + The surface map to read from. Can be a file path to a .gii file or a numpy array. + + Returns + ------- + np.ndarray + The data from the surface map. + + Examples + -------- + >>> data = read_data_from_surf_map('example.func.gii') + >>> data.shape + (1000,) + """ + if isinstance(surf_map, str): + if surf_map.endswith('.gii'): data = nib.load(surf_map).darrays[0].data - else: - raise TypeError("surf_mesh string not recognized as metric gii") - elif isinstance(surf_map,np.ndarray): + else: + raise TypeError("surf_map string not recognized as metric gii") + elif isinstance(surf_map, np.ndarray): data = surf_map return data -def get_data_limits(surf_map): +def get_data_limits(surf_map: Union[str, np.ndarray]) -> Tuple[float, float]: + """Get the data limits from a surface map. + + Parameters + ---------- + surf_map : str or np.ndarray + The surface map to get data limits from. + + Returns + ------- + tuple + The minimum and maximum values of the data. + + Examples + -------- + >>> get_data_limits('example.func.gii') + (0.0, 1.0) + """ data = read_data_from_surf_map(surf_map) - return (data.min(),data.max()) - + return data.min(), data.max() - -def get_surf_limits(surf_mesh): +def get_surf_limits(surf_mesh: Union[str, Tuple[np.ndarray, np.ndarray]]) -> Tuple[dict, dict]: + """Get the surface limits from a surface mesh. + + Parameters + ---------- + surf_mesh : str or tuple + The surface mesh to get limits from. Can be a file path to a .gii file or a tuple of arrays. + + Returns + ------- + tuple + The x and y limits as dictionaries. + + Examples + -------- + >>> xlim, ylim = get_surf_limits('example.surf.gii') + >>> xlim + {'left': -50.0, 'right': 50.0} + >>> ylim + {'bottom': -50.0, 'top': 50.0} + """ points = read_pointset_from_surf_mesh(surf_mesh) # Calculate the ranges for each dimension @@ -63,12 +168,26 @@ def get_surf_limits(surf_mesh): ylim_kwargs = {'bottom': y_min_cropped, 'top': y_max_cropped} return xlim_kwargs, ylim_kwargs - -def get_legend_elements_from_label_gii(label_map): - """ not used yet -- this uses colors from gifti metadata, but - the matplotlib colormap isn't overrided yet, just the legend..""" - +def get_legend_elements_from_label_gii(label_map: str) -> List: + """Get legend elements from a label GIFTI file. + + Parameters + ---------- + label_map : str + The path to the label GIFTI file. + + Returns + ------- + list + A list of legend elements. + + Examples + -------- + >>> legend_elements = get_legend_elements_from_label_gii('example.label.gii') + >>> isinstance(legend_elements, list) + True + """ from matplotlib.patches import Patch # Load the GIFTI file