Skip to content

Commit a4aabef

Browse files
committed
feat: branch coloring by feature and some widget ux improvements
1 parent b695d77 commit a4aabef

3 files changed

Lines changed: 149 additions & 42 deletions

File tree

vesskel/_napari.py

Lines changed: 103 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,40 @@
77
from typing import TYPE_CHECKING
88

99
from magicgui import magicgui
10-
from magicgui.widgets import Container, PushButton
10+
from magicgui.widgets import Container, Label, PushButton
1111
from napari.layers import Layer
1212
from napari.utils.notifications import show_error, show_info
1313
from qtpy.QtWidgets import QFileDialog
1414

15+
from vesskel._batch import _save_skeleton, _save_radius, _write_csv
16+
from vesskel.pipeline import analyze_binary_image
17+
1518
if TYPE_CHECKING:
1619
# These imports are only used for annotations and are therefore
1720
# guarded by TYPE_CHECKING to avoid runtime import-time coupling.
1821
from napari.layers import Image # noqa: F401
1922

2023
from vesskel.config import (
24+
COLORABLE_BRANCH_PROPERTIES,
2125
ExtractionConfig,
2226
OutputConfig,
2327
PipelineConfig,
2428
load_pipeline_config,
2529
save_pipeline_config,
2630
)
27-
from vesskel._batch import _save_skeleton, _save_radius, _write_csv
28-
from vesskel.pipeline import analyze_binary_image
31+
32+
_RADIUS_REQUIRED_PROPS = {
33+
"mean_radius",
34+
"std_radius",
35+
"min_radius",
36+
"max_radius",
37+
"mean_diameter",
38+
"std_diameter",
39+
"min_diameter",
40+
"max_diameter",
41+
"volume",
42+
"surface_area",
43+
}
2944

3045

