vesskel 1.0.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.
- vesskel-1.0.0/LICENSE +34 -0
- vesskel-1.0.0/PKG-INFO +76 -0
- vesskel-1.0.0/README.md +54 -0
- vesskel-1.0.0/pyproject.toml +36 -0
- vesskel-1.0.0/setup.cfg +4 -0
- vesskel-1.0.0/tests/test_2d_thinning_regression.py +151 -0
- vesskel-1.0.0/tests/test_3d_skimage_comparison.py +27 -0
- vesskel-1.0.0/tests/test_3d_thinning_regression.py +119 -0
- vesskel-1.0.0/vesskel/__init__.py +0 -0
- vesskel-1.0.0/vesskel/_napari.py +25 -0
- vesskel-1.0.0/vesskel/features.py +111 -0
- vesskel-1.0.0/vesskel/hrf.py +129 -0
- vesskel-1.0.0/vesskel/thin.py +29 -0
- vesskel-1.0.0/vesskel/thin_2d.py +173 -0
- vesskel-1.0.0/vesskel/thin_3d.py +416 -0
- vesskel-1.0.0/vesskel.egg-info/PKG-INFO +76 -0
- vesskel-1.0.0/vesskel.egg-info/SOURCES.txt +19 -0
- vesskel-1.0.0/vesskel.egg-info/dependency_links.txt +1 -0
- vesskel-1.0.0/vesskel.egg-info/entry_points.txt +2 -0
- vesskel-1.0.0/vesskel.egg-info/requires.txt +14 -0
- vesskel-1.0.0/vesskel.egg-info/top_level.txt +1 -0
vesskel-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simon
|
|
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.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## HRF Dataset Attribution
|
|
26
|
+
|
|
27
|
+
This project uses the High-Resolution Fundus (HRF) Image Database, which is
|
|
28
|
+
licensed under the Creative Commons 4.0 Attribution License, cited as:
|
|
29
|
+
|
|
30
|
+
Budai, Attila; Bock, Rüdiger; Maier, Andreas; Hornegger, Joachim; Michelson, Georg.
|
|
31
|
+
Robust Vessel Segmentation in Fundus Images.
|
|
32
|
+
International Journal of Biomedical Imaging, vol. 2013, 2013
|
|
33
|
+
|
|
34
|
+
For more information, visit: https://www5.cs.fau.de/research/data/fundus-images/
|
vesskel-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vesskel
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: 2D and 3D Skeletonization and Graph-based Analysis
|
|
5
|
+
License-Expression: MIT AND (Apache-2.0 OR BSD-2-Clause)
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: numba>=0.65.0
|
|
10
|
+
Requires-Dist: numpy>=2.4.4
|
|
11
|
+
Requires-Dist: pillow>=12.2.0
|
|
12
|
+
Requires-Dist: scipy>=1.17.1
|
|
13
|
+
Requires-Dist: skan>=0.13.1
|
|
14
|
+
Provides-Extra: napari
|
|
15
|
+
Requires-Dist: napari[all]; extra == "napari"
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=9.0.2; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-xdist>=3.8.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pooch>=1.9.0; extra == "dev"
|
|
20
|
+
Requires-Dist: scikit-image>=0.26.0; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# VesSkel
|
|
24
|
+
|
|
25
|
+
Vessel Skeletonization and Graph-Based Phenotype Analysis in Retinal Fundus Images
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
uv sync # core only
|
|
31
|
+
uv sync --extra dev # + test tools
|
|
32
|
+
uv sync --extra napari # + napari GUI
|
|
33
|
+
uv sync --all-extras # everything
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Napari
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
uv sync --extra napari && uv run napari
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Open a `manual1` TIFF from the HRF folder, then run **Lee94 Thinning** from the VesSkel plugin menu to see the skeleton.
|
|
43
|
+
|
|
44
|
+
## Tests
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
uv sync --extra dev && uv run pytest
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- **2D regression** - thinning + feature extraction on all 45 HRF samples, compared against saved baselines
|
|
51
|
+
- **3D regression** - thinning + features on a brain volume (from scikit-image), same baseline approach
|
|
52
|
+
- **3D comparison** - vesskel `lee94_thin` vs `skimage.morphology.skeletonize` on the brain volume, asserting identical output
|
|
53
|
+
|
|
54
|
+
First run (or `--update-baseline`) generates baselines in `tests/skeletons/` and `tests/features/`.
|
|
55
|
+
|
|
56
|
+
## Dataset
|
|
57
|
+
|
|
58
|
+
This project uses the High-Resolution Fundus (HRF) Image Database, established by a collaborative research group to support comparative studies on automatic segmentation algorithms on retinal fundus images.
|
|
59
|
+
|
|
60
|
+
The database contains 45 images total:
|
|
61
|
+
- 15 images of healthy patients
|
|
62
|
+
- 15 images of patients with diabetic retinopathy
|
|
63
|
+
- 15 images of glaucomatous patients
|
|
64
|
+
|
|
65
|
+
Binary gold standard vessel segmentation images and field of view (FOV) masks are available for each image.
|
|
66
|
+
|
|
67
|
+
### License
|
|
68
|
+
|
|
69
|
+
> Budai, Attila; Bock, Rüdiger; Maier, Andreas; Hornegger, Joachim; Michelson, Georg.
|
|
70
|
+
> Robust Vessel Segmentation in Fundus Images.
|
|
71
|
+
> International Journal of Biomedical Imaging, vol. 2013, 2013
|
|
72
|
+
|
|
73
|
+
The HRF dataset is released under the **Creative Commons 4.0 Attribution License**.
|
|
74
|
+
|
|
75
|
+
For more information, visit the [HRF Image Database](https://www5.cs.fau.de/research/data/fundus-images/).
|
|
76
|
+
|
vesskel-1.0.0/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# VesSkel
|
|
2
|
+
|
|
3
|
+
Vessel Skeletonization and Graph-Based Phenotype Analysis in Retinal Fundus Images
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
uv sync # core only
|
|
9
|
+
uv sync --extra dev # + test tools
|
|
10
|
+
uv sync --extra napari # + napari GUI
|
|
11
|
+
uv sync --all-extras # everything
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Napari
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
uv sync --extra napari && uv run napari
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Open a `manual1` TIFF from the HRF folder, then run **Lee94 Thinning** from the VesSkel plugin menu to see the skeleton.
|
|
21
|
+
|
|
22
|
+
## Tests
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
uv sync --extra dev && uv run pytest
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- **2D regression** - thinning + feature extraction on all 45 HRF samples, compared against saved baselines
|
|
29
|
+
- **3D regression** - thinning + features on a brain volume (from scikit-image), same baseline approach
|
|
30
|
+
- **3D comparison** - vesskel `lee94_thin` vs `skimage.morphology.skeletonize` on the brain volume, asserting identical output
|
|
31
|
+
|
|
32
|
+
First run (or `--update-baseline`) generates baselines in `tests/skeletons/` and `tests/features/`.
|
|
33
|
+
|
|
34
|
+
## Dataset
|
|
35
|
+
|
|
36
|
+
This project uses the High-Resolution Fundus (HRF) Image Database, established by a collaborative research group to support comparative studies on automatic segmentation algorithms on retinal fundus images.
|
|
37
|
+
|
|
38
|
+
The database contains 45 images total:
|
|
39
|
+
- 15 images of healthy patients
|
|
40
|
+
- 15 images of patients with diabetic retinopathy
|
|
41
|
+
- 15 images of glaucomatous patients
|
|
42
|
+
|
|
43
|
+
Binary gold standard vessel segmentation images and field of view (FOV) masks are available for each image.
|
|
44
|
+
|
|
45
|
+
### License
|
|
46
|
+
|
|
47
|
+
> Budai, Attila; Bock, Rüdiger; Maier, Andreas; Hornegger, Joachim; Michelson, Georg.
|
|
48
|
+
> Robust Vessel Segmentation in Fundus Images.
|
|
49
|
+
> International Journal of Biomedical Imaging, vol. 2013, 2013
|
|
50
|
+
|
|
51
|
+
The HRF dataset is released under the **Creative Commons 4.0 Attribution License**.
|
|
52
|
+
|
|
53
|
+
For more information, visit the [HRF Image Database](https://www5.cs.fau.de/research/data/fundus-images/).
|
|
54
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vesskel"
|
|
3
|
+
license = "MIT AND (Apache-2.0 OR BSD-2-Clause)"
|
|
4
|
+
version = "1.0.0"
|
|
5
|
+
description = "2D and 3D Skeletonization and Graph-based Analysis"
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">=3.13"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"numba>=0.65.0",
|
|
10
|
+
"numpy>=2.4.4",
|
|
11
|
+
"pillow>=12.2.0",
|
|
12
|
+
"scipy>=1.17.1",
|
|
13
|
+
"skan>=0.13.1",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
napari = ["napari[all]"]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=9.0.2",
|
|
20
|
+
"pytest-xdist>=3.8.0",
|
|
21
|
+
"pooch>=1.9.0",
|
|
22
|
+
"scikit-image>=0.26.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["setuptools"]
|
|
27
|
+
build-backend = "setuptools.build_meta"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
include = ["vesskel*"]
|
|
31
|
+
|
|
32
|
+
[project.entry-points."napari.manifest"]
|
|
33
|
+
vesskel = "vesskel:napari.yaml"
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
addopts = "-n auto"
|
vesskel-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Regression test suite.
|
|
2
|
+
|
|
3
|
+
On first run (or when --update-baseline is passed), skeletons and features are
|
|
4
|
+
generated and saved as baseline reference files.
|
|
5
|
+
|
|
6
|
+
On subsequent runs, skeletons and features are regenerated and compared against
|
|
7
|
+
saved baselines. Any difference is reported as a test failure, indicating a
|
|
8
|
+
regression (or an intentional algorithm change).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import hashlib
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from vesskel.features import extract_vessel_features
|
|
19
|
+
from vesskel.hrf import HRFDataset, preprocess_segmentation
|
|
20
|
+
from vesskel.thin import lee94_thin
|
|
21
|
+
|
|
22
|
+
BASELINE_DIR = Path(__file__).parent / "skeletons"
|
|
23
|
+
FEATURE_DIR = Path(__file__).parent / "features"
|
|
24
|
+
HRF_PATH = "HRF"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _skeleton_path(name: str) -> Path:
|
|
28
|
+
return BASELINE_DIR / f"skeleton_{name}.npz"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _feature_path(name: str) -> Path:
|
|
32
|
+
return FEATURE_DIR / f"features_{name}.csv"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _compute_skeleton(dataset: HRFDataset, index: int) -> np.ndarray:
|
|
36
|
+
_, seg, mask, _ = dataset.load_sample(index)
|
|
37
|
+
cleaned = preprocess_segmentation(seg, mask)
|
|
38
|
+
return lee94_thin(cleaned)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _write_feature_csv(path: Path, features: dict[str, float]) -> None:
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
with path.open("w", newline="") as f:
|
|
44
|
+
writer = csv.writer(f)
|
|
45
|
+
writer.writerow(["feature", "value"])
|
|
46
|
+
for key in sorted(features):
|
|
47
|
+
writer.writerow([key, f"{features[key]:.17g}"])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read_feature_csv(path: Path) -> dict[str, float]:
|
|
51
|
+
loaded: dict[str, float] = {}
|
|
52
|
+
with path.open(newline="") as f:
|
|
53
|
+
reader = csv.reader(f)
|
|
54
|
+
header = next(reader, None)
|
|
55
|
+
if header != ["feature", "value"]:
|
|
56
|
+
raise ValueError("invalid feature csv header")
|
|
57
|
+
for row in reader:
|
|
58
|
+
if len(row) != 2:
|
|
59
|
+
raise ValueError("invalid feature csv row")
|
|
60
|
+
key, value = row
|
|
61
|
+
loaded[key] = float(value)
|
|
62
|
+
return loaded
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _hash_array(arr: np.ndarray) -> str:
|
|
66
|
+
return hashlib.sha256(arr.tobytes()).hexdigest()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.fixture(scope="module")
|
|
70
|
+
def dataset():
|
|
71
|
+
return HRFDataset(HRF_PATH)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestThinningRegression:
|
|
75
|
+
"""Run thinning on every HRF sample and compare against saved baselines."""
|
|
76
|
+
|
|
77
|
+
@pytest.mark.parametrize(
|
|
78
|
+
"index",
|
|
79
|
+
range(len(HRFDataset(HRF_PATH))),
|
|
80
|
+
ids=[info["name"] for info in HRFDataset(HRF_PATH).image_list],
|
|
81
|
+
)
|
|
82
|
+
def test_skeleton_matches_baseline(self, dataset, index, request):
|
|
83
|
+
info = dataset.image_list[index]
|
|
84
|
+
name = info["name"]
|
|
85
|
+
skeleton = _compute_skeleton(dataset, index)
|
|
86
|
+
features = extract_vessel_features(skeleton)
|
|
87
|
+
baseline_file = _skeleton_path(name)
|
|
88
|
+
feature_file = _feature_path(name)
|
|
89
|
+
|
|
90
|
+
baseline_changed = False
|
|
91
|
+
|
|
92
|
+
if request.config.getoption("--update-baseline") or not baseline_file.exists():
|
|
93
|
+
BASELINE_DIR.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
np.savez_compressed(baseline_file, skeleton=skeleton)
|
|
95
|
+
baseline_changed = True
|
|
96
|
+
|
|
97
|
+
features_changed = False
|
|
98
|
+
if request.config.getoption("--update-baseline") or not feature_file.exists():
|
|
99
|
+
_write_feature_csv(feature_file, features)
|
|
100
|
+
features_changed = True
|
|
101
|
+
|
|
102
|
+
if baseline_changed or features_changed:
|
|
103
|
+
baseline_action = (
|
|
104
|
+
"updated"
|
|
105
|
+
if request.config.getoption("--update-baseline")
|
|
106
|
+
else "created"
|
|
107
|
+
)
|
|
108
|
+
artifacts = []
|
|
109
|
+
if baseline_changed:
|
|
110
|
+
artifacts.append("skeleton baseline")
|
|
111
|
+
if features_changed:
|
|
112
|
+
artifacts.append("feature baseline")
|
|
113
|
+
pytest.skip(f"{', '.join(artifacts)} {baseline_action} for sample {name}")
|
|
114
|
+
|
|
115
|
+
with np.load(baseline_file) as data:
|
|
116
|
+
baseline = data["skeleton"]
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
baseline_features = _read_feature_csv(feature_file)
|
|
120
|
+
except (OSError, ValueError) as exc:
|
|
121
|
+
pytest.fail(f"Sample {name}: invalid feature baseline CSV: {exc}")
|
|
122
|
+
|
|
123
|
+
assert skeleton.shape == baseline.shape, (
|
|
124
|
+
f"Sample {name}: shape mismatch "
|
|
125
|
+
f"got {skeleton.shape}, expected {baseline.shape}"
|
|
126
|
+
)
|
|
127
|
+
assert np.array_equal(skeleton, baseline), (
|
|
128
|
+
f"Sample {name}: skeleton differs from baseline "
|
|
129
|
+
f"(hash {_hash_array(skeleton)} vs {_hash_array(baseline)})"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
feature_keys = sorted(features)
|
|
133
|
+
baseline_feature_keys = sorted(baseline_features)
|
|
134
|
+
assert feature_keys == baseline_feature_keys, (
|
|
135
|
+
f"Sample {name}: feature set differs from baseline "
|
|
136
|
+
f"(got {feature_keys}, expected {baseline_feature_keys})"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
feature_values = np.array(
|
|
140
|
+
[features[key] for key in feature_keys], dtype=np.float64
|
|
141
|
+
)
|
|
142
|
+
baseline_feature_values = np.array(
|
|
143
|
+
[baseline_features[key] for key in feature_keys], dtype=np.float64
|
|
144
|
+
)
|
|
145
|
+
np.testing.assert_allclose(
|
|
146
|
+
feature_values,
|
|
147
|
+
baseline_feature_values,
|
|
148
|
+
rtol=1e-8,
|
|
149
|
+
atol=1e-10,
|
|
150
|
+
err_msg=f"Sample {name}: feature values differ from baseline",
|
|
151
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Regression test comparing vesskel.thin with scikit-image skeletonize on brain image."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
from skimage import data
|
|
6
|
+
from skimage.morphology import skeletonize
|
|
7
|
+
|
|
8
|
+
from vesskel.thin import lee94_thin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestSkeletonizeComparison:
|
|
12
|
+
"""Compare vesskel.thin with scikit-image skeletonize on brain image."""
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(scope="class")
|
|
15
|
+
def image(self):
|
|
16
|
+
return data.brain()
|
|
17
|
+
|
|
18
|
+
def test_vesskel_vs_scikit_skeletonize(self, image):
|
|
19
|
+
vesskel_skel = lee94_thin(image)
|
|
20
|
+
scikit_skel = skeletonize(image)
|
|
21
|
+
|
|
22
|
+
assert (
|
|
23
|
+
vesskel_skel.shape == scikit_skel.shape
|
|
24
|
+
), f"shape mismatch: vesskel {vesskel_skel.shape} vs scikit {scikit_skel.shape}"
|
|
25
|
+
assert np.array_equal(
|
|
26
|
+
vesskel_skel, scikit_skel
|
|
27
|
+
), "skeleton mismatch: algorithms produce different results"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Regression test for 3D thinning using brain image from scikit-image."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import hashlib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pytest
|
|
9
|
+
from skimage import data
|
|
10
|
+
|
|
11
|
+
from vesskel.features import extract_vessel_features
|
|
12
|
+
from vesskel.thin import lee94_thin
|
|
13
|
+
|
|
14
|
+
BASELINE_DIR = Path(__file__).parent / "skeletons"
|
|
15
|
+
FEATURE_DIR = Path(__file__).parent / "features"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _skeleton_path(name: str) -> Path:
|
|
19
|
+
return BASELINE_DIR / f"skeleton_{name}.npz"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _feature_path(name: str) -> Path:
|
|
23
|
+
return FEATURE_DIR / f"features_{name}.csv"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _compute_skeleton(image: np.ndarray) -> np.ndarray:
|
|
27
|
+
return lee94_thin(image)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _write_feature_csv(path: Path, features: dict[str, float]) -> None:
|
|
31
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
with path.open("w", newline="") as f:
|
|
33
|
+
writer = csv.writer(f)
|
|
34
|
+
writer.writerow(["feature", "value"])
|
|
35
|
+
for key in sorted(features):
|
|
36
|
+
writer.writerow([key, f"{features[key]:.17g}"])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_feature_csv(path: Path) -> dict[str, float]:
|
|
40
|
+
loaded: dict[str, float] = {}
|
|
41
|
+
with path.open(newline="") as f:
|
|
42
|
+
reader = csv.reader(f)
|
|
43
|
+
header = next(reader, None)
|
|
44
|
+
if header != ["feature", "value"]:
|
|
45
|
+
raise ValueError("invalid feature csv header")
|
|
46
|
+
for row in reader:
|
|
47
|
+
if len(row) != 2:
|
|
48
|
+
raise ValueError("invalid feature csv row")
|
|
49
|
+
key, value = row
|
|
50
|
+
loaded[key] = float(value)
|
|
51
|
+
return loaded
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _hash_array(arr: np.ndarray) -> str:
|
|
55
|
+
return hashlib.sha256(arr.tobytes()).hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Test3DThinningRegression:
|
|
59
|
+
"""Run 3D thinning on brain image and compare against saved baselines."""
|
|
60
|
+
|
|
61
|
+
@pytest.fixture(scope="class")
|
|
62
|
+
def image(self):
|
|
63
|
+
return data.brain()
|
|
64
|
+
|
|
65
|
+
def test_skeleton_matches_baseline(self, image, request):
|
|
66
|
+
skeleton = _compute_skeleton(image)
|
|
67
|
+
features = extract_vessel_features(skeleton)
|
|
68
|
+
name = "brain"
|
|
69
|
+
baseline_file = _skeleton_path(name)
|
|
70
|
+
feature_file = _feature_path(name)
|
|
71
|
+
|
|
72
|
+
baseline_changed = False
|
|
73
|
+
if request.config.getoption("--update-baseline") or not baseline_file.exists():
|
|
74
|
+
BASELINE_DIR.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
np.savez_compressed(baseline_file, skeleton=skeleton)
|
|
76
|
+
baseline_changed = True
|
|
77
|
+
|
|
78
|
+
features_changed = False
|
|
79
|
+
if request.config.getoption("--update-baseline") or not feature_file.exists():
|
|
80
|
+
FEATURE_DIR.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
_write_feature_csv(feature_file, features)
|
|
82
|
+
features_changed = True
|
|
83
|
+
|
|
84
|
+
if baseline_changed or features_changed:
|
|
85
|
+
artifacts = []
|
|
86
|
+
if baseline_changed:
|
|
87
|
+
artifacts.append("skeleton baseline")
|
|
88
|
+
if features_changed:
|
|
89
|
+
artifacts.append("feature baseline")
|
|
90
|
+
pytest.skip(f"{', '.join(artifacts)} created for baseline")
|
|
91
|
+
|
|
92
|
+
with np.load(baseline_file) as data:
|
|
93
|
+
baseline = data["skeleton"]
|
|
94
|
+
|
|
95
|
+
assert (
|
|
96
|
+
skeleton.shape == baseline.shape
|
|
97
|
+
), f"shape mismatch got {skeleton.shape}, expected {baseline.shape}"
|
|
98
|
+
assert np.array_equal(
|
|
99
|
+
skeleton, baseline
|
|
100
|
+
), f"skeleton differs (hash {_hash_array(skeleton)} vs {_hash_array(baseline)})"
|
|
101
|
+
|
|
102
|
+
baseline_features = _read_feature_csv(feature_file)
|
|
103
|
+
feature_keys = sorted(features)
|
|
104
|
+
baseline_feature_keys = sorted(baseline_features)
|
|
105
|
+
assert (
|
|
106
|
+
feature_keys == baseline_feature_keys
|
|
107
|
+
), f"feature set differs (got {feature_keys}, expected {baseline_feature_keys})"
|
|
108
|
+
|
|
109
|
+
feature_values = np.array([features[k] for k in feature_keys], dtype=np.float64)
|
|
110
|
+
baseline_feature_values = np.array(
|
|
111
|
+
[baseline_features[k] for k in feature_keys], dtype=np.float64
|
|
112
|
+
)
|
|
113
|
+
np.testing.assert_allclose(
|
|
114
|
+
feature_values,
|
|
115
|
+
baseline_feature_values,
|
|
116
|
+
rtol=1e-8,
|
|
117
|
+
atol=1e-10,
|
|
118
|
+
err_msg="feature values differ from baseline",
|
|
119
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from napari.layers import Image
|
|
5
|
+
from napari.types import LayerDataTuple
|
|
6
|
+
from napari.utils.notifications import show_info
|
|
7
|
+
|
|
8
|
+
from vesskel.thin import lee94_thin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def lee94_thin_widget(
|
|
12
|
+
img: Image,
|
|
13
|
+
) -> LayerDataTuple:
|
|
14
|
+
data = img.data
|
|
15
|
+
binary = (data > 0).astype(np.uint8)
|
|
16
|
+
n_fg = int(binary.sum())
|
|
17
|
+
t0 = time.perf_counter()
|
|
18
|
+
result = lee94_thin(binary)
|
|
19
|
+
elapsed = time.perf_counter() - t0
|
|
20
|
+
n_skel = int(result.sum())
|
|
21
|
+
show_info(
|
|
22
|
+
f"Thinned {img.name}: {n_fg} -> {n_skel} pixels "
|
|
23
|
+
f"({100 * n_skel / n_fg:.1f}% of foreground) in {elapsed:.1f}s"
|
|
24
|
+
)
|
|
25
|
+
return (result, {"name": f"{img.name}_thinned"}, "labels")
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.sparse import coo_matrix
|
|
3
|
+
from scipy.sparse.csgraph import connected_components
|
|
4
|
+
from skan import Skeleton, summarize
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_vessel_graph(skeleton: np.ndarray) -> Skeleton:
|
|
8
|
+
"""Build a graph representation from a binary vessel skeleton."""
|
|
9
|
+
return Skeleton((skeleton > 0).astype(np.uint8))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def extract_vessel_features(
|
|
13
|
+
skeleton: np.ndarray,
|
|
14
|
+
) -> dict[str, float]:
|
|
15
|
+
"""Extract graph-topology and segment statistics from a vessel skeleton."""
|
|
16
|
+
graph = build_vessel_graph(skeleton)
|
|
17
|
+
branch_data = summarize(graph, separator="-")
|
|
18
|
+
|
|
19
|
+
if branch_data.empty:
|
|
20
|
+
return {
|
|
21
|
+
"num_nodes": 0.0,
|
|
22
|
+
"num_edges": 0.0,
|
|
23
|
+
"num_endpoints": 0.0,
|
|
24
|
+
"num_bifurcations": 0.0,
|
|
25
|
+
"total_length": 0.0,
|
|
26
|
+
"mean_length": 0.0,
|
|
27
|
+
"std_length": 0.0,
|
|
28
|
+
"max_length": 0.0,
|
|
29
|
+
"min_length": 0.0,
|
|
30
|
+
"mean_tortuosity": 0.0,
|
|
31
|
+
"std_tortuosity": 0.0,
|
|
32
|
+
"num_components": 0.0,
|
|
33
|
+
"mean_degree": 0.0,
|
|
34
|
+
"max_degree": 0.0,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
src_nodes = branch_data["node-id-src"].to_numpy(dtype=np.int64)
|
|
38
|
+
dst_nodes = branch_data["node-id-dst"].to_numpy(dtype=np.int64)
|
|
39
|
+
edge_nodes = np.concatenate((src_nodes, dst_nodes))
|
|
40
|
+
unique_nodes = np.unique(edge_nodes)
|
|
41
|
+
|
|
42
|
+
num_edges = int(len(branch_data))
|
|
43
|
+
num_nodes = int(unique_nodes.size)
|
|
44
|
+
|
|
45
|
+
max_node_id = int(np.max(unique_nodes))
|
|
46
|
+
degrees = np.bincount(edge_nodes, minlength=max_node_id + 1)
|
|
47
|
+
node_degrees = degrees[unique_nodes]
|
|
48
|
+
|
|
49
|
+
num_endpoints = int(np.count_nonzero(node_degrees == 1))
|
|
50
|
+
num_bifurcations = int(np.count_nonzero(node_degrees >= 3))
|
|
51
|
+
mean_degree = float(np.mean(node_degrees)) if node_degrees.size else 0.0
|
|
52
|
+
max_degree = float(np.max(node_degrees)) if node_degrees.size else 0.0
|
|
53
|
+
|
|
54
|
+
node_to_index = {node: idx for idx, node in enumerate(unique_nodes)}
|
|
55
|
+
src_idx = np.fromiter((node_to_index[src] for src in src_nodes), dtype=np.int64)
|
|
56
|
+
dst_idx = np.fromiter((node_to_index[dst] for dst in dst_nodes), dtype=np.int64)
|
|
57
|
+
adjacency = coo_matrix(
|
|
58
|
+
(
|
|
59
|
+
np.ones(src_idx.size * 2, dtype=np.uint8),
|
|
60
|
+
(
|
|
61
|
+
np.concatenate((src_idx, dst_idx)),
|
|
62
|
+
np.concatenate((dst_idx, src_idx)),
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
shape=(num_nodes, num_nodes),
|
|
66
|
+
).tocsr()
|
|
67
|
+
num_components = int(
|
|
68
|
+
connected_components(adjacency, directed=False, return_labels=False)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
lengths = branch_data["branch-distance"].to_numpy(dtype=float)
|
|
72
|
+
euclidean = branch_data["euclidean-distance"].to_numpy(dtype=float)
|
|
73
|
+
|
|
74
|
+
if lengths.size:
|
|
75
|
+
total_length = float(np.sum(lengths))
|
|
76
|
+
mean_length = float(np.mean(lengths))
|
|
77
|
+
std_length = float(np.std(lengths))
|
|
78
|
+
max_length = float(np.max(lengths))
|
|
79
|
+
min_length = float(np.min(lengths))
|
|
80
|
+
else:
|
|
81
|
+
total_length = 0.0
|
|
82
|
+
mean_length = 0.0
|
|
83
|
+
std_length = 0.0
|
|
84
|
+
max_length = 0.0
|
|
85
|
+
min_length = 0.0
|
|
86
|
+
|
|
87
|
+
valid_tortuosity = euclidean > 0
|
|
88
|
+
tortuosity = lengths[valid_tortuosity] / euclidean[valid_tortuosity]
|
|
89
|
+
if tortuosity.size:
|
|
90
|
+
mean_tortuosity = float(np.mean(tortuosity))
|
|
91
|
+
std_tortuosity = float(np.std(tortuosity))
|
|
92
|
+
else:
|
|
93
|
+
mean_tortuosity = 0.0
|
|
94
|
+
std_tortuosity = 0.0
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
"num_nodes": float(num_nodes),
|
|
98
|
+
"num_edges": float(num_edges),
|
|
99
|
+
"num_endpoints": float(num_endpoints),
|
|
100
|
+
"num_bifurcations": float(num_bifurcations),
|
|
101
|
+
"total_length": total_length,
|
|
102
|
+
"mean_length": mean_length,
|
|
103
|
+
"std_length": std_length,
|
|
104
|
+
"max_length": max_length,
|
|
105
|
+
"min_length": min_length,
|
|
106
|
+
"mean_tortuosity": mean_tortuosity,
|
|
107
|
+
"std_tortuosity": std_tortuosity,
|
|
108
|
+
"num_components": float(num_components),
|
|
109
|
+
"mean_degree": mean_degree,
|
|
110
|
+
"max_degree": max_degree,
|
|
111
|
+
}
|