yabplot 0.5.0__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. yabplot-0.5.1/CITATION.cff +21 -0
  2. yabplot-0.5.1/CODE_OF_CONDUCT.md +27 -0
  3. yabplot-0.5.1/CONTRIBUTING.md +34 -0
  4. yabplot-0.5.1/MANIFEST.in +9 -0
  5. {yabplot-0.5.0/yabplot.egg-info → yabplot-0.5.1}/PKG-INFO +1 -1
  6. {yabplot-0.5.0 → yabplot-0.5.1}/pyproject.toml +6 -1
  7. yabplot-0.5.1/tests/conftest.py +51 -0
  8. yabplot-0.5.1/tests/test_data.py +41 -0
  9. yabplot-0.5.1/tests/test_mapping.py +78 -0
  10. yabplot-0.5.1/tests/test_mesh.py +24 -0
  11. yabplot-0.5.1/tests/test_plotting.py +93 -0
  12. yabplot-0.5.1/tests/test_projection.py +25 -0
  13. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/projection.py +34 -28
  14. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/utils.py +17 -15
  15. {yabplot-0.5.0 → yabplot-0.5.1/yabplot.egg-info}/PKG-INFO +1 -1
  16. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot.egg-info/SOURCES.txt +9 -1
  17. yabplot-0.5.0/MANIFEST.in +0 -4
  18. yabplot-0.5.0/tests/test_smoke.py +0 -80
  19. {yabplot-0.5.0 → yabplot-0.5.1}/LICENSE +0 -0
  20. {yabplot-0.5.0 → yabplot-0.5.1}/README.md +0 -0
  21. {yabplot-0.5.0 → yabplot-0.5.1}/setup.cfg +0 -0
  22. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/__init__.py +0 -0
  23. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/atlas_builder.py +0 -0
  24. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/data/__init__.py +0 -0
  25. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/data/registry.txt +0 -0
  26. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/mesh.py +0 -0
  27. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/plotting.py +0 -0
  28. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/scene.py +0 -0
  29. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/wrappers.py +0 -0
  30. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot.egg-info/dependency_links.txt +0 -0
  31. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot.egg-info/requires.txt +0 -0
  32. {yabplot-0.5.0 → yabplot-0.5.1}/yabplot.egg-info/top_level.txt +0 -0