3146
class VesselAnalysisWidget(Container):
@@ -44,6 +59,7 @@ def _setup_ui(self):
4459
def _extraction_params(
4560
image: "napari.layers.Image", # noqa: F821
4661
extract_branches: bool = False,
62+
branch_color_property: str = "tortuosity",
4763
extract_branch_text: bool = False,
4864
extract_nodes: bool = False,
4965
extract_summary: bool = False,
@@ -62,6 +78,12 @@ def _extraction_params(
6278
_extraction_params,
6379
image={"label": "Input image"},
6480
extract_branches={"annotation": bool, "value": False},
81+
branch_color_property={
82+
"annotation": str,
83+
"value": "tortuosity",
84+
"choices": COLORABLE_BRANCH_PROPERTIES,
85+
"widget_type": "ComboBox",
86+
},
6587
extract_branch_text={"annotation": bool, "value": False},
6688
extract_summary={"annotation": bool, "value": False},
6789
include_fractal={"annotation": bool, "value": False},
@@ -120,6 +142,28 @@ def _output_params(
120142
self.extract_branches_widget = extraction_gui.extract_branches
121143
self.extract_branches_widget.label = "Extract branches"
122144

145+
self.branch_color_widget = extraction_gui.branch_color_property
146+
self.branch_color_widget.label = "Branch color by"
147+
self.branch_color_widget.enabled = False
148+
149+
self.branch_color_warning = Label(value="⚠️ Requires Vessel Radius")
150+
self.branch_color_warning.visible = False
151+
152+
def _on_branches_toggle(enabled: bool | None = None) -> None:
153+
self.branch_color_widget.enabled = self.extract_branches_widget.value
154+
155+
self.extract_branches_widget.changed.connect(_on_branches_toggle)
156+
157+
def _update_branch_color_warning(*args) -> None:
158+
needs_radius = self.branch_color_widget.value in _RADIUS_REQUIRED_PROPS
159+
radius_off = not self.include_vessel_radius_widget.value
160+
self.branch_color_warning.visible = needs_radius and radius_off
161+
162+
self.branch_color_widget.changed.connect(_update_branch_color_warning)
163+
164+
# connection to include_vessel_radius_widget happens below
165+
# after that widget is created
166+
123167
self.extract_branch_text_widget = extraction_gui.extract_branch_text
124168
self.extract_branch_text_widget.label = "Add branch labels"
125169

@@ -130,51 +174,81 @@ def _output_params(
130174
self.extract_nodes_widget.label = "Extract node features"
131175

132176
extraction_group.append(self.extract_branches_widget)
177+
extraction_group.append(self.branch_color_widget)
178+
extraction_group.append(self.branch_color_warning)
133179
extraction_group.append(self.extract_branch_text_widget)
134180
extraction_group.append(self.extract_summary_widget)
135181
extraction_group.append(self.extract_nodes_widget)
136182

137183
# ============================================================
138-
# Advanced Features
184+
# Cleanup
139185
# ============================================================
140-
advanced_group = Container()
141-
advanced_group.label = "Advanced Features"
186+
cleanup_group = Container()
187+
cleanup_group.label = "Cleanup"
142188

143-
self.include_fractal_widget = extraction_gui.include_fractal
144-
self.include_fractal_widget.label = "Include fractal dimension (slow)"
189+
self.fill_holes_widget = extraction_gui.fill_holes
190+
self.fill_holes_widget.label = "Fill holes in segmentation"
145191

146-
advanced_group.append(self.include_fractal_widget)
192+
self.max_hole_size_widget = extraction_gui.max_hole_size
193+
self.max_hole_size_widget.label = "Max hole size (pixels)"
194+
self.max_hole_size_widget.enabled = False
147195

148-
self.include_vessel_radius_widget = extraction_gui.include_vessel_radius
149-
self.include_vessel_radius_widget.label = "Compute vessel radius and diameter"
196+
def _on_fill_holes_toggle(enabled: bool | None = None) -> None:
197+
self.max_hole_size_widget.enabled = self.fill_holes_widget.value
150198

151-
advanced_group.append(self.include_vessel_radius_widget)
199+
self.fill_holes_widget.changed.connect(_on_fill_holes_toggle)
200+
201+
self.closing_iterations_widget = extraction_gui.closing_iterations
202+
self.closing_iterations_widget.label = "Closing iterations"
152203

153204
self.junction_cleanup_widget = extraction_gui.junction_cleanup
154205
self.junction_cleanup_widget.label = "Collapse triangle junction artifacts"
155206

156207
self.cleanup_threshold_widget = extraction_gui.cleanup_threshold_factor
157208
self.cleanup_threshold_widget.label = "Cleanup threshold factor"
209+
self.cleanup_threshold_widget.enabled = False
158210

159-
advanced_group.append(self.junction_cleanup_widget)
160-
advanced_group.append(self.cleanup_threshold_widget)
161-
162-
self.fill_holes_widget = extraction_gui.fill_holes
163-
self.fill_holes_widget.label = "Fill holes in segmentation"
164-
165-
self.closing_iterations_widget = extraction_gui.closing_iterations
166-
self.closing_iterations_widget.label = "Closing iterations"
211+
def _on_junction_cleanup_toggle(enabled: bool | None = None) -> None:
212+
self.cleanup_threshold_widget.enabled = self.junction_cleanup_widget.value
167213

168-
self.max_hole_size_widget = extraction_gui.max_hole_size
169-
self.max_hole_size_widget.label = "Max hole size (pixels)"
214+
self.junction_cleanup_widget.changed.connect(_on_junction_cleanup_toggle)
170215

171216
self.show_preprocessed_widget = extraction_gui.show_preprocessed
172217
self.show_preprocessed_widget.label = "Show preprocessed binary layer"
218+
self.show_preprocessed_widget.enabled = False
219+
220+
def _update_preprocessed_enabled(*args) -> None:
221+
self.show_preprocessed_widget.enabled = (
222+
self.fill_holes_widget.value or self.closing_iterations_widget.value > 0
223+
)
173224

174-
advanced_group.append(self.fill_holes_widget)
175-
advanced_group.append(self.closing_iterations_widget)
176-
advanced_group.append(self.max_hole_size_widget)
177-
advanced_group.append(self.show_preprocessed_widget)
225+
self.fill_holes_widget.changed.connect(_update_preprocessed_enabled)
226+
self.closing_iterations_widget.changed.connect(_update_preprocessed_enabled)
227+
228+
cleanup_group.append(self.fill_holes_widget)
229+
cleanup_group.append(self.max_hole_size_widget)
230+
cleanup_group.append(self.closing_iterations_widget)
231+
cleanup_group.append(self.junction_cleanup_widget)
232+
cleanup_group.append(self.cleanup_threshold_widget)
233+
cleanup_group.append(self.show_preprocessed_widget)
234+
235+
# ============================================================
236+
# Advanced Features
237+
# ============================================================
238+
advanced_group = Container()
239+
advanced_group.label = "Advanced Features"
240+
241+
self.include_fractal_widget = extraction_gui.include_fractal
242+
self.include_fractal_widget.label = "Fractal dimension"
243+
244+
advanced_group.append(self.include_fractal_widget)
245+
246+
self.include_vessel_radius_widget = extraction_gui.include_vessel_radius
247+
self.include_vessel_radius_widget.label = "Radius features"
248+
249+
self.include_vessel_radius_widget.changed.connect(_update_branch_color_warning)
250+
251+
advanced_group.append(self.include_vessel_radius_widget)
178252

179253
# ============================================================
180254
# Output Settings (CLI file export options)
@@ -244,6 +318,7 @@ def _output_params(
244318
# ============================================================
245319
self.append(self.image_widget)
246320
self.append(extraction_group)
321+
self.append(cleanup_group)
247322
self.append(advanced_group)
248323
self.append(output_group)
249324
self.append(outdir_group)
@@ -259,6 +334,7 @@ def _get_current_pipeline_config(self) -> PipelineConfig:
259334
return PipelineConfig(
260335
extraction=ExtractionConfig(
261336
branches=self.extract_branches_widget.value,
337+
branch_color_property=self.branch_color_widget.value,
262338
branch_text=self.extract_branch_text_widget.value,
263339
nodes=self.extract_nodes_widget.value,
264340
summary=self.extract_summary_widget.value,
@@ -284,6 +360,7 @@ def _get_current_pipeline_config(self) -> PipelineConfig:
284360
def _set_pipeline_config(self, config: PipelineConfig) -> None:
285361
e = config.extraction
286362
self.extract_branches_widget.value = e.branches
363+
self.branch_color_widget.value = e.branch_color_property
287364
self.extract_branch_text_widget.value = e.branch_text
288365
self.extract_nodes_widget.value = e.nodes
289366
self.extract_summary_widget.value = e.summary

vesskel/config.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,26 @@
88
from pathlib import Path
99
from typing import Any
1010

11-
CONFIG_SCHEMA_VERSION = 2
11+
CONFIG_SCHEMA_VERSION = 3
12+
13+
COLORABLE_BRANCH_PROPERTIES = [
14+
"tortuosity",
15+
"branch-distance",
16+
"euclidean-distance",
17+
"straightness",
18+
"mean-pixel-value",
19+
"stdev-pixel-value",
20+
"mean_radius",
21+
"std_radius",
22+
"min_radius",
23+
"max_radius",
24+
"mean_diameter",
25+
"std_diameter",
26+
"min_diameter",
27+
"max_diameter",
28+
"volume",
29+
"surface_area",
30+
]
1231

1332

1433
def _warn_unknown_keys(known: set[str], data: dict[str, Any]) -> None:
@@ -25,6 +44,7 @@ class ExtractionConfig:
2544
"""Configuration for what to extract from a skeleton."""
2645

2746
branches: bool = False
47+
branch_color_property: str = "tortuosity"
2848
branch_text: bool = False
2949
nodes: bool = False
3050
summary: bool = False
@@ -40,6 +60,7 @@ class ExtractionConfig:
4060
def to_dict(self) -> dict[str, Any]:
4161
return {
4262
"branches": self.branches,
63+
"branch_color_property": self.branch_color_property,
4364
"branch_text": self.branch_text,
4465
"nodes": self.nodes,
4566
"summary": self.summary,
@@ -58,6 +79,7 @@ def from_dict(cls, data: dict[str, Any]) -> ExtractionConfig:
5879
_warn_unknown_keys({f.name for f in fields(cls)}, data)
5980
return cls(
6081
branches=data.get("branches", False),
82+
branch_color_property=data.get("branch_color_property", "tortuosity"),
6183
branch_text=data.get("branch_text", False),
6284
nodes=data.get("nodes", False),
6385
summary=data.get("summary", False),

vesskel/napari_layers.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ def extract_skeleton_layers(
4747
layers = []
4848

4949
if config.branches:
50-
branch_layer = _extract_branch_features_layer(base_name, graph, branch_data)
50+
branch_layer = _extract_branch_features_layer(
51+
base_name,
52+
graph,
53+
branch_data,
54+
color_property=config.branch_color_property,
55+
)
5156
if branch_layer is not None:
5257
layers.append(branch_layer)
5358

@@ -103,6 +108,7 @@ def _extract_branch_features_layer(
103108
base_name: str,
104109
graph: Skeleton,
105110
branch_data,
111+
color_property: str = "tortuosity",
106112
) -> "napari.types.LayerDataTuple | None": # noqa: F821
107113
"""Extract branch features and generate paths layer.
108114
@@ -114,6 +120,9 @@ def _extract_branch_features_layer(
114120
Pre-built skan Skeleton graph.
115121
branch_data : DataFrame
116122
Pre-computed branch summary from `skan.summarize`.
123+
color_property : str
124+
Branch property to use for edge coloring. Must be a numeric column
125+
in branch_data. Defaults to "tortuosity".
117126
118127
Returns
119128
-------
@@ -134,11 +143,6 @@ def _extract_branch_features_layer(
134143

135144
path_data = [graph.path_coordinates(i) for i in range(len(branch_data))]
136145

137-
finite_tortuosity = tortuosity[np.isfinite(tortuosity)]
138-
varied_tortuosity = finite_tortuosity.size > 0 and float(
139-
np.min(finite_tortuosity)
140-
) < float(np.max(finite_tortuosity))
141-
142146
meta = {
143147
"name": f"{base_name}_branches",
144148
"shape_type": "path",
@@ -148,15 +152,19 @@ def _extract_branch_features_layer(
148152
"opacity": 0.95,
149153
}
150154

151-
if varied_tortuosity:
152-
vmin = float(np.min(finite_tortuosity))
153-
vmax = float(np.max(finite_tortuosity))
154-
meta["edge_color"] = "tortuosity"
155-
meta["edge_colormap"] = "turbo"
156-
meta["edge_contrast_limits"] = (vmin, vmax)
157-
else:
158-
meta["edge_color"] = "#30d5c8"
159-
155+
values = branch_data.get(color_property)
156+
if values is not None:
157+
numeric = np.asarray(values, dtype=float)
158+
finite = numeric[np.isfinite(numeric)]
159+
if finite.size > 1 and float(np.min(finite)) < float(np.max(finite)):
160+
vmin = float(np.min(finite))
161+
vmax = float(np.max(finite))
162+
meta["edge_color"] = color_property
163+
meta["edge_colormap"] = "turbo"
164+
meta["edge_contrast_limits"] = (vmin, vmax)
165+
return (path_data, meta, "shapes")
166+
167+
meta["edge_color"] = "#30d5c8"
160168
return (path_data, meta, "shapes")
161169

162170

0 commit comments

Comments
 (0)