sgtlib 3.3.9__py3-none-any.whl
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.
- StructuralGT/__init__.py +31 -0
- StructuralGT/apps/__init__.py +0 -0
- StructuralGT/apps/cli_main.py +258 -0
- StructuralGT/apps/gui_main.py +69 -0
- StructuralGT/apps/gui_mcw/__init__.py +0 -0
- StructuralGT/apps/gui_mcw/checkbox_model.py +91 -0
- StructuralGT/apps/gui_mcw/controller.py +1073 -0
- StructuralGT/apps/gui_mcw/image_provider.py +74 -0
- StructuralGT/apps/gui_mcw/imagegrid_model.py +75 -0
- StructuralGT/apps/gui_mcw/qthread_worker.py +102 -0
- StructuralGT/apps/gui_mcw/table_model.py +79 -0
- StructuralGT/apps/gui_mcw/tree_model.py +154 -0
- StructuralGT/apps/sgt_qml/CenterMainContent.qml +19 -0
- StructuralGT/apps/sgt_qml/LeftContent.qml +48 -0
- StructuralGT/apps/sgt_qml/MainWindow.qml +762 -0
- StructuralGT/apps/sgt_qml/RightLoggingPanel.qml +125 -0
- StructuralGT/apps/sgt_qml/assets/icons/.DS_Store +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/back_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/brightness_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/cancel_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/crop_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/edit_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/graph_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/hide_panel.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/next_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/notify_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/rescale_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/show_panel.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/square_icon.png +0 -0
- StructuralGT/apps/sgt_qml/assets/icons/undo_icon.png +0 -0
- StructuralGT/apps/sgt_qml/components/ImageFilters.qml +82 -0
- StructuralGT/apps/sgt_qml/components/ImageProperties.qml +112 -0
- StructuralGT/apps/sgt_qml/components/ProjectNav.qml +127 -0
- StructuralGT/apps/sgt_qml/widgets/BinaryFilterWidget.qml +151 -0
- StructuralGT/apps/sgt_qml/widgets/BrightnessControlWidget.qml +103 -0
- StructuralGT/apps/sgt_qml/widgets/CreateProjectWidget.qml +112 -0
- StructuralGT/apps/sgt_qml/widgets/GTWidget.qml +94 -0
- StructuralGT/apps/sgt_qml/widgets/GraphComputeWidget.qml +77 -0
- StructuralGT/apps/sgt_qml/widgets/GraphExtractWidget.qml +175 -0
- StructuralGT/apps/sgt_qml/widgets/GraphPropertyWidget.qml +77 -0
- StructuralGT/apps/sgt_qml/widgets/ImageFilterWidget.qml +137 -0
- StructuralGT/apps/sgt_qml/widgets/ImagePropertyWidget.qml +78 -0
- StructuralGT/apps/sgt_qml/widgets/ImageViewWidget.qml +585 -0
- StructuralGT/apps/sgt_qml/widgets/MenuBarWidget.qml +137 -0
- StructuralGT/apps/sgt_qml/widgets/MicroscopyPropertyWidget.qml +80 -0
- StructuralGT/apps/sgt_qml/widgets/ProjectWidget.qml +141 -0
- StructuralGT/apps/sgt_qml/widgets/RescaleControlWidget.qml +83 -0
- StructuralGT/apps/sgt_qml/widgets/RibbonWidget.qml +406 -0
- StructuralGT/apps/sgt_qml/widgets/StatusBarWidget.qml +173 -0
- StructuralGT/compute/__init__.py +0 -0
- StructuralGT/compute/c_lang/include/sgt_base.h +21 -0
- StructuralGT/compute/graph_analyzer.py +1499 -0
- StructuralGT/entrypoints.py +49 -0
- StructuralGT/imaging/__init__.py +0 -0
- StructuralGT/imaging/base_image.py +403 -0
- StructuralGT/imaging/image_processor.py +780 -0
- StructuralGT/modules.py +29 -0
- StructuralGT/networks/__init__.py +0 -0
- StructuralGT/networks/fiber_network.py +490 -0
- StructuralGT/networks/graph_skeleton.py +425 -0
- StructuralGT/networks/sknw_mod.py +199 -0
- StructuralGT/utils/__init__.py +0 -0
- StructuralGT/utils/config_loader.py +244 -0
- StructuralGT/utils/configs.ini +97 -0
- StructuralGT/utils/progress_update.py +67 -0
- StructuralGT/utils/sgt_utils.py +291 -0
- sgtlib-3.3.9.dist-info/METADATA +789 -0
- sgtlib-3.3.9.dist-info/RECORD +72 -0
- sgtlib-3.3.9.dist-info/WHEEL +5 -0
- sgtlib-3.3.9.dist-info/entry_points.txt +3 -0
- sgtlib-3.3.9.dist-info/licenses/LICENSE +674 -0
- sgtlib-3.3.9.dist-info/top_level.txt +1 -0
@@ -0,0 +1,425 @@
|
|
1
|
+
# SPDX-License-Identifier: GNU GPL v3
|
2
|
+
|
3
|
+
"""
|
4
|
+
Create a graph skeleton from an image binary
|
5
|
+
"""
|
6
|
+
|
7
|
+
import math
|
8
|
+
import numpy as np
|
9
|
+
from scipy import ndimage
|
10
|
+
from cv2.typing import MatLike
|
11
|
+
from skimage.morphology import binary_dilation as dilate, binary_closing
|
12
|
+
from skimage.morphology import disk, skeletonize, remove_small_objects
|
13
|
+
|
14
|
+
|
15
|
+
class GraphSkeleton:
|
16
|
+
"""A class that is used for estimating the width of edges and compute their weights using binerized 2D/3D images."""
|
17
|
+
|
18
|
+
def __init__(self, img_bin: MatLike, configs: dict = None, is_2d: bool = True, progress_func = None):
|
19
|
+
"""
|
20
|
+
A class that builds a skeleton graph from an image.
|
21
|
+
The skeleton will be 3D so that it can be analyzed with OVITO
|
22
|
+
|
23
|
+
:param img_bin: OpenCV image in binary format.
|
24
|
+
:param configs: Options and parameters.
|
25
|
+
|
26
|
+
>>> import cv2
|
27
|
+
>>> import numpy
|
28
|
+
>>> opt_gte = {}
|
29
|
+
>>> opt_gte["merge_nearby_nodes"]["value"] = 1
|
30
|
+
>>> opt_gte["remove_disconnected_segments"]["value"] = 1
|
31
|
+
>>> opt_gte["remove_object_size"]["value"] = 500
|
32
|
+
>>> opt_gte["prune_dangling_edges"]["value"] = 1
|
33
|
+
>>> dummy_image = 127 * numpy.ones((40, 40), dtype = np.uint8)
|
34
|
+
>>> img = cv2.threshold(dummy_image, 127, 255, cv2.THRESH_BINARY)[1]
|
35
|
+
>>> graph_skel = GraphSkeleton(img, opt_gte)
|
36
|
+
|
37
|
+
"""
|
38
|
+
self.img_bin = img_bin
|
39
|
+
self.configs = configs
|
40
|
+
self.is_2d = is_2d
|
41
|
+
self.update_progress = progress_func
|
42
|
+
self.skeleton, self.skeleton_3d = None, None
|
43
|
+
if configs is not None:
|
44
|
+
self._build_skeleton()
|
45
|
+
|
46
|
+
def _build_skeleton(self):
|
47
|
+
"""
|
48
|
+
Creates a graph skeleton of the image.
|
49
|
+
|
50
|
+
:return:
|
51
|
+
"""
|
52
|
+
|
53
|
+
# rebuilding the binary image as a boolean for skeletonizing
|
54
|
+
self.img_bin = np.squeeze(self.img_bin)
|
55
|
+
img_bin_int = np.asarray(self.img_bin, dtype=np.uint16)
|
56
|
+
|
57
|
+
# making the initial skeleton image
|
58
|
+
temp_skeleton = skeletonize(img_bin_int)
|
59
|
+
|
60
|
+
# Use medial axis with distance transform
|
61
|
+
# skeleton, distance = medial_axis(img_bin_int, return_distance=True)
|
62
|
+
# Scale thickness by distance (optional)
|
63
|
+
# temp_skeleton = skeleton * distance
|
64
|
+
|
65
|
+
# if self.configs["remove_bubbles"]["value"] == 1:
|
66
|
+
# temp_skeleton = GraphSkeleton.remove_bubbles(temp_skeleton, img_bin_int, mask_elements)
|
67
|
+
# if self.update_progress is not None:
|
68
|
+
# self.update_progress([56, f"Ran remove_bubbles for image skeleton..."])
|
69
|
+
|
70
|
+
if self.configs["merge_nearby_nodes"]["value"] == 1:
|
71
|
+
node_radius_size = 2 # int(self.configs["merge_nearby_nodes"]["items"][0]["value"])
|
72
|
+
temp_skeleton = GraphSkeleton.merge_nodes(temp_skeleton, node_radius_size)
|
73
|
+
if self.update_progress is not None:
|
74
|
+
self.update_progress([52, f"Ran merge_nodes for image skeleton..."])
|
75
|
+
|
76
|
+
if self.configs["remove_disconnected_segments"]["value"] == 1:
|
77
|
+
min_size = int(self.configs["remove_disconnected_segments"]["items"][0]["value"])
|
78
|
+
temp_skeleton = remove_small_objects(temp_skeleton, min_size=min_size, connectivity=2)
|
79
|
+
if self.update_progress is not None:
|
80
|
+
self.update_progress([54, f"Ran remove_small_objects for image skeleton..."])
|
81
|
+
|
82
|
+
if self.configs["prune_dangling_edges"]["value"] == 1:
|
83
|
+
max_iter = 500 # int(self.configs["prune_dangling_edges"]["items"][0]["value"])
|
84
|
+
b_points = GraphSkeleton.get_branched_points(temp_skeleton)
|
85
|
+
temp_skeleton = GraphSkeleton.prune_edges(temp_skeleton, max_iter, b_points)
|
86
|
+
if self.update_progress is not None:
|
87
|
+
self.update_progress([56, f"Ran prune_dangling_edges for image skeleton..."])
|
88
|
+
|
89
|
+
self.skeleton = np.asarray(temp_skeleton, dtype=np.uint16)
|
90
|
+
# self.skeleton = self.skeleton.astype(int)
|
91
|
+
self.skeleton_3d = np.asarray([self.skeleton]) if self.is_2d else self.skeleton
|
92
|
+
|
93
|
+
def assign_weights(self, edge_pts: MatLike, weight_type: str = None, weight_options: dict = None,
|
94
|
+
pixel_dim: float = 1, rho_dim: float = 1):
|
95
|
+
"""
|
96
|
+
Compute and assign weights to a line edge between 2 nodes.
|
97
|
+
|
98
|
+
:param edge_pts: A list of pts that trace along a graph edge.
|
99
|
+
:param weight_type: Basis of computation for the weight (i.e., length, width, resistance, conductance, etc.)
|
100
|
+
:param weight_options: weight types to be used in computation of weights.
|
101
|
+
:param pixel_dim: Physical size of a single pixel width in nanometers.
|
102
|
+
:param rho_dim: The resistivity value of the material.
|
103
|
+
:return: Width pixel count of edge, computed weight.
|
104
|
+
"""
|
105
|
+
|
106
|
+
# Initialize parameters: Idea copied from 'sknw' library
|
107
|
+
pix_length = np.linalg.norm(edge_pts[1:] - edge_pts[:-1], axis=1).sum()
|
108
|
+
epsilon = 0.001 # to avoid division by zero
|
109
|
+
pix_length += epsilon
|
110
|
+
|
111
|
+
if len(edge_pts) < 2:
|
112
|
+
# check to see if ge is an empty or unity list, if so, set pixel counts to 0
|
113
|
+
# Assume only 1/2 pixel exists between edge points
|
114
|
+
pix_width = 0.5
|
115
|
+
pix_angle = None
|
116
|
+
else:
|
117
|
+
# if ge exists, find the midpoint of the trace, and orthogonal unit vector
|
118
|
+
pix_width, pix_angle = self._estimate_edge_width(edge_pts)
|
119
|
+
pix_width += 0.5 # (normalization) to make it larger than empty widths
|
120
|
+
|
121
|
+
if weight_type is None:
|
122
|
+
wt = pix_width / 10
|
123
|
+
elif weight_options.get(weight_type) == weight_options.get('DIA'):
|
124
|
+
wt = pix_width * pixel_dim
|
125
|
+
elif weight_options.get(weight_type) == weight_options.get('AREA'):
|
126
|
+
wt = math.pi * (pix_width * pixel_dim * 0.5) ** 2
|
127
|
+
elif weight_options.get(weight_type) == weight_options.get('LEN') or weight_options.get(weight_type) == weight_options.get('INV_LEN'):
|
128
|
+
wt = pix_length * pixel_dim
|
129
|
+
if weight_options.get(weight_type) == weight_options.get('INV_LEN'):
|
130
|
+
wt = wt + epsilon if wt == 0 else wt
|
131
|
+
wt = wt ** -1
|
132
|
+
elif weight_options.get(weight_type) == weight_options.get('ANGLE'):
|
133
|
+
"""
|
134
|
+
Edge angle centrality" in graph theory refers to a measure of an edge's importance within a network,
|
135
|
+
based on the angles formed between the edges connected to its endpoints, essentially assessing how "central"
|
136
|
+
an edge is in terms of its connection to other edges within the network, with edges forming more acute
|
137
|
+
angles generally considered more central.
|
138
|
+
To calculate edge angle centrality, you would typically:
|
139
|
+
1. For each edge, identify the connected edges at its endpoints.
|
140
|
+
2. Calculate the angles between these connected edges.
|
141
|
+
3. Assign a higher centrality score to edges with smaller angles, indicating a more central position in the network structure.
|
142
|
+
"""
|
143
|
+
sym_angle = np.minimum(pix_angle, (360 - pix_angle))
|
144
|
+
wt = (sym_angle + epsilon) ** -1
|
145
|
+
elif weight_options.get(weight_type) == weight_options.get('FIX_CON') or weight_options.get(weight_type) == weight_options.get('VAR_CON') or weight_options.get(weight_type) == weight_options.get('RES'):
|
146
|
+
# Varies with width
|
147
|
+
length = pix_length * pixel_dim
|
148
|
+
area = math.pi * (pix_width * pixel_dim * 0.5) ** 2
|
149
|
+
if weight_options.get(weight_type) == weight_options.get('FIX_CON'):
|
150
|
+
area = math.pi * (1 * pixel_dim) ** 2
|
151
|
+
num = length * rho_dim
|
152
|
+
area = area + epsilon if area == 0 else area
|
153
|
+
num = num + epsilon if num == 0 else num
|
154
|
+
wt = (num / area) # Resistance
|
155
|
+
if weight_options.get(weight_type) == weight_options.get('VAR_CON') or weight_options.get(weight_type) == weight_options.get('FIX_CON'):
|
156
|
+
wt = wt ** -1 # Conductance is inverse of resistance
|
157
|
+
else:
|
158
|
+
raise TypeError('Invalid weight type')
|
159
|
+
return pix_width, pix_angle, wt
|
160
|
+
|
161
|
+
def _estimate_edge_width(self, graph_edge_coords: MatLike):
|
162
|
+
"""Estimates the edge width of a graph edge."""
|
163
|
+
|
164
|
+
def find_orthogonal(u, v):
|
165
|
+
# Inputs:
|
166
|
+
# u, v: two coordinates (x, y) or (x, y, z)
|
167
|
+
vec = u - v # find the vector between u and v
|
168
|
+
|
169
|
+
if np.linalg.norm(vec) == 0:
|
170
|
+
n = np.array([0, ] * len(u), dtype=np.float16)
|
171
|
+
else:
|
172
|
+
# make n a unit vector along u,v
|
173
|
+
n = vec / np.linalg.norm(vec)
|
174
|
+
|
175
|
+
hl = np.linalg.norm(vec) / 2 # find the half-length of the vector u,v
|
176
|
+
ortho_arr = np.random.randn(len(u)) # take a random vector
|
177
|
+
ortho_arr -= ortho_arr.dot(n) * n # make it orthogonal to vector u,v
|
178
|
+
ortho_arr /= np.linalg.norm(ortho_arr) # make it a unit vector
|
179
|
+
|
180
|
+
# Returns the coordinates of the vector u,v midpoint; the orthogonal unit vector
|
181
|
+
return (v + n * hl), ortho_arr
|
182
|
+
|
183
|
+
# 1. Estimate orthogonal and mid-point
|
184
|
+
end_index = len(graph_edge_coords) - 1
|
185
|
+
pt1 = graph_edge_coords[0]
|
186
|
+
pt2 = graph_edge_coords[end_index]
|
187
|
+
# mid_index = int(len(graph_edge_coords) / 2)
|
188
|
+
# mid_pt = graph_edge_coords[mid_index]
|
189
|
+
|
190
|
+
mid_pt, ortho = find_orthogonal(pt1, pt2)
|
191
|
+
# mid: the midpoint of a trace of an edge
|
192
|
+
# ortho: an orthogonal unit vector
|
193
|
+
mid_pt = mid_pt.astype(int)
|
194
|
+
|
195
|
+
# 2. Compute the angle in Radians
|
196
|
+
# Delta X and Y: Compute the difference in x and y coordinates:
|
197
|
+
dx = pt2[0] - pt1[0]
|
198
|
+
dy = pt2[1] - pt1[1]
|
199
|
+
# Angle Calculation: Use the arc-tangent function to get the angle in radians:
|
200
|
+
angle_rad = math.atan2(dy, dx)
|
201
|
+
angle_deg = math.degrees(angle_rad)
|
202
|
+
if angle_deg < 0:
|
203
|
+
angle_deg += 360
|
204
|
+
|
205
|
+
# 3. Estimate width
|
206
|
+
check = 0 # initializing boolean check
|
207
|
+
i = 0 # initializing iterative variable
|
208
|
+
l1 = np.nan
|
209
|
+
l2 = np.nan
|
210
|
+
while check == 0: # iteratively check along orthogonal vector to see if the coordinate is either...
|
211
|
+
pt_check = mid_pt + i * ortho # ... out of bounds, or no longer within the fiber in img_bin
|
212
|
+
pt_check = pt_check.astype(int)
|
213
|
+
is_in_edge = GraphSkeleton.point_check(self.img_bin, pt_check)
|
214
|
+
|
215
|
+
if is_in_edge:
|
216
|
+
edge = mid_pt + (i - 1) * ortho
|
217
|
+
edge = edge.astype(int)
|
218
|
+
l1 = edge # When the check indicates oob or black space, assign width to l1
|
219
|
+
check = 1
|
220
|
+
else:
|
221
|
+
i += 1
|
222
|
+
|
223
|
+
check = 0
|
224
|
+
i = 0
|
225
|
+
while check == 0: # Repeat, but following the negative orthogonal vector
|
226
|
+
pt_check = mid_pt - i * ortho
|
227
|
+
pt_check = pt_check.astype(int)
|
228
|
+
is_in_edge = GraphSkeleton.point_check(self.img_bin, pt_check)
|
229
|
+
|
230
|
+
if is_in_edge:
|
231
|
+
edge = mid_pt - (i - 1) * ortho
|
232
|
+
edge = edge.astype(int)
|
233
|
+
l2 = edge # When the check indicates oob or black space, assign width to l2
|
234
|
+
check = 1
|
235
|
+
else:
|
236
|
+
i += 1
|
237
|
+
|
238
|
+
# returns the length between l1 and l2, which is the width of the fiber associated with an edge, at its midpoint
|
239
|
+
edge_width = np.linalg.norm(l1 - l2)
|
240
|
+
return edge_width, angle_deg
|
241
|
+
|
242
|
+
@classmethod
|
243
|
+
def _generate_transformations(cls, pattern):
|
244
|
+
"""
|
245
|
+
Generate common transformations for a pattern.
|
246
|
+
|
247
|
+
* flipud is flipping them up-down
|
248
|
+
* t_branch_2 is t_branch_0 transposed, which permutes it in all directions (might not be using that word right)
|
249
|
+
* t_branch_3 is t_branch_2 flipped left right
|
250
|
+
* those 3 functions are used to create all possible branches with just a few starting arrays below
|
251
|
+
|
252
|
+
:param pattern: Pattern of the box as a numpy array.
|
253
|
+
|
254
|
+
"""
|
255
|
+
return [
|
256
|
+
pattern,
|
257
|
+
np.flipud(pattern),
|
258
|
+
np.fliplr(pattern),
|
259
|
+
np.fliplr(np.flipud(pattern)),
|
260
|
+
pattern.T,
|
261
|
+
np.flipud(pattern.T),
|
262
|
+
np.fliplr(pattern.T),
|
263
|
+
np.fliplr(np.flipud(pattern.T))
|
264
|
+
]
|
265
|
+
|
266
|
+
@classmethod
|
267
|
+
def get_branched_points(cls, skeleton: MatLike):
|
268
|
+
"""Identify and retrieve the branched points from the graph skeleton."""
|
269
|
+
skel_int = skeleton * 1
|
270
|
+
|
271
|
+
# Define base patterns
|
272
|
+
base_patterns = [
|
273
|
+
[[1, 0, 1], [0, 1, 0], [1, 0, 1]], # x_branch
|
274
|
+
[[0, 1, 0], [1, 1, 1], [0, 1, 0]], # x_branch variant
|
275
|
+
[[0, 0, 0], [1, 1, 1], [0, 1, 0]], # t_branch
|
276
|
+
[[1, 0, 1], [0, 1, 0], [1, 0, 0]], # t_branch variant
|
277
|
+
[[1, 0, 1], [0, 1, 0], [0, 1, 0]], # y_branch
|
278
|
+
[[0, 1, 0], [1, 1, 0], [0, 0, 1]], # y_branch variant
|
279
|
+
[[0, 1, 0], [1, 1, 0], [1, 0, 1]], # off_branch
|
280
|
+
[[0, 1, 1], [0, 1, 1], [1, 0, 0]], # clust_branch
|
281
|
+
[[1, 1, 1], [0, 1, 1], [1, 0, 0]], # clust_branch variant
|
282
|
+
[[1, 1, 1], [0, 1, 1], [1, 0, 1]], # clust_branch variant
|
283
|
+
[[1, 0, 0], [1, 1, 1], [0, 1, 0]] # cross_branch
|
284
|
+
]
|
285
|
+
|
286
|
+
# Generate all transformations
|
287
|
+
all_patterns = []
|
288
|
+
for pattern in base_patterns:
|
289
|
+
all_patterns.extend(cls._generate_transformations(np.array(pattern)))
|
290
|
+
|
291
|
+
# Remove duplicate patterns (if any)
|
292
|
+
unique_patterns = []
|
293
|
+
for pattern in all_patterns:
|
294
|
+
if not any(np.array_equal(pattern, existing) for existing in unique_patterns):
|
295
|
+
unique_patterns.append(pattern)
|
296
|
+
|
297
|
+
# Apply binary hit-or-miss for all unique patterns
|
298
|
+
br = sum(ndimage.binary_hit_or_miss(skel_int, pattern) for pattern in unique_patterns)
|
299
|
+
return br
|
300
|
+
|
301
|
+
@classmethod
|
302
|
+
def get_end_points(cls, skeleton: MatLike):
|
303
|
+
"""
|
304
|
+
Identify and retrieve the end points from the graph skeleton.
|
305
|
+
"""
|
306
|
+
skel_int = skeleton * 1
|
307
|
+
|
308
|
+
# List of endpoint patterns
|
309
|
+
endpoints = [
|
310
|
+
[[0, 0, 0], [0, 1, 0], [0, 1, 0]],
|
311
|
+
[[0, 0, 0], [0, 1, 0], [0, 0, 1]],
|
312
|
+
[[0, 0, 0], [0, 1, 1], [0, 0, 0]],
|
313
|
+
[[0, 0, 1], [0, 1, 0], [0, 0, 0]],
|
314
|
+
[[0, 1, 0], [0, 1, 0], [0, 0, 0]],
|
315
|
+
[[1, 0, 0], [0, 1, 0], [0, 0, 0]],
|
316
|
+
[[0, 0, 0], [1, 1, 0], [0, 0, 0]],
|
317
|
+
[[0, 0, 0], [0, 1, 0], [1, 0, 0]],
|
318
|
+
[[0, 0, 0], [0, 1, 0], [0, 0, 0]]
|
319
|
+
]
|
320
|
+
|
321
|
+
# Apply binary hit-or-miss for each pattern and sum results
|
322
|
+
ep = sum(ndimage.binary_hit_or_miss(skel_int, np.array(pattern)) for pattern in endpoints)
|
323
|
+
return ep
|
324
|
+
|
325
|
+
@classmethod
|
326
|
+
def prune_edges(cls, skeleton: MatLike, max_num, branch_points):
|
327
|
+
"""Prune dangling edges around b_points. Remove iteratively end points 'size' times from the skeleton"""
|
328
|
+
temp_skeleton = skeleton.copy()
|
329
|
+
for i in range(0, max_num):
|
330
|
+
end_points = GraphSkeleton.get_end_points(temp_skeleton)
|
331
|
+
points = np.logical_and(end_points, branch_points)
|
332
|
+
end_points = np.logical_xor(end_points, points)
|
333
|
+
end_points = np.logical_not(end_points)
|
334
|
+
temp_skeleton = np.logical_and(temp_skeleton, end_points)
|
335
|
+
return temp_skeleton
|
336
|
+
|
337
|
+
@classmethod
|
338
|
+
def merge_nodes(cls, skeleton: MatLike, node_radius):
|
339
|
+
"""Merge nearby nodes in the graph skeleton."""
|
340
|
+
# overlay a disk over each branch point and find the overlaps to combine nodes
|
341
|
+
skeleton_int = 1 * skeleton
|
342
|
+
mask_elem = disk(node_radius)
|
343
|
+
bp_skel = GraphSkeleton.get_branched_points(skeleton)
|
344
|
+
bp_skel = 1 * (dilate(bp_skel, mask_elem))
|
345
|
+
|
346
|
+
# wide-nodes is initially an empty image the same size as the skeleton image
|
347
|
+
skel_shape = skeleton_int.shape
|
348
|
+
wide_nodes = np.zeros(skel_shape, dtype='int')
|
349
|
+
|
350
|
+
# this overlays the two skeletons
|
351
|
+
# skeleton_integer is the full map, bp_skel is just the branch points blown up to a larger size
|
352
|
+
for x in range(skel_shape[0]):
|
353
|
+
for y in range(skel_shape[1]):
|
354
|
+
if skeleton_int[x, y] == 0 and bp_skel[x, y] == 0:
|
355
|
+
wide_nodes[x, y] = 0
|
356
|
+
else:
|
357
|
+
wide_nodes[x, y] = 1
|
358
|
+
|
359
|
+
# re-skeletonizing wide-nodes and returning it, nearby nodes in radius 2 of each other should have been merged
|
360
|
+
temp_skeleton = skeletonize(wide_nodes)
|
361
|
+
return temp_skeleton
|
362
|
+
|
363
|
+
@classmethod
|
364
|
+
def remove_bubbles(cls, img_bin: MatLike, mask_elements: list):
|
365
|
+
"""Remove bubbles from graph skeleton."""
|
366
|
+
if not isinstance(mask_elements, list):
|
367
|
+
return None
|
368
|
+
|
369
|
+
canvas = img_bin.copy()
|
370
|
+
for mask_elem in mask_elements:
|
371
|
+
canvas = skeletonize(mask_elem)
|
372
|
+
canvas = binary_closing(canvas, footprint=mask_elem)
|
373
|
+
|
374
|
+
temp_skeleton = skeletonize(canvas)
|
375
|
+
return temp_skeleton
|
376
|
+
|
377
|
+
@staticmethod
|
378
|
+
def point_check(img_bin: MatLike, pt_check):
|
379
|
+
"""Checks and verifies that a point is on a graph edge."""
|
380
|
+
|
381
|
+
def boundary_check(coord, w, h, d=None):
|
382
|
+
"""
|
383
|
+
|
384
|
+
Args:
|
385
|
+
coord: the coordinate (x,y) to check; no (x,y,z) compatibility yet.
|
386
|
+
w: width of the image to set the boundaries.
|
387
|
+
h: the height of the image to set the boundaries.
|
388
|
+
d: the depth of the image to set the boundaries.
|
389
|
+
Returns:
|
390
|
+
|
391
|
+
"""
|
392
|
+
out_of_bounds = 0 # Generate a boolean check for out-of-boundary
|
393
|
+
# Check if coordinate is within the boundary
|
394
|
+
if d is None:
|
395
|
+
if coord[0] < 0 or coord[1] < 0 or coord[-2] > (w - 1) or coord[-1] > (h - 1):
|
396
|
+
out_of_bounds = 1
|
397
|
+
else:
|
398
|
+
# if sum(coord < 0) > 0 or sum(coord > [w - 1, h - 1, d - 1]) > 0:
|
399
|
+
if sum(coord < 0) > 0 or coord[-3] > (d - 1) or coord[-2] > (w - 1) or coord[-1] > (h - 1):
|
400
|
+
out_of_bounds = 1
|
401
|
+
|
402
|
+
# returns the boolean oob (1 if there is a boundary error); coordinates (resets to (1,1) if boundary error)
|
403
|
+
return out_of_bounds
|
404
|
+
|
405
|
+
# Check if the image is 2D
|
406
|
+
if len(img_bin.shape) == 2:
|
407
|
+
is_2d = True
|
408
|
+
height, width = img_bin.shape # finds dimensions of img_bin for boundary check
|
409
|
+
depth = 0
|
410
|
+
else:
|
411
|
+
is_2d = False
|
412
|
+
depth, height, width = img_bin.shape
|
413
|
+
|
414
|
+
try:
|
415
|
+
if is_2d:
|
416
|
+
# Checks if the point in fiber is out-of-bounds (oob) or black space (img_bin(x,y) = 0)
|
417
|
+
oob = boundary_check(pt_check, width, height)
|
418
|
+
not_in_edge = True if (oob == 1) else True if (img_bin[pt_check[-2], pt_check[-1]] == 0) else False
|
419
|
+
else:
|
420
|
+
# Checks if the point in fiber is out-of-bounds (oob) or black space (img_bin(d,x,y) = 0)
|
421
|
+
oob = boundary_check(pt_check, width, height, d=depth)
|
422
|
+
not_in_edge = True if (oob == 1) else True if (img_bin[pt_check[-3], pt_check[-2], pt_check[-1]] == 0) else False
|
423
|
+
except IndexError:
|
424
|
+
not_in_edge = True
|
425
|
+
return not_in_edge
|
@@ -0,0 +1,199 @@
|
|
1
|
+
"""
|
2
|
+
|
3
|
+
This code is a modified version of the original `sknw.py` by Yan Xiaolong.
|
4
|
+
|
5
|
+
Modifications were made to address bugs caused by 'numba' on some platforms (e.g., Windows) and to replace fixed-size
|
6
|
+
NumPy buffers with dynamically resizable ones, improving robustness for large images and graphs.
|
7
|
+
|
8
|
+
Original author: Yan Xiaolong
|
9
|
+
Original source: https://github.com/Image-Py/sknw.git
|
10
|
+
|
11
|
+
"""
|
12
|
+
|
13
|
+
|
14
|
+
import numpy as np
|
15
|
+
# from numba import jit
|
16
|
+
import networkx as nx
|
17
|
+
|
18
|
+
|
19
|
+
def neighbors(shape):
|
20
|
+
dim = len(shape)
|
21
|
+
block = np.ones([3] * dim)
|
22
|
+
block[tuple([1] * dim)] = 0
|
23
|
+
idx = np.where(block > 0)
|
24
|
+
idx = np.array(idx, dtype=np.uint8).T
|
25
|
+
idx = np.array(idx - [1] * dim)
|
26
|
+
acc = np.cumprod((1,) + shape[::-1][:-1])
|
27
|
+
return np.dot(idx, acc[::-1])
|
28
|
+
|
29
|
+
|
30
|
+
# @jit(nopython=True) # my mark
|
31
|
+
def mark(img, nbs): # mark the array use (0, 1, 2)
|
32
|
+
img = img.ravel()
|
33
|
+
for p in range(len(img)):
|
34
|
+
if img[p] == 0: continue
|
35
|
+
s = 0
|
36
|
+
for dp in nbs:
|
37
|
+
if img[p + dp] != 0: s += 1
|
38
|
+
if s == 2:
|
39
|
+
img[p] = 1
|
40
|
+
else:
|
41
|
+
img[p] = 2
|
42
|
+
|
43
|
+
|
44
|
+
# @jit(nopython=True) # trans index to r, c...
|
45
|
+
def idx2rc(idx, acc):
|
46
|
+
rst = np.zeros((len(idx), len(acc)), dtype=np.int16)
|
47
|
+
for i in range(len(idx)):
|
48
|
+
for j in range(len(acc)):
|
49
|
+
rst[i, j] = idx[i] // acc[j]
|
50
|
+
idx[i] -= rst[i, j] * acc[j]
|
51
|
+
rst -= 1
|
52
|
+
return rst
|
53
|
+
|
54
|
+
|
55
|
+
# @jit(nopython=True) # fill a node (maybe two or more points)
|
56
|
+
def fill(img, p, num, nbs, acc):
|
57
|
+
cap = 131072
|
58
|
+
buf = np.empty(cap, dtype=np.int64)
|
59
|
+
img[p] = num
|
60
|
+
buf[0] = p
|
61
|
+
cur = 0
|
62
|
+
s = 1
|
63
|
+
iso = True
|
64
|
+
|
65
|
+
while cur < s:
|
66
|
+
p = buf[cur]
|
67
|
+
for dp in nbs:
|
68
|
+
cp = p + dp
|
69
|
+
if img[cp] == 2:
|
70
|
+
img[cp] = num
|
71
|
+
if s >= cap:
|
72
|
+
cap *= 2
|
73
|
+
buf = np.resize(buf, cap)
|
74
|
+
buf[s] = cp
|
75
|
+
s += 1
|
76
|
+
elif img[cp] == 1:
|
77
|
+
iso = False
|
78
|
+
cur += 1
|
79
|
+
|
80
|
+
return iso, idx2rc(buf[:s], acc)
|
81
|
+
|
82
|
+
|
83
|
+
# @jit(nopython=True) # trace the edge and use a buffer, then buf.copy if you use [] numba not works
|
84
|
+
def trace(img, p, nbs, acc):
|
85
|
+
cap = 2048
|
86
|
+
buf = np.empty(cap, dtype=np.int64)
|
87
|
+
cur = 1
|
88
|
+
c1 = 0
|
89
|
+
c2 = 0
|
90
|
+
newp = 0
|
91
|
+
|
92
|
+
while True:
|
93
|
+
if cur >= cap:
|
94
|
+
cap *= 2
|
95
|
+
buf = np.resize(buf, cap)
|
96
|
+
buf[cur] = p
|
97
|
+
img[p] = 0
|
98
|
+
cur += 1
|
99
|
+
|
100
|
+
for dp in nbs:
|
101
|
+
cp = p + dp
|
102
|
+
if img[cp] >= 10:
|
103
|
+
if c1 == 0:
|
104
|
+
c1 = img[cp]
|
105
|
+
buf[0] = cp
|
106
|
+
else:
|
107
|
+
c2 = img[cp]
|
108
|
+
if cur >= cap:
|
109
|
+
cap += 1
|
110
|
+
buf = np.resize(buf, cap)
|
111
|
+
buf[cur] = cp
|
112
|
+
elif img[cp] == 1:
|
113
|
+
newp = cp
|
114
|
+
|
115
|
+
p = newp
|
116
|
+
if c2 != 0:
|
117
|
+
break
|
118
|
+
|
119
|
+
return c1 - 10, c2 - 10, idx2rc(buf[:cur + 1], acc)
|
120
|
+
|
121
|
+
|
122
|
+
# @jit(nopython=True) # parse the image, then get the nodes and edges
|
123
|
+
def parse_struc(img, nbs, acc, iso, ring):
|
124
|
+
img = img.ravel()
|
125
|
+
# buf = np.zeros(13107200, dtype=np.int64)
|
126
|
+
num = 10
|
127
|
+
nodes = []
|
128
|
+
for p in range(len(img)):
|
129
|
+
if img[p] == 2:
|
130
|
+
isiso, nds = fill(img, p, num, nbs, acc)
|
131
|
+
if isiso and not iso: continue
|
132
|
+
num += 1
|
133
|
+
nodes.append(nds)
|
134
|
+
edges = []
|
135
|
+
for p in range(len(img)):
|
136
|
+
if img[p] < 10: continue
|
137
|
+
for dp in nbs:
|
138
|
+
if img[p + dp] == 1:
|
139
|
+
edge = trace(img, p + dp, nbs, acc)
|
140
|
+
edges.append(edge)
|
141
|
+
if not ring: return nodes, edges
|
142
|
+
for p in range(len(img)):
|
143
|
+
if img[p] != 1: continue
|
144
|
+
img[p] = num
|
145
|
+
num += 1
|
146
|
+
nodes.append(idx2rc([p], acc))
|
147
|
+
for dp in nbs:
|
148
|
+
if img[p + dp] == 1:
|
149
|
+
edge = trace(img, p + dp, nbs, acc)
|
150
|
+
edges.append(edge)
|
151
|
+
return nodes, edges
|
152
|
+
|
153
|
+
|
154
|
+
# use nodes and edges to build a networkx graph
|
155
|
+
def build_graph(nodes, edges, multi=False, full=True):
|
156
|
+
os = np.array([i.mean(axis=0) for i in nodes])
|
157
|
+
if full: os = os.round().astype(np.uint16)
|
158
|
+
graph = nx.MultiGraph() if multi else nx.Graph()
|
159
|
+
for i in range(len(nodes)):
|
160
|
+
graph.add_node(i, pts=nodes[i], o=os[i])
|
161
|
+
for s, e, pts in edges:
|
162
|
+
if full: pts[[0, -1]] = os[[s, e]]
|
163
|
+
l = np.linalg.norm(pts[1:] - pts[:-1], axis=1).sum()
|
164
|
+
graph.add_edge(s, e, pts=pts, weight=l)
|
165
|
+
return graph
|
166
|
+
|
167
|
+
|
168
|
+
def mark_node(ske):
|
169
|
+
buf = np.pad(ske, (1, 1), mode='constant').astype(np.uint16)
|
170
|
+
nbs = neighbors(buf.shape)
|
171
|
+
# acc = np.cumprod((1,) + buf.shape[::-1][:-1])[::-1]
|
172
|
+
mark(buf, nbs)
|
173
|
+
return buf
|
174
|
+
|
175
|
+
|
176
|
+
def build_sknw(ske, multi=False, iso=True, ring=True, full=True):
|
177
|
+
buf = np.pad(ske, (1, 1), mode='constant').astype(np.uint16)
|
178
|
+
nbs = neighbors(buf.shape)
|
179
|
+
acc = np.cumprod((1,) + buf.shape[::-1][:-1])[::-1]
|
180
|
+
mark(buf, nbs)
|
181
|
+
nodes, edges = parse_struc(buf, nbs, acc, iso, ring)
|
182
|
+
return build_graph(nodes, edges, multi, full)
|
183
|
+
|
184
|
+
|
185
|
+
# draw the graph
|
186
|
+
def draw_graph(img, graph, cn=255, ce=128):
|
187
|
+
acc = np.cumprod((1,) + img.shape[::-1][:-1])[::-1]
|
188
|
+
img = img.ravel()
|
189
|
+
for (s, e) in graph.edges():
|
190
|
+
eds = graph[s][e]
|
191
|
+
if isinstance(graph, nx.MultiGraph):
|
192
|
+
for i in eds:
|
193
|
+
pts = eds[i]['pts']
|
194
|
+
img[np.dot(pts, acc)] = ce
|
195
|
+
else:
|
196
|
+
img[np.dot(eds['pts'], acc)] = ce
|
197
|
+
for idx in graph.nodes():
|
198
|
+
pts = graph.nodes[idx]['pts']
|
199
|
+
img[np.dot(pts, acc)] = cn
|
File without changes
|