@@ -0,0 +1,21 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use yabplot in your research, please cite it as below."
3
+ authors:
4
+ - family-names: "Anijärv"
5
+ given-names: "Toomas Erik"
6
+ orcid: "https://orcid.org/0000-0002-3650-4230"
7
+ title: "yabplot: yet another brain plot for unified neuroimaging visualization in Python"
8
+ doi: 10.5281/zenodo.18237144
9
+ version: 0.5.1
10
+ url: "https://github.com/teanijarv/yabplot"
11
+ preferred-citation:
12
+ type: software
13
+ authors:
14
+ - family-names: "Anijärv"
15
+ given-names: "Toomas Erik"
16
+ orcid: "https://orcid.org/0000-0002-3650-4230"
17
+ title: "yabplot: yet another brain plot for unified neuroimaging visualization in Python"
18
+ year: 2026
19
+ doi: 10.5281/zenodo.18237144
20
+ url: "https://github.com/teanijarv/yabplot"
21
+ abstract: "An open-source Python package for 3D neuroimaging visualization."
@@ -0,0 +1,27 @@
1
+ # code of conduct
2
+
3
+ ## our pledge
4
+ in the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
5
+
6
+ ## our standards
7
+ examples of behavior that contributes to creating a positive environment include:
8
+ * using welcoming and inclusive language
9
+ * being respectful of differing viewpoints and experiences
10
+ * gracefully accepting constructive criticism
11
+ * focusing on what is best for the community
12
+ * showing empathy towards other community members
13
+
14
+ examples of unacceptable behavior by participants include:
15
+ * the use of sexualized language or imagery and unwelcome sexual attention or advances
16
+ * trolling, insulting/derogatory comments, and personal or political attacks
17
+ * public or private harassment
18
+ * publishing others' private information, such as a physical or electronic address, without explicit permission
19
+ * other conduct which could reasonably be considered inappropriate in a professional setting
20
+
21
+ ## our responsibilities
22
+ as the maintainers of this project, we are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
23
+
24
+ we have the right and responsibility to remove, edit, or reject comments, commits, code, docs edits, issues, and other contributions that are not aligned to this code of conduct, or to ban temporarily or permanently any contributor for other behaviors that we deem inappropriate, threatening, offensive, or harmful.
25
+
26
+ ## enforcement
27
+ instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting us at teanijarv@pm.me. all complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. we are obligated to maintain confidentiality with regard to the reporter of an incident.
@@ -0,0 +1,34 @@
1
+ # contributing to yabplot
2
+
3
+ thank you for your interest in contributing to `yabplot`! we welcome contributions from the neuroimaging and open-source communities.
4
+
5
+ ## how to contribute
6
+
7
+ ### reporting bugs
8
+ - use the [github issue tracker](https://github.com/teanijarv/yabplot/issues) to report bugs.
9
+ - include a minimal reproducible example and information about your environment (os, python version, package versions).
10
+
11
+ ### suggesting enhancements
12
+ - open an issue describing the proposed feature and why it would be useful for the community.
13
+
14
+ ### pull requests
15
+ 1. fork the repository and create your branch from `main`.
16
+ 2. if you've added code that should be tested, add tests in the `tests/` directory.
17
+ 3. if you've changed API, update the documentation.
18
+ 4. ensure the test suite passes: `uv run pytest tests/`
19
+ 5. submit a pull request with a clear description of the changes.
20
+
21
+ ## development setup
22
+
23
+ we use `uv` for dependency management. to set up your development environment:
24
+
25
+ ```bash
26
+ git clone https://github.com/teanijarv/yabplot.git
27
+ cd yabplot
28
+ uv venv
29
+ uv pip install -e ".[docs]"
30
+ uv pip install pytest
31
+ ```
32
+
33
+ ## community
34
+ by contributing, you agree to abide by our [code of conduct](CODE_OF_CONDUCT.md).
@@ -0,0 +1,9 @@
1
+ include pyproject.toml
2
+ include README.md
3
+ include LICENSE
4
+ include CITATION.cff
5
+ include CONTRIBUTING.md
6
+ include CODE_OF_CONDUCT.md
7
+
8
+ recursive-include yabplot *.txt *.json *.md
9
+ recursive-include tests *.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yabplot
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: yet another brain plot
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "yabplot"
3
- version = "0.5.0"
3
+ version = "0.5.1"
4
4
  description = "yet another brain plot"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -32,3 +32,8 @@ include = ["yabplot*"]
32
32
 
33
33
  [tool.setuptools.package-data]
34
34
  "yabplot" = ["*.txt", "*.json"]
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "pytest>=9.0.3",
39
+ ]
@@ -0,0 +1,51 @@
1
+ import pytest
2
+ import numpy as np
3
+ import nibabel as nib
4
+ import pyvista as pv
5
+
6
+ pv.OFF_SCREEN = True
7
+
8
+ @pytest.fixture
9
+ def synthetic_nifti(tmp_path):
10
+ """Generates a simple 3D NIfTI file with a sphere of high intensity."""
11
+ shape = (20, 20, 20)
12
+ data = np.zeros(shape)
13
+
14
+ # create a simple sphere
15
+ cz, cy, cx = 10, 10, 10
16
+ r = 5
17
+ z, y, x = np.ogrid[:shape[0], :shape[1], :shape[2]]
18
+ mask = (z - cz)**2 + (y - cy)**2 + (x - cx)**2 <= r**2
19
+ data[mask] = 10.0
20
+
21
+ affine = np.eye(4)
22
+ img = nib.Nifti1Image(data, affine)
23
+ file_path = tmp_path / "synthetic.nii.gz"
24
+ nib.save(img, file_path)
25
+ return str(file_path)
26
+
27
+ @pytest.fixture
28
+ def synthetic_nifti_4d(tmp_path):
29
+ """Generates a 4D NIfTI file."""
30
+ shape = (20, 20, 20, 3)
31
+ data = np.random.rand(*shape)
32
+ affine = np.eye(4)
33
+ img = nib.Nifti1Image(data, affine)
34
+ file_path = tmp_path / "synthetic_4d.nii.gz"
35
+ nib.save(img, file_path)
36
+ return str(file_path)
37
+
38
+ @pytest.fixture
39
+ def synthetic_tractogram(tmp_path):
40
+ """Generates a simple tractogram."""
41
+ from nibabel.streamlines.tractogram import Tractogram
42
+ from nibabel.streamlines.trk import TrkFile
43
+
44
+ streamlines = [
45
+ np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]], dtype=np.float32),
46
+ np.array([[10, 10, 10], [11, 10, 10], [12, 10, 10]], dtype=np.float32)
47
+ ]
48
+ tractogram = Tractogram(streamlines, affine_to_rasmm=np.eye(4))
49
+ file_path = tmp_path / "synthetic.trk"
50
+ TrkFile(tractogram).save(file_path)
51
+ return str(file_path)
@@ -0,0 +1,41 @@
1
+ import pytest
2
+ from yabplot.data import get_available_resources, get_atlas_regions
3
+
4
+ def test_get_available_resources():
5
+ """Verify that all resource categories exist and contain data."""
6
+ # all categories
7
+ res_all = get_available_resources(None)
8
+ assert isinstance(res_all, dict)
9
+
10
+ expected_categories = ['cortical', 'subcortical', 'tracts', 'bmesh']
11
+ for cat in expected_categories:
12
+ assert cat in res_all, f"expected category {cat} to be in resources"
13
+ assert len(res_all[cat]) > 0
14
+
15
+ # specific category
16
+ res_cortical = get_available_resources('cortical')
17
+ assert isinstance(res_cortical, list)
18
+ assert 'aparc' in res_cortical
19
+
20
+ def test_get_atlas_regions_cortical():
21
+ """Verify that cortical atlas regions are correctly retrieved."""
22
+ regions = get_atlas_regions('aparc', 'cortical')
23
+ assert isinstance(regions, list)
24
+ assert len(regions) > 0
25
+
26
+ def test_get_atlas_regions_subcortical():
27
+ """Verify that subcortical atlas regions are correctly retrieved."""
28
+ regions = get_atlas_regions('aseg', 'subcortical')
29
+ assert isinstance(regions, list)
30
+ assert len(regions) > 0
31
+
32
+ def test_get_atlas_regions_tracts():
33
+ """Verify that tract atlas regions are correctly retrieved."""
34
+ regions = get_atlas_regions('xtract_tiny', 'tracts')
35
+ assert isinstance(regions, list)
36
+ assert len(regions) > 0
37
+
38
+ def test_get_atlas_regions_invalid():
39
+ """Verify that invalid categories return an empty list gracefully."""
40
+ regions = get_atlas_regions('aparc', 'invalid_category')
41
+ assert regions == []
@@ -0,0 +1,78 @@
1
+ import pytest
2
+ import numpy as np
3
+ import pandas as pd
4
+ from yabplot.mesh import map_values_to_surface
5
+ from yabplot.utils import prep_data
6
+
7
+ def test_prep_data_array():
8
+ """Verify that 1D arrays are correctly mapped to a dictionary by position."""
9
+ regions = ['A', 'B', 'C']
10
+ data = [1, 2, 3]
11
+ result = prep_data(data, regions, 'test_atlas', 'cortical')
12
+ assert isinstance(result, dict)
13
+ assert result['A'] == 1
14
+ assert result['C'] == 3
15
+
16
+ def test_prep_data_dict():
17
+ """Verify that dictionaries are passed through correctly."""
18
+ regions = ['A', 'B', 'C']
19
+ data = {'A': 10, 'B': 20}
20
+ result = prep_data(data, regions, 'test_atlas', 'cortical')
21
+ assert isinstance(result, dict)
22
+ assert result['A'] == 10
23
+ assert result['B'] == 20
24
+
25
+ def test_prep_data_series():
26
+ """Verify that pandas Series are correctly converted using their index."""
27
+ regions = ['A', 'B', 'C']
28
+ data = pd.Series([100, 200], index=['A', 'C'])
29
+ result = prep_data(data, regions, 'test_atlas', 'cortical')
30
+ assert result['A'] == 100
31
+ assert result['C'] == 200
32
+
33
+ def test_prep_data_dataframe():
34
+ """Verify that pandas DataFrames are correctly handled by index or column mapping."""
35
+ regions = ['A', 'B', 'C']
36
+ # 1-col dataframe will use index as labels
37
+ data_1col = pd.DataFrame({'val': [5, 6]}, index=['B', 'A'])
38
+ result_1col = prep_data(data_1col, regions, 'test_atlas', 'cortical')
39
+ assert result_1col['A'] == 6
40
+ assert result_1col['B'] == 5
41
+
42
+ # 2-col dataframe will use first column as labels and second as values
43
+ df2 = pd.DataFrame({'Region': ['B', 'A'], 'Value': [50, 60]})
44
+ result_2col = prep_data(df2, regions, 'test_atlas', 'cortical')
45
+ assert result_2col['A'] == 60
46
+ assert result_2col['B'] == 50
47
+
48
+ def test_prep_data_length_mismatch():
49
+ """Verify that an array length mismatch raises a ValueError."""
50
+ regions = ['A', 'B', 'C']
51
+ data = [1, 2] # length mismatch
52
+ with pytest.raises(ValueError, match="Data length mismatch"):
53
+ prep_data(data, regions, 'test_atlas', 'cortical')
54
+
55
+ def test_map_values_to_surface():
56
+ """Verify that atlas scalar mapping correctly handles data injection and NaNs."""
57
+ target_labels = np.array([0, 1, 2, 1, 0, 3])
58
+ lut_ids = np.array([0, 1, 2, 3])
59
+ dense_lut_names = ['Region0', 'Region1', 'Region2', 'Region3']
60
+
61
+ # dict input
62
+ data_dict = {'Region1': 10.0, 'Region2': 20.0}
63
+ res_dict = map_values_to_surface(data_dict, target_labels, lut_ids, dense_lut_names)
64
+ assert np.isnan(res_dict[0]) # region 0 mapped to NaN
65
+ assert res_dict[1] == 10.0 # region 1 mapped to 10
66
+ assert res_dict[2] == 20.0 # region 2 mapped to 20
67
+ assert np.isnan(res_dict[5]) # target 3 -> region 3 mapped to NaN
68
+
69
+ # array input
70
+ data_arr = [0.0, 1.0, 2.0, 3.0]
71
+ res_arr = map_values_to_surface(data_arr, target_labels, lut_ids, dense_lut_names)
72
+ assert res_arr[0] == 0.0
73
+ assert res_arr[1] == 1.0
74
+ assert res_arr[5] == 3.0
75
+
76
+ # mismatch length array
77
+ with pytest.raises(ValueError):
78
+ map_values_to_surface([1.0, 2.0], target_labels, lut_ids, dense_lut_names)
@@ -0,0 +1,24 @@
1
+ import pytest
2
+ import numpy as np
3
+ import pyvista as pv
4
+ from yabplot.mesh import load_nii_as_mesh, make_cortical_mesh
5
+
6
+ def test_load_nii_as_mesh(synthetic_nifti):
7
+ """Verify that marching cubes mesh extraction works on a NIfTI volume."""
8
+ mesh = load_nii_as_mesh(synthetic_nifti, threshold=5.0, blur_sigma=0, smooth_i=0)
9
+ assert isinstance(mesh, pv.PolyData)
10
+ assert mesh.n_points > 0
11
+ assert mesh.n_cells > 0
12
+
13
+ def test_make_cortical_mesh():
14
+ """Verify that a manual mesh can be constructed from vertex and face arrays."""
15
+ verts = np.array([[0,0,0], [1,0,0], [0,1,0], [0,0,1]], dtype=np.float32)
16
+ faces = np.array([[0,1,2], [0,1,3], [0,2,3], [1,2,3]], dtype=np.int64)
17
+ scalars = np.array([1, 2, 3, 4], dtype=np.float32)
18
+ mesh = make_cortical_mesh(verts, faces, scalars, scalar_name='TestData')
19
+
20
+ assert isinstance(mesh, pv.PolyData)
21
+ assert mesh.n_points == 4
22
+ assert mesh.n_cells == 4
23
+ assert 'TestData' in mesh.point_data
24
+ assert np.all(mesh.point_data['TestData'] == scalars)
@@ -0,0 +1,93 @@
1
+ import pytest
2
+ import os
3
+ import numpy as np
4
+ import matplotlib.pyplot as plt
5
+ import yabplot as yab
6
+ import pyvista as pv
7
+
8
+ # tell PyVista to run in "off-screen" mode so it doesn't try to open a real window
9
+ pv.OFF_SCREEN = True
10
+
11
+ def test_version():
12
+ """Check that the package has a version string."""
13
+ assert yab.__version__ is not None
14
+
15
+ def test_none_returns_empty_dict():
16
+ """Verify that passing None disables the background mesh."""
17
+ result = yab.mesh.load_bmesh(None)
18
+ assert result == {}
19
+
20
+ def test_dict_passthrough():
21
+ """Verify that custom dictionary keys for hemispheres are properly sanitized."""
22
+ mesh_l = pv.Sphere()
23
+ mesh_r = pv.Cube()
24
+ mesh_other = pv.Cone()
25
+ d = {'left': mesh_l, 'RIGHT': mesh_r, 'other': mesh_other}
26
+ result = yab.mesh.load_bmesh(d)
27
+ expected = {'L': mesh_l, 'R': mesh_r, 'other': mesh_other}
28
+ assert result == expected
29
+
30
+ def test_polydata_wrapped_in_both():
31
+ """Verify that passing a single PyVista mesh safely wraps it."""
32
+ mesh = pv.Sphere()
33
+ result = yab.mesh.load_bmesh(mesh)
34
+ assert 'both' in result
35
+ assert result['both'] is mesh
36
+
37
+ def test_plotter_instantiation():
38
+ """Smoke test: can we create a Plotter without crashing?"""
39
+ plotter = pv.Plotter(off_screen=True)
40
+ plotter.add_mesh(pv.Sphere())
41
+ plotter.show()
42
+ plotter.close()
43
+
44
+ def test_plot_cortical():
45
+ """Smoke test: verify that plot_cortical renders with standard atlas."""
46
+ yab.plot_cortical(atlas='aparc', display_type='matplotlib')
47
+
48
+ def test_plot_subcortical():
49
+ """Smoke test: verify that plot_subcortical renders with standard atlas."""
50
+ yab.plot_subcortical(atlas='aseg', display_type='matplotlib')
51
+
52
+ def test_plot_tracts():
53
+ """Smoke test: verify that plot_tracts renders with standard atlas."""
54
+ yab.plot_tracts(atlas='xtract_tiny', display_type='matplotlib')
55
+
56
+ def test_plot_vertexwise():
57
+ """Smoke test: verify that plot_vertexwise renders with custom meshes."""
58
+ lh = pv.Sphere()
59
+ rh = pv.Sphere()
60
+ lh['Data'] = np.random.rand(lh.n_points)
61
+ rh['Data'] = np.random.rand(rh.n_points)
62
+ yab.plot_vertexwise(lh, rh, display_type='matplotlib')
63
+
64
+ def test_plot_voxelwise(synthetic_nifti):
65
+ """Smoke test: verify that plot_voxelwise renders with synthetic volume."""
66
+ yab.plot_voxelwise(synthetic_nifti, threshold=5.0, blur_sigma=0, display_type='matplotlib')
67
+
68
+ def test_plot_connectome():
69
+ """Smoke test: verify that plot_connectome renders both nodes-only and matrix modes."""
70
+ yab.plot_connectome(atlas='aparc', display_type='matplotlib')
71
+ # plotting with a dummy matrix
72
+ regions = yab.get_atlas_regions('aparc', 'cortical')
73
+ n = len(regions)
74
+ matrix = np.random.rand(n, n)
75
+ yab.plot_connectome(matrix=matrix, atlas='aparc', edge_threshold=0.5, display_type='matplotlib')
76
+
77
+
78
+ def test_matplotlib_ax_compatibility():
79
+ """Verify that plotting on a provided Matplotlib axis works correctly."""
80
+ fig, ax = plt.subplots()
81
+ ret_ax = yab.plot_cortical(atlas='aparc', ax=ax, display_type='matplotlib')
82
+ assert ret_ax is ax
83
+ # verify ax is usable
84
+ ret_ax.set_title("Test Title")
85
+ assert ret_ax.get_title() == "Test Title"
86
+ plt.close(fig)
87
+
88
+ def test_export_path(tmp_path):
89
+ """Verify that export_path correctly saves a file to disk."""
90
+ out_file = tmp_path / "test_export.png"
91
+ yab.plot_cortical(atlas='aparc', display_type='matplotlib', export_path=str(out_file))
92
+ assert out_file.exists()
93
+ assert out_file.stat().st_size > 0
@@ -0,0 +1,25 @@
1
+ import pytest
2
+ import numpy as np
3
+ from yabplot.projection import project_vol2surf, project_vol2tract
4
+
5
+ def test_project_vol2surf(synthetic_nifti):
6
+ """Verify that a 3D NIfTI can be projected onto the cortical surface."""
7
+ lh_data, rh_data = project_vol2surf(synthetic_nifti, bmesh='midthickness', mask_medial_wall=False, interpolation='linear')
8
+ assert isinstance(lh_data, np.ndarray)
9
+ assert isinstance(rh_data, np.ndarray)
10
+ assert len(lh_data) > 0
11
+ assert len(rh_data) > 0
12
+
13
+ def test_project_vol2tract(synthetic_tractogram, synthetic_nifti):
14
+ """Verify that a 3D NIfTI can be projected onto a tractogram."""
15
+ data = project_vol2tract(synthetic_tractogram, synthetic_nifti, interpolation='linear')
16
+ assert isinstance(data, np.ndarray)
17
+ assert len(data) > 0
18
+
19
+ def test_project_invalid_interpolation(synthetic_nifti):
20
+ """Verify that invalid interpolation modes raise an error."""
21
+ with pytest.raises(ValueError, match="interpolation must be"):
22
+ project_vol2surf(synthetic_nifti, bmesh='midthickness', interpolation='invalid')
23
+
24
+ with pytest.raises(ValueError, match="interpolation must be"):
25
+ project_vol2tract("dummy.trk", synthetic_nifti, interpolation='invalid')
@@ -7,26 +7,26 @@ def project_vol2surf(nii_path, bmesh='midthickness', mask_medial_wall=True, inte
7
7
  """
8
8
  Projects a 3D NIfTI volume onto 2D cortical surface vertices.
9
9
 
10
- It maps volumetric data directly onto surface meshes by converting real-world coordinates
10
+ It maps volumetric data directly onto surface meshes by converting real-world coordinates
11
11
  using the image affine and sampling the data array at those exact points.
12
12
 
13
13
  Parameters
14
14
  ----------
15
15
  nii_path : str
16
- absolute path to the 3D or 4D NIfTI volume.
16
+ absolute path to the 3D or 4D NIfTI volume.
17
17
  if 4D, only the first volume/timepoint is used.
18
18
  bmesh : str, dict, or pyvista.PolyData, optional
19
- background mesh to use for projection coordinates. accepts a standard
20
- string (e.g., 'midthickness') or a dictionary of custom pyvista meshes
19
+ background mesh to use for projection coordinates. accepts a standard
20
+ string (e.g., 'midthickness') or a dictionary of custom pyvista meshes
21
21
  {'L': mesh, 'R': mesh}. default is 'midthickness'.
22
22
  mask_medial_wall : bool, optional
23
- whether to automatically set the medial wall vertices to NaN to prevent
24
- subcortical signal from bleeding onto the cortical surface.
23
+ whether to automatically set the medial wall vertices to NaN to prevent
24
+ subcortical signal from bleeding onto the cortical surface.
25
25
  Note: only supported if `bmesh` is a standard string. default is True.
26
26
  interpolation : {'linear', 'nearest'}, optional
27
- interpolation method for sampling the volume. 'linear' performs trilinear
28
- interpolation (smoother, good for continuous t-stats), while 'nearest'
29
- snaps to the closest voxel center (strictly required for p-values or atlases).
27
+ interpolation method for sampling the volume. 'linear' performs trilinear
28
+ interpolation (smoother, good for continuous t-stats), while 'nearest'
29
+ snaps to the closest voxel center (strictly required for p-values or atlases).
30
30
  default is 'linear'.
31
31
 
32
32
  Returns
@@ -42,19 +42,22 @@ def project_vol2surf(nii_path, bmesh='midthickness', mask_medial_wall=True, inte
42
42
  # load volume
43
43
  img = nib.load(nii_path)
44
44
  vol_data = img.get_fdata()
45
-
45
+
46
46
  if vol_data.ndim > 3:
47
47
  warnings.warn(f"[WARNING] detected {vol_data.ndim}d nifti volume. using the first volume (index 0).")
48
- vol_data = vol_data[..., 0]
49
-
48
+ vol_data = vol_data[..., 0]
49
+
50
50
  inv_affine = np.linalg.inv(img.affine)
51
51
 
52
52
  # load brain mesh
53
53
  loaded_meshes = load_bmesh(bmesh)
54
-
54
+
55
+ if interpolation not in ['linear', 'nearest']:
56
+ raise ValueError("interpolation must be 'linear' or 'nearest'")
57
+
55
58
  if 'L' not in loaded_meshes or 'R' not in loaded_meshes:
56
59
  raise ValueError("project_vol2surf requires both 'L' and 'R' hemispheres in the bmesh dictionary.")
57
-
60
+
58
61
  # extract raw coordinates for math
59
62
  lh_v, _ = extract_polydata(loaded_meshes['L'])
60
63
  rh_v, _ = extract_polydata(loaded_meshes['R'])
@@ -89,8 +92,8 @@ def project_vol2surf(nii_path, bmesh='midthickness', mask_medial_wall=True, inte
89
92
  def project_vol2tract_atlas(nii_path, atlas='xtract_tiny', custom_atlas_path=None, interpolation='linear'):
90
93
  """
91
94
  Samples a 3D volume across all tracts in a specific atlas.
92
- This is a convenience function around `project_vol2tract` that automatically
93
- resolves the atlas paths, loops through all available tractograms, and returns
95
+ This is a convenience function around `project_vol2tract` that automatically
96
+ resolves the atlas paths, loops through all available tractograms, and returns
94
97
  a dictionary ready to be passed directly to `plot_tracts`.
95
98
 
96
99
  Parameters
@@ -110,23 +113,23 @@ def project_vol2tract_atlas(nii_path, atlas='xtract_tiny', custom_atlas_path=Non
110
113
  dictionary mapping tract names to their 1D sampled data arrays.
111
114
  """
112
115
  from .data import _resolve_resource_path, _find_tract_files
113
-
116
+
114
117
  # resolve the atlas directory and locate all tract files
115
118
  atlas_dir = _resolve_resource_path(atlas, 'tracts', custom_path=custom_atlas_path)
116
119
  tract_files = _find_tract_files(atlas_dir)
117
-
120
+
118
121
  tract_data = {}
119
-
122
+
120
123
  # loop through and map the volume to each tract
121
124
  for name, trk_path in tract_files.items():
122
125
  tract_data[name] = project_vol2tract(trk_path, nii_path, interpolation)
123
-
126
+
124
127
  return tract_data
125
128
 
126
129
 
127
130
  def project_vol2tract(trk_path, nii_path, interpolation='linear'):
128
131
  """
129
- Samples a 3D volume natively at every vertex of a tractogram. Maps the streamline
132
+ Samples a 3D volume natively at every vertex of a tractogram. Maps the streamline
130
133
  coordinates directly into the volumetric voxel space using the image affine.
131
134
 
132
135
  Parameters
@@ -141,29 +144,32 @@ def project_vol2tract(trk_path, nii_path, interpolation='linear'):
141
144
  Returns
142
145
  -------
143
146
  numpy.ndarray
144
- 1D array of sampled values corresponding exactly to the flattened
147
+ 1D array of sampled values corresponding exactly to the flattened
145
148
  points of the tractogram, ready to be injected into plot_tracts.
146
149
  """
150
+ if interpolation not in ['linear', 'nearest']:
151
+ raise ValueError("interpolation must be 'linear' or 'nearest'")
152
+
147
153
  # load the 3D volume
148
154
  img = nib.load(nii_path)
149
155
  vol_data = img.get_fdata()
150
156
  if vol_data.ndim > 3:
151
157
  vol_data = vol_data[..., 0]
152
-
158
+
153
159
  inv_affine = np.linalg.inv(img.affine)
154
160
 
155
161
  # load the tractogram
156
162
  trk = nib.streamlines.load(trk_path)
157
-
163
+
158
164
  # stack all streamline coordinates into a single (n_points, 3) array
159
165
  points = np.vstack(trk.streamlines)
160
-
166
+
161
167
  # convert coordinates using the inverse affine
162
168
  coords_homo = np.hstack((points, np.ones((points.shape[0], 1))))
163
169
  vox_coords = inv_affine.dot(coords_homo.T)[:3, :]
164
-
170
+
165
171
  # sample the volume
166
172
  order = 1 if interpolation == 'linear' else 0
167
173
  sampled_data = map_coordinates(vol_data, vox_coords, order=order, mode='nearest')
168
-
169
- return sampled_data
174
+
175
+ return sampled_data
@@ -15,7 +15,7 @@ def load_gii(gii_path):
15
15
  def load_gii2pv(gii_path, smooth_i=0, smooth_f=0.1):
16
16
  """
17
17
  Load GIfTI and convert to PyVista format with optional smoothing.
18
-
18
+
19
19
  Parameters
20
20
  ----------
21
21
  smooth_i : int
@@ -24,18 +24,18 @@ def load_gii2pv(gii_path, smooth_i=0, smooth_f=0.1):
24
24
  Relaxation factor (0.0 to 1.0, e.g. 0.6).
25
25
  """
26
26
  verts, faces = load_gii(gii_path)
27
-
27
+
28
28
  # create pyvista mesh
29
29
  faces_pv = np.hstack([np.full((faces.shape[0], 1), 3), faces]).flatten().astype(int)
30
30
  mesh = pv.PolyData(verts, faces_pv)
31
-
31
+
32
32
  # apply smoothing
33
33
  if smooth_i > 0:
34
34
  # use Laplacian smoothing (standard vtkSmoothPolyDataFilter)
35
35
  # note: higher relaxation factors can shrink the mesh significantly
36
36
  # if shrinkage is an issue, could consider mesh.smooth_taubin() instead
37
37
  mesh = mesh.smooth(n_iter=smooth_i, relaxation_factor=smooth_f)
38
-
38
+
39
39
  return mesh
40
40
 
41
41
  def array_to_gifti(arr, out_path):
@@ -57,6 +57,8 @@ def prep_data(data, regions, atlas, category):
57
57
  if isinstance(data, pd.DataFrame):
58
58
  if data.shape[1] >= 2:
59
59
  data = dict(zip(data.iloc[:, 0], data.iloc[:, 1]))
60
+ else:
61
+ data = data.iloc[:, 0].to_dict()
60
62
  elif isinstance(data, pd.Series):
61
63
  data = data.to_dict()
62
64
  elif isinstance(data, (list, np.ndarray, tuple)):
@@ -94,34 +96,34 @@ def parse_lut(lut_path):
94
96
  # load and sort by ID to ensure strict order (1..N)
95
97
  df = pd.read_csv(lut_path, sep=r'\s+', header=None)
96
98
  df = df.sort_values(by=0)
97
-
99
+
98
100
  ids = df[0].values
99
101
  names = df[1].tolist()
100
102
  rgb = df.iloc[:, 2:5].values / 255.0
101
-
103
+
102
104
  max_id = ids.max()
103
-
104
- lut_colors = np.full((max_id + 1, 3), 0.5)
105
+
106
+ lut_colors = np.full((max_id + 1, 3), 0.5)
105
107
  lut_names_list = ["Unknown"] * (max_id + 1)
106
-
108
+
107
109
  lut_colors[ids] = rgb
108
110
  for idx, name in zip(ids, names):
109
111
  lut_names_list[idx] = name
110
-
112
+
111
113
  return ids, lut_colors, lut_names_list, max_id
112
114
 
113
115
 
114
116
  def load_tsf(tsf_path: str) -> np.ndarray:
115
117
  """
116
- Reads an MRtrix3 .tsf (track scalar file). Useful for users who
117
- have already computed tractometry metrics using MRtrix3's `tcksample`
118
+ Reads an MRtrix3 .tsf (track scalar file). Useful for users who
119
+ have already computed tractometry metrics using MRtrix3's `tcksample`
118
120
  command and want to plot the resulting values in yabplot.
119
-
121
+
120
122
  Parameters
121
123
  ----------
122
124
  tsf_path : str
123
125
  absolute path to the .tsf file.
124
-
126
+
125
127
  Returns
126
128
  -------
127
129
  numpy.ndarray
@@ -129,7 +131,7 @@ def load_tsf(tsf_path: str) -> np.ndarray:
129
131
  """
130
132
  if not os.path.isfile(tsf_path):
131
133
  raise FileNotFoundError(f"File not found: {tsf_path}")
132
-
134
+
133
135
  header: dict[str, str] = {}
134
136
  data_offset: int | None = None
135
137
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yabplot
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: yet another brain plot
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -1,8 +1,16 @@
1
+ CITATION.cff
2
+ CODE_OF_CONDUCT.md
3
+ CONTRIBUTING.md
1
4
  LICENSE
2
5
  MANIFEST.in
3
6
  README.md
4
7
  pyproject.toml
5
- tests/test_smoke.py
8
+ tests/conftest.py
9
+ tests/test_data.py
10
+ tests/test_mapping.py
11
+ tests/test_mesh.py
12
+ tests/test_plotting.py
13
+ tests/test_projection.py
6
14
  yabplot/__init__.py
7
15
  yabplot/atlas_builder.py
8
16
  yabplot/mesh.py
yabplot-0.5.0/MANIFEST.in DELETED
@@ -1,4 +0,0 @@
1
- include pyproject.toml
2
- include README.md
3
- include LICENSE
4
- recursive-include yabplot *.txt *.json *.md
@@ -1,80 +0,0 @@
1
- import pytest
2
- import numpy as np
3
- import yabplot as yab
4
- import pyvista as pv
5
-
6
- # tell PyVista to run in "off-screen" mode so it doesn't try to open a real window
7
- pv.OFF_SCREEN = True
8
-
9
- def test_version():
10
- """Check that the package has a version string."""
11
- assert yab.__version__ is not None
12
-
13
- def test_none_returns_empty_dict():
14
- """
15
- Unit test: Verify that passing None disables the background mesh
16
- by correctly returning an empty dictionary.
17
- """
18
- result = yab.mesh.load_bmesh(None)
19
- assert result == {}
20
-
21
- def test_dict_passthrough():
22
- """
23
- Unit test: Verify that custom dictionary keys for hemispheres
24
- are properly sanitized to strict 'L' and 'R' keys.
25
- """
26
- mesh_l = pv.Sphere()
27
- mesh_r = pv.Cube()
28
- mesh_other = pv.Cone()
29
- d = {'left': mesh_l, 'RIGHT': mesh_r, 'other': mesh_other}
30
- result = yab.mesh.load_bmesh(d)
31
- expected = {'L': mesh_l, 'R': mesh_r, 'other': mesh_other}
32
- assert result == expected
33
-
34
- def test_polydata_wrapped_in_both():
35
- """
36
- Unit test: Verify that passing a single PyVista mesh (whole brain)
37
- safely wraps it in a dictionary with the 'both' key.
38
- """
39
- mesh = pv.Sphere()
40
- result = yab.mesh.load_bmesh(mesh)
41
- assert 'both' in result
42
- assert result['both'] is mesh
43
-
44
- def test_plotter_instantiation():
45
- """
46
- Smoke test: Can we create a Plotter without crashing?
47
- This verifies VTK and PyVista are correctly linked to the system display.
48
- """
49
- plotter = pv.Plotter(off_screen=True)
50
- plotter.add_mesh(pv.Sphere())
51
- plotter.show()
52
- plotter.close()
53
-
54
- def test_plot_cortical():
55
- """
56
- Integration test: Downloads 'aparc' and plots it.
57
- """
58
- yab.plot_cortical(atlas='aparc', display_type=None)
59
-
60
- def test_plot_subcortical():
61
- """
62
- Integration test: Downloads 'aseg' and plots it.
63
- """
64
- yab.plot_subcortical(atlas='aseg', display_type=None)
65
-
66
- def test_plot_tracts():
67
- """
68
- Integration test: Downloads 'xtract_tiny' and plots it.
69
- """
70
- yab.plot_tracts(atlas='xtract_tiny', display_type=None)
71
-
72
- def test_plot_vertexwise():
73
- """
74
- Integration test: plot_vertexwise with synthetic sphere meshes.
75
- """
76
- lh = pv.Sphere()
77
- rh = pv.Sphere()
78
- lh['Data'] = np.random.rand(lh.n_points)
79
- rh['Data'] = np.random.rand(rh.n_points)
80
- yab.plot_vertexwise(lh, rh, display_type=None)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes