sn-graph 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sn_graph-0.1.0/LICENSE +21 -0
- sn_graph-0.1.0/PKG-INFO +64 -0
- sn_graph-0.1.0/README.md +45 -0
- sn_graph-0.1.0/pyproject.toml +97 -0
- sn_graph-0.1.0/src/sn_graph/__init__.py +4 -0
- sn_graph-0.1.0/src/sn_graph/core.py +506 -0
- sn_graph-0.1.0/src/sn_graph/visualisation.py +134 -0
sn_graph-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Alexandra Institute
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sn_graph-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: sn-graph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python implementation of SN-Graph algorithm.
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Tomasz Prytula
|
|
7
|
+
Author-email: tomasz.prytula@alexandra.dk
|
|
8
|
+
Requires-Python: >=3.11,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Dist: matplotlib (>=3.9.3,<4.0.0)
|
|
14
|
+
Requires-Dist: numpy (==2.0)
|
|
15
|
+
Requires-Dist: scikit-fmm (>=2024.9.16,<2025.0.0)
|
|
16
|
+
Requires-Dist: scikit-image (>=0.24.0,<0.25.0)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# SN-Graph: a graph skeletonisation algorithm.
|
|
20
|
+
|
|
21
|
+
A Python implementation of an SN-Graph skeletonisation algorithm. Based on the article *SN-Graph: a Minimalist 3D Object Representation for Classification* [arXiv:2105.14784](https://arxiv.org/abs/2105.14784).
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
26
|
+
## Description
|
|
27
|
+
|
|
28
|
+
SN-Graph works by:
|
|
29
|
+
|
|
30
|
+
1. Creating vertices as centres of spheres inscribed in the image, where one balances the size of the spheres with their coverage of the shape, and pariwise distances from one another.
|
|
31
|
+
3. Adding edges between the neighbouring spheres, subject to a few common-sense criteria.
|
|
32
|
+
|
|
33
|
+
The resulting graph serves as a lightweight 1-dimensional representation of the original image, potentially useful for further analysis.
|
|
34
|
+
|
|
35
|
+
## Basic Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import numpy as np
|
|
39
|
+
import sn_graph as sn
|
|
40
|
+
|
|
41
|
+
# Create a simple square image
|
|
42
|
+
img = np.zeros((100, 100))
|
|
43
|
+
img[40:60, 40:60] = 1 # Create a square region
|
|
44
|
+
|
|
45
|
+
# Generate the SN graph
|
|
46
|
+
centers, edges = sn.create_sn_graph(
|
|
47
|
+
img,
|
|
48
|
+
max_num_vertices=10,
|
|
49
|
+
edge_threshold=1.0
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Key Parameters
|
|
55
|
+
|
|
56
|
+
- `max_num_vertices`: Maximum number of vertices in the graph
|
|
57
|
+
- `max_edge_length`: Maximum allowed edge length
|
|
58
|
+
- `edge_threshold`: Threshold for determining what portion of an edge must be contained within the shape
|
|
59
|
+
- `minimal_sphere_radius`: Minimum radius allowed for spheres
|
|
60
|
+
- `edge_sphere_threshold`: Threshold value for deciding how close can an edge be to a non-enpdpoint spheres
|
|
61
|
+
|
|
62
|
+
## Authors
|
|
63
|
+
- Tomasz Prytuła (<tomasz.prytula@alexandra.dk>)
|
|
64
|
+
|
sn_graph-0.1.0/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# SN-Graph: a graph skeletonisation algorithm.
|
|
2
|
+
|
|
3
|
+
A Python implementation of an SN-Graph skeletonisation algorithm. Based on the article *SN-Graph: a Minimalist 3D Object Representation for Classification* [arXiv:2105.14784](https://arxiv.org/abs/2105.14784).
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
## Description
|
|
9
|
+
|
|
10
|
+
SN-Graph works by:
|
|
11
|
+
|
|
12
|
+
1. Creating vertices as centres of spheres inscribed in the image, where one balances the size of the spheres with their coverage of the shape, and pariwise distances from one another.
|
|
13
|
+
3. Adding edges between the neighbouring spheres, subject to a few common-sense criteria.
|
|
14
|
+
|
|
15
|
+
The resulting graph serves as a lightweight 1-dimensional representation of the original image, potentially useful for further analysis.
|
|
16
|
+
|
|
17
|
+
## Basic Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import numpy as np
|
|
21
|
+
import sn_graph as sn
|
|
22
|
+
|
|
23
|
+
# Create a simple square image
|
|
24
|
+
img = np.zeros((100, 100))
|
|
25
|
+
img[40:60, 40:60] = 1 # Create a square region
|
|
26
|
+
|
|
27
|
+
# Generate the SN graph
|
|
28
|
+
centers, edges = sn.create_sn_graph(
|
|
29
|
+
img,
|
|
30
|
+
max_num_vertices=10,
|
|
31
|
+
edge_threshold=1.0
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Key Parameters
|
|
37
|
+
|
|
38
|
+
- `max_num_vertices`: Maximum number of vertices in the graph
|
|
39
|
+
- `max_edge_length`: Maximum allowed edge length
|
|
40
|
+
- `edge_threshold`: Threshold for determining what portion of an edge must be contained within the shape
|
|
41
|
+
- `minimal_sphere_radius`: Minimum radius allowed for spheres
|
|
42
|
+
- `edge_sphere_threshold`: Threshold value for deciding how close can an edge be to a non-enpdpoint spheres
|
|
43
|
+
|
|
44
|
+
## Authors
|
|
45
|
+
- Tomasz Prytuła (<tomasz.prytula@alexandra.dk>)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "sn-graph"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Python implementation of SN-Graph algorithm."
|
|
5
|
+
authors = ["Tomasz Prytula <tomasz.prytula@alexandra.dk>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
|
|
9
|
+
[tool.poetry.dependencies]
|
|
10
|
+
python = "^3.11"
|
|
11
|
+
numpy = "2.0"
|
|
12
|
+
matplotlib = "^3.9.3"
|
|
13
|
+
scikit-image = "^0.24.0"
|
|
14
|
+
scikit-fmm = "^2024.9.16"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
[tool.poetry.group.dev.dependencies]
|
|
18
|
+
jupyter = "^1.1.1"
|
|
19
|
+
ipykernel = "^6.29.5"
|
|
20
|
+
pytest = "^8.0.0"
|
|
21
|
+
pytest-cov = "^4.1.0"
|
|
22
|
+
black = "^24.3.0"
|
|
23
|
+
isort = "^5.12.0"
|
|
24
|
+
flake8 = "^6.0.0"
|
|
25
|
+
mypy = "^1.3.0"
|
|
26
|
+
pylint = "^2.17.4"
|
|
27
|
+
pre-commit = "^4.1.0"
|
|
28
|
+
ruff = "^0.9.4"
|
|
29
|
+
scikit-learn = "^1.6.1"
|
|
30
|
+
pandas = "^2.2.3"
|
|
31
|
+
pillow = "^11.1.0"
|
|
32
|
+
torch = "^2.6.0"
|
|
33
|
+
torch-geometric = "^2.6.1"
|
|
34
|
+
pooch = "^1.8.2"
|
|
35
|
+
trimesh = "^4.6.4"
|
|
36
|
+
rtree = "^1.4.0"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
[tool.black]
|
|
41
|
+
line-length = 88
|
|
42
|
+
target-version = ["py38"]
|
|
43
|
+
include = '\.pyi?$'
|
|
44
|
+
|
|
45
|
+
[tool.isort]
|
|
46
|
+
profile = "black"
|
|
47
|
+
multi_line_output = 3
|
|
48
|
+
line_length = 88
|
|
49
|
+
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
python_version = "3.8"
|
|
52
|
+
disallow_untyped_defs = true
|
|
53
|
+
disallow_incomplete_defs = true
|
|
54
|
+
check_untyped_defs = true
|
|
55
|
+
disallow_untyped_decorators = true
|
|
56
|
+
no_implicit_optional =true
|
|
57
|
+
warn_redundant_casts = true
|
|
58
|
+
warn_unused_ignores = true
|
|
59
|
+
warn_return_any = true
|
|
60
|
+
warn_unreachable = true
|
|
61
|
+
strict_optional = true
|
|
62
|
+
|
|
63
|
+
[tool.pylint.messages_control]
|
|
64
|
+
disable = [
|
|
65
|
+
"C0111", # missing-docstring
|
|
66
|
+
"R0903", # too-few-public-methods
|
|
67
|
+
"C0103", # invalid-name
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.pytest.ini_options]
|
|
71
|
+
addopts = "--cov=src.sn_graph --cov-report=term-missing"
|
|
72
|
+
testpaths = ["tests"]
|
|
73
|
+
|
|
74
|
+
[tool.coverage.run]
|
|
75
|
+
source = ["src"]
|
|
76
|
+
branch = true
|
|
77
|
+
|
|
78
|
+
[tool.coverage.report]
|
|
79
|
+
exclude_lines = [
|
|
80
|
+
"pragma: no cover",
|
|
81
|
+
"def __repr__",
|
|
82
|
+
"if self.debug:",
|
|
83
|
+
"raise NotImplementedError",
|
|
84
|
+
"if __name__ == .__main__.:",
|
|
85
|
+
"pass",
|
|
86
|
+
"raise ImportError",
|
|
87
|
+
]
|
|
88
|
+
ignore_errors = true
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
[build-system]
|
|
96
|
+
requires = ["poetry-core"]
|
|
97
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import skfmm
|
|
3
|
+
from skimage.draw import line_nd
|
|
4
|
+
from typing import Tuple, Union, Any
|
|
5
|
+
import warnings
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_sn_graph(
|
|
10
|
+
image: np.ndarray,
|
|
11
|
+
max_num_vertices: int = -1,
|
|
12
|
+
edge_threshold: float = 1.0,
|
|
13
|
+
max_edge_length: int = -1,
|
|
14
|
+
minimal_sphere_radius: float = 5.0,
|
|
15
|
+
edge_sphere_threshold: float = 1.0,
|
|
16
|
+
return_sdf: bool = False,
|
|
17
|
+
) -> Union[Tuple[list, list, np.ndarray], Tuple[list, list]]:
|
|
18
|
+
"""Create a graph from an image/volume using the Sphere-Node (SN) graph skeletonisation algorithm.
|
|
19
|
+
|
|
20
|
+
This function converts a grayscale image/volume into a graph representation by first computing
|
|
21
|
+
its signed distance field (assuming boundary contour has value 0), then placing sphere centers as vertices and creating edges between neighboring spheres based on specified criteria.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
image : np.ndarray
|
|
26
|
+
Grayscale input image/volume where foreground is positive and background is 0.
|
|
27
|
+
Can be a 2D or 3D numpy array.
|
|
28
|
+
max_num_vertices : int, optional
|
|
29
|
+
Maximum number of vertices (sphere centers) to generate.
|
|
30
|
+
If -1, no limit is applied.
|
|
31
|
+
Default is -1.
|
|
32
|
+
edge_threshold : float, optional
|
|
33
|
+
Threshold value for determining what is the minimal portion of an edge that has to lie within the object.
|
|
34
|
+
Higher value is more restrictive, with 1 requiring edge to be fully contained in the object.
|
|
35
|
+
Default is 1.0.
|
|
36
|
+
max_edge_length : int, optional
|
|
37
|
+
Maximum allowed length for edges between vertices.
|
|
38
|
+
If -1, no limit is applied. Default is -1.
|
|
39
|
+
minimal_sphere_radius : float, optional
|
|
40
|
+
Minimum radius allowed for spheres when placing vertices.
|
|
41
|
+
Default is 5
|
|
42
|
+
edge_sphere_threshold: float, optional
|
|
43
|
+
Threshold value for deciding how close can edge be to a non-endpoint spheres. Higher value is more restrictive, with 1 allowing no overlap whatsoever.
|
|
44
|
+
Default is 1.0
|
|
45
|
+
return_sdf : bool, optional
|
|
46
|
+
If True, the signed distance field array is returned as well.
|
|
47
|
+
Default is False
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
Tuple[List[Tuple[int, ...]], List[Tuple[Tuple[int, ...], Tuple[int, ...]]]]
|
|
52
|
+
A tuple containing:
|
|
53
|
+
- List of sphere centers as coordinate tuples
|
|
54
|
+
- List of edges as pairs of vertex coordinates
|
|
55
|
+
"""
|
|
56
|
+
(
|
|
57
|
+
image,
|
|
58
|
+
max_num_vertices,
|
|
59
|
+
edge_threshold,
|
|
60
|
+
max_edge_length,
|
|
61
|
+
minimal_sphere_radius,
|
|
62
|
+
edge_sphere_threshold,
|
|
63
|
+
return_sdf,
|
|
64
|
+
) = _validate_args(
|
|
65
|
+
image,
|
|
66
|
+
max_num_vertices,
|
|
67
|
+
edge_threshold,
|
|
68
|
+
max_edge_length,
|
|
69
|
+
minimal_sphere_radius,
|
|
70
|
+
edge_sphere_threshold,
|
|
71
|
+
return_sdf,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
print("Computing SDF array...")
|
|
75
|
+
start = time.time()
|
|
76
|
+
|
|
77
|
+
# Pad the image with 1's to avoid edge effects in the signed distance field computation
|
|
78
|
+
padded_image = np.pad(image, 1)
|
|
79
|
+
padded_sdf_array = skfmm.distance(padded_image, dx=1, periodic=False)
|
|
80
|
+
# Remove padding
|
|
81
|
+
slice_tuple = tuple(slice(1, -1) for _ in range(image.ndim))
|
|
82
|
+
sdf_array = padded_sdf_array[slice_tuple]
|
|
83
|
+
|
|
84
|
+
end = time.time()
|
|
85
|
+
print(f"Time taken: {end - start:.4f} seconds")
|
|
86
|
+
|
|
87
|
+
print("Computing sphere centres...")
|
|
88
|
+
start = time.time()
|
|
89
|
+
|
|
90
|
+
spheres_centres = choose_sphere_centres(
|
|
91
|
+
sdf_array, max_num_vertices, minimal_sphere_radius
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
end = time.time()
|
|
95
|
+
print(f"Time taken: {end - start:.4f} seconds")
|
|
96
|
+
|
|
97
|
+
print("Computing edges...")
|
|
98
|
+
start = time.time()
|
|
99
|
+
|
|
100
|
+
edges = determine_edges(
|
|
101
|
+
spheres_centres,
|
|
102
|
+
sdf_array,
|
|
103
|
+
max_edge_length,
|
|
104
|
+
edge_threshold,
|
|
105
|
+
edge_sphere_threshold,
|
|
106
|
+
)
|
|
107
|
+
end = time.time()
|
|
108
|
+
print(f"Time taken: {end - start:.4f} seconds")
|
|
109
|
+
|
|
110
|
+
if return_sdf:
|
|
111
|
+
return spheres_centres, edges, sdf_array
|
|
112
|
+
return spheres_centres, edges
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _validate_args(
|
|
116
|
+
image: np.ndarray,
|
|
117
|
+
max_num_vertices: int,
|
|
118
|
+
edge_threshold: float,
|
|
119
|
+
max_edge_length: int,
|
|
120
|
+
minimal_sphere_radius: float,
|
|
121
|
+
edge_sphere_threshold: float,
|
|
122
|
+
return_sdf: bool,
|
|
123
|
+
) -> Tuple[np.ndarray, int, float, int, float, float, bool]:
|
|
124
|
+
assert isinstance(
|
|
125
|
+
image, np.ndarray
|
|
126
|
+
), f"input must be a numpy array, got {type(image)}"
|
|
127
|
+
image = np.squeeze(image)
|
|
128
|
+
if image.ndim > 3:
|
|
129
|
+
warnings.warn(
|
|
130
|
+
f"Running algorithm on an input of high dimension. Input dimension: {image.ndim}",
|
|
131
|
+
RuntimeWarning,
|
|
132
|
+
)
|
|
133
|
+
assert isinstance(
|
|
134
|
+
max_num_vertices, int
|
|
135
|
+
), f"max_num_vertices must be integer, got {type(max_num_vertices)}"
|
|
136
|
+
assert isinstance(
|
|
137
|
+
edge_threshold, (int, float)
|
|
138
|
+
), f"edge_threshold must be numeric, got {type(edge_threshold)}"
|
|
139
|
+
assert isinstance(
|
|
140
|
+
max_edge_length, int
|
|
141
|
+
), f"max_edge_length must be integer, got {type(max_edge_length)}"
|
|
142
|
+
assert isinstance(
|
|
143
|
+
minimal_sphere_radius, (int, float)
|
|
144
|
+
), f"minimal_sphere_radius must be numeric, got {type(minimal_sphere_radius)}"
|
|
145
|
+
assert isinstance(
|
|
146
|
+
edge_sphere_threshold, (int, float)
|
|
147
|
+
), f"edge_sphere_threshold must be numeric, got {type(edge_sphere_threshold)}"
|
|
148
|
+
assert isinstance(
|
|
149
|
+
return_sdf, bool
|
|
150
|
+
), f"return_sdf must be boolean, got {type(return_sdf)}"
|
|
151
|
+
assert (
|
|
152
|
+
max_num_vertices == -1 or max_num_vertices >= 0
|
|
153
|
+
), f"max_num_vertices must be -1 or non-negative, got {max_num_vertices}"
|
|
154
|
+
if max_num_vertices == -1:
|
|
155
|
+
max_num_vertices = np.inf
|
|
156
|
+
|
|
157
|
+
assert (
|
|
158
|
+
edge_threshold >= 0
|
|
159
|
+
), f"edge_threshold must be non-negative, got {edge_threshold}"
|
|
160
|
+
assert (
|
|
161
|
+
max_edge_length == -1 or max_edge_length >= 0
|
|
162
|
+
), f"max_edge_length must be -1 or non-negative, got {max_edge_length}"
|
|
163
|
+
if max_edge_length == -1:
|
|
164
|
+
max_edge_length = np.inf
|
|
165
|
+
assert (
|
|
166
|
+
minimal_sphere_radius >= 0
|
|
167
|
+
), f"minimal_sphere_radius must be non-negative, got {minimal_sphere_radius}"
|
|
168
|
+
assert (
|
|
169
|
+
edge_sphere_threshold >= 0
|
|
170
|
+
), f"edge_sphere_threshold must be positive, got {edge_sphere_threshold}"
|
|
171
|
+
assert return_sdf in [
|
|
172
|
+
True,
|
|
173
|
+
False,
|
|
174
|
+
], f"return_sdf must be a boolean, got {return_sdf}"
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
image,
|
|
178
|
+
max_num_vertices,
|
|
179
|
+
edge_threshold,
|
|
180
|
+
max_edge_length,
|
|
181
|
+
minimal_sphere_radius,
|
|
182
|
+
edge_sphere_threshold,
|
|
183
|
+
return_sdf,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# First functions to get vertices
|
|
188
|
+
def _sn_graph_distance_vectorized(
|
|
189
|
+
v_i: np.ndarray, v_j: np.ndarray, sdf_array: np.ndarray
|
|
190
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
191
|
+
"""Compute vectorized version of SN-Graph paper distance between vertices, and a mask of valid distances.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
v_i: np.ndarray, shape (N, ndim), coordinates of set of vertices already in the graph
|
|
195
|
+
v_j: np.ndarray, shape (M, ndim), coordinates of candidate vertices
|
|
196
|
+
sdf_array: np.ndarray, signed distance field array
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Tuple[np.ndarray, np.ndarray]: distances between vertices, and mask of valid distances
|
|
200
|
+
"""
|
|
201
|
+
diff = v_i[:, None, :] - v_j[None, :, :] # Shape: (N, M, ndim)
|
|
202
|
+
distances = np.sqrt(np.sum(diff**2, axis=2)) # Shape: (N, M)
|
|
203
|
+
|
|
204
|
+
sdf_vi = np.array([sdf_array[tuple(coord)] for coord in v_i])
|
|
205
|
+
sdf_vj = np.array([sdf_array[tuple(coord)] for coord in v_j])
|
|
206
|
+
|
|
207
|
+
valid_mask = distances > (sdf_vi[:, None] + sdf_vj[None, :])
|
|
208
|
+
final_distances = distances - sdf_vi[:, None] + 2 * sdf_vj[None, :]
|
|
209
|
+
return final_distances, valid_mask
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _choose_next_sphere(
|
|
213
|
+
sdf_array: np.ndarray, sphere_centres: list, candidates_sparse: np.ndarray
|
|
214
|
+
) -> Tuple[Union[Any, Tuple[int, ...]], np.ndarray]:
|
|
215
|
+
"""Choose the next sphere center and return both the center and valid candidates mask.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
sdf_array: np.ndarray, signed distance field array
|
|
219
|
+
sphere_centres: list, existing sphere centers
|
|
220
|
+
candidates_sparse: np.ndarray, candidate points
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
Tuple containing the next sphere center and valid candidates mask
|
|
224
|
+
"""
|
|
225
|
+
if not sphere_centres:
|
|
226
|
+
return tuple(np.unravel_index(sdf_array.argmax(), sdf_array.shape)), None
|
|
227
|
+
|
|
228
|
+
if len(candidates_sparse) == 0:
|
|
229
|
+
return None, None
|
|
230
|
+
|
|
231
|
+
sphere_centres = np.array(sphere_centres)
|
|
232
|
+
|
|
233
|
+
# Get distances and validity mask
|
|
234
|
+
distances, valid_mask = _sn_graph_distance_vectorized(
|
|
235
|
+
sphere_centres, candidates_sparse, sdf_array
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# A candidate is only valid if it has valid distances to ALL existing spheres
|
|
239
|
+
valid_candidates = np.all(valid_mask, axis=0)
|
|
240
|
+
|
|
241
|
+
if not np.any(valid_candidates):
|
|
242
|
+
return None, None
|
|
243
|
+
|
|
244
|
+
# Only consider distances for valid candidates
|
|
245
|
+
valid_distances = distances[:, valid_candidates]
|
|
246
|
+
min_distances_valid = np.min(valid_distances, axis=0)
|
|
247
|
+
best_valid_idx = np.argmax(min_distances_valid)
|
|
248
|
+
|
|
249
|
+
# Map back to original candidate index
|
|
250
|
+
original_idx = np.where(valid_candidates)[0][best_valid_idx]
|
|
251
|
+
|
|
252
|
+
return tuple(candidates_sparse[original_idx]), valid_candidates
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def choose_sphere_centres(
|
|
256
|
+
sdf_array: np.ndarray, max_num_vertices: int, minimal_sphere_radius: float
|
|
257
|
+
) -> list:
|
|
258
|
+
"""Choose sphere centers based on SN-graph algorithm. Essentially iteratively applies choose_next_sphere function.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
sdf_array: np.ndarray, signed distance field array
|
|
262
|
+
max_num_vertices: int, maximum number of vertices to generate
|
|
263
|
+
minimal_sphere_radius: float, minimal radius of spheres
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
list: list of sphere centers as coordinates (tuple of ndim integers)
|
|
267
|
+
"""
|
|
268
|
+
sphere_centres: list = []
|
|
269
|
+
|
|
270
|
+
if max_num_vertices == 0:
|
|
271
|
+
warnings.warn(
|
|
272
|
+
"max_num_vertices is 0, no vertices will be placed.", RuntimeWarning
|
|
273
|
+
)
|
|
274
|
+
return sphere_centres
|
|
275
|
+
|
|
276
|
+
# Initialize candidates as sparse coordinates
|
|
277
|
+
if minimal_sphere_radius > 0:
|
|
278
|
+
candidates_mask = sdf_array >= minimal_sphere_radius
|
|
279
|
+
else:
|
|
280
|
+
candidates_mask = sdf_array > 0
|
|
281
|
+
|
|
282
|
+
if not np.any(candidates_mask):
|
|
283
|
+
warnings.warn(
|
|
284
|
+
f"Image is empty or there are no spheres larger than the minimal_sphere_radius: {minimal_sphere_radius}. No vertices will be placed.",
|
|
285
|
+
RuntimeWarning,
|
|
286
|
+
)
|
|
287
|
+
return sphere_centres
|
|
288
|
+
|
|
289
|
+
# Convert to sparse coordinates
|
|
290
|
+
candidates_sparse = np.array(np.where(candidates_mask)).T
|
|
291
|
+
|
|
292
|
+
i = 0
|
|
293
|
+
while i < max_num_vertices:
|
|
294
|
+
next_centre, valid_candidates = _choose_next_sphere(
|
|
295
|
+
sdf_array, sphere_centres, candidates_sparse
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if next_centre is None:
|
|
299
|
+
break
|
|
300
|
+
|
|
301
|
+
sphere_centres.append(next_centre)
|
|
302
|
+
|
|
303
|
+
# Update candidates using the valid_mask from choose_next_sphere
|
|
304
|
+
if valid_candidates is not None: # Skip for first sphere
|
|
305
|
+
candidates_sparse = candidates_sparse[valid_candidates]
|
|
306
|
+
|
|
307
|
+
i += 1
|
|
308
|
+
|
|
309
|
+
return sphere_centres
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# now functions to get edges
|
|
313
|
+
def _edges_mostly_within_object_mask(
|
|
314
|
+
edges: np.ndarray, edge_threshold: float, sdf_array: np.ndarray
|
|
315
|
+
) -> np.ndarray:
|
|
316
|
+
"""Check if a sufficient portion of each edge lies within the object.
|
|
317
|
+
|
|
318
|
+
Arguments:
|
|
319
|
+
edges -- array of shape (n_edges, 2, ndim) where each edge is defined by its start and end points
|
|
320
|
+
edge_threshold -- threshold value for how much of edge has to be within the object
|
|
321
|
+
sdf_array -- signed distance field array
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
np.ndarray -- Boolean array of shape (n_edges,)
|
|
325
|
+
"""
|
|
326
|
+
n_edges = edges.shape[0]
|
|
327
|
+
is_mostly_within = np.zeros(n_edges, dtype=bool)
|
|
328
|
+
|
|
329
|
+
for i in range(n_edges):
|
|
330
|
+
start = edges[i, 0].astype(int)
|
|
331
|
+
end = edges[i, 1].astype(int)
|
|
332
|
+
|
|
333
|
+
# Use line_nd for any number of dimensions
|
|
334
|
+
pixel_indices = line_nd(start, end)
|
|
335
|
+
good_part = (sdf_array[tuple(pixel_indices)] > 0).sum()
|
|
336
|
+
amount_of_pixels = len(pixel_indices[0])
|
|
337
|
+
|
|
338
|
+
is_mostly_within[i] = good_part >= edge_threshold * amount_of_pixels
|
|
339
|
+
|
|
340
|
+
return is_mostly_within
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _points_intervals_distances(points: np.ndarray, edges: np.ndarray) -> np.ndarray:
|
|
344
|
+
"""Calculate distances from each point to each edge.
|
|
345
|
+
The algorithm uses a classical linear alegbra formula for orthogonally projecting one vector onto another. Based on whether the projection falls within the edge or outside of it, the distance in question is the distance to one of the endpoints, or the distance to the projection.
|
|
346
|
+
|
|
347
|
+
Arguments:
|
|
348
|
+
points -- array of shape (n_points, ndim)
|
|
349
|
+
edges -- array of shape (n_edges, 2, ndim) where each edge is defined by start and end points
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
np.ndarray -- array of shape (n_points, n_edges) containing distances
|
|
353
|
+
"""
|
|
354
|
+
n_points = points.shape[0]
|
|
355
|
+
n_edges = edges.shape[0]
|
|
356
|
+
ndim = points.shape[1]
|
|
357
|
+
|
|
358
|
+
# Reshape arrays for broadcasting
|
|
359
|
+
p = points.reshape(n_points, 1, ndim) # points to be projected on edges
|
|
360
|
+
a = edges[:, 0].reshape(1, n_edges, ndim) # edge starts
|
|
361
|
+
b = edges[:, 1].reshape(1, n_edges, ndim) # edge ends
|
|
362
|
+
|
|
363
|
+
ba = b - a # Shape: (1, n_edges, ndim)
|
|
364
|
+
ba_length_squared = np.sum(ba**2, axis=2, keepdims=True) # Shape: (1, n_edges, 1)
|
|
365
|
+
ba_length = np.sqrt(ba_length_squared) # Shape: (1, n_edges, 1)
|
|
366
|
+
|
|
367
|
+
# Handle degenerate edges
|
|
368
|
+
degenerate_mask = ba_length < 1e-10
|
|
369
|
+
|
|
370
|
+
# Calculate projection
|
|
371
|
+
pa = p - a # Shape: (n_points, n_edges, ndim)
|
|
372
|
+
t = np.sum(pa * ba, axis=2, keepdims=True) / (
|
|
373
|
+
ba_length_squared + 1e-10
|
|
374
|
+
) # Shape: (n_points, n_edges, 1)
|
|
375
|
+
|
|
376
|
+
# Create masks and compute distances for three possible cases
|
|
377
|
+
|
|
378
|
+
# p is projected before the start of the edge
|
|
379
|
+
mask_before = t <= 0
|
|
380
|
+
d_before = np.linalg.norm(
|
|
381
|
+
pa, axis=2
|
|
382
|
+
) # Distance to start point is the distance to the edge
|
|
383
|
+
|
|
384
|
+
# p is projected after the end of the edge
|
|
385
|
+
mask_after = t >= 1
|
|
386
|
+
d_after = np.linalg.norm(
|
|
387
|
+
p - b, axis=2
|
|
388
|
+
) # Distance to end point is the distance to the edge
|
|
389
|
+
|
|
390
|
+
# Project points onto the edges
|
|
391
|
+
h = a + t * ba
|
|
392
|
+
d_between = np.linalg.norm(
|
|
393
|
+
p - h, axis=2
|
|
394
|
+
) # Distance to h (the proejction) is the distance to the edge
|
|
395
|
+
|
|
396
|
+
# Combine results based on masks
|
|
397
|
+
distances = np.where(
|
|
398
|
+
mask_before[..., 0], d_before, np.where(mask_after[..., 0], d_after, d_between)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Handle degenerate edges
|
|
402
|
+
distances = np.where(degenerate_mask[..., 0], d_before, distances)
|
|
403
|
+
|
|
404
|
+
return distances # Shape: (n_points, n_edges)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _edges_not_too_close_to_many_spheres_mask(
|
|
408
|
+
edges: np.ndarray,
|
|
409
|
+
spheres_centres_array: np.ndarray,
|
|
410
|
+
sdf_array: np.ndarray,
|
|
411
|
+
edge_sphere_threshold: float,
|
|
412
|
+
) -> np.ndarray:
|
|
413
|
+
"""Determine which edges are not too close to more than 2 sphere (Every edge is intersecting 2 spheres at least which are its endpoints).
|
|
414
|
+
|
|
415
|
+
Arguments:
|
|
416
|
+
edges -- array of shape (n_edges, 2, ndim)
|
|
417
|
+
spheres_centres_array -- array of shape (n_spheres, ndim)
|
|
418
|
+
sdf_array -- signed distance field array
|
|
419
|
+
edge_sphere_threshold -- threshold for edge closeness to spheres
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
np.ndarray -- Boolean array of shape (n_edges,)
|
|
423
|
+
"""
|
|
424
|
+
n_edges = edges.shape[0]
|
|
425
|
+
if n_edges == 0:
|
|
426
|
+
return np.zeros(n_edges, dtype=bool)
|
|
427
|
+
|
|
428
|
+
# Calculate distances between all sphere centers and all edges
|
|
429
|
+
distances = _points_intervals_distances(
|
|
430
|
+
spheres_centres_array, edges
|
|
431
|
+
) # Shape: (n_spheres, n_edges)
|
|
432
|
+
|
|
433
|
+
# For other dimensions, use tuple indexing
|
|
434
|
+
thresholds = np.array(
|
|
435
|
+
[
|
|
436
|
+
edge_sphere_threshold * sdf_array[tuple(coord.astype(int))]
|
|
437
|
+
for coord in spheres_centres_array
|
|
438
|
+
]
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# Compare distances with thresholds
|
|
442
|
+
close_mask = distances < thresholds[:, np.newaxis] # Shape: (n_spheres, n_edges)
|
|
443
|
+
|
|
444
|
+
# Count close spheres for each edge
|
|
445
|
+
close_spheres_count = np.sum(close_mask, axis=0) # Shape: (n_edges,)
|
|
446
|
+
|
|
447
|
+
# Keep edges with <= 2 close spheres
|
|
448
|
+
keep_mask = close_spheres_count <= 2
|
|
449
|
+
|
|
450
|
+
return keep_mask
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def determine_edges(
|
|
454
|
+
spheres_centres: list,
|
|
455
|
+
sdf_array: np.ndarray,
|
|
456
|
+
max_edge_length: float,
|
|
457
|
+
edge_threshold: float,
|
|
458
|
+
edge_sphere_threshold: float,
|
|
459
|
+
) -> list:
|
|
460
|
+
"""Determine valid edges between sphere centers.
|
|
461
|
+
|
|
462
|
+
Arguments:
|
|
463
|
+
spheres_centres -- list of tuples, each tuple contains coordinates of a sphere center
|
|
464
|
+
sdf_array -- signed distance field array
|
|
465
|
+
max_edge_length -- maximum allowed edge length
|
|
466
|
+
edge_threshold -- threshold for edge being within object
|
|
467
|
+
edge_sphere_threshold -- threshold for edge closeness to spheres
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
list -- list containing valid edges
|
|
471
|
+
"""
|
|
472
|
+
# Convert list of tuples to numpy array for vectorized operations
|
|
473
|
+
spheres_centres_array = np.array(spheres_centres)
|
|
474
|
+
n_spheres = spheres_centres_array.shape[0]
|
|
475
|
+
|
|
476
|
+
if n_spheres == 0:
|
|
477
|
+
return []
|
|
478
|
+
|
|
479
|
+
# Create all possible pairs of indices
|
|
480
|
+
idx_i, idx_j = np.where(np.triu(np.ones((n_spheres, n_spheres)), k=1))
|
|
481
|
+
|
|
482
|
+
# Get the corresponding sphere centers
|
|
483
|
+
edges = np.stack(
|
|
484
|
+
[np.stack([spheres_centres_array[idx_i], spheres_centres_array[idx_j]], axis=1)]
|
|
485
|
+
)[0]
|
|
486
|
+
|
|
487
|
+
# Calculate edge lengths
|
|
488
|
+
edge_lengths = np.linalg.norm(edges[:, 1] - edges[:, 0], axis=1)
|
|
489
|
+
|
|
490
|
+
# Filter by length
|
|
491
|
+
length_mask = edge_lengths < max_edge_length
|
|
492
|
+
edges = edges[length_mask]
|
|
493
|
+
|
|
494
|
+
# Filter by being within object
|
|
495
|
+
within_object_mask = _edges_mostly_within_object_mask(
|
|
496
|
+
edges, edge_threshold, sdf_array
|
|
497
|
+
)
|
|
498
|
+
edges = edges[within_object_mask]
|
|
499
|
+
|
|
500
|
+
# Filter by closeness to too many spheres
|
|
501
|
+
not_too_close_mask = _edges_not_too_close_to_many_spheres_mask(
|
|
502
|
+
edges, spheres_centres_array, sdf_array, edge_sphere_threshold
|
|
503
|
+
)
|
|
504
|
+
valid_edges = edges[not_too_close_mask]
|
|
505
|
+
|
|
506
|
+
return list(valid_edges)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from skimage.draw import line, circle_perimeter, line_nd
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def draw_sn_graph(
|
|
7
|
+
spheres_centres: list,
|
|
8
|
+
edges: list,
|
|
9
|
+
sdf_array: np.ndarray,
|
|
10
|
+
background_image: Optional[np.ndarray] = None,
|
|
11
|
+
draw_circles: bool = True,
|
|
12
|
+
) -> np.ndarray:
|
|
13
|
+
"""
|
|
14
|
+
Draw a graph of spheres and edges on an image/volume.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
spheres_centres: list of tuples, each tuple contains coordinates of a sphere's centre.
|
|
18
|
+
edges: list of tuples of tuples, each tuple contains coordinates of the two ends of an edge.
|
|
19
|
+
sdf_array: np.ndarray, the signed distance function array.
|
|
20
|
+
background_image: optional(np.ndarray), the image/volume on which to draw the graph.
|
|
21
|
+
draw_circles: bool, whether to draw the circles/spheres around the sphere centers.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
np.ndarray: the image/volume (or blank background) with the graph drawn on it.
|
|
25
|
+
"""
|
|
26
|
+
# Determine dimensionality based on sdf_array
|
|
27
|
+
ndim = sdf_array.ndim
|
|
28
|
+
|
|
29
|
+
if background_image is not None:
|
|
30
|
+
assert (
|
|
31
|
+
background_image.shape == sdf_array.shape
|
|
32
|
+
), "background_image must have the same shape as sdf_array"
|
|
33
|
+
|
|
34
|
+
img = (
|
|
35
|
+
background_image.copy()
|
|
36
|
+
if background_image is not None
|
|
37
|
+
else np.zeros(sdf_array.shape)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Draw edges
|
|
41
|
+
for edge in edges:
|
|
42
|
+
if ndim == 2:
|
|
43
|
+
pixels = line(edge[0][0], edge[0][1], edge[1][0], edge[1][1])
|
|
44
|
+
img[pixels] = 2
|
|
45
|
+
else: # 3D or higher
|
|
46
|
+
# Use line_nd for higher dimensions
|
|
47
|
+
start = np.array(edge[0])
|
|
48
|
+
end = np.array(edge[1])
|
|
49
|
+
pixels = line_nd(start, end)
|
|
50
|
+
|
|
51
|
+
# Create a valid indexing tuple
|
|
52
|
+
valid_indices = []
|
|
53
|
+
for i in range(len(pixels)):
|
|
54
|
+
mask = (pixels[i] >= 0) & (pixels[i] < sdf_array.shape[i])
|
|
55
|
+
valid_indices.append(mask)
|
|
56
|
+
|
|
57
|
+
valid_mask = np.all(np.stack(valid_indices, axis=0), axis=0)
|
|
58
|
+
|
|
59
|
+
# Only use valid pixel coordinates
|
|
60
|
+
pixel_indices = tuple(p[valid_mask] for p in pixels)
|
|
61
|
+
if pixel_indices[0].size > 0:
|
|
62
|
+
img[pixel_indices] = 2
|
|
63
|
+
|
|
64
|
+
# If no sdf_array given or draw_circles is False, don't draw spheres
|
|
65
|
+
if not draw_circles:
|
|
66
|
+
return img
|
|
67
|
+
|
|
68
|
+
# Draw spheres
|
|
69
|
+
for center in spheres_centres:
|
|
70
|
+
if ndim == 2:
|
|
71
|
+
# For 2D, use circle_perimeter
|
|
72
|
+
center_tuple = tuple(int(c) for c in center)
|
|
73
|
+
radius = int(np.ceil(sdf_array[center_tuple]))
|
|
74
|
+
circle_coords = circle_perimeter(
|
|
75
|
+
center_tuple[0], center_tuple[1], radius, shape=img.shape
|
|
76
|
+
)
|
|
77
|
+
img[circle_coords] = 4
|
|
78
|
+
else: # 3D or higher
|
|
79
|
+
# For 3D, draw sphere surface
|
|
80
|
+
center_array = np.array(center)
|
|
81
|
+
radius = int(np.ceil(sdf_array[tuple(center_array.astype(int))]))
|
|
82
|
+
|
|
83
|
+
# Generate sphere surface using a more efficient algorithm
|
|
84
|
+
sphere_coords = generate_sphere_surface(
|
|
85
|
+
center_array, radius, sdf_array.shape
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if sphere_coords[0].size > 0:
|
|
89
|
+
img[sphere_coords] = 4
|
|
90
|
+
|
|
91
|
+
return img
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def generate_sphere_surface(center: np.ndarray, radius: int, shape: tuple) -> tuple:
|
|
95
|
+
"""
|
|
96
|
+
Generate coordinates of a sphere surface efficiently.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
center: np.ndarray, center coordinates of the sphere
|
|
100
|
+
radius: int, radius of the sphere
|
|
101
|
+
shape: tuple, shape of the target array
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
tuple of np.ndarrays: coordinates of the sphere surface
|
|
105
|
+
"""
|
|
106
|
+
# For efficiency, only iterate over the bounding box of the sphere
|
|
107
|
+
ranges = []
|
|
108
|
+
for i, c in enumerate(center):
|
|
109
|
+
ranges.append(
|
|
110
|
+
np.arange(max(0, int(c - radius - 1)), min(shape[i], int(c + radius + 2)))
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Create meshgrid of coordinates within the bounding box
|
|
114
|
+
coords = np.meshgrid(*ranges, indexing="ij")
|
|
115
|
+
coord_points = np.stack([c.flatten() for c in coords], axis=-1)
|
|
116
|
+
|
|
117
|
+
# Calculate distances from each point to the center
|
|
118
|
+
distances = np.sqrt(np.sum((coord_points - center) ** 2, axis=1))
|
|
119
|
+
|
|
120
|
+
# Find points that are on the sphere surface (within a small threshold)
|
|
121
|
+
surface_threshold = 0.5 # Adjust this value for thickness of the surface
|
|
122
|
+
surface_mask = np.abs(distances - radius) < surface_threshold
|
|
123
|
+
|
|
124
|
+
# Return coordinates as tuple for indexing
|
|
125
|
+
surface_points = coord_points[surface_mask]
|
|
126
|
+
|
|
127
|
+
# Convert to tuple of arrays for indexing
|
|
128
|
+
if surface_points.shape[0] > 0:
|
|
129
|
+
return tuple(
|
|
130
|
+
surface_points[:, i].astype(int) for i in range(surface_points.shape[1])
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
# Return empty arrays with proper shape if no points match
|
|
134
|
+
return tuple(np.array([], dtype=int) for _ in range(len(center)))
|