Skip to content

Commit 4e8327b

Browse files
authored
Merge pull request #29 from Deep-MI/remove-freesurfer
Remove freesurfer dependencies and add tests
2 parents 90acfbb + 4b641e7 commit 4e8327b

File tree

14 files changed

+585
-157
lines changed

14 files changed

+585
-157
lines changed

.github/workflows/pytest.yml

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,21 @@ on:
1212
- '**.py'
1313
workflow_dispatch:
1414

15+
env:
16+
SUBJECTS_DIR: /home/runner/work/BrainPrint/BrainPrint/data
17+
SUBJECT_ID: test
18+
DESTINATION_DIR: /home/runner/work/BrainPrint/BrainPrint/data
19+
1520
jobs:
1621
pytest:
1722
timeout-minutes: 30
1823
strategy:
1924
fail-fast: false
2025
matrix:
21-
os: [ubuntu, macos, windows]
22-
python-version: ["3.9", "3.10", "3.11", "3.12"]
26+
# os: [ubuntu, macos, windows]
27+
# python-version: [3.8, 3.9, "3.10", "3.11"]
28+
os: [ubuntu]
29+
python-version: ["3.10"]
2330
name: ${{ matrix.os }} - py${{ matrix.python-version }}
2431
runs-on: ${{ matrix.os }}-latest
2532
defaults:
@@ -32,12 +39,25 @@ jobs:
3239
uses: actions/setup-python@v5
3340
with:
3441
python-version: ${{ matrix.python-version }}
42+
architecture: 'x64'
3543
- name: Install package
3644
run: |
3745
python -m pip install --progress-bar off --upgrade pip setuptools wheel
3846
python -m pip install --progress-bar off .[test]
47+
- name: Create data folders
48+
run: |
49+
mkdir -p data/test/mri
50+
mkdir -p data/test/surf
51+
mkdir -p data/test/temp
3952
- name: Display system information
4053
run: brainprint-sys_info --developer
54+
- name: Download files
55+
run: |
56+
wget https://surfer.nmr.mgh.harvard.edu/pub/data/tutorial_data/buckner_data/tutorial_subjs/good_output/mri/aseg.mgz -O data/test/mri/aseg.mgz
57+
wget https://surfer.nmr.mgh.harvard.edu/pub/data/tutorial_data/buckner_data/tutorial_subjs/good_output/surf/lh.white -O data/test/surf/lh.white
58+
wget https://surfer.nmr.mgh.harvard.edu/pub/data/tutorial_data/buckner_data/tutorial_subjs/good_output/surf/rh.white -O data/test/surf/rh.white
59+
wget https://surfer.nmr.mgh.harvard.edu/pub/data/tutorial_data/buckner_data/tutorial_subjs/good_output/surf/lh.pial -O data/test/surf/lh.pial
60+
wget https://surfer.nmr.mgh.harvard.edu/pub/data/tutorial_data/buckner_data/tutorial_subjs/good_output/surf/rh.pial -O data/test/surf/rh.pial
4161
- name: Run pytest
4262
run: pytest brainprint --cov=brainprint --cov-report=xml --cov-config=pyproject.toml
4363
- name: Upload to codecov

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ cortical parcellations or label files).
1212

1313
## Installation
1414

15-
Use the following code to install the latest release of LaPy into your local
15+
Use the following code to install the latest release into your local
1616
Python package directory:
1717

