Skip to content

Commit 813bbf5

Browse files
committed
test: add graphml output tests
1 parent ad82857 commit 813bbf5

6 files changed

Lines changed: 283 additions & 40 deletions

File tree

tests/_helpers.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,40 @@ def read_feature_csv(path: Path) -> dict[str, float]:
4242

4343
def hash_array(arr: np.ndarray) -> str:
4444
return hashlib.sha256(arr.tobytes()).hexdigest()
45+
46+
47+
def cross_image(size: int = 32) -> np.ndarray:
48+
"""Binary cross: one junction and four endpoints."""
49+
img = np.zeros((size, size), dtype=np.uint8)
50+
img[size // 2, size // 4 : 3 * size // 4] = 1
51+
img[size // 4 : 3 * size // 4, size // 2] = 1
52+
return img
53+
54+
55+
def loop_image(size: int = 20) -> np.ndarray:
56+
"""Binary rectangle ring: two junctions connected by parallel branches."""
57+
img = np.zeros((size, size), dtype=np.uint8)
58+
margin = size // 4
59+
inner = size - margin
60+
img[margin, margin:inner] = 1
61+
img[inner - 1, margin:inner] = 1
62+
img[margin:inner, margin] = 1
63+
img[margin:inner, inner - 1] = 1
64+
return img
65+
66+
67+
def cross_volume(size: int = 16) -> np.ndarray:
68+
"""Binary volume with two perpendicular lines crossing at the center."""
69+
vol = np.zeros((size, size, size), dtype=np.uint8)
70+
vol[size // 2, size // 2, :] = 1
71+
vol[size // 2, :, size // 2] = 1
72+
return vol
73+
74+
75+
def line_volume(shape: tuple[int, int, int], axis: int = 0) -> np.ndarray:
76+
"""Binary volume with a single straight line through the center along *axis*."""
77+
vol = np.zeros(shape, dtype=np.uint8)
78+
idx = [s // 2 for s in shape]
79+
idx[axis] = slice(None)
80+
vol[tuple(idx)] = 1
81+
return vol

tests/conftest.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,37 @@
1+
import numpy as np
2+
import pytest
3+
from skan import Skeleton, summarize
4+
5+
from ._helpers import cross_image, cross_volume, loop_image
6+
7+
18
def pytest_addoption(parser):
29
parser.addoption(
310
"--update-baseline",
411
action="store_true",
512
default=False,
613
help="Regenerate and save baseline skeletons",
714
)
15+
16+
17+
@pytest.fixture
18+
def cross_skel() -> np.ndarray:
19+
return cross_image()
20+
21+
22+
@pytest.fixture
23+
def cross_graph(cross_skel):
24+
graph = Skeleton(cross_skel)
25+
return graph, summarize(graph, separator="-")
26+
27+
28+
@pytest.fixture
29+
def loop_graph():
30+
graph = Skeleton(loop_image())
31+
return graph, summarize(graph, separator="-")
32+
33+
34+
@pytest.fixture
35+
def cross_volume_graph():
36+
graph = Skeleton(cross_volume())
37+
return graph, summarize(graph, separator="-")

tests/test_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def test_defaults(self):
6666
assert c.write_summary_csv is True
6767
assert c.write_branch_csv is False
6868
assert c.write_radius is False
69+
assert c.write_graphml is False
6970

7071
def test_round_trip_dict(self):
7172
original = OutputConfig(
@@ -74,6 +75,7 @@ def test_round_trip_dict(self):
7475
write_summary_csv=True,
7576
write_branch_csv=True,
7677
write_radius=True,
78+
write_graphml=True,
7779
)
7880
restored = OutputConfig.from_dict(original.to_dict())
7981
assert restored == original

tests/test_graphml.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Tests for vesskel.graphml GraphML export."""
2+
3+
import networkx as nx
4+
import numpy as np
5+
from skan import Skeleton
6+
7+
from vesskel.graphml import build_networkx_graph, write_graphml
8+
9+
10+
def _junction_endpoint_count(graph: Skeleton) -> int:
11+
"""Number of skan nodes that are junctions or endpoints (degree != 2)."""
12+
return int((graph.degrees != 2).sum())
13+
14+
15+
class TestBuildNetworkxGraph:
16+
def test_nodes_are_junctions_or_endpoints(self, cross_graph):
17+
graph, branch_data = cross_graph
18+
G = build_networkx_graph(graph, branch_data)
19+
20+
assert isinstance(G, nx.MultiGraph)
21+
assert G.number_of_nodes() == _junction_endpoint_count(graph)
22+
assert G.number_of_edges() == len(branch_data)
23+
assert all(int(graph.degrees[n]) != 2 for n in G.nodes())
24+
25+
def test_node_attributes(self, cross_graph):
26+
graph, branch_data = cross_graph
27+
G = build_networkx_graph(graph, branch_data)
28+
29+
node_id, data = next(iter(G.nodes(data=True)))
30+
assert data["coord_0"] == int(graph.coordinates[node_id, 0])
31+
assert data["coord_1"] == int(graph.coordinates[node_id, 1])
32+
assert data["degree"] == int(graph.degrees[node_id])
33+
34+
endpoints = [data for _, data in G.nodes(data=True) if data["is_endpoint"]]
35+
assert len(endpoints) >= 2
36+
37+
def test_node_radius_absent_without_radius_matrix(self, cross_graph):
38+
graph, branch_data = cross_graph
39+
G = build_networkx_graph(graph, branch_data)
40+
assert all("radius" not in data for _, data in G.nodes(data=True))
41+
42+
def test_node_radius_sampled_when_provided(self, cross_graph, cross_skel):
43+
graph, branch_data = cross_graph
44+
radius_matrix = np.full(cross_skel.shape, 2.5, dtype=np.float64)
45+
G = build_networkx_graph(graph, branch_data, radius_matrix=radius_matrix)
46+
for node_id, data in G.nodes(data=True):
47+
assert data["radius"] == 2.5
48+
assert data["radius"] == float(
49+
radius_matrix[tuple(graph.coordinates[node_id])]
50+
)
51+
52+
def test_edge_attributes_and_exclusions(self, cross_graph):
53+
graph, branch_data = cross_graph
54+
G = build_networkx_graph(graph, branch_data)
55+
56+
_, _, data = next(iter(G.edges(data=True)))
57+
assert "branch-distance" in data
58+
assert "euclidean-distance" in data
59+
assert "node-id-src" not in data
60+
assert "node-id-dst" not in data
61+
assert not any(k.startswith("coord-") for k in data)
62+
assert not any(k.startswith("image-coord-") for k in data)
63+
64+
def test_parallel_branches_preserved(self, loop_graph):
65+
graph, branch_data = loop_graph
66+
pairs = list(zip(branch_data["node-id-src"], branch_data["node-id-dst"]))
67+
assert len(pairs) > len(set(pairs)) # fixture has parallel branches
68+
69+
G = build_networkx_graph(graph, branch_data)
70+
assert G.number_of_edges() == len(pairs)
71+
72+
def test_nan_attributes_dropped(self, cross_graph):
73+
graph, branch_data = cross_graph
74+
branch_data = branch_data.copy()
75+
branch_data["tortuosity"] = np.nan
76+
branch_data["straightness"] = np.inf
77+
G = build_networkx_graph(graph, branch_data)
78+
79+
for _, _, data in G.edges(data=True):
80+
assert "tortuosity" not in data
81+
assert "straightness" not in data
82+
83+
def test_summary_features_attached_and_nan_dropped(self, cross_graph):
84+
graph, branch_data = cross_graph
85+
G = build_networkx_graph(
86+
graph,
87+
branch_data,
88+
summary_features={"num_nodes": 5.0, "mean_tortuosity": np.nan},
89+
)
90+
assert G.graph["num_nodes"] == 5.0
91+
assert "mean_tortuosity" not in G.graph
92+
93+
def test_3d_skeleton(self, cross_volume_graph):
94+
graph, branch_data = cross_volume_graph
95+
G = build_networkx_graph(graph, branch_data)
96+
97+
assert G.number_of_nodes() == _junction_endpoint_count(graph)
98+
_, data = next(iter(G.nodes(data=True)))
99+
assert "coord_2" in data
100+
101+
102+
class TestWriteGraphml:
103+
def test_write_and_read_round_trip(self, tmp_path, cross_graph, cross_skel):
104+
graph, branch_data = cross_graph
105+
radius_matrix = np.full(cross_skel.shape, 3.0, dtype=np.float64)
106+
path = tmp_path / "img_graph.graphml"
107+
write_graphml(
108+
graph,
109+
branch_data,
110+
path,
111+
summary_features={"total_length": 42.0},
112+
radius_matrix=radius_matrix,
113+
)
114+
115+
G = nx.read_graphml(str(path), node_type=int)
116+
assert G.number_of_nodes() == _junction_endpoint_count(graph)
117+
assert G.number_of_edges() == len(branch_data)
118+
assert G.graph["total_length"] == 42.0
119+
120+
node_id, data = next(iter(G.nodes(data=True)))
121+
assert data["coord_0"] == int(graph.coordinates[node_id, 0])
122+
assert data["radius"] == 3.0
123+
assert "degree" in data
124+
assert "branch-distance" in next(iter(G.edges(data=True)))[2]
125+
126+
def test_parallel_edges_round_trip_as_multigraph(self, tmp_path, loop_graph):
127+
graph, branch_data = loop_graph
128+
path = tmp_path / "loop_graph.graphml"
129+
write_graphml(graph, branch_data, path)
130+
131+
G = nx.read_graphml(str(path), node_type=int)
132+
assert isinstance(G, nx.MultiGraph)
133+
assert G.number_of_edges() == len(branch_data)
134+
135+
def test_empty_branch_data(self, tmp_path, cross_graph):
136+
graph, branch_data = cross_graph
137+
path = tmp_path / "empty_graph.graphml"
138+
write_graphml(graph, branch_data.iloc[0:0], path)
139+
G = nx.read_graphml(str(path), node_type=int)
140+
assert G.number_of_nodes() == 0
141+
assert G.number_of_edges() == 0

tests/test_io.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import numpy as np
66
import pytest
7+
from skan import Skeleton, summarize
78

89
from vesskel._io import save_analysis_outputs
910
from vesskel.config import OutputConfig
@@ -220,3 +221,35 @@ def test_base_name_with_spaces(self, tmp_path):
220221
d = tmp_path / "my img"
221222
assert d.is_dir()
222223
assert (d / "my img_skeleton.npy").exists()
224+
225+
# -- graphml -----------------------------------------------------------
226+
227+
@pytest.fixture
228+
def graph_result(self, cross_skel) -> AnalysisResult:
229+
graph = Skeleton(cross_skel)
230+
branch_data = summarize(graph, separator="-")
231+
return AnalysisResult(
232+
skeleton=cross_skel,
233+
layers=[],
234+
summary_features={"num_nodes": float(len(branch_data))},
235+
branch_records=[],
236+
node_records=[],
237+
graph=graph,
238+
branch_data=branch_data,
239+
radius_matrix=np.ones_like(cross_skel, dtype=np.float64) * 2.0,
240+
)
241+
242+
def test_saves_graphml(self, tmp_path, graph_result):
243+
cfg = OutputConfig(write_graphml=True)
244+
save_analysis_outputs(tmp_path, "img", graph_result, cfg)
245+
assert (tmp_path / "img" / "img_graph.graphml").exists()
246+
247+
def test_skips_graphml_when_disabled(self, tmp_path, graph_result):
248+
cfg = OutputConfig(write_graphml=False)
249+
save_analysis_outputs(tmp_path, "img", graph_result, cfg)
250+
assert not (tmp_path / "img" / "img_graph.graphml").exists()
251+
252+
def test_skips_graphml_when_graph_missing(self, tmp_path):
253+
cfg = OutputConfig(write_graphml=True)
254+
save_analysis_outputs(tmp_path, "img", self._result(), cfg)
255+
assert not (tmp_path / "img" / "img_graph.graphml").exists()

0 commit comments

Comments
 (0)