saline-dbs 0.3.0__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.
- saline_dbs-0.3.0/LICENSE +21 -0
- saline_dbs-0.3.0/PKG-INFO +129 -0
- saline_dbs-0.3.0/README.md +97 -0
- saline_dbs-0.3.0/pyproject.toml +60 -0
- saline_dbs-0.3.0/saline/__init__.py +3 -0
- saline_dbs-0.3.0/saline/cli.py +151 -0
- saline_dbs-0.3.0/saline/detect.py +52 -0
- saline_dbs-0.3.0/saline/external.py +165 -0
- saline_dbs-0.3.0/saline/fit.py +62 -0
- saline_dbs-0.3.0/saline/pipeline.py +75 -0
- saline_dbs-0.3.0/saline/segment.py +122 -0
- saline_dbs-0.3.0/saline/volume.py +6 -0
- saline_dbs-0.3.0/saline_dbs.egg-info/PKG-INFO +129 -0
- saline_dbs-0.3.0/saline_dbs.egg-info/SOURCES.txt +17 -0
- saline_dbs-0.3.0/saline_dbs.egg-info/dependency_links.txt +1 -0
- saline_dbs-0.3.0/saline_dbs.egg-info/entry_points.txt +2 -0
- saline_dbs-0.3.0/saline_dbs.egg-info/requires.txt +8 -0
- saline_dbs-0.3.0/saline_dbs.egg-info/top_level.txt +1 -0
- saline_dbs-0.3.0/setup.cfg +4 -0
saline_dbs-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sriranga Kashyap
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: saline-dbs
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: SALINE: filter-based localization and segmentation of DBS electrodes in clinical MRI
|
|
5
|
+
Author: Vanessa H. Yu, Edward Chen, Jürgen Germann, Alexandre Boutet, Andres M. Lozano, Kâmil Uludağ, Sriranga Kashyap
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/srikash/SALINE
|
|
8
|
+
Project-URL: Repository, https://github.com/srikash/SALINE
|
|
9
|
+
Project-URL: Issues, https://github.com/srikash/SALINE/issues
|
|
10
|
+
Project-URL: Paper, https://doi.org/10.1109/ISBI61048.2026.11515562
|
|
11
|
+
Keywords: deep brain stimulation,dbs,electrode segmentation,mri,neuroimaging
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: numpy
|
|
24
|
+
Requires-Dist: nibabel
|
|
25
|
+
Requires-Dist: scipy
|
|
26
|
+
Requires-Dist: scikit-image
|
|
27
|
+
Requires-Dist: scikit-learn
|
|
28
|
+
Requires-Dist: click
|
|
29
|
+
Requires-Dist: rich
|
|
30
|
+
Requires-Dist: tqdm
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# SALINE (part of DBS-ElecNet)
|
|
34
|
+
## <b><ins>S</ins></b>egmentation <b><ins>A</ins></b>lgorithm using <b><ins>LIN</ins></b>e-fitting for <b><ins>E</ins></b>lectrodes
|
|
35
|
+
|
|
36
|
+
*V. H. Yu et al., "DBS-ElecNet: Automated Localization and Segmentation of DBS Electrodes in Clinical MRI," 2026 IEEE 23rd International Symposium on Biomedical Imaging (ISBI), London, United Kingdom, 2026, pp. 1-4, doi:[10.1109/ISBI61048.2026.11515562](https://doi.org/10.1109/ISBI61048.2026.11515562)*
|
|
37
|
+
|
|
38
|
+
Give SALINE a raw clinical T1-weighted MR image and it handles the rest: resampling,
|
|
39
|
+
skull-stripping (SynthStrip), bias correction (N4), and segmentation (SynthSeg)
|
|
40
|
+
all run via Docker, with no other install needed. It runs as a fully standalone CLI tool.
|
|
41
|
+
|
|
42
|
+
It is the core of [DBS-ElecNet](https://github.com/BRAIN-TO/DBS-ElecNet)'s
|
|
43
|
+
classical (non-deep-learning) segmentation pipeline.
|
|
44
|
+
|
|
45
|
+
## Why SALINE?
|
|
46
|
+
|
|
47
|
+
SALINE is a classical, filtering-based automatic segmentation method for DBS
|
|
48
|
+
electrodes: no training, no manual annotation, no GPU. Give it an MRI, a brain
|
|
49
|
+
mask, and a SynthSeg segmentation, and it finds the electrode track with a
|
|
50
|
+
Laplacian/Frangi filter cascade and a line fit.
|
|
51
|
+
|
|
52
|
+
That makes it well suited to building a large database of electrode
|
|
53
|
+
segmentations, at scale, without a human labeling each scan by hand. In the
|
|
54
|
+
DBS-ElecNet paper, SALINE segmented 280 post-operative MRI scans in about 1.5
|
|
55
|
+
minutes each, and those segmentations became the training data for
|
|
56
|
+
DBS-ElecNet's 3D U-Net. Any similar segmentation model can be trained the
|
|
57
|
+
same way: run SALINE over a cohort, use its output as ground truth.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
Requires Python 3.12+ and [Docker](https://www.docker.com) (recommended; see
|
|
62
|
+
below for running without it).
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install git+https://github.com/srikash/SALINE.git@v0.3.0
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
Dual-electrode (default, also available explicitly as `saline dual`):
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
saline --input subject.nii.gz
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Single-electrode:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
saline single --input subject.nii.gz
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`--input` is a raw, native-space clinical MRI. SALINE resamples it to 1mm
|
|
83
|
+
isotropic, skull-strips it (SynthStrip), bias-corrects it (N4), and segments
|
|
84
|
+
it (SynthSeg) before finding the electrode track. All of this runs via
|
|
85
|
+
Docker, pulling [`antsx/ants`](https://hub.docker.com/r/antsx/ants),
|
|
86
|
+
[`freesurfer/synthstrip`](https://hub.docker.com/r/freesurfer/synthstrip), and
|
|
87
|
+
[`cookpa/synthseg`](https://hub.docker.com/r/cookpa/synthseg) on first use.
|
|
88
|
+
|
|
89
|
+
**Output:**
|
|
90
|
+
|
|
91
|
+
* `subject_saline_elecSeg.nii.gz`: the electrode segmentation, in `--input`'s native space.
|
|
92
|
+
* `subject_1mm_iso.nii.gz`: the resampled MRI, kept for reference.
|
|
93
|
+
* `subject_1mm_iso_saline_elecSeg.nii.gz`: the electrode segmentation, in 1mm-isotropic space.
|
|
94
|
+
|
|
95
|
+
**Running without Docker:**
|
|
96
|
+
|
|
97
|
+
If Docker isn't available, pass precomputed files instead. Both must already
|
|
98
|
+
be in the same 1mm-isotropic space as `subject_1mm_iso.nii.gz`:
|
|
99
|
+
|
|
100
|
+
* `--brain_mask PATH`: skips Docker-based SynthStrip.
|
|
101
|
+
* `--synthseg PATH`: skips Docker-based SynthSeg.
|
|
102
|
+
|
|
103
|
+
ANTs (resampling, N4, mask multiply) falls back to a local install
|
|
104
|
+
(`ResampleImage`, `N4BiasFieldCorrection`, `ImageMath` on `PATH`) if Docker
|
|
105
|
+
isn't available. There's no equivalent precomputed-file option for those.
|
|
106
|
+
|
|
107
|
+
**Optional arguments:**
|
|
108
|
+
|
|
109
|
+
* `--threads` (default `5`): threads for SynthSeg.
|
|
110
|
+
* `--laplacian_threshold` (default `0.21`)
|
|
111
|
+
* `--frangi_threshold` (default `0.25`)
|
|
112
|
+
* `--lower_frangi_threshold` (default `0.2`): used if the higher threshold finds no candidates.
|
|
113
|
+
* `--expand_radius` (default `6`): dilation radius applied to the final line mask.
|
|
114
|
+
* `--save_intermediate`: also save the thresholded Laplacian, Frangi, and combined masks.
|
|
115
|
+
|
|
116
|
+
## Citation
|
|
117
|
+
|
|
118
|
+
If you use this in your work, please cite the paper above (see [`CITATION.cff`](CITATION.cff)
|
|
119
|
+
for the full machine-readable record).
|
|
120
|
+
|
|
121
|
+
## Releasing
|
|
122
|
+
|
|
123
|
+
Publishing a GitHub Release triggers `.github/workflows/release.yml`, which
|
|
124
|
+
builds the package and publishes it to PyPI via
|
|
125
|
+
[trusted publishing](https://docs.pypi.org/trusted-publishers/) (no API token
|
|
126
|
+
stored in the repo). One-time setup on PyPI, before the first release:
|
|
127
|
+
add a trusted publisher on the `saline-dbs` project (or as a pending
|
|
128
|
+
publisher if the project doesn't exist yet) pointing at this repository,
|
|
129
|
+
workflow `release.yml`, environment `pypi`.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SALINE (part of DBS-ElecNet)
|
|
2
|
+
## <b><ins>S</ins></b>egmentation <b><ins>A</ins></b>lgorithm using <b><ins>LIN</ins></b>e-fitting for <b><ins>E</ins></b>lectrodes
|
|
3
|
+
|
|
4
|
+
*V. H. Yu et al., "DBS-ElecNet: Automated Localization and Segmentation of DBS Electrodes in Clinical MRI," 2026 IEEE 23rd International Symposium on Biomedical Imaging (ISBI), London, United Kingdom, 2026, pp. 1-4, doi:[10.1109/ISBI61048.2026.11515562](https://doi.org/10.1109/ISBI61048.2026.11515562)*
|
|
5
|
+
|
|
6
|
+
Give SALINE a raw clinical T1-weighted MR image and it handles the rest: resampling,
|
|
7
|
+
skull-stripping (SynthStrip), bias correction (N4), and segmentation (SynthSeg)
|
|
8
|
+
all run via Docker, with no other install needed. It runs as a fully standalone CLI tool.
|
|
9
|
+
|
|
10
|
+
It is the core of [DBS-ElecNet](https://github.com/BRAIN-TO/DBS-ElecNet)'s
|
|
11
|
+
classical (non-deep-learning) segmentation pipeline.
|
|
12
|
+
|
|
13
|
+
## Why SALINE?
|
|
14
|
+
|
|
15
|
+
SALINE is a classical, filtering-based automatic segmentation method for DBS
|
|
16
|
+
electrodes: no training, no manual annotation, no GPU. Give it an MRI, a brain
|
|
17
|
+
mask, and a SynthSeg segmentation, and it finds the electrode track with a
|
|
18
|
+
Laplacian/Frangi filter cascade and a line fit.
|
|
19
|
+
|
|
20
|
+
That makes it well suited to building a large database of electrode
|
|
21
|
+
segmentations, at scale, without a human labeling each scan by hand. In the
|
|
22
|
+
DBS-ElecNet paper, SALINE segmented 280 post-operative MRI scans in about 1.5
|
|
23
|
+
minutes each, and those segmentations became the training data for
|
|
24
|
+
DBS-ElecNet's 3D U-Net. Any similar segmentation model can be trained the
|
|
25
|
+
same way: run SALINE over a cohort, use its output as ground truth.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Requires Python 3.12+ and [Docker](https://www.docker.com) (recommended; see
|
|
30
|
+
below for running without it).
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install git+https://github.com/srikash/SALINE.git@v0.3.0
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
Dual-electrode (default, also available explicitly as `saline dual`):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
saline --input subject.nii.gz
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Single-electrode:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
saline single --input subject.nii.gz
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`--input` is a raw, native-space clinical MRI. SALINE resamples it to 1mm
|
|
51
|
+
isotropic, skull-strips it (SynthStrip), bias-corrects it (N4), and segments
|
|
52
|
+
it (SynthSeg) before finding the electrode track. All of this runs via
|
|
53
|
+
Docker, pulling [`antsx/ants`](https://hub.docker.com/r/antsx/ants),
|
|
54
|
+
[`freesurfer/synthstrip`](https://hub.docker.com/r/freesurfer/synthstrip), and
|
|
55
|
+
[`cookpa/synthseg`](https://hub.docker.com/r/cookpa/synthseg) on first use.
|
|
56
|
+
|
|
57
|
+
**Output:**
|
|
58
|
+
|
|
59
|
+
* `subject_saline_elecSeg.nii.gz`: the electrode segmentation, in `--input`'s native space.
|
|
60
|
+
* `subject_1mm_iso.nii.gz`: the resampled MRI, kept for reference.
|
|
61
|
+
* `subject_1mm_iso_saline_elecSeg.nii.gz`: the electrode segmentation, in 1mm-isotropic space.
|
|
62
|
+
|
|
63
|
+
**Running without Docker:**
|
|
64
|
+
|
|
65
|
+
If Docker isn't available, pass precomputed files instead. Both must already
|
|
66
|
+
be in the same 1mm-isotropic space as `subject_1mm_iso.nii.gz`:
|
|
67
|
+
|
|
68
|
+
* `--brain_mask PATH`: skips Docker-based SynthStrip.
|
|
69
|
+
* `--synthseg PATH`: skips Docker-based SynthSeg.
|
|
70
|
+
|
|
71
|
+
ANTs (resampling, N4, mask multiply) falls back to a local install
|
|
72
|
+
(`ResampleImage`, `N4BiasFieldCorrection`, `ImageMath` on `PATH`) if Docker
|
|
73
|
+
isn't available. There's no equivalent precomputed-file option for those.
|
|
74
|
+
|
|
75
|
+
**Optional arguments:**
|
|
76
|
+
|
|
77
|
+
* `--threads` (default `5`): threads for SynthSeg.
|
|
78
|
+
* `--laplacian_threshold` (default `0.21`)
|
|
79
|
+
* `--frangi_threshold` (default `0.25`)
|
|
80
|
+
* `--lower_frangi_threshold` (default `0.2`): used if the higher threshold finds no candidates.
|
|
81
|
+
* `--expand_radius` (default `6`): dilation radius applied to the final line mask.
|
|
82
|
+
* `--save_intermediate`: also save the thresholded Laplacian, Frangi, and combined masks.
|
|
83
|
+
|
|
84
|
+
## Citation
|
|
85
|
+
|
|
86
|
+
If you use this in your work, please cite the paper above (see [`CITATION.cff`](CITATION.cff)
|
|
87
|
+
for the full machine-readable record).
|
|
88
|
+
|
|
89
|
+
## Releasing
|
|
90
|
+
|
|
91
|
+
Publishing a GitHub Release triggers `.github/workflows/release.yml`, which
|
|
92
|
+
builds the package and publishes it to PyPI via
|
|
93
|
+
[trusted publishing](https://docs.pypi.org/trusted-publishers/) (no API token
|
|
94
|
+
stored in the repo). One-time setup on PyPI, before the first release:
|
|
95
|
+
add a trusted publisher on the `saline-dbs` project (or as a pending
|
|
96
|
+
publisher if the project doesn't exist yet) pointing at this repository,
|
|
97
|
+
workflow `release.yml`, environment `pypi`.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "saline-dbs"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "SALINE: filter-based localization and segmentation of DBS electrodes in clinical MRI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Vanessa H. Yu" },
|
|
15
|
+
{ name = "Edward Chen" },
|
|
16
|
+
{ name = "Jürgen Germann" },
|
|
17
|
+
{ name = "Alexandre Boutet" },
|
|
18
|
+
{ name = "Andres M. Lozano" },
|
|
19
|
+
{ name = "Kâmil Uludağ" },
|
|
20
|
+
{ name = "Sriranga Kashyap" },
|
|
21
|
+
]
|
|
22
|
+
keywords = [
|
|
23
|
+
"deep brain stimulation",
|
|
24
|
+
"dbs",
|
|
25
|
+
"electrode segmentation",
|
|
26
|
+
"mri",
|
|
27
|
+
"neuroimaging",
|
|
28
|
+
]
|
|
29
|
+
classifiers = [
|
|
30
|
+
"Development Status :: 4 - Beta",
|
|
31
|
+
"Intended Audience :: Science/Research",
|
|
32
|
+
"Operating System :: OS Independent",
|
|
33
|
+
"Programming Language :: Python :: 3",
|
|
34
|
+
"Programming Language :: Python :: 3.12",
|
|
35
|
+
"Programming Language :: Python :: 3.13",
|
|
36
|
+
"Topic :: Scientific/Engineering :: Image Processing",
|
|
37
|
+
"Topic :: Scientific/Engineering :: Medical Science Apps.",
|
|
38
|
+
]
|
|
39
|
+
dependencies = [
|
|
40
|
+
"numpy",
|
|
41
|
+
"nibabel",
|
|
42
|
+
"scipy",
|
|
43
|
+
"scikit-image",
|
|
44
|
+
"scikit-learn",
|
|
45
|
+
"click",
|
|
46
|
+
"rich",
|
|
47
|
+
"tqdm",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[project.urls]
|
|
51
|
+
Homepage = "https://github.com/srikash/SALINE"
|
|
52
|
+
Repository = "https://github.com/srikash/SALINE"
|
|
53
|
+
Issues = "https://github.com/srikash/SALINE/issues"
|
|
54
|
+
Paper = "https://doi.org/10.1109/ISBI61048.2026.11515562"
|
|
55
|
+
|
|
56
|
+
[project.scripts]
|
|
57
|
+
saline = "saline.cli:main"
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.packages.find]
|
|
60
|
+
include = ["saline*"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import nibabel as nib
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from tqdm import tqdm
|
|
7
|
+
|
|
8
|
+
from . import external
|
|
9
|
+
from .pipeline import preprocess
|
|
10
|
+
from .segment import segment
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
CITATION = (
|
|
15
|
+
'V. H. Yu et al., "DBS-ElecNet: Automated Localization and Segmentation of DBS '
|
|
16
|
+
'Electrodes in Clinical MRI," 2026 IEEE 23rd International Symposium on Biomedical '
|
|
17
|
+
'Imaging (ISBI), London, United Kingdom, 2026, pp. 1-4, '
|
|
18
|
+
'doi:10.1109/ISBI61048.2026.11515562'
|
|
19
|
+
)
|
|
20
|
+
CITATION_NOTICE = f"If you use this in your work, please cite {CITATION}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DefaultGroup(click.Group):
|
|
24
|
+
"""A click.Group where an unrecognized/absent subcommand falls back to one
|
|
25
|
+
named command, so `saline --input ...` still means `saline dual --input ...`
|
|
26
|
+
without `dual`'s options leaking onto the group itself (which would make
|
|
27
|
+
them required even when running `saline single ...`).
|
|
28
|
+
|
|
29
|
+
Click's own option parsing for the group runs before `resolve_command` is
|
|
30
|
+
ever reached, so the default has to be injected in `parse_args`, before
|
|
31
|
+
Click tries (and fails) to interpret `--input` as a group-level option."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, *args, default_command=None, **kwargs):
|
|
34
|
+
super().__init__(*args, **kwargs)
|
|
35
|
+
self.default_command = default_command
|
|
36
|
+
|
|
37
|
+
def parse_args(self, ctx, args):
|
|
38
|
+
if args and args[0] not in ('-h', '--help') and (
|
|
39
|
+
args[0].startswith('-') or args[0] not in self.commands
|
|
40
|
+
):
|
|
41
|
+
args = [self.default_command, *args]
|
|
42
|
+
return super().parse_args(ctx, args)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _common_options(f):
|
|
46
|
+
f = click.option('--input', 'input_path', required=True, type=str,
|
|
47
|
+
help='path to the raw, native-space input MRI')(f)
|
|
48
|
+
f = click.option('--brain_mask', 'brain_mask_override', type=str, default=None,
|
|
49
|
+
help='precomputed brain mask (1mm-isotropic space); skips Docker-based SynthStrip')(f)
|
|
50
|
+
f = click.option('--synthseg', 'synthseg_override', type=str, default=None,
|
|
51
|
+
help='precomputed SynthSeg segmentation (1mm-isotropic space); skips Docker-based SynthSeg')(f)
|
|
52
|
+
f = click.option('--threads', type=int, default=5, show_default=True,
|
|
53
|
+
help='threads for SynthSeg')(f)
|
|
54
|
+
f = click.option('--laplacian_threshold', type=float, default=0.21, show_default=True, help='the threshold value for laplacian filtered image')(f)
|
|
55
|
+
f = click.option('--frangi_threshold', type=float, default=0.25, show_default=True, help='the threshold value for frangi filtered image')(f)
|
|
56
|
+
f = click.option('--lower_frangi_threshold', type=float, default=0.2, show_default=True, help='the threshold value for frangi filtered image if the higher one failed')(f)
|
|
57
|
+
f = click.option('--expand_radius', type=int, default=6, show_default=True, help='the radius of the final dilation')(f)
|
|
58
|
+
f = click.option('--save_intermediate', is_flag=True, help='save intermediate images (thresholded laplacian and frangi images; combined image) if specified')(f)
|
|
59
|
+
return f
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _run(num_regions, input_path, brain_mask_override, synthseg_override, threads,
|
|
63
|
+
laplacian_threshold, frangi_threshold, lower_frangi_threshold,
|
|
64
|
+
expand_radius, save_intermediate):
|
|
65
|
+
nname = os.path.basename(input_path).split('.nii')[0]
|
|
66
|
+
console.print(f"[bold]SALINE[/bold] start — [cyan]{nname}[/cyan]")
|
|
67
|
+
|
|
68
|
+
with tqdm(desc="SALINE", unit="stage", leave=False) as bar:
|
|
69
|
+
def on_stage(message):
|
|
70
|
+
bar.set_description(message)
|
|
71
|
+
bar.update(1)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
paths = preprocess(
|
|
75
|
+
input_path, brain_mask_override=brain_mask_override,
|
|
76
|
+
synthseg_override=synthseg_override, threads=threads, on_stage=on_stage,
|
|
77
|
+
)
|
|
78
|
+
except external.ExternalToolError as e:
|
|
79
|
+
console.print(f"[red]✗[/red] {e}")
|
|
80
|
+
raise SystemExit(1)
|
|
81
|
+
|
|
82
|
+
img = nib.load(paths["n4"])
|
|
83
|
+
br_mask_data = nib.load(paths["brain_mask"]).get_fdata()
|
|
84
|
+
seg_data = nib.load(paths["synthseg"]).get_fdata()
|
|
85
|
+
|
|
86
|
+
result = segment(
|
|
87
|
+
img.get_fdata(), br_mask_data, seg_data, num_regions,
|
|
88
|
+
laplacian_threshold, frangi_threshold, lower_frangi_threshold,
|
|
89
|
+
expand_radius=expand_radius,
|
|
90
|
+
save_intermediate=lambda name, array: (
|
|
91
|
+
nib.save(nib.Nifti1Image(array.astype('uint8'), img.affine, img.header),
|
|
92
|
+
f"{paths['base']}_{name}.nii.gz")
|
|
93
|
+
if save_intermediate else None
|
|
94
|
+
),
|
|
95
|
+
on_stage=on_stage,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
for intermediate in ("stripped", "brain_mask", "n4", "synthseg"):
|
|
99
|
+
try:
|
|
100
|
+
os.remove(paths[intermediate])
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
if result is None:
|
|
105
|
+
console.print(f"[red]✗[/red] no candidates found for [cyan]{nname}[/cyan], skipping")
|
|
106
|
+
console.print(f"\n[dim]{CITATION_NOTICE}[/dim]")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
on_stage("Resampling result back to native space")
|
|
110
|
+
iso_result_path = f"{paths['base']}_1mm_iso_saline_elecSeg.nii.gz"
|
|
111
|
+
nib.save(nib.Nifti1Image(result, img.affine, img.header), iso_result_path)
|
|
112
|
+
native_result_path = f"{paths['base']}_saline_elecSeg.nii.gz"
|
|
113
|
+
external.resample_to_native(iso_result_path, native_result_path, paths["native_shape"])
|
|
114
|
+
|
|
115
|
+
console.print(f"[green]✓ SALINE done[/green] — [cyan]{nname}[/cyan]")
|
|
116
|
+
console.print(f"\n[dim]{CITATION_NOTICE}[/dim]")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@click.group(cls=DefaultGroup, default_command='dual', epilog=CITATION_NOTICE)
|
|
120
|
+
def cli():
|
|
121
|
+
"""Segmenting the electrode(s) with SALINE."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@cli.command(epilog=CITATION_NOTICE)
|
|
125
|
+
@_common_options
|
|
126
|
+
def dual(input_path, brain_mask_override, synthseg_override, threads,
|
|
127
|
+
laplacian_threshold, frangi_threshold, lower_frangi_threshold,
|
|
128
|
+
expand_radius, save_intermediate):
|
|
129
|
+
"""Dual-electrode mode (left/right split). This is the default."""
|
|
130
|
+
_run(2, input_path, brain_mask_override, synthseg_override, threads,
|
|
131
|
+
laplacian_threshold, frangi_threshold, lower_frangi_threshold,
|
|
132
|
+
expand_radius, save_intermediate)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@cli.command(epilog=CITATION_NOTICE)
|
|
136
|
+
@_common_options
|
|
137
|
+
def single(input_path, brain_mask_override, synthseg_override, threads,
|
|
138
|
+
laplacian_threshold, frangi_threshold, lower_frangi_threshold,
|
|
139
|
+
expand_radius, save_intermediate):
|
|
140
|
+
"""Single-electrode mode."""
|
|
141
|
+
_run(1, input_path, brain_mask_override, synthseg_override, threads,
|
|
142
|
+
laplacian_threshold, frangi_threshold, lower_frangi_threshold,
|
|
143
|
+
expand_radius, save_intermediate)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main():
|
|
147
|
+
cli()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
main()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.ndimage import laplace
|
|
3
|
+
from skimage.filters import frangi
|
|
4
|
+
from skimage import morphology
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def detect_candidates(image, br_mask, csf, laplacian_threshold, frangi_threshold):
|
|
8
|
+
"""
|
|
9
|
+
Detect electrode-track candidate voxels in `image` by combining a Laplacian-based
|
|
10
|
+
edge mask and a Frangi vesselness mask, both restricted to `csf` and `br_mask`.
|
|
11
|
+
|
|
12
|
+
Shared by both single- and dual-electrode segmentation: this step never differed
|
|
13
|
+
between the two modes, only the line-fitting that follows it did.
|
|
14
|
+
|
|
15
|
+
Returns (combined, lap, normalized_frangi):
|
|
16
|
+
combined: the main candidate mask (frangi_img & lap).
|
|
17
|
+
lap: the thresholded Laplacian mask alone, used by the laplacian-only fallback.
|
|
18
|
+
normalized_frangi: the normalized Frangi response, re-thresholded by callers
|
|
19
|
+
at a lower threshold if `combined` turns out too small.
|
|
20
|
+
"""
|
|
21
|
+
brain_mask = morphology.isotropic_erosion(br_mask, radius=20)
|
|
22
|
+
laplacian_filtered = laplace(image)
|
|
23
|
+
pos = np.where(laplacian_filtered > 0, laplacian_filtered, 0)
|
|
24
|
+
neg = -np.where(laplacian_filtered < 0, laplacian_filtered, 0)
|
|
25
|
+
normalized_pos = pos / np.max(pos)
|
|
26
|
+
normalized_neg = neg / np.max(neg)
|
|
27
|
+
|
|
28
|
+
thresholded_pos = normalized_pos > laplacian_threshold
|
|
29
|
+
thresholded_neg = normalized_neg > laplacian_threshold
|
|
30
|
+
thresholded_img = thresholded_pos | thresholded_neg
|
|
31
|
+
thresholded_img = thresholded_img.astype(np.uint8) * brain_mask.astype(np.uint8)
|
|
32
|
+
thresholded_img = morphology.isotropic_closing(thresholded_img, radius=3)
|
|
33
|
+
thresholded_img = morphology.remove_small_objects(thresholded_img, max_size=4)
|
|
34
|
+
thresholded_img = morphology.isotropic_dilation(thresholded_img, radius=2)
|
|
35
|
+
lap = thresholded_img * csf
|
|
36
|
+
|
|
37
|
+
brain_mask = morphology.isotropic_erosion(br_mask, radius=2)
|
|
38
|
+
filtered = frangi(image)
|
|
39
|
+
filtered = filtered * brain_mask.astype(np.uint8)
|
|
40
|
+
min_val = np.min(filtered)
|
|
41
|
+
max_val = np.max(filtered)
|
|
42
|
+
normalized_frangi = (filtered - min_val) / (max_val - min_val)
|
|
43
|
+
|
|
44
|
+
thresholded_image = normalized_frangi > frangi_threshold
|
|
45
|
+
thresholded_image = morphology.isotropic_closing(thresholded_image, radius=3)
|
|
46
|
+
thresholded_image = morphology.remove_small_objects(thresholded_image, max_size=4)
|
|
47
|
+
thresholded_image = morphology.isotropic_closing(thresholded_image, radius=8)
|
|
48
|
+
thresholded_image = morphology.isotropic_dilation(thresholded_image, radius=2)
|
|
49
|
+
frangi_img = thresholded_image * csf
|
|
50
|
+
|
|
51
|
+
combined = frangi_img & lap
|
|
52
|
+
return combined, lap, normalized_frangi
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Runs the external tools SALINE's standalone pipeline needs: ANTs
|
|
2
|
+
(resampling, N4 bias correction, mask multiply), SynthStrip, and SynthSeg.
|
|
3
|
+
|
|
4
|
+
Docker is tried first for all three, since it needs no local install:
|
|
5
|
+
- ANTs: https://hub.docker.com/r/antsx/ants
|
|
6
|
+
- SynthStrip: https://hub.docker.com/r/freesurfer/synthstrip
|
|
7
|
+
- SynthSeg: https://hub.docker.com/r/cookpa/synthseg
|
|
8
|
+
|
|
9
|
+
ANTs falls back to a local install (`ResampleImage`/`N4BiasFieldCorrection`/
|
|
10
|
+
`ImageMath` on PATH) if Docker isn't available, since there's no other
|
|
11
|
+
precomputed file a caller could hand in for a resampling step. SynthStrip
|
|
12
|
+
and SynthSeg have no such local fallback here -- if Docker isn't available,
|
|
13
|
+
the caller (pipeline.preprocess) expects a precomputed brain mask or
|
|
14
|
+
segmentation instead.
|
|
15
|
+
|
|
16
|
+
Every path handed to a container is resolved to an absolute path first: the
|
|
17
|
+
container's working directory is not the caller's cwd, so a relative path
|
|
18
|
+
like `saline --input subject.nii.gz` would otherwise mount the right
|
|
19
|
+
directory but still fail to find the file by its relative name inside it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
|
|
26
|
+
ANTS_DOCKER_IMAGE = os.environ.get("SALINE_ANTS_IMAGE", "antsx/ants:latest")
|
|
27
|
+
SYNTHSTRIP_DOCKER_IMAGE = os.environ.get("SALINE_SYNTHSTRIP_IMAGE", "freesurfer/synthstrip:1.8")
|
|
28
|
+
SYNTHSEG_DOCKER_IMAGE = os.environ.get("SALINE_SYNTHSEG_IMAGE", "cookpa/synthseg:conda-0.2")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ExternalToolError(RuntimeError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def docker_available():
|
|
36
|
+
if shutil.which("docker") is None:
|
|
37
|
+
return False
|
|
38
|
+
try:
|
|
39
|
+
subprocess.run(
|
|
40
|
+
["docker", "info"],
|
|
41
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
42
|
+
timeout=10, check=True,
|
|
43
|
+
)
|
|
44
|
+
return True
|
|
45
|
+
except (subprocess.SubprocessError, OSError):
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run(cmd):
|
|
50
|
+
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
51
|
+
if result.returncode != 0:
|
|
52
|
+
raise ExternalToolError(
|
|
53
|
+
f"command failed ({result.returncode}): {' '.join(cmd)}\n{result.stdout}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_in_docker(image, args):
|
|
58
|
+
"""`args` must already be absolute paths (see module docstring) mixed with
|
|
59
|
+
plain string flags/values; only `str` entries that are existing parent
|
|
60
|
+
directories matter for mounting, so callers pass the full arg list and we
|
|
61
|
+
mount every absolute path's parent directory found in it."""
|
|
62
|
+
dirs = sorted({os.path.dirname(a) for a in args if a.startswith("/")})
|
|
63
|
+
mounts = []
|
|
64
|
+
for d in dirs:
|
|
65
|
+
mounts += ["-v", f"{d}:{d}"]
|
|
66
|
+
cmd = [
|
|
67
|
+
"docker", "run", "--rm",
|
|
68
|
+
"-u", f"{os.getuid()}:{os.getgid()}",
|
|
69
|
+
*mounts, image, *args,
|
|
70
|
+
]
|
|
71
|
+
_run(cmd)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resample_to_iso(input_path, output_path, spacing="1.0x1.0x1.0"):
|
|
75
|
+
"""Resample `input_path` to isotropic `spacing` mm, windowed-sinc (Lanczos)
|
|
76
|
+
interpolated -- suitable for a continuous-intensity MRI, not a label mask."""
|
|
77
|
+
input_path = os.path.realpath(input_path)
|
|
78
|
+
output_path = os.path.realpath(output_path)
|
|
79
|
+
args = ["ResampleImage", "3", input_path, output_path, spacing, "0", "3[l]"]
|
|
80
|
+
if docker_available():
|
|
81
|
+
_run_in_docker(ANTS_DOCKER_IMAGE, args)
|
|
82
|
+
elif shutil.which("ResampleImage"):
|
|
83
|
+
_run(args)
|
|
84
|
+
else:
|
|
85
|
+
raise ExternalToolError(
|
|
86
|
+
"Neither Docker nor a local ResampleImage (ANTs) was found. "
|
|
87
|
+
"Install Docker, or install ANTs locally."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resample_to_native(input_path, output_path, native_shape):
|
|
92
|
+
"""Resample a label/mask volume at `input_path` to `native_shape`
|
|
93
|
+
(a (d1, d2, d3) voxel-count tuple), nearest-neighbor interpolated so
|
|
94
|
+
binary values aren't blurred."""
|
|
95
|
+
input_path = os.path.realpath(input_path)
|
|
96
|
+
output_path = os.path.realpath(output_path)
|
|
97
|
+
size = "x".join(str(int(d)) for d in native_shape)
|
|
98
|
+
args = ["ResampleImage", "3", input_path, output_path, size, "1", "1"]
|
|
99
|
+
if docker_available():
|
|
100
|
+
_run_in_docker(ANTS_DOCKER_IMAGE, args)
|
|
101
|
+
elif shutil.which("ResampleImage"):
|
|
102
|
+
_run(args)
|
|
103
|
+
else:
|
|
104
|
+
raise ExternalToolError(
|
|
105
|
+
"Neither Docker nor a local ResampleImage (ANTs) was found. "
|
|
106
|
+
"Install Docker, or install ANTs locally."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def n4_correct(input_path, mask_path, output_path):
|
|
111
|
+
input_path = os.path.realpath(input_path)
|
|
112
|
+
mask_path = os.path.realpath(mask_path)
|
|
113
|
+
output_path = os.path.realpath(output_path)
|
|
114
|
+
args = ["N4BiasFieldCorrection", "-d", "3", "-x", mask_path, "-i", input_path, "-o", output_path]
|
|
115
|
+
if docker_available():
|
|
116
|
+
_run_in_docker(ANTS_DOCKER_IMAGE, args)
|
|
117
|
+
elif shutil.which("N4BiasFieldCorrection"):
|
|
118
|
+
_run(args)
|
|
119
|
+
else:
|
|
120
|
+
raise ExternalToolError(
|
|
121
|
+
"Neither Docker nor a local N4BiasFieldCorrection (ANTs) was found. "
|
|
122
|
+
"Install Docker, or install ANTs locally."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def mask_multiply(output_path, image_path, mask_path):
|
|
127
|
+
"""output = image * mask, i.e. a skull-stripped image given a brain mask."""
|
|
128
|
+
output_path = os.path.realpath(output_path)
|
|
129
|
+
image_path = os.path.realpath(image_path)
|
|
130
|
+
mask_path = os.path.realpath(mask_path)
|
|
131
|
+
args = ["ImageMath", "3", output_path, "m", image_path, mask_path]
|
|
132
|
+
if docker_available():
|
|
133
|
+
_run_in_docker(ANTS_DOCKER_IMAGE, args)
|
|
134
|
+
elif shutil.which("ImageMath"):
|
|
135
|
+
_run(args)
|
|
136
|
+
else:
|
|
137
|
+
raise ExternalToolError(
|
|
138
|
+
"Neither Docker nor a local ImageMath (ANTs) was found. "
|
|
139
|
+
"Install Docker, or install ANTs locally."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def synthstrip(input_path, stripped_out, mask_out):
|
|
144
|
+
if not docker_available():
|
|
145
|
+
raise ExternalToolError(
|
|
146
|
+
"Docker isn't available to run SynthStrip. "
|
|
147
|
+
"Install Docker, or pass a precomputed brain mask instead."
|
|
148
|
+
)
|
|
149
|
+
input_path = os.path.realpath(input_path)
|
|
150
|
+
stripped_out = os.path.realpath(stripped_out)
|
|
151
|
+
mask_out = os.path.realpath(mask_out)
|
|
152
|
+
args = ["-i", input_path, "-o", stripped_out, "-m", mask_out]
|
|
153
|
+
_run_in_docker(SYNTHSTRIP_DOCKER_IMAGE, args)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def synthseg(input_path, seg_out, threads=5):
|
|
157
|
+
if not docker_available():
|
|
158
|
+
raise ExternalToolError(
|
|
159
|
+
"Docker isn't available to run SynthSeg. "
|
|
160
|
+
"Install Docker, or pass a precomputed segmentation instead."
|
|
161
|
+
)
|
|
162
|
+
input_path = os.path.realpath(input_path)
|
|
163
|
+
seg_out = os.path.realpath(seg_out)
|
|
164
|
+
args = ["--i", input_path, "--o", seg_out, "--threads", str(threads), "--cpu"]
|
|
165
|
+
_run_in_docker(SYNTHSEG_DOCKER_IMAGE, args)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from skimage import morphology
|
|
3
|
+
from sklearn.linear_model import RANSACRegressor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fit_best_fit_line_RANSAC(points):
|
|
7
|
+
X = points[:, :2] # Use the first two coordinates (x, y) as features
|
|
8
|
+
y = points[:, 2] # Use the third coordinate (z) as the target
|
|
9
|
+
|
|
10
|
+
ransac = RANSACRegressor(min_samples=2, random_state=42, loss='absolute_error')
|
|
11
|
+
ransac.fit(X, y)
|
|
12
|
+
|
|
13
|
+
inlier_points = points[ransac.inlier_mask_]
|
|
14
|
+
centroid = np.mean(inlier_points, axis=0)
|
|
15
|
+
centered_points = inlier_points - centroid
|
|
16
|
+
_, _, vt = np.linalg.svd(centered_points)
|
|
17
|
+
direction = vt[0]
|
|
18
|
+
|
|
19
|
+
return centroid, direction
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fit_best_fit_line(points):
|
|
23
|
+
centroid = np.mean(points, axis=0)
|
|
24
|
+
centered_points = points - centroid
|
|
25
|
+
_, _, vt = np.linalg.svd(centered_points)
|
|
26
|
+
direction = vt[0]
|
|
27
|
+
|
|
28
|
+
return centroid, direction
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def draw_lines(ends, img_shape, fits, br_mask, radius=6):
|
|
32
|
+
"""
|
|
33
|
+
Draw one dilated binary mask covering every region's fitted line.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
ends: per-region starting slice index, same length as `fits` (length 1 for
|
|
37
|
+
single-electrode, length 2 for dual-electrode: left end, right end).
|
|
38
|
+
img_shape: shape of the 3D volume, used to keep points in bounds.
|
|
39
|
+
fits: per-region (centroid, direction) tuples, one per region.
|
|
40
|
+
br_mask: brain mask the result is multiplied by before dilation.
|
|
41
|
+
radius: dilation radius applied to the final mask.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
A binary mask with ones along every region's line, dilated by `radius`.
|
|
45
|
+
"""
|
|
46
|
+
step = 0.1
|
|
47
|
+
final_result = np.zeros(img_shape)
|
|
48
|
+
|
|
49
|
+
for end, (centroid, direction) in zip(ends, fits):
|
|
50
|
+
z = np.arange(end, img_shape[-1], step)
|
|
51
|
+
# k is the scalar in the line definition equation (point = centroid + k * direction)
|
|
52
|
+
k = (z - centroid[-1]) / direction[-1]
|
|
53
|
+
elec = np.floor(centroid + np.outer(k, direction)).astype(int)
|
|
54
|
+
|
|
55
|
+
cond = (elec[:, 0] > 0) & (elec[:, 0] < img_shape[0]) & \
|
|
56
|
+
(elec[:, 1] > 0) & (elec[:, 1] < img_shape[1]) & \
|
|
57
|
+
(elec[:, 2] > 0) & (elec[:, 2] < img_shape[2])
|
|
58
|
+
final_result[tuple(elec[cond].T)] = 1
|
|
59
|
+
|
|
60
|
+
final_result = final_result * br_mask
|
|
61
|
+
final_result = morphology.isotropic_dilation(final_result, radius=radius)
|
|
62
|
+
return final_result
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
|
|
4
|
+
from . import external
|
|
5
|
+
from .volume import get_shape
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def derive_base(input_path):
|
|
9
|
+
"""Strip the .nii/.nii.gz suffix, e.g. '/a/b/subject.nii.gz' -> '/a/b/subject'."""
|
|
10
|
+
idx = input_path.find(".nii")
|
|
11
|
+
return input_path[:idx] if idx != -1 else input_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _copy_if_distinct(src, dst):
|
|
15
|
+
"""shutil.copy, but a no-op when src already *is* dst (e.g. the caller's
|
|
16
|
+
override file happens to sit at the pipeline's own derived path)."""
|
|
17
|
+
if os.path.realpath(src) != os.path.realpath(dst):
|
|
18
|
+
shutil.copy(src, dst)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def preprocess(raw_input_path, brain_mask_override=None, synthseg_override=None,
|
|
22
|
+
threads=5, on_stage=None):
|
|
23
|
+
"""
|
|
24
|
+
Run the full SALINE preprocessing pipeline on a raw, native-space clinical
|
|
25
|
+
MRI: resample to 1mm isotropic, skull-strip (SynthStrip), N4 bias correct,
|
|
26
|
+
segment (SynthSeg). SynthStrip and SynthSeg run via Docker by default; pass
|
|
27
|
+
`brain_mask_override` / `synthseg_override` (paths to precomputed files,
|
|
28
|
+
already in 1mm-isotropic space) to skip either step, e.g. when Docker isn't
|
|
29
|
+
available.
|
|
30
|
+
|
|
31
|
+
Returns a dict with the paths of every file produced (including the
|
|
32
|
+
retained 1mm-isotropic MRI) and `native_shape`, the original volume's
|
|
33
|
+
voxel shape, needed to resample results back to native space afterward.
|
|
34
|
+
"""
|
|
35
|
+
on_stage = on_stage or (lambda message: None)
|
|
36
|
+
base = derive_base(raw_input_path)
|
|
37
|
+
|
|
38
|
+
native_shape = get_shape(raw_input_path)
|
|
39
|
+
|
|
40
|
+
iso_path = f"{base}_1mm_iso.nii.gz"
|
|
41
|
+
stripped_path = f"{base}_1mm_iso_skull_striped.nii.gz"
|
|
42
|
+
mask_path = f"{base}_1mm_iso_brain_mask.nii.gz"
|
|
43
|
+
n4_path = f"{base}_1mm_iso_skull_striped_n4.nii.gz"
|
|
44
|
+
seg_path = f"{base}_1mm_iso_skull_striped_n4_seg.nii.gz"
|
|
45
|
+
|
|
46
|
+
on_stage("Resampling to 1mm isotropic")
|
|
47
|
+
external.resample_to_iso(raw_input_path, iso_path)
|
|
48
|
+
|
|
49
|
+
if brain_mask_override:
|
|
50
|
+
on_stage("Using provided brain mask")
|
|
51
|
+
_copy_if_distinct(brain_mask_override, mask_path)
|
|
52
|
+
external.mask_multiply(stripped_path, iso_path, mask_path)
|
|
53
|
+
else:
|
|
54
|
+
on_stage("Running SynthStrip")
|
|
55
|
+
external.synthstrip(iso_path, stripped_path, mask_path)
|
|
56
|
+
|
|
57
|
+
on_stage("N4 bias correction")
|
|
58
|
+
external.n4_correct(stripped_path, mask_path, n4_path)
|
|
59
|
+
|
|
60
|
+
if synthseg_override:
|
|
61
|
+
on_stage("Using provided SynthSeg segmentation")
|
|
62
|
+
_copy_if_distinct(synthseg_override, seg_path)
|
|
63
|
+
else:
|
|
64
|
+
on_stage("Running SynthSeg")
|
|
65
|
+
external.synthseg(n4_path, seg_path, threads=threads)
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"base": base,
|
|
69
|
+
"native_shape": native_shape,
|
|
70
|
+
"iso": iso_path,
|
|
71
|
+
"stripped": stripped_path,
|
|
72
|
+
"brain_mask": mask_path,
|
|
73
|
+
"n4": n4_path,
|
|
74
|
+
"synthseg": seg_path,
|
|
75
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from skimage import morphology
|
|
3
|
+
|
|
4
|
+
from .detect import detect_candidates
|
|
5
|
+
from .fit import fit_best_fit_line, fit_best_fit_line_RANSAC, draw_lines
|
|
6
|
+
|
|
7
|
+
CSF_EXCLUDED_LABELS = (24, 4, 43, 44, 5, 15) # SynthSeg labels for CSF / excluded structures
|
|
8
|
+
VENTRAL_DC_LABELS = (28, 60) # left, right VentralDC: electrode track ends near its base
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _electrode_ends(seg, num_regions):
|
|
12
|
+
_, __, left_end = np.min(np.transpose(np.nonzero(seg == VENTRAL_DC_LABELS[0])), axis=0)
|
|
13
|
+
_, __, right_end = np.min(np.transpose(np.nonzero(seg == VENTRAL_DC_LABELS[1])), axis=0)
|
|
14
|
+
if num_regions == 1:
|
|
15
|
+
return (max(left_end, right_end) + 5,)
|
|
16
|
+
return (left_end + 5, right_end + 5)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _region_points(mask, num_regions):
|
|
20
|
+
"""Split `mask` into left/right halves for dual-electrode mode, or take it whole."""
|
|
21
|
+
if num_regions == 1:
|
|
22
|
+
return [np.transpose(np.nonzero(mask))]
|
|
23
|
+
half = mask.shape[0] // 2
|
|
24
|
+
left = np.zeros_like(mask)
|
|
25
|
+
right = np.zeros_like(mask)
|
|
26
|
+
left[:half, :, :] = mask[:half, :, :]
|
|
27
|
+
right[half:, :, :] = mask[half:, :, :]
|
|
28
|
+
return [np.transpose(np.nonzero(left)), np.transpose(np.nonzero(right))]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _regions_have_spread(points_list, axis, min_spread):
|
|
32
|
+
if any(pts.shape[0] == 0 for pts in points_list):
|
|
33
|
+
return False
|
|
34
|
+
for pts in points_list:
|
|
35
|
+
spread = pts[:, axis].max() - pts[:, axis].min()
|
|
36
|
+
if spread < min_spread:
|
|
37
|
+
return False
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _fit_regions_with_ransac_check(points_list):
|
|
42
|
+
"""Fallback-path fit: use RANSAC when points look like they contain outliers."""
|
|
43
|
+
fits = []
|
|
44
|
+
for pts in points_list:
|
|
45
|
+
y_spread = pts[:, 1].max() - pts[:, 1].min()
|
|
46
|
+
if y_spread > 30:
|
|
47
|
+
fits.append(fit_best_fit_line_RANSAC(pts))
|
|
48
|
+
else:
|
|
49
|
+
fits.append(fit_best_fit_line(pts))
|
|
50
|
+
return fits
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def segment(image, br_mask, seg, num_regions,
|
|
54
|
+
laplacian_threshold, frangi_threshold, lower_frangi_threshold,
|
|
55
|
+
expand_radius=6, save_intermediate=None, on_stage=None):
|
|
56
|
+
"""
|
|
57
|
+
Segment one (num_regions=1) or two (num_regions=2, left/right split) electrode
|
|
58
|
+
tracks in `image`.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
image, br_mask, seg: np.ndarray volumes — the subject MRI, its brain mask,
|
|
62
|
+
and its SynthSeg label volume.
|
|
63
|
+
num_regions: 1 for single-electrode mode, 2 for dual-electrode mode.
|
|
64
|
+
laplacian_threshold, frangi_threshold, lower_frangi_threshold: detection
|
|
65
|
+
thresholds (see `detect.detect_candidates`).
|
|
66
|
+
expand_radius: dilation radius (voxels) applied to the final line mask.
|
|
67
|
+
save_intermediate: optional `callback(name, mask)` invoked with each
|
|
68
|
+
intermediate mask that's worth persisting, so this function stays
|
|
69
|
+
array-in/array-out and callers decide whether/how to save to disk.
|
|
70
|
+
on_stage: optional `callback(message: str)` invoked as processing enters
|
|
71
|
+
each named stage, so callers can drive their own progress display
|
|
72
|
+
(e.g. a progress bar) without this function knowing it exists.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A binary mask (np.ndarray) covering the detected electrode track(s), or
|
|
76
|
+
None if no candidate voxels could be found even after both fallbacks.
|
|
77
|
+
"""
|
|
78
|
+
on_stage = on_stage or (lambda message: None)
|
|
79
|
+
|
|
80
|
+
on_stage("Computing CSF mask")
|
|
81
|
+
csf = np.ones(seg.shape, dtype=bool)
|
|
82
|
+
for label in CSF_EXCLUDED_LABELS:
|
|
83
|
+
csf &= (seg != label)
|
|
84
|
+
csf = morphology.isotropic_erosion(csf, radius=3)
|
|
85
|
+
|
|
86
|
+
ends = _electrode_ends(seg, num_regions)
|
|
87
|
+
|
|
88
|
+
on_stage("Detecting candidate voxels (Laplacian + Frangi)")
|
|
89
|
+
combined, lap, normalized_frangi = detect_candidates(
|
|
90
|
+
image, br_mask, csf, laplacian_threshold, frangi_threshold)
|
|
91
|
+
|
|
92
|
+
if save_intermediate:
|
|
93
|
+
save_intermediate("thr-lap", lap)
|
|
94
|
+
save_intermediate("comb", combined)
|
|
95
|
+
|
|
96
|
+
points = _region_points(combined, num_regions)
|
|
97
|
+
|
|
98
|
+
if _regions_have_spread(points, axis=2, min_spread=10):
|
|
99
|
+
on_stage("Fitting line(s)")
|
|
100
|
+
fits = [fit_best_fit_line(pts) for pts in points]
|
|
101
|
+
else:
|
|
102
|
+
on_stage("Lowering Frangi threshold")
|
|
103
|
+
frangi_img = normalized_frangi > lower_frangi_threshold
|
|
104
|
+
frangi_img = frangi_img * csf
|
|
105
|
+
frangi_img = morphology.isotropic_closing(frangi_img, radius=3)
|
|
106
|
+
frangi_img = morphology.remove_small_objects(frangi_img, max_size=4)
|
|
107
|
+
if save_intermediate:
|
|
108
|
+
save_intermediate("frangi", frangi_img)
|
|
109
|
+
points = _region_points(frangi_img, num_regions)
|
|
110
|
+
|
|
111
|
+
if any(pts.shape[0] == 0 for pts in points):
|
|
112
|
+
on_stage("Falling back to Laplacian-only mask")
|
|
113
|
+
points = _region_points(lap, num_regions)
|
|
114
|
+
if any(pts.shape[0] == 0 for pts in points):
|
|
115
|
+
on_stage("No candidate voxels found — skipping")
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
on_stage("Fitting line(s) (fallback)")
|
|
119
|
+
fits = _fit_regions_with_ransac_check(points)
|
|
120
|
+
|
|
121
|
+
on_stage("Drawing segmentation mask")
|
|
122
|
+
return draw_lines(ends, image.shape, fits, br_mask, radius=expand_radius)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: saline-dbs
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: SALINE: filter-based localization and segmentation of DBS electrodes in clinical MRI
|
|
5
|
+
Author: Vanessa H. Yu, Edward Chen, Jürgen Germann, Alexandre Boutet, Andres M. Lozano, Kâmil Uludağ, Sriranga Kashyap
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/srikash/SALINE
|
|
8
|
+
Project-URL: Repository, https://github.com/srikash/SALINE
|
|
9
|
+
Project-URL: Issues, https://github.com/srikash/SALINE/issues
|
|
10
|
+
Project-URL: Paper, https://doi.org/10.1109/ISBI61048.2026.11515562
|
|
11
|
+
Keywords: deep brain stimulation,dbs,electrode segmentation,mri,neuroimaging
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: numpy
|
|
24
|
+
Requires-Dist: nibabel
|
|
25
|
+
Requires-Dist: scipy
|
|
26
|
+
Requires-Dist: scikit-image
|
|
27
|
+
Requires-Dist: scikit-learn
|
|
28
|
+
Requires-Dist: click
|
|
29
|
+
Requires-Dist: rich
|
|
30
|
+
Requires-Dist: tqdm
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# SALINE (part of DBS-ElecNet)
|
|
34
|
+
## <b><ins>S</ins></b>egmentation <b><ins>A</ins></b>lgorithm using <b><ins>LIN</ins></b>e-fitting for <b><ins>E</ins></b>lectrodes
|
|
35
|
+
|
|
36
|
+
*V. H. Yu et al., "DBS-ElecNet: Automated Localization and Segmentation of DBS Electrodes in Clinical MRI," 2026 IEEE 23rd International Symposium on Biomedical Imaging (ISBI), London, United Kingdom, 2026, pp. 1-4, doi:[10.1109/ISBI61048.2026.11515562](https://doi.org/10.1109/ISBI61048.2026.11515562)*
|
|
37
|
+
|
|
38
|
+
Give SALINE a raw clinical T1-weighted MR image and it handles the rest: resampling,
|
|
39
|
+
skull-stripping (SynthStrip), bias correction (N4), and segmentation (SynthSeg)
|
|
40
|
+
all run via Docker, with no other install needed. It runs as a fully standalone CLI tool.
|
|
41
|
+
|
|
42
|
+
It is the core of [DBS-ElecNet](https://github.com/BRAIN-TO/DBS-ElecNet)'s
|
|
43
|
+
classical (non-deep-learning) segmentation pipeline.
|
|
44
|
+
|
|
45
|
+
## Why SALINE?
|
|
46
|
+
|
|
47
|
+
SALINE is a classical, filtering-based automatic segmentation method for DBS
|
|
48
|
+
electrodes: no training, no manual annotation, no GPU. Give it an MRI, a brain
|
|
49
|
+
mask, and a SynthSeg segmentation, and it finds the electrode track with a
|
|
50
|
+
Laplacian/Frangi filter cascade and a line fit.
|
|
51
|
+
|
|
52
|
+
That makes it well suited to building a large database of electrode
|
|
53
|
+
segmentations, at scale, without a human labeling each scan by hand. In the
|
|
54
|
+
DBS-ElecNet paper, SALINE segmented 280 post-operative MRI scans in about 1.5
|
|
55
|
+
minutes each, and those segmentations became the training data for
|
|
56
|
+
DBS-ElecNet's 3D U-Net. Any similar segmentation model can be trained the
|
|
57
|
+
same way: run SALINE over a cohort, use its output as ground truth.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
Requires Python 3.12+ and [Docker](https://www.docker.com) (recommended; see
|
|
62
|
+
below for running without it).
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install git+https://github.com/srikash/SALINE.git@v0.3.0
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
Dual-electrode (default, also available explicitly as `saline dual`):
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
saline --input subject.nii.gz
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Single-electrode:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
saline single --input subject.nii.gz
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`--input` is a raw, native-space clinical MRI. SALINE resamples it to 1mm
|
|
83
|
+
isotropic, skull-strips it (SynthStrip), bias-corrects it (N4), and segments
|
|
84
|
+
it (SynthSeg) before finding the electrode track. All of this runs via
|
|
85
|
+
Docker, pulling [`antsx/ants`](https://hub.docker.com/r/antsx/ants),
|
|
86
|
+
[`freesurfer/synthstrip`](https://hub.docker.com/r/freesurfer/synthstrip), and
|
|
87
|
+
[`cookpa/synthseg`](https://hub.docker.com/r/cookpa/synthseg) on first use.
|
|
88
|
+
|
|
89
|
+
**Output:**
|
|
90
|
+
|
|
91
|
+
* `subject_saline_elecSeg.nii.gz`: the electrode segmentation, in `--input`'s native space.
|
|
92
|
+
* `subject_1mm_iso.nii.gz`: the resampled MRI, kept for reference.
|
|
93
|
+
* `subject_1mm_iso_saline_elecSeg.nii.gz`: the electrode segmentation, in 1mm-isotropic space.
|
|
94
|
+
|
|
95
|
+
**Running without Docker:**
|
|
96
|
+
|
|
97
|
+
If Docker isn't available, pass precomputed files instead. Both must already
|
|
98
|
+
be in the same 1mm-isotropic space as `subject_1mm_iso.nii.gz`:
|
|
99
|
+
|
|
100
|
+
* `--brain_mask PATH`: skips Docker-based SynthStrip.
|
|
101
|
+
* `--synthseg PATH`: skips Docker-based SynthSeg.
|
|
102
|
+
|
|
103
|
+
ANTs (resampling, N4, mask multiply) falls back to a local install
|
|
104
|
+
(`ResampleImage`, `N4BiasFieldCorrection`, `ImageMath` on `PATH`) if Docker
|
|
105
|
+
isn't available. There's no equivalent precomputed-file option for those.
|
|
106
|
+
|
|
107
|
+
**Optional arguments:**
|
|
108
|
+
|
|
109
|
+
* `--threads` (default `5`): threads for SynthSeg.
|
|
110
|
+
* `--laplacian_threshold` (default `0.21`)
|
|
111
|
+
* `--frangi_threshold` (default `0.25`)
|
|
112
|
+
* `--lower_frangi_threshold` (default `0.2`): used if the higher threshold finds no candidates.
|
|
113
|
+
* `--expand_radius` (default `6`): dilation radius applied to the final line mask.
|
|
114
|
+
* `--save_intermediate`: also save the thresholded Laplacian, Frangi, and combined masks.
|
|
115
|
+
|
|
116
|
+
## Citation
|
|
117
|
+
|
|
118
|
+
If you use this in your work, please cite the paper above (see [`CITATION.cff`](CITATION.cff)
|
|
119
|
+
for the full machine-readable record).
|
|
120
|
+
|
|
121
|
+
## Releasing
|
|
122
|
+
|
|
123
|
+
Publishing a GitHub Release triggers `.github/workflows/release.yml`, which
|
|
124
|
+
builds the package and publishes it to PyPI via
|
|
125
|
+
[trusted publishing](https://docs.pypi.org/trusted-publishers/) (no API token
|
|
126
|
+
stored in the repo). One-time setup on PyPI, before the first release:
|
|
127
|
+
add a trusted publisher on the `saline-dbs` project (or as a pending
|
|
128
|
+
publisher if the project doesn't exist yet) pointing at this repository,
|
|
129
|
+
workflow `release.yml`, environment `pypi`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
saline/__init__.py
|
|
5
|
+
saline/cli.py
|
|
6
|
+
saline/detect.py
|
|
7
|
+
saline/external.py
|
|
8
|
+
saline/fit.py
|
|
9
|
+
saline/pipeline.py
|
|
10
|
+
saline/segment.py
|
|
11
|
+
saline/volume.py
|
|
12
|
+
saline_dbs.egg-info/PKG-INFO
|
|
13
|
+
saline_dbs.egg-info/SOURCES.txt
|
|
14
|
+
saline_dbs.egg-info/dependency_links.txt
|
|
15
|
+
saline_dbs.egg-info/entry_points.txt
|
|
16
|
+
saline_dbs.egg-info/requires.txt
|
|
17
|
+
saline_dbs.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
saline
|