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 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
+
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ }