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.
@@ -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,3 @@
1
+ from .segment import segment
2
+
3
+ __all__ = ["segment"]
@@ -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,6 @@
1
+ import nibabel as nib
2
+
3
+
4
+ def get_shape(path):
5
+ """Return the (dim1, dim2, dim3) voxel shape of a NIfTI volume at `path`."""
6
+ return nib.load(path).get_fdata().shape
@@ -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,2 @@
1
+ [console_scripts]
2
+ saline = saline.cli:main
@@ -0,0 +1,8 @@
1
+ numpy
2
+ nibabel
3
+ scipy
4
+ scikit-image
5
+ scikit-learn
6
+ click
7
+ rich
8
+ tqdm
@@ -0,0 +1 @@
1
+ saline
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+