Skip to content

Commit ad82857

Browse files
committed
feat: optional graphml output
1 parent 33ee33b commit ad82857

5 files changed

Lines changed: 156 additions & 0 deletions

File tree

vesskel/_io.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from PIL import Image
1212

1313
from vesskel.config import OutputConfig
14+
from vesskel.graphml import write_graphml
1415
from vesskel.pipeline import AnalysisResult
1516

1617

@@ -114,6 +115,19 @@ def save_analysis_outputs(
114115
if config.write_radius and result.radius_matrix is not None:
115116
save_radius(out / f"{base_name}_radius", result.radius_matrix)
116117

118+
if (
119+
config.write_graphml
120+
and result.graph is not None
121+
and result.branch_data is not None
122+
):
123+
write_graphml(
124+
result.graph,
125+
result.branch_data,
126+
out / f"{base_name}_graph.graphml",
127+
summary_features=result.summary_features,
128+
radius_matrix=result.radius_matrix,
129+
)
130+
117131
if config.write_branch_csv and result.branch_records:
118132
write_csv(out / f"{base_name}_branches.csv", result.branch_records)
119133

vesskel/_napari.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ def _output_params(
134134
write_branch_csv: bool = False,
135135
write_node_csv: bool = False,
136136
write_radius: bool = False,
137+
write_graphml: bool = False,
137138
) -> None:
138139
return None
139140

@@ -320,12 +321,16 @@ def _on_nodes_toggle_write_node_csv(*args) -> None:
320321

321322
self.extract_nodes_widget.changed.connect(_on_nodes_toggle_write_node_csv)
322323

324+
self.write_graphml_widget = output_gui.write_graphml
325+
self.write_graphml_widget.label = "Write graph (.graphml)"
326+
323327
output_group.append(self.write_skeleton_npy_widget)
324328
output_group.append(self.write_skeleton_png_widget)
325329
output_group.append(self.write_summary_csv_widget)
326330
output_group.append(self.write_branch_csv_widget)
327331
output_group.append(self.write_node_csv_widget)
328332
output_group.append(self.write_radius_widget)
333+
output_group.append(self.write_graphml_widget)
329334

330335
# ============================================================
331336
# Output Directory
@@ -400,6 +405,7 @@ def _get_current_pipeline_config(self) -> PipelineConfig:
400405
write_branch_csv=self.write_branch_csv_widget.value,
401406
write_node_csv=self.write_node_csv_widget.value,
402407
write_radius=self.write_radius_widget.value,
408+
write_graphml=self.write_graphml_widget.value,
403409
),
404410
)
405411

@@ -426,6 +432,7 @@ def _set_pipeline_config(self, config: PipelineConfig) -> None:
426432
self.write_branch_csv_widget.value = o.write_branch_csv
427433
self.write_node_csv_widget.value = o.write_node_csv
428434
self.write_radius_widget.value = o.write_radius
435+
self.write_graphml_widget.value = o.write_graphml
429436

430437
# ------------------------------------------------------------------
431438
# Actions

vesskel/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ class OutputConfig:
9090
write_branch_csv: bool = False
9191
write_node_csv: bool = False
9292
write_radius: bool = False
93+
write_graphml: bool = False
9394

9495
def to_dict(self) -> dict[str, Any]:
9596
return {
@@ -99,6 +100,7 @@ def to_dict(self) -> dict[str, Any]:
99100
"write_branch_csv": self.write_branch_csv,
100101
"write_node_csv": self.write_node_csv,
101102
"write_radius": self.write_radius,
103+
"write_graphml": self.write_graphml,
102104
}
103105

104106
@classmethod

