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.
- yabplot-0.5.1/CITATION.cff +21 -0
- yabplot-0.5.1/CODE_OF_CONDUCT.md +27 -0
- yabplot-0.5.1/CONTRIBUTING.md +34 -0
- yabplot-0.5.1/MANIFEST.in +9 -0
- {yabplot-0.5.0/yabplot.egg-info → yabplot-0.5.1}/PKG-INFO +1 -1
- {yabplot-0.5.0 → yabplot-0.5.1}/pyproject.toml +6 -1
- yabplot-0.5.1/tests/conftest.py +51 -0
- yabplot-0.5.1/tests/test_data.py +41 -0
- yabplot-0.5.1/tests/test_mapping.py +78 -0
- yabplot-0.5.1/tests/test_mesh.py +24 -0
- yabplot-0.5.1/tests/test_plotting.py +93 -0
- yabplot-0.5.1/tests/test_projection.py +25 -0
- {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/projection.py +34 -28
- {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/utils.py +17 -15
- {yabplot-0.5.0 → yabplot-0.5.1/yabplot.egg-info}/PKG-INFO +1 -1
- {yabplot-0.5.0 → yabplot-0.5.1}/yabplot.egg-info/SOURCES.txt +9 -1
- yabplot-0.5.0/MANIFEST.in +0 -4
- yabplot-0.5.0/tests/test_smoke.py +0 -80
- {yabplot-0.5.0 → yabplot-0.5.1}/LICENSE +0 -0
- {yabplot-0.5.0 → yabplot-0.5.1}/README.md +0 -0
- {yabplot-0.5.0 → yabplot-0.5.1}/setup.cfg +0 -0
- {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/__init__.py +0 -0
- {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/atlas_builder.py +0 -0
- {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/data/__init__.py +0 -0
- {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/data/registry.txt +0 -0
- {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/mesh.py +0 -0
- {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/plotting.py +0 -0
- {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/scene.py +0 -0
- {yabplot-0.5.0 → yabplot-0.5.1}/yabplot/wrappers.py +0 -0
- {yabplot-0.5.0 → yabplot-0.5.1}/yabplot.egg-info/dependency_links.txt +0 -0
- {yabplot-0.5.0 → yabplot-0.5.1}/yabplot.egg-info/requires.txt +0 -0
- {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).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "yabplot"
|
|
3
|
-
version = "0.5.
|
|
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,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/
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|