Skip to content

Commit beb0ec1

Browse files
committed
fix: build skeleton only once and pass through pipeline
1 parent c6e5ac3 commit beb0ec1

7 files changed

Lines changed: 73 additions & 61 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ Inside the **Analyze Vessels** widget, tune extraction settings and use **Save C
2626
Use the same JSON preset exported from napari to batch-process images.
2727

2828
```sh
29-
uv run vesskel config-init --out config.json
30-
uv run vesskel validate-config --config config.json
31-
uv run vesskel run --input HRF/manual1 --config config.json --out outputs
29+
vesskel config-init --out config.json
30+
vesskel validate-config --config config.json
31+
vesskel run --input HRF/manual1 --config config.json --out outputs
3232
```
3333

3434
CLI outputs:

tests/test_2d_thinning_regression.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010

1111
import numpy as np
1212
import pytest
13+
from skan import summarize
1314

14-
from vesskel.features import extract_vessel_features
15+
from vesskel.features import build_vessel_graph, extract_vessel_features
1516
from vesskel.hrf import HRFDataset, preprocess_segmentation
1617
from vesskel.thin import lee94_thin
1718

@@ -50,7 +51,9 @@ def test_skeleton_matches_baseline(self, dataset, index, request):
5051
info = dataset.image_list[index]
5152
name = info["name"]
5253
skeleton = _compute_skeleton(dataset, index)
53-
features = extract_vessel_features(skeleton)
54+
graph = build_vessel_graph(skeleton)
55+
branch_data = summarize(graph, separator="-")
56+
features = extract_vessel_features(skeleton, graph, branch_data)
5457
baseline_file = skeleton_path(name)
5558
feature_file = feature_path(name)
5659

tests/test_3d_thinning_regression.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import numpy as np
44
import pytest
5+
from skan import summarize
56
from skimage import data
67

7-
from vesskel.features import extract_vessel_features
8+
from vesskel.features import build_vessel_graph, extract_vessel_features
89
from vesskel.thin import lee94_thin
910