vesskel/graphml.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""GraphML export for vessel skeleton graphs."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import TYPE_CHECKING
7+
8+
import networkx as nx
9+
import numpy as np
10+
from skan.csr import skeleton_to_nx
11+
12+
if TYPE_CHECKING:
13+
from skan import Skeleton
14+
15+
# Branch-table columns describing topology or node positions; excluded from
16+
# edge attributes (node positions live on the node elements instead).
17+
_EXCLUDED_COLUMNS = {
18+
"node-id-src",
19+
"node-id-dst",
20+
"skeleton-id",
21+
}
22+
23+
24+
def _is_missing(value: object) -> bool:
25+
"""True for NaN/Inf floats, which GraphML cannot represent."""
26+
if isinstance(value, (float, np.floating)):
27+
return bool(np.isnan(value) or np.isinf(value))
28+
return False
29+
30+
31+
def _edge_attributes(row: dict[str, object]) -> dict[str, object]:
32+
return {
33+
key: value
34+
for key, value in row.items()
35+
if not key.startswith("coord-")
36+
and not key.startswith("image-coord-")
37+
and key not in _EXCLUDED_COLUMNS
38+
and not _is_missing(value)
39+
}
40+
41+
42+
def build_networkx_graph(
43+
graph: Skeleton,
44+
branch_data,
45+
summary_features: dict[str, float] | None = None,
46+
radius_matrix: np.ndarray | None = None,
47+
) -> nx.MultiGraph:
48+
"""Convert a skan skeleton graph into a networkx MultiGraph.
49+
50+
Nodes carry coordinates, degree, node-type flags, and ``radius`` (when
51+
*radius_matrix* is provided). Edges carry the per-branch features from
52+
*branch_data*. NaN/Inf attributes are omitted. *summary_features* is
53+
attached as graph-level attributes.
54+
55+
Parameters
56+
----------
57+
graph : Skeleton
58+
Pre-built skan Skeleton graph (e.g. from ``build_vessel_graph``).
59+
branch_data : DataFrame
60+
Pre-computed branch summary (e.g. from ``skan.summarize``).
61+
summary_features : dict[str, float], optional
62+
Per-image summary features attached as graph-level attributes.
63+
radius_matrix : ndarray, optional
64+
EDT radius array from ``compute_radii``; radius at each node is
65+
attached as a ``radius`` node attribute when provided.
66+
"""
67+
G = skeleton_to_nx(graph, branch_data)
68+
G.graph.clear()
69+
70+
for summary in (summary_features or {}).items():
71+
if not _is_missing(summary[1]):
72+
G.graph[summary[0]] = summary[1]
73+
74+
for node_id in G.nodes():
75+
coords = graph.coordinates[node_id]
76+
degree = int(graph.degrees[node_id])
77+
attrs = {f"coord_{d}": int(c) for d, c in enumerate(coords)}
78+
attrs["degree"] = degree
79+
attrs["is_endpoint"] = degree == 1
80+
attrs["is_junction"] = degree >= 3
81+
attrs["is_pass_through"] = degree == 2
82+
if radius_matrix is not None:
83+
attrs["radius"] = float(radius_matrix[tuple(coords)])
84+
G.nodes[node_id].update(attrs)
85+
86+
G.clear_edges()
87+
for row in branch_data.to_dict(orient="records"):
88+
G.add_edge(
89+
int(row["node-id-src"]),
90+
int(row["node-id-dst"]),
91+
**_edge_attributes(row),
92+
)
93+
94+
return G
95+
96+
97+
def write_graphml(
98+
graph: Skeleton,
99+
branch_data,
100+
path: str | Path,
101+
*,
102+
summary_features: dict[str, float] | None = None,
103+
radius_matrix: np.ndarray | None = None,
104+
) -> None:
105+
"""Write a skeleton graph to a GraphML file.
106+
107+
Parameters
108+
----------
109+
graph : Skeleton
110+
Pre-built skan Skeleton graph (e.g. from ``build_vessel_graph``).
111+
branch_data : DataFrame
112+
Pre-computed branch summary (e.g. from ``skan.summarize``).
113+
path : str or Path
114+
Destination file path.
115+
summary_features : dict[str, float], optional
116+
Per-image summary features attached as graph-level attributes.
117+
radius_matrix : ndarray, optional
118+
EDT radius array from ``compute_radii``; radius at each node is
119+
attached as a ``radius`` node attribute when provided.
120+
"""
121+
G = build_networkx_graph(
122+
graph,
123+
branch_data,
124+
summary_features=summary_features,
125+
radius_matrix=radius_matrix,
126+
)
127+
nx.write_graphml(G, str(path))

vesskel/pipeline.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525

2626
if TYPE_CHECKING:
2727
from napari.types import LayerDataTuple # noqa: F401
28+
from pandas import DataFrame
29+
from skan import Skeleton
2830

2931

3032
@dataclass
@@ -38,6 +40,8 @@ class AnalysisResult:
3840
node_records: list[dict[str, object]]
3941
radius_matrix: np.ndarray | None = None
4042
preprocessed_binary: np.ndarray | None = None
43+
graph: Skeleton | None = None
44+
branch_data: DataFrame | None = None
4145

4246

4347
def preprocess_binary(
@@ -212,4 +216,6 @@ def analyze_binary_image(
212216
node_records=node_records,
213217
radius_matrix=radius_matrix,
214218
preprocessed_binary=preprocessed_binary,
219+
graph=graph,
220+
branch_data=branch_data,
215221
)

0 commit comments

Comments
 (0)