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 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.
@@ -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
+ ![Example of a binary image and the skeletal graph](/assets/horse_graph.png "SN-graph generated out of an scikit-image's horse image.")
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
+
@@ -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
+ ![Example of a binary image and the skeletal graph](/assets/horse_graph.png "SN-graph generated out of an scikit-image's horse image.")
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,4 @@
1
+ from .core import create_sn_graph as create_sn_graph
2
+ from .visualisation import draw_sn_graph as draw_sn_graph
3
+
4
+ __all__ = ["create_sn_graph", "draw_sn_graph"]
@@ -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)))