Skip to content

Commit 9c04062

Browse files
Implement skeleton to graph conversion
1 parent 846f6c2 commit 9c04062

5 files changed

Lines changed: 186 additions & 27 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2551,6 +2551,27 @@ skeletons = bic.skeleton.teasar_labels(
25512551
# {original_label: (vertices, edges, radii), ...}
25522552
```
25532553

2554+
Convert the topology of either result to the native undirected graph while
2555+
keeping coordinates and radii in their NumPy arrays:
2556+
2557+
```python
2558+
import numpy as np
2559+
2560+
graph = bic.skeleton.skeleton_to_graph(vertices, edges)
2561+
2562+
# Node ids index vertices and radii directly.
2563+
degrees = np.fromiter(
2564+
(len(graph.node_adjacency(node)) for node in range(graph.number_of_nodes)),
2565+
dtype=np.uint64,
2566+
count=graph.number_of_nodes,
2567+
)
2568+
endpoint_vertices = vertices[degrees <= 1]
2569+
branch_vertices = vertices[degrees > 2]
2570+
```
2571+
2572+
The conversion also preserves disconnected forests and isolated vertices. The
2573+
graph stores topology only; it does not copy or own ``vertices`` or ``radii``.
2574+
25542575
Important differences and current scope:
25552576

25562577
- `teasar(mask)` treats every nonzero value as one foreground class. It

examples/skeleton/teasar.py

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@
55
import napari
66
import numpy as np
77

8-
from bioimage_cpp.skeleton import teasar
8+
from bioimage_cpp.skeleton import skeleton_to_graph, teasar
99

1010

1111
def main():
1212
path = Path(__file__).with_name("00004_gt_mask.mrc")
1313

14-
full_crop = True
14+
full_crop = False
1515
if full_crop:
1616
bb = np.s_[:]
1717
else:
18-
bb = np.s_[100:200, 1024:1700, 1024:1700]
18+
bb = np.s_[100:300, 1024:2048, 1024:2048]
1919

2020
print("Load data ...")
2121
with mrcfile.mmap(path, mode="r", permissive=True) as mrc:
@@ -32,9 +32,17 @@ def main():
3232
vertices, edges, _ = teasar(mask, scale=3.0, number_of_threads=8)
3333
print("... done in:", time.time() - t0, "s")
3434

35-
# degrees = np.bincount(edges.ravel(), minlength=len(vertices))
36-
# endpoints = degrees <= 1
37-
# branch_points = degrees > 2
35+
graph = skeleton_to_graph(vertices, edges)
36+
degrees = np.fromiter(
37+
(
38+
len(graph.node_adjacency(node))
39+
for node in range(graph.number_of_nodes)
40+
),
41+
dtype=np.uint64,
42+
count=graph.number_of_nodes,
43+
)
44+
endpoints = degrees <= 1
45+
branch_points = degrees > 2
3846

3947
viewer = napari.Viewer(ndisplay=3)
4048
viewer.add_labels(mask, name="mask", opacity=0.6)
@@ -47,26 +55,26 @@ def main():
4755
opacity=0.9,
4856
blending="translucent_no_depth",
4957
)
50-
# viewer.add_points(
51-
# vertices[endpoints],
52-
# name="endpoints",
53-
# size=3,
54-
# face_color="#ffd166",
55-
# border_color="#1b1b1b",
56-
# border_width=0.15,
57-
# n_dimensional=True,
58-
# blending="translucent_no_depth",
59-
# )
60-
# viewer.add_points(
61-
# vertices[branch_points],
62-
# name="branch points",
63-
# size=5,
64-
# face_color="#ff4d6d",
65-
# border_color="#1b1b1b",
66-
# border_width=0.15,
67-
# n_dimensional=True,
68-
# blending="translucent_no_depth",
69-
# )
58+
viewer.add_points(
59+
vertices[endpoints],
60+
name="endpoints",
61+
size=3,
62+
face_color="#ffd166",
63+
border_color="#1b1b1b",
64+
border_width=0.15,
65+
n_dimensional=True,
66+
blending="translucent_no_depth",
67+
)
68+
viewer.add_points(
69+
vertices[branch_points],
70+
name="branch points",
71+
size=5,
72+
face_color="#ff4d6d",
73+
border_color="#1b1b1b",
74+
border_width=0.15,
75+
n_dimensional=True,
76+
blending="translucent_no_depth",
77+
)
7078
napari.run()
7179

7280

src/bioimage_cpp/skeleton/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .. import _core
1010
from .._validation import strict_index
1111
from ..distance._distance import _as_binary_input, _normalize_sampling, _normalize_threads
12+
from ._graph import skeleton_to_graph
1213

1314

1415
_TEASAR_LABELS_BY_DTYPE = {
@@ -108,6 +109,9 @@ def teasar(
108109
shape ``(V,)`` and contains physical distance-to-boundary values.
109110
With several components the tuple represents their skeleton forest in
110111
first-voxel C-order. An empty mask returns correctly typed empty arrays.
112+
Use :func:`skeleton_to_graph` to convert the vertex and edge arrays to
113+
an undirected graph; graph node ids continue to index ``vertices`` and
114+
``radii``.
111115
112116
Raises
113117
------
@@ -198,4 +202,4 @@ def teasar_labels(
198202
return run(labels_c, labels_array.dtype.type(background_value), *options)
199203

200204

201-
__all__ = ["teasar", "teasar_labels"]
205+
__all__ = ["skeleton_to_graph", "teasar", "teasar_labels"]
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Utilities for working with skeleton topology."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
7+
from ..graph import UndirectedGraph
8+
9+
10+
def skeleton_to_graph(
11+
vertices: np.ndarray,
12+
edges: np.ndarray,
13+
) -> UndirectedGraph:
14+
"""Convert skeleton vertex and edge arrays to an undirected graph.
15+
16+
This accepts the ``vertices`` and ``edges`` arrays returned by
17+
:func:`bioimage_cpp.skeleton.teasar`, or by an individual result from
18+
:func:`bioimage_cpp.skeleton.teasar_labels`. Graph node ids correspond
19+
directly to rows in ``vertices`` (and in the accompanying TEASAR
20+
``radii`` array). The returned graph stores topology only; coordinates and
21+
radii remain in their original arrays.
22+
23+
Parameters
24+
----------
25+
vertices:
26+
Two-dimensional array with one row per skeleton vertex. Only the
27+
number of rows is used to construct the graph.
28+
edges:
29+
Integer array with shape ``(E, 2)`` containing vertex ids. Edge
30+
endpoints must be smaller than ``len(vertices)`` and self-edges are
31+
not supported.
32+
33+
Returns
34+
-------
35+
bioimage_cpp.graph.UndirectedGraph
36+
An undirected graph with one node per vertex and the topology given by
37+
``edges``. Empty skeletons and isolated vertices are preserved.
38+
39+
Raises
40+
------
41+
ValueError
42+
If ``vertices`` is not two-dimensional or the edge shape/topology is
43+
invalid.
44+
TypeError
45+
If ``edges`` is not an integer array.
46+
IndexError
47+
If an edge endpoint is outside the vertex range.
48+
"""
49+
vertex_array = np.asarray(vertices)
50+
if vertex_array.ndim != 2:
51+
raise ValueError(
52+
f"vertices must be a 2D array, got ndim={vertex_array.ndim}"
53+
)
54+
return UndirectedGraph.from_edges(vertex_array.shape[0], edges)
55+
56+
57+
__all__ = ["skeleton_to_graph"]
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import numpy as np
2+
import pytest
3+
4+
import bioimage_cpp as bic
5+
6+
7+
def _node_degrees(graph):
8+
return np.fromiter(
9+
(
10+
len(graph.node_adjacency(node))
11+
for node in range(graph.number_of_nodes)
12+
),
13+
dtype=np.uint64,
14+
count=graph.number_of_nodes,
15+
)
16+
17+
18+
def test_skeleton_to_graph_preserves_teasar_topology_and_vertex_ids():
19+
mask = np.zeros((9, 11, 10), dtype=np.uint8)
20+
mask[4, 5, 1:5] = 1
21+
for step in range(5):
22+
mask[4, 5 - step, 4 + step] = 1
23+
mask[4, 5 + step, 4 + step] = 1
24+
25+
vertices, edges, _ = bic.skeleton.teasar(mask)
26+
graph = bic.skeleton.skeleton_to_graph(vertices, edges)
27+
28+
assert isinstance(graph, bic.graph.UndirectedGraph)
29+
assert graph.number_of_nodes == len(vertices)
30+
assert graph.number_of_edges == len(edges)
31+
np.testing.assert_array_equal(graph.uv_ids(), edges)
32+
33+
degrees = _node_degrees(graph)
34+
endpoints = {
35+
tuple(vertex.astype(int)) for vertex in vertices[degrees <= 1]
36+
}
37+
assert endpoints == {(4, 5, 1), (4, 1, 8), (4, 9, 8)}
38+
assert np.count_nonzero(degrees > 2) == 1
39+
40+
41+
def test_skeleton_to_graph_preserves_empty_and_isolated_vertices():
42+
empty = bic.skeleton.skeleton_to_graph(
43+
np.empty((0, 3), dtype=np.float64),
44+
np.empty((0, 2), dtype=np.uint64),
45+
)
46+
assert empty.number_of_nodes == 0
47+
assert empty.number_of_edges == 0
48+
49+
isolated = bic.skeleton.skeleton_to_graph(
50+
np.array([[2.0, 3.0, 4.0]]),
51+
np.empty((0, 2), dtype=np.uint64),
52+
)
53+
assert isolated.number_of_nodes == 1
54+
assert isolated.number_of_edges == 0
55+
np.testing.assert_array_equal(_node_degrees(isolated), [0])
56+
57+
58+
def test_skeleton_to_graph_rejects_invalid_inputs():
59+
with pytest.raises(ValueError, match="vertices must be a 2D array"):
60+
bic.skeleton.skeleton_to_graph(
61+
np.array([0.0, 1.0, 2.0]),
62+
np.empty((0, 2), dtype=np.uint64),
63+
)
64+
65+
vertices = np.zeros((2, 3), dtype=np.float64)
66+
with pytest.raises(ValueError, match="uvs must have shape"):
67+
bic.skeleton.skeleton_to_graph(vertices, [0, 1])
68+
with pytest.raises(IndexError, match="node id must be < number_of_nodes"):
69+
bic.skeleton.skeleton_to_graph(vertices, [[0, 2]])

0 commit comments

Comments
 (0)