1011
from ._helpers import (
@@ -31,7 +32,9 @@ def image(self):
3132

3233
def test_skeleton_matches_baseline(self, image, request):
3334
skeleton = _compute_skeleton(image)
34-
features = extract_vessel_features(skeleton)
35+
graph = build_vessel_graph(skeleton)
36+
branch_data = summarize(graph, separator="-")
37+
features = extract_vessel_features(skeleton, graph, branch_data)
3538
name = "brain"
3639
baseline_file = skeleton_path(name)
3740
feature_file = feature_path(name)

vesskel/cli.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,12 @@ def _run_batch(args: argparse.Namespace) -> int:
164164
out_dir.mkdir(parents=True, exist_ok=True)
165165

166166
summary_rows: list[dict[str, object]] = []
167-
168167
output_name_counts: dict[str, int] = {}
168+
total = len(input_paths)
169+
170+
for idx, in_path in enumerate(input_paths, 1):
171+
print(f"[{idx}/{total}] {in_path.name}", flush=True)
169172

170-
for in_path in input_paths:
171173
image = _load_image(in_path)
172174
result = analyze_binary_image(
173175
image=image, base_name=in_path.stem, config=config

vesskel/extraction.py

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,10 @@
33
from typing import TYPE_CHECKING
44

55
import numpy as np
6-
from skan import summarize
6+
from skan import Skeleton
77

88
from vesskel.config import ExtractionConfig
9-
from vesskel.features import (
10-
build_vessel_graph,
11-
compute_tortuosity,
12-
extract_vessel_features,
13-
)
9+
from vesskel.features import compute_tortuosity
1410

1511
if TYPE_CHECKING:
1612
from napari.types import LayerDataTuple
@@ -19,6 +15,8 @@
1915
def extract_skeleton_layers(
2016
skeleton: np.ndarray,
2117
base_name: str,
18+
graph: Skeleton,
19+
branch_data,
2220
config: ExtractionConfig | None = None,
2321
features: dict[str, float] | None = None,
2422
) -> list["napari.types.LayerDataTuple"]:
@@ -30,19 +28,22 @@ def extract_skeleton_layers(
3028
Binary 2D or 3D skeleton array.
3129
base_name : str
3230
Base name for layer naming.
31+
graph : Skeleton
32+
Pre-built skan Skeleton graph (e.g. from `build_vessel_graph`).
33+
branch_data : DataFrame
34+
Pre-computed branch summary (e.g. from `skan.summarize(graph, ...)`).
3335
config : ExtractionConfig, optional
3436
Configuration for what to extract. Defaults to all except fractal_dimension.
3537
features : dict, optional
36-
Pre-computed feature dictionary to avoid recomputation when caller
37-
already has it (e.g. from extract_vessel_features).
38+
Pre-computed summary feature dictionary (e.g. from `extract_vessel_features`).
3839
"""
3940
if config is None:
4041
config = ExtractionConfig()
4142

4243
layers = []
4344

4445
if config.branches:
45-
branch_layer = _extract_branch_features_layer(skeleton, base_name)
46+
branch_layer = _extract_branch_features_layer(base_name, graph, branch_data)
4647
if branch_layer is not None:
4748
layers.append(branch_layer)
4849

@@ -54,7 +55,6 @@ def extract_skeleton_layers(
5455
summary_layer = _extract_summary_features_layer(
5556
skeleton,
5657
base_name,
57-
include_fractal=config.fractal_dimension,
5858
features=features,
5959
)
6060
layers.append(summary_layer)
@@ -63,33 +63,40 @@ def extract_skeleton_layers(
6363

6464

6565
def _extract_branch_features_layer(
66-
skeleton: np.ndarray,
6766
base_name: str,
67+
graph: Skeleton,
68+
branch_data,
6869
) -> "napari.types.LayerDataTuple | None":
6970
"""Extract branch features and generate paths layer.
7071
71-
Returns None if skeleton has no branches.
72+
Parameters
73+
----------
74+
base_name : str
75+
Base name used for layer naming.
76+
graph : Skeleton
77+
Pre-built skan Skeleton graph.
78+
branch_data : DataFrame
79+
Pre-computed branch summary from `skan.summarize`.
80+
81+
Returns
82+
-------
83+
LayerDataTuple or None
84+
Napari shapes layer for branch paths, or None if skeleton has no branches.
7285
"""
73-
graph = build_vessel_graph(skeleton)
74-
branch_data = summarize(graph, separator="-")
75-
7686
if branch_data.empty:
7787
return None
7888

7989
branch_data = branch_data.reset_index(drop=True).copy()
8090
branch_data["branch_id"] = np.arange(len(branch_data), dtype=np.int64)
8191

82-
# Compute tortuosity
8392
euclidean = branch_data["euclidean-distance"].to_numpy(dtype=float)
8493
branch_len = branch_data["branch-distance"].to_numpy(dtype=float)
8594
tortuosity = compute_tortuosity(branch_len, euclidean)
8695
tortuosity = np.nan_to_num(tortuosity, nan=1.0)
8796
branch_data["tortuosity"] = tortuosity
8897

89-
# Get branch path coordinates
9098
path_data = [graph.path_coordinates(i) for i in range(len(branch_data))]
9199

92-
# Determine if tortuosity varies significantly
93100
finite_tortuosity = tortuosity[np.isfinite(tortuosity)]
94101
varied_tortuosity = finite_tortuosity.size > 0 and float(
95102
np.min(finite_tortuosity)
@@ -120,11 +127,9 @@ def _extract_branch_text_layer(
120127
branch_layer: "napari.types.LayerDataTuple",
121128
base_name: str,
122129
) -> "napari.types.LayerDataTuple":
123-
"""Create text labels for branches."""
124130
path_data = branch_layer[0]
125131
branch_data = branch_layer[1]["properties"]
126132

127-
# Compute label positions as mean of each path
128133
label_points = []
129134
for coords in path_data:
130135
if len(coords) == 0:
@@ -154,24 +159,22 @@ def _extract_branch_text_layer(
154159
def _extract_summary_features_layer(
155160
skeleton: np.ndarray,
156161
base_name: str,
157-
include_fractal: bool = False,
158-
features: dict[str, float] | None = None,
162+
features: dict[str, float],
159163
) -> "napari.types.LayerDataTuple":
160-
"""Extract global skeleton features and create summary point layer.
164+
"""Create a summary point layer displaying global skeleton features.
161165
162166
Parameters
163167
----------
164-
features : dict, optional
165-
Pre-computed feature dictionary. If provided, skips computation.
168+
skeleton : ndarray
169+
Binary 2D or 3D skeleton array. Used to position the summary label.
170+
base_name : str
171+
Base name for layer naming.
172+
features : dict[str, float]
173+
Pre-computed summary feature dictionary
174+
(e.g. from `extract_vessel_features`).
166175
"""
167-
if features is None:
168-
features = extract_vessel_features(
169-
skeleton,
170-
include_fractal=include_fractal,
171-
)
172176
meta_features = {k: [v] for k, v in features.items()}
173177

174-
# Find center of foreground
175178
fg = np.argwhere(skeleton > 0)
176179
if fg.size:
177180
center = fg.mean(axis=0, dtype=float)

vesskel/features.py

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from numba import njit, prange
33
from scipy.sparse import coo_matrix
44
from scipy.sparse.csgraph import connected_components
5-
from skan import Skeleton, summarize
5+
from skan import Skeleton
66

77
from ._utils import to_binary
88

@@ -122,16 +122,6 @@ def build_vessel_graph(skeleton: np.ndarray) -> Skeleton:
122122
return Skeleton(to_binary(skeleton))
123123

124124

125-
def summarize_skeleton(skeleton: np.ndarray):
126-
"""Build graph representation and return summarized branch data."""
127-
if not np.any(skeleton):
128-
from pandas import DataFrame
129-
130-
return DataFrame()
131-
graph = build_vessel_graph(skeleton)
132-
return summarize(graph, separator="-")
133-
134-
135125
_EMPTY_FEATURES: dict[str, float] = {
136126
"num_nodes": 0.0,
137127
"num_edges": 0.0,
@@ -154,19 +144,25 @@ def summarize_skeleton(skeleton: np.ndarray):
154144

155145
def extract_vessel_features(
156146
skeleton: np.ndarray,
147+
graph: Skeleton,
148+
branch_data,
149+
*,
157150
include_fractal: bool = True,
158151
) -> dict[str, float]:
159152
"""Extract graph-topology and segment statistics from a vessel skeleton.
160153
161154
Parameters
162155
----------
163156
skeleton : ndarray
164-
Binary 2D or 3D skeleton array.
157+
Binary 2D or 3D skeleton array. Used only for fractal dimension
158+
computation, not for graph topology.
159+
graph : Skeleton
160+
Pre-built skan Skeleton graph (e.g. from `build_vessel_graph`).
161+
branch_data : DataFrame
162+
Pre-computed branch summary (e.g. from `skan.summarize(graph, ...)`).
165163
include_fractal : bool, optional
166164
Whether to compute fractal dimension (expensive-ish). Default is True.
167165
"""
168-
graph = build_vessel_graph(skeleton)
169-
branch_data = summarize(graph, separator="-")
170166
if branch_data.empty:
171167
return dict(_EMPTY_FEATURES)
172168

vesskel/pipeline.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
from typing import TYPE_CHECKING
77

88
import numpy as np
9+
from skan import summarize
910

1011
from vesskel._utils import to_binary
1112
from vesskel.config import PipelineConfig
1213
from vesskel.extraction import extract_skeleton_layers
13-
from vesskel.features import extract_vessel_features, summarize_skeleton
14+
from vesskel.features import build_vessel_graph, extract_vessel_features
1415
from vesskel.thin import lee94_thin
1516

1617
if TYPE_CHECKING:
@@ -54,26 +55,30 @@ def analyze_binary_image(
5455
branch_records=[],
5556
)
5657

58+
graph = build_vessel_graph(skeleton)
59+
branch_data = summarize(graph, separator="-")
60+
5761
summary_features: dict[str, float] = {}
5862
if config.extraction.summary:
5963
summary_features = extract_vessel_features(
6064
skeleton,
65+
graph,
66+
branch_data,
6167
include_fractal=config.extraction.fractal_dimension,
6268
)
6369

6470
layers = extract_skeleton_layers(
6571
skeleton,
6672
base_name,
67-
config.extraction,
73+
graph=graph,
74+
branch_data=branch_data,
75+
config=config.extraction,
6876
features=summary_features if config.extraction.summary else None,
6977
)
7078

7179
branch_records: list[dict[str, object]] = []
72-
if config.extraction.branches:
73-
branch_table = summarize_skeleton(skeleton)
74-
if not branch_table.empty:
75-
# Keep native column names for traceability with skan output.
76-
branch_records = branch_table.to_dict(orient="records")
80+
if config.extraction.branches and not branch_data.empty:
81+
branch_records = branch_data.to_dict(orient="records")
7782

7883
return AnalysisResult(
7984
skeleton=skeleton,

0 commit comments

Comments
 (0)