1818
`python3 -m pip install brainprint`
@@ -88,7 +88,14 @@ asymmetry calculation is performed and/or for the eigenvectors (CLI `--evecs` fl
8888

8989
## Changes
9090

91-
There are some changes in functionality in comparison to the original [BrainPrint](https://github.com/Deep-MI/BrainPrint-legacy)
91+
Since version 0.5.0, some changes break compatibility with earlier versions (0.4.0 and lower) as well as the [original BrainPrint](https://github.com/Deep-MI/BrainPrint-legacy). These changes include:
92+
93+
- for the creation of surfaces from voxel-based segmentations, we have replaced FreeSurfer's marching cube algorithm by scikit-image's marching cube algorithm. Similarly, other FreeSurfer binaries have been replaced by custom Python functions. As a result, a parallel FreeSurfer installation is no longer a requirement for running the brainprint software.
94+
- we have changed / removed the following composite structures from the brainprint shape descriptor: the left and right *striatum* (composite of caudate, putamen, and nucleus accumbens) and the left and right *ventricles* (composite of lateral, inferior lateral, 3rd ventricle, choroid plexus, and CSF) have been removed; the left and right *cerebellum-white-matter* and *cerebellum-cortex* have been merged into left and right *cerebellum*.
95+
96+
As a result of these changes, numerical values for the brainprint shape descriptor that are obtained from version 0.5.0 and higher are expected to differ from earlier versions when applied to the same data, but should remain highly correlated with earlier results.
97+
98+
There are some changes in version 0.4.0 (and lower) in functionality in comparison to the original [BrainPrint](https://github.com/Deep-MI/BrainPrint-legacy)
9299
scripts:
93100

94101
- currently no support for tetrahedral meshes

brainprint/asymmetry.py

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,10 @@ def compute_asymmetry(
2525
dict[str, float]
2626
{left_label}_{right_label}, distance.
2727
"""
28-
# Define structures
29-
30-
# combined and individual aseg labels:
31-
# - Left Striatum: left Caudate + Putamen + Accumbens
32-
# - Right Striatum: right Caudate + Putamen + Accumbens
33-
# - CorpusCallosum: 5 subregions combined
34-
# - Cerebellum: brainstem + (left+right) cerebellum WM and GM
35-
# - Ventricles: (left+right) lat.vent + inf.lat.vent + choroidplexus + 3rdVent + CSF
36-
# - Lateral-Ventricle: lat.vent + inf.lat.vent + choroidplexus
37-
# - 3rd-Ventricle: 3rd-Ventricle + CSF
3828

3929
structures_left_right = [
40-
("Left-Striatum", "Right-Striatum"),
4130
("Left-Lateral-Ventricle", "Right-Lateral-Ventricle"),
42-
(
43-
"Left-Cerebellum-White-Matter",
44-
"Right-Cerebellum-White-Matter",
45-
),
46-
("Left-Cerebellum-Cortex", "Right-Cerebellum-Cortex"),
31+
("Left-Cerebellum", "Right-Cerebellum"),
4732
("Left-Thalamus-Proper", "Right-Thalamus-Proper"),
4833
("Left-Caudate", "Right-Caudate"),
4934
("Left-Putamen", "Right-Putamen"),

brainprint/brainprint.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616
from .utils.utils import (
1717
create_output_paths,
1818
export_brainprint_results,
19-
test_freesurfer,
20-
validate_environment,
2119
validate_subject_dir,
2220
)
2321

@@ -76,7 +74,6 @@ def compute_surface_brainprint(
7674
Parameters
7775
----------
7876
path : Path
79-
8077
Path to the *.vtk* surface path.
8178
return_eigenvectors : bool, optional
8279
Whether to store eigenvectors in the result (default is True).
@@ -249,8 +246,6 @@ def run_brainprint(
249246
- Eigenvectors
250247
- Distances
251248
""" # noqa: E501
252-
validate_environment()
253-
test_freesurfer()
254249
subject_dir = validate_subject_dir(subjects_dir, subject_id)
255250
destination = create_output_paths(
256251
subject_dir=subject_dir,
@@ -301,8 +296,6 @@ def __init__(
301296
asymmetry: bool = False,
302297
asymmetry_distance: str = "euc",
303298
keep_temp: bool = False,
304-
environment_validation: bool = True,
305-
freesurfer_validation: bool = True,
306299
use_cholmod: bool = False,
307300
) -> None:
308301
"""
@@ -353,11 +346,6 @@ def __init__(
353346
self._eigenvectors = None
354347
self._distances = None
355348

356-
if environment_validation:
357-
validate_environment()
358-
if freesurfer_validation:
359-
test_freesurfer()
360-
361349
def run(self, subject_id: str, destination: Path = None) -> dict[str, Path]:
362350
"""
363351
Run Brainprint analysis for a specified subject.

brainprint/cli/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
BrainPrint analysis CLI.
33
"""
4+
45
from ..brainprint import run_brainprint
56
from .parser import parse_options
67

brainprint/cli/help_text.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Help text strings for the :mod:`brainprint.cli` module.
33
"""
4+
45
CLI_DESCRIPTION: str = (
56
"This program conducts a brainprint analysis of FreeSurfer output."
67
)
@@ -20,7 +21,9 @@
2021
ASYM_DISTANCE: str = (
2122
"Distance measurement to use for asymmetry calculation (default: euc)"
2223
)
23-
CHOLMOD: str = "Use cholesky decomposition (faster) instead of LU decomposition (slower). May require manual install of scikit-sparse library. Default is LU decomposition."
24+
CHOLMOD: str = (
25+
"Use cholesky decomposition (faster) instead of LU decomposition (slower). May require manual install of scikit-sparse library. Default is LU decomposition."
26+
)
2427
KEEP_TEMP: str = (
2528
"Whether to keep the temporary files directory or not, by default False"
2629
)
@@ -44,14 +47,11 @@
4447
4548
CorpusCallosum [251, 252, 253, 254, 255]
4649
Cerebellum [7, 8, 16, 46, 47]
47-
Ventricles [4, 5, 14, 24, 31, 43, 44, 63]
4850
3rd-Ventricle [14, 24]
4951
4th-Ventricle 15
5052
Brain-Stem 16
51-
Left-Striatum [11, 12, 26]
5253
Left-Lateral-Ventricle [4, 5, 31]
53-
Left-Cerebellum-White-Matter 7
54-
Left-Cerebellum-Cortex 8
54+
Left-Cerebellum [7, 8]
5555
Left-Thalamus-Proper 10
5656
Left-Caudate 11
5757
Left-Putamen 12
@@ -60,10 +60,8 @@
6060
Left-Amygdala 18
6161
Left-Accumbens-area 26
6262
Left-VentralDC 28
63-
Right-Striatum [50, 51, 58]
6463
Right-Lateral-Ventricle [43, 44, 63]
65-
Right-Cerebellum-White-Matter 46
66-
Right-Cerebellum-Cortex 47
64+
Right-Cerebellum [46, 47]
6765
Right-Thalamus-Proper 49
6866
Right-Caudate 50
6967
Right-Putamen 51

brainprint/cli/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Utility functions for the :mod:`brainprint.cli` module.
33
"""
4+
45
from . import help_text
56

67

brainprint/surfaces.py

Lines changed: 69 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""
22
Utility module holding surface generation related functions.
33
"""
4-
import uuid
4+
import os
55
from pathlib import Path
66

7+
import nibabel as nb
8+
import numpy as np
79
from lapy import TriaMesh
8-
9-
from .utils.utils import run_shell_command
10+
from scipy import sparse as sp
11+
from skimage.measure import marching_cubes
1012

1113

1214
def create_aseg_surface(
@@ -30,51 +32,76 @@ def create_aseg_surface(
3032
Path to the generated surface in VTK format.
3133
"""
3234
aseg_path = subject_dir / "mri/aseg.mgz"
33-
norm_path = subject_dir / "mri/norm.mgz"
34-
temp_name = f"temp/aseg.{uuid.uuid4()}"
35+
temp_name = "temp/aseg.{indices}".format(indices="_".join(indices))
3536
indices_mask = destination / f"{temp_name}.mgz"
36-
# binarize on selected labels (creates temp indices_mask)
37-
# always binarize first, otherwise pretess may scale aseg if labels are
38-
# larger than 255 (e.g. aseg+aparc, bug in mri_pretess?)
39-
binarize_template = "mri_binarize --i {source} --match {match} --o {destination}"
40-
binarize_command = binarize_template.format(
41-
source=aseg_path, match=" ".join(indices), destination=indices_mask
42-
)
43-
run_shell_command(binarize_command)
4437

45-
label_value = "1"
46-
# if norm exist, fix label (pretess)
47-
if norm_path.is_file():
48-
pretess_template = (
49-
"mri_pretess {source} {label_value} {norm_path} {destination}"
50-
)
51-
pretess_command = pretess_template.format(
52-
source=indices_mask,
53-
label_value=label_value,
54-
norm_path=norm_path,
55-
destination=indices_mask,
56-
)
57-
run_shell_command(pretess_command)
38+
# binarize on selected labels (creates temp indices_mask)
39+
aseg = nb.load(aseg_path)
40+
indices_num = [int(x) for x in indices]
41+
aseg_data_bin = np.isin(aseg.get_fdata(), indices_num).astype(np.float32)
42+
aseg_bin = nb.MGHImage(dataobj=aseg_data_bin, affine=aseg.affine)
43+
nb.save(img=aseg_bin, filename=indices_mask)
44+
45+
# legacy code for applying mask smoothing
46+
# from scipy import ndimage as sn
47+
# k = 1.0 / np.sqrt(np.array([
48+
# [[3, 2, 3], [2, 1, 2], [3, 2, 3]],
49+
# [[2, 1, 2], [1, 1, 1], [2, 1, 1]],
50+
# [[3, 2, 3], [2, 1, 2], [3, 2, 3]],
51+
# ]))
52+
# aseg_data_bin = sn.convolve(aseg_data_bin, k)
53+
# aseg_data_bin = np.round(aseg_data_bin / np.sum(k))
54+
# nb.save(img=nb.MGHImage(dataobj=aseg_data_bin, affine=aseg.affine), \
55+
# filename=str(indices_mask).replace(".mgz", "-filter.mgz"))
56+
57+
# legacy code for running FreeSurfer's mri_pretess
58+
# import subprocess
59+
# subprocess.run(["cp", str(indices_mask), \
60+
# str(indices_mask).replace(".mgz", "-no_pretess.mgz")])
61+
##subprocess.run(["mri_pretess", \
62+
## str(indices_mask).replace(".mgz", "-no_pretess.mgz"), \
63+
## "pretess" , \
64+
## str(indices_mask).replace(".mgz", "-no_pretess.mgz"), \
65+
## str(indices_mask)])
66+
##subprocess.run(["mri_pretess", \
67+
## str(indices_mask).replace(".mgz", "-no_pretess.mgz"), \
68+
## "pretess" , \
69+
## str(subject_dir / "mri/norm.mgz"), str(indices_mask)])
70+
# aseg_data_bin = nb.load(indices_mask).get_fdata()
5871

5972
# runs marching cube to extract surface
60-
surface_name = f"{temp_name}.surf"
61-
surface_path = destination / surface_name
62-
extraction_template = "mri_mc {source} {label_value} {destination}"
63-
extraction_command = extraction_template.format(
64-
source=indices_mask, label_value=label_value, destination=surface_path
73+
vertices, trias, _, _ = marching_cubes(
74+
volume=aseg_data_bin, level=0.5, allow_degenerate=False, method="lorensen"
6575
)
66-
run_shell_command(extraction_command)
76+
77+
# convert to surface RAS
78+
vertices = np.matmul(
79+
aseg.header.get_vox2ras_tkr(),
80+
np.append(vertices, np.ones((vertices.shape[0], 1)), axis=1).transpose(),
81+
).transpose()[:, 0:3]
82+
83+
# create tria mesh
84+
aseg_mesh = TriaMesh(v=vertices, t=trias)
85+
86+
# keep largest connected component
87+
comps = sp.csgraph.connected_components(aseg_mesh.adj_sym, directed=False)
88+
if comps[0] > 1:
89+
comps_largest = np.argmax(np.unique(comps[1], return_counts=True)[1])
90+
vtcs_remove = np.where(comps[1] != comps_largest)
91+
tria_keep = np.sum(np.isin(aseg_mesh.t, vtcs_remove), axis=1) == 0
92+
aseg_mesh.t = aseg_mesh.t[tria_keep, :]
93+
94+
# remove free vertices
95+
aseg_mesh.rm_free_vertices_()
6796

6897
# convert to vtk
6998
relative_path = "surfaces/aseg.final.{indices}.vtk".format(
7099
indices="_".join(indices)
71100
)
101+
72102
conversion_destination = destination / relative_path
73-
conversion_template = "mris_convert {source} {destination}"
74-
conversion_command = conversion_template.format(
75-
source=surface_path, destination=conversion_destination
76-
)
77-
run_shell_command(conversion_command)
103+
os.makedirs(os.path.dirname(conversion_destination), exist_ok=True)
104+
aseg_mesh.write_vtk(filename=conversion_destination)
78105

79106
return conversion_destination
80107

@@ -98,25 +125,21 @@ def create_aseg_surfaces(subject_dir: Path, destination: Path) -> dict[str, Path
98125
# Define aseg labels
99126

100127
# combined and individual aseg labels:
101-
# - Left Striatum: left Caudate + Putamen + Accumbens
102-
# - Right Striatum: right Caudate + Putamen + Accumbens
103128
# - CorpusCallosum: 5 subregions combined
104129
# - Cerebellum: brainstem + (left+right) cerebellum WM and GM
105-
# - Ventricles: (left+right) lat.vent + inf.lat.vent + choroidplexus + 3rdVent + CSF
130+
# - Left-Cerebellum: left cerebellum WM and GM
131+
# - Right-Cerebellum: right cerebellum WM and GM
106132
# - Lateral-Ventricle: lat.vent + inf.lat.vent + choroidplexus
107133
# - 3rd-Ventricle: 3rd-Ventricle + CSF
108134

109135
aseg_labels = {
110136
"CorpusCallosum": ["251", "252", "253", "254", "255"],
111137
"Cerebellum": ["7", "8", "16", "46", "47"],
112-
"Ventricles": ["4", "5", "14", "24", "31", "43", "44", "63"],
113138
"3rd-Ventricle": ["14", "24"],
114139
"4th-Ventricle": ["15"],
115140
"Brain-Stem": ["16"],
116-
"Left-Striatum": ["11", "12", "26"],
117141
"Left-Lateral-Ventricle": ["4", "5", "31"],
118-
"Left-Cerebellum-White-Matter": ["7"],
119-
"Left-Cerebellum-Cortex": ["8"],
142+
"Left-Cerebellum": ["7", "8"],
120143
"Left-Thalamus-Proper": ["10"],
121144
"Left-Caudate": ["11"],
122145
"Left-Putamen": ["12"],
@@ -125,10 +148,8 @@ def create_aseg_surfaces(subject_dir: Path, destination: Path) -> dict[str, Path
125148
"Left-Amygdala": ["18"],
126149
"Left-Accumbens-area": ["26"],
127150
"Left-VentralDC": ["28"],
128-
"Right-Striatum": ["50", "51", "58"],
129151
"Right-Lateral-Ventricle": ["43", "44", "63"],
130-
"Right-Cerebellum-White-Matter": ["46"],
131-
"Right-Cerebellum-Cortex": ["47"],
152+
"Right-Cerebellum": ["46", "47"],
132153
"Right-Thalamus-Proper": ["49"],
133154
"Right-Caudate": ["50"],
134155
"Right-Putamen": ["51"],
@@ -249,5 +270,5 @@ def surf_to_vtk(source: Path, destination: Path) -> Path:
249270
Path
250271
Resulting *.vtk* file.
251272
"""
252-
TriaMesh.read_fssurf(source).write_vtk(destination)
273+
TriaMesh.read_fssurf(source).write_vtk(str(destination))
253274
return destination

0 commit comments

Comments
 (0)