nettracer3d 1.2.7__py3-none-any.whl → 1.3.1__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.
Potentially problematic release.
This version of nettracer3d might be problematic. Click here for more details.
- nettracer3d/branch_stitcher.py +245 -142
- nettracer3d/nettracer.py +205 -32
- nettracer3d/nettracer_gui.py +1975 -2026
- nettracer3d/network_analysis.py +16 -4
- nettracer3d/network_graph_widget.py +2066 -0
- nettracer3d/painting.py +158 -298
- nettracer3d/simple_network.py +4 -4
- nettracer3d/smart_dilate.py +19 -7
- nettracer3d/tutorial.py +38 -3
- {nettracer3d-1.2.7.dist-info → nettracer3d-1.3.1.dist-info}/METADATA +51 -17
- {nettracer3d-1.2.7.dist-info → nettracer3d-1.3.1.dist-info}/RECORD +15 -14
- {nettracer3d-1.2.7.dist-info → nettracer3d-1.3.1.dist-info}/WHEEL +0 -0
- {nettracer3d-1.2.7.dist-info → nettracer3d-1.3.1.dist-info}/entry_points.txt +0 -0
- {nettracer3d-1.2.7.dist-info → nettracer3d-1.3.1.dist-info}/licenses/LICENSE +0 -0
- {nettracer3d-1.2.7.dist-info → nettracer3d-1.3.1.dist-info}/top_level.txt +0 -0
nettracer3d/branch_stitcher.py
CHANGED
|
@@ -1,131 +1,177 @@
|
|
|
1
1
|
import numpy as np
|
|
2
2
|
import networkx as nx
|
|
3
|
-
from . import nettracer as n3d
|
|
4
|
-
from scipy.ndimage import distance_transform_edt, gaussian_filter, binary_fill_holes
|
|
5
3
|
from scipy.spatial import cKDTree
|
|
4
|
+
from collections import deque
|
|
6
5
|
from . import smart_dilate as sdl
|
|
7
|
-
from skimage.morphology import remove_small_objects, skeletonize
|
|
8
|
-
import warnings
|
|
9
|
-
warnings.filterwarnings('ignore')
|
|
10
6
|
|
|
11
7
|
|
|
12
8
|
class VesselDenoiser:
|
|
13
9
|
"""
|
|
14
10
|
Denoise vessel segmentations using graph-based geometric features
|
|
11
|
+
IMPROVED: Uses skeleton topology to compute endpoint directions
|
|
15
12
|
"""
|
|
16
13
|
|
|
17
14
|
def __init__(self,
|
|
18
15
|
score_thresh = 2,
|
|
19
16
|
xy_scale = 1,
|
|
20
|
-
z_scale = 1
|
|
21
|
-
|
|
17
|
+
z_scale = 1,
|
|
18
|
+
trace_length = 10):
|
|
22
19
|
self.score_thresh = score_thresh
|
|
23
20
|
self.xy_scale = xy_scale
|
|
24
21
|
self.z_scale = z_scale
|
|
22
|
+
self.trace_length = trace_length # How far to trace from endpoint
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
def select_kernel_points_topology(self, data, skeleton):
|
|
24
|
+
def _build_skeleton_graph(self, skeleton):
|
|
28
25
|
"""
|
|
29
|
-
|
|
26
|
+
Build a graph from skeleton where nodes are voxel coordinates
|
|
27
|
+
and edges connect 26-connected neighbors
|
|
30
28
|
"""
|
|
31
29
|
skeleton_coords = np.argwhere(skeleton)
|
|
32
30
|
if len(skeleton_coords) == 0:
|
|
33
|
-
return
|
|
31
|
+
return None, None
|
|
34
32
|
|
|
35
|
-
# Map
|
|
33
|
+
# Map coordinate tuple -> node index
|
|
36
34
|
coord_to_idx = {tuple(c): i for i, c in enumerate(skeleton_coords)}
|
|
37
35
|
|
|
38
|
-
# Build
|
|
36
|
+
# Build graph
|
|
39
37
|
skel_graph = nx.Graph()
|
|
40
38
|
for i, c in enumerate(skeleton_coords):
|
|
41
39
|
skel_graph.add_node(i, pos=c)
|
|
42
40
|
|
|
41
|
+
# 26-connected neighborhood
|
|
43
42
|
nbr_offsets = [(dz, dy, dx)
|
|
44
43
|
for dz in (-1, 0, 1)
|
|
45
44
|
for dy in (-1, 0, 1)
|
|
46
45
|
for dx in (-1, 0, 1)
|
|
47
46
|
if not (dz == dy == dx == 0)]
|
|
48
47
|
|
|
48
|
+
# Add edges
|
|
49
49
|
for i, c in enumerate(skeleton_coords):
|
|
50
50
|
cz, cy, cx = c
|
|
51
51
|
for dz, dy, dx in nbr_offsets:
|
|
52
52
|
nb = (cz + dz, cy + dy, cx + dx)
|
|
53
|
-
j = coord_to_idx.get(nb
|
|
53
|
+
j = coord_to_idx.get(nb)
|
|
54
54
|
if j is not None and j > i:
|
|
55
55
|
skel_graph.add_edge(i, j)
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
return skel_graph, coord_to_idx
|
|
58
|
+
|
|
59
|
+
def select_kernel_points_topology(self, data, skeleton):
|
|
60
|
+
"""
|
|
61
|
+
Returns only skeleton endpoints (degree=1 nodes)
|
|
62
|
+
"""
|
|
63
|
+
skel_graph, coord_to_idx = self._build_skeleton_graph(skeleton)
|
|
64
|
+
|
|
65
|
+
if skel_graph is None:
|
|
66
|
+
return np.array([]), None, None
|
|
67
|
+
|
|
68
|
+
# Get degree per node
|
|
58
69
|
deg = dict(skel_graph.degree())
|
|
59
70
|
|
|
60
71
|
# ONLY keep endpoints (degree=1)
|
|
61
|
-
endpoints =
|
|
72
|
+
endpoints = [i for i, d in deg.items() if d == 1]
|
|
62
73
|
|
|
63
|
-
#
|
|
74
|
+
# Get coordinates
|
|
75
|
+
skeleton_coords = np.argwhere(skeleton)
|
|
64
76
|
kernel_coords = np.array([skeleton_coords[i] for i in endpoints])
|
|
65
|
-
return kernel_coords
|
|
66
77
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
return kernel_coords, skel_graph, coord_to_idx
|
|
79
|
+
|
|
80
|
+
def _compute_endpoint_direction(self, skel_graph, endpoint_idx, trace_length=None):
|
|
81
|
+
"""
|
|
82
|
+
Compute direction by tracing along skeleton from endpoint.
|
|
83
|
+
Returns direction vector pointing INTO the skeleton (away from endpoint).
|
|
84
|
+
|
|
85
|
+
Parameters:
|
|
86
|
+
-----------
|
|
87
|
+
skel_graph : networkx.Graph
|
|
88
|
+
Skeleton graph with node positions
|
|
89
|
+
endpoint_idx : int
|
|
90
|
+
Node index of the endpoint
|
|
91
|
+
trace_length : int
|
|
92
|
+
How many steps to trace along skeleton
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
--------
|
|
96
|
+
direction : ndarray
|
|
97
|
+
Normalized direction vector pointing into skeleton from endpoint
|
|
98
|
+
"""
|
|
99
|
+
if trace_length is None:
|
|
100
|
+
trace_length = self.trace_length
|
|
101
|
+
|
|
102
|
+
# Get endpoint position
|
|
103
|
+
endpoint_pos = skel_graph.nodes[endpoint_idx]['pos']
|
|
104
|
+
|
|
105
|
+
# BFS from endpoint to collect positions along skeleton path
|
|
106
|
+
visited = {endpoint_idx}
|
|
107
|
+
queue = deque([endpoint_idx])
|
|
108
|
+
path_positions = []
|
|
109
|
+
|
|
110
|
+
while queue and len(path_positions) < trace_length:
|
|
111
|
+
current = queue.popleft()
|
|
112
|
+
|
|
113
|
+
# Get neighbors
|
|
114
|
+
for neighbor in skel_graph.neighbors(current):
|
|
115
|
+
if neighbor not in visited:
|
|
116
|
+
visited.add(neighbor)
|
|
117
|
+
queue.append(neighbor)
|
|
118
|
+
|
|
119
|
+
# Add this position to path
|
|
120
|
+
neighbor_pos = skel_graph.nodes[neighbor]['pos']
|
|
121
|
+
path_positions.append(neighbor_pos)
|
|
122
|
+
|
|
123
|
+
if len(path_positions) >= trace_length:
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
# If we couldn't trace far enough, use what we have
|
|
127
|
+
if len(path_positions) == 0:
|
|
128
|
+
# Isolated endpoint, return arbitrary direction
|
|
129
|
+
return np.array([0., 0., 1.])
|
|
130
|
+
|
|
131
|
+
# Compute direction as average vector from endpoint to traced positions
|
|
132
|
+
# This gives us the direction the skeleton is "extending" from the endpoint
|
|
133
|
+
path_positions = np.array(path_positions)
|
|
134
|
+
|
|
135
|
+
# Weight more distant points more heavily (they better represent overall direction)
|
|
136
|
+
weights = np.linspace(1.0, 2.0, len(path_positions))
|
|
137
|
+
weights = weights / weights.sum()
|
|
138
|
+
|
|
139
|
+
# Weighted average position along the path
|
|
140
|
+
weighted_target = np.sum(path_positions * weights[:, None], axis=0)
|
|
141
|
+
|
|
142
|
+
# Direction from endpoint toward this position
|
|
143
|
+
direction = weighted_target - endpoint_pos
|
|
144
|
+
|
|
145
|
+
# Normalize
|
|
146
|
+
norm = np.linalg.norm(direction)
|
|
147
|
+
if norm < 1e-10:
|
|
148
|
+
return np.array([0., 0., 1.])
|
|
149
|
+
|
|
150
|
+
return direction / norm
|
|
151
|
+
|
|
152
|
+
def extract_kernel_features(self, skeleton, distance_map, kernel_pos,
|
|
153
|
+
skel_graph, coord_to_idx, endpoint_idx):
|
|
154
|
+
"""Extract geometric features for a kernel at a skeleton endpoint"""
|
|
70
155
|
z, y, x = kernel_pos
|
|
71
|
-
shape = skeleton.shape
|
|
72
156
|
|
|
73
157
|
features = {}
|
|
74
158
|
|
|
75
159
|
# Vessel radius at this point
|
|
76
160
|
features['radius'] = distance_map[z, y, x]
|
|
77
|
-
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
y_min = max(0, y - radius)
|
|
82
|
-
y_max = min(shape[1], y + radius + 1)
|
|
83
|
-
x_min = max(0, x - radius)
|
|
84
|
-
x_max = min(shape[2], x + radius + 1)
|
|
85
|
-
|
|
86
|
-
local_region = skeleton[z_min:z_max, y_min:y_max, x_min:x_max]
|
|
87
|
-
features['local_density'] = np.sum(local_region) / max(local_region.size, 1)
|
|
88
|
-
|
|
89
|
-
# Local direction vector
|
|
90
|
-
features['direction'] = self._compute_local_direction(
|
|
91
|
-
skeleton, kernel_pos, radius
|
|
161
|
+
|
|
162
|
+
# Direction vector using topology-based tracing
|
|
163
|
+
features['direction'] = self._compute_endpoint_direction(
|
|
164
|
+
skel_graph, endpoint_idx, self.trace_length
|
|
92
165
|
)
|
|
93
166
|
|
|
94
167
|
# Position
|
|
95
168
|
features['pos'] = np.array(kernel_pos)
|
|
96
169
|
|
|
97
|
-
#
|
|
170
|
+
# All kernels are endpoints
|
|
98
171
|
features['is_endpoint'] = True
|
|
99
172
|
|
|
100
173
|
return features
|
|
101
174
|
|
|
102
|
-
|
|
103
|
-
def _compute_local_direction(self, skeleton, pos, radius=5):
|
|
104
|
-
"""Compute principal direction of skeleton in local neighborhood"""
|
|
105
|
-
z, y, x = pos
|
|
106
|
-
shape = skeleton.shape
|
|
107
|
-
|
|
108
|
-
z_min = max(0, z - radius)
|
|
109
|
-
z_max = min(shape[0], z + radius + 1)
|
|
110
|
-
y_min = max(0, y - radius)
|
|
111
|
-
y_max = min(shape[1], y + radius + 1)
|
|
112
|
-
x_min = max(0, x - radius)
|
|
113
|
-
x_max = min(shape[2], x + radius + 1)
|
|
114
|
-
|
|
115
|
-
local_skel = skeleton[z_min:z_max, y_min:y_max, x_min:x_max]
|
|
116
|
-
coords = np.argwhere(local_skel)
|
|
117
|
-
|
|
118
|
-
if len(coords) < 2:
|
|
119
|
-
return np.array([0., 0., 1.])
|
|
120
|
-
|
|
121
|
-
# PCA to find principal direction
|
|
122
|
-
centered = coords - coords.mean(axis=0)
|
|
123
|
-
cov = np.cov(centered.T)
|
|
124
|
-
eigenvalues, eigenvectors = np.linalg.eigh(cov)
|
|
125
|
-
principal_direction = eigenvectors[:, -1] # largest eigenvalue
|
|
126
|
-
|
|
127
|
-
return principal_direction / (np.linalg.norm(principal_direction) + 1e-10)
|
|
128
|
-
|
|
129
175
|
def group_endpoints_by_vertex(self, skeleton_points, verts):
|
|
130
176
|
"""
|
|
131
177
|
Group endpoints by which vertex (labeled blob) they belong to
|
|
@@ -154,66 +200,106 @@ class VesselDenoiser:
|
|
|
154
200
|
|
|
155
201
|
def compute_edge_features(self, feat_i, feat_j):
|
|
156
202
|
"""
|
|
157
|
-
Compute features for potential connection between two endpoints
|
|
158
|
-
|
|
203
|
+
Compute features for potential connection between two endpoints.
|
|
204
|
+
IMPROVED: Uses proper directional alignment (not abs value).
|
|
205
|
+
|
|
206
|
+
Two endpoints should connect if:
|
|
207
|
+
- Their skeletons are pointing TOWARD each other (negative dot product of directions)
|
|
208
|
+
- They have similar radii
|
|
209
|
+
- The connection vector aligns with both skeleton directions
|
|
159
210
|
"""
|
|
160
211
|
features = {}
|
|
161
212
|
|
|
162
|
-
#
|
|
213
|
+
# Vector from endpoint i to endpoint j
|
|
163
214
|
pos_diff = feat_j['pos'] - feat_i['pos']
|
|
164
215
|
features['distance'] = np.linalg.norm(pos_diff)
|
|
165
216
|
|
|
217
|
+
if features['distance'] < 1e-10:
|
|
218
|
+
# Same point, shouldn't happen
|
|
219
|
+
features['connection_vector'] = np.array([0., 0., 1.])
|
|
220
|
+
else:
|
|
221
|
+
features['connection_vector'] = pos_diff / features['distance']
|
|
222
|
+
|
|
166
223
|
# Radius similarity
|
|
167
224
|
r_i, r_j = feat_i['radius'], feat_j['radius']
|
|
168
225
|
features['radius_diff'] = abs(r_i - r_j)
|
|
169
226
|
features['radius_ratio'] = min(r_i, r_j) / (max(r_i, r_j) + 1e-10)
|
|
170
227
|
features['mean_radius'] = (r_i + r_j) / 2.0
|
|
171
228
|
|
|
172
|
-
#
|
|
173
|
-
|
|
229
|
+
# CRITICAL: Check if skeletons point toward each other
|
|
230
|
+
# If both directions point into their skeletons (away from endpoints),
|
|
231
|
+
# they should point in OPPOSITE directions across the gap
|
|
232
|
+
dir_i = feat_i['direction']
|
|
233
|
+
dir_j = feat_j['direction']
|
|
234
|
+
connection_vec = features['connection_vector']
|
|
174
235
|
|
|
175
|
-
#
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
features['alignment'] = (align_i + align_j) / 2.0
|
|
236
|
+
# How well does endpoint i's skeleton direction align with the gap vector?
|
|
237
|
+
# (positive = pointing toward j)
|
|
238
|
+
align_i = np.dot(dir_i, connection_vec)
|
|
179
239
|
|
|
180
|
-
#
|
|
181
|
-
|
|
240
|
+
# How well does endpoint j's skeleton direction align AGAINST the gap vector?
|
|
241
|
+
# (negative = pointing toward i)
|
|
242
|
+
align_j = np.dot(dir_j, connection_vec)
|
|
182
243
|
|
|
183
|
-
#
|
|
184
|
-
|
|
244
|
+
# For good connection: align_i should be positive (i pointing toward j)
|
|
245
|
+
# and align_j should be negative (j pointing toward i)
|
|
246
|
+
# So align_i - align_j should be large and positive
|
|
247
|
+
features['approach_score'] = align_i - align_j
|
|
248
|
+
|
|
249
|
+
# Individual alignment scores (for diagnostics)
|
|
250
|
+
features['align_i'] = align_i
|
|
251
|
+
features['align_j'] = align_j
|
|
252
|
+
|
|
253
|
+
# How parallel/antiparallel are the two skeleton directions?
|
|
254
|
+
# -1 = pointing toward each other (good for connection)
|
|
255
|
+
# +1 = pointing in same direction (bad, parallel branches)
|
|
256
|
+
features['direction_similarity'] = np.dot(dir_i, dir_j)
|
|
185
257
|
|
|
186
258
|
return features
|
|
187
259
|
|
|
188
260
|
def score_connection(self, edge_features):
|
|
261
|
+
"""
|
|
262
|
+
Score potential connection between two endpoints.
|
|
263
|
+
FIXED: Directions point INTO skeletons (away from endpoints)
|
|
264
|
+
"""
|
|
189
265
|
score = 0.0
|
|
190
|
-
|
|
191
|
-
#
|
|
192
|
-
|
|
266
|
+
|
|
267
|
+
# For good connections when directions point INTO skeletons:
|
|
268
|
+
# - align_i should be NEGATIVE (skeleton i extends away from j)
|
|
269
|
+
# - align_j should be POSITIVE (skeleton j extends away from i)
|
|
270
|
+
# - Both skeletons extend away from the gap (good!)
|
|
271
|
+
|
|
272
|
+
# HARD REJECT: If skeletons point in same direction (parallel branches)
|
|
273
|
+
if edge_features['direction_similarity'] > 0.7:
|
|
274
|
+
return -999
|
|
275
|
+
|
|
276
|
+
# HARD REJECT: If both skeletons extend TOWARD the gap (diverging structure)
|
|
277
|
+
# This means: align_i > 0 and align_j < 0 (both point at gap = fork/divergence)
|
|
278
|
+
if edge_features['align_i'] > 0.3 and edge_features['align_j'] < -0.3:
|
|
279
|
+
return -999
|
|
280
|
+
|
|
281
|
+
# HARD REJECT: If either skeleton extends the wrong way
|
|
282
|
+
# align_i should be negative, align_j should be positive
|
|
283
|
+
if edge_features['align_i'] > 0.3 or edge_features['align_j'] < -0.3:
|
|
193
284
|
return -999
|
|
194
285
|
|
|
195
286
|
# Base similarity scoring
|
|
196
|
-
score += edge_features['radius_ratio'] *
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
#
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
#
|
|
211
|
-
|
|
212
|
-
# score -= penalty
|
|
213
|
-
|
|
214
|
-
# Size bonus: ONLY if vessels already match well
|
|
215
|
-
|
|
216
|
-
if edge_features['radius_ratio'] > 0.7 and edge_features['alignment'] > 0.5:
|
|
287
|
+
score += edge_features['radius_ratio'] * 15.0
|
|
288
|
+
|
|
289
|
+
# REWARD: Skeletons extending away from each other across gap
|
|
290
|
+
# When directions point into skeletons:
|
|
291
|
+
# Good connection has align_i < 0 and align_j > 0
|
|
292
|
+
# So we want to MAXIMIZE: -align_i + align_j (both terms positive)
|
|
293
|
+
extension_score = (-edge_features['align_i'] + edge_features['align_j'])
|
|
294
|
+
score += extension_score * 10.0
|
|
295
|
+
|
|
296
|
+
# REWARD: Skeletons pointing in opposite directions (antiparallel)
|
|
297
|
+
# direction_similarity should be negative
|
|
298
|
+
antiparallel_bonus = max(0, -edge_features['direction_similarity']) * 5.0
|
|
299
|
+
score += antiparallel_bonus
|
|
300
|
+
|
|
301
|
+
# SIZE BONUS: Reward large, well-matched vessels
|
|
302
|
+
if edge_features['radius_ratio'] > 0.7 and extension_score > 1.0:
|
|
217
303
|
mean_radius = edge_features['mean_radius']
|
|
218
304
|
score += mean_radius * 1.5
|
|
219
305
|
|
|
@@ -222,8 +308,8 @@ class VesselDenoiser:
|
|
|
222
308
|
def connect_vertices_across_gaps(self, skeleton_points, kernel_features,
|
|
223
309
|
labeled_skeleton, vertex_to_endpoints, verbose=False):
|
|
224
310
|
"""
|
|
225
|
-
Connect vertices by finding best endpoint pair across each vertex
|
|
226
|
-
Each vertex makes at most one connection
|
|
311
|
+
Connect vertices by finding best endpoint pair across each vertex.
|
|
312
|
+
Each vertex makes at most one connection.
|
|
227
313
|
"""
|
|
228
314
|
# Initialize label dictionary: label -> label (identity mapping)
|
|
229
315
|
unique_labels = np.unique(labeled_skeleton[labeled_skeleton > 0])
|
|
@@ -246,7 +332,6 @@ class VesselDenoiser:
|
|
|
246
332
|
# Iterate through each vertex
|
|
247
333
|
for vertex_label, endpoint_indices in vertex_to_endpoints.items():
|
|
248
334
|
if len(endpoint_indices) < 2:
|
|
249
|
-
# Need at least 2 endpoints to make a connection
|
|
250
335
|
continue
|
|
251
336
|
|
|
252
337
|
if verbose and len(endpoint_indices) > 0:
|
|
@@ -276,11 +361,17 @@ class VesselDenoiser:
|
|
|
276
361
|
if root_i == root_j:
|
|
277
362
|
continue
|
|
278
363
|
|
|
279
|
-
# Compute edge features
|
|
364
|
+
# Compute edge features
|
|
280
365
|
edge_feat = self.compute_edge_features(feat_i, feat_j)
|
|
281
366
|
|
|
282
367
|
# Score this connection
|
|
283
368
|
score = self.score_connection(edge_feat)
|
|
369
|
+
#print(score)
|
|
370
|
+
|
|
371
|
+
if verbose and score > -900:
|
|
372
|
+
print(f" Pair {idx_i}-{idx_j}: score={score:.2f}, "
|
|
373
|
+
f"approach={edge_feat['approach_score']:.2f}, "
|
|
374
|
+
f"dir_sim={edge_feat['direction_similarity']:.2f}")
|
|
284
375
|
|
|
285
376
|
# Apply threshold
|
|
286
377
|
if score > self.score_thresh and score > best_score:
|
|
@@ -296,7 +387,7 @@ class VesselDenoiser:
|
|
|
296
387
|
root_i = find_root(label_i)
|
|
297
388
|
root_j = find_root(label_j)
|
|
298
389
|
|
|
299
|
-
# Unify labels
|
|
390
|
+
# Unify labels
|
|
300
391
|
if root_i < root_j:
|
|
301
392
|
label_dict[root_j] = root_i
|
|
302
393
|
unified_label = root_i
|
|
@@ -315,27 +406,9 @@ class VesselDenoiser:
|
|
|
315
406
|
def denoise(self, data, skeleton, labeled_skeleton, verts, verbose=False):
|
|
316
407
|
"""
|
|
317
408
|
Main pipeline: unify skeleton labels by connecting endpoints at vertices
|
|
318
|
-
|
|
319
|
-
Parameters:
|
|
320
|
-
-----------
|
|
321
|
-
data : ndarray
|
|
322
|
-
3D binary segmentation (for distance transform)
|
|
323
|
-
skeleton : ndarray
|
|
324
|
-
3D binary skeleton
|
|
325
|
-
labeled_skeleton : ndarray
|
|
326
|
-
Labeled skeleton (each branch has unique label)
|
|
327
|
-
verts : ndarray
|
|
328
|
-
Labeled vertices (blobs where branches meet)
|
|
329
|
-
verbose : bool
|
|
330
|
-
Print progress
|
|
331
|
-
|
|
332
|
-
Returns:
|
|
333
|
-
--------
|
|
334
|
-
label_dict : dict
|
|
335
|
-
Dictionary mapping old labels to unified labels
|
|
336
409
|
"""
|
|
337
410
|
if verbose:
|
|
338
|
-
print("Starting skeleton label unification...")
|
|
411
|
+
print("Starting skeleton label unification (IMPROVED VERSION)...")
|
|
339
412
|
print(f"Initial unique labels: {len(np.unique(labeled_skeleton[labeled_skeleton > 0]))}")
|
|
340
413
|
|
|
341
414
|
# Compute distance transform
|
|
@@ -343,14 +416,19 @@ class VesselDenoiser:
|
|
|
343
416
|
print("Computing distance transform...")
|
|
344
417
|
distance_map = sdl.compute_distance_transform_distance(data, fast_dil = True)
|
|
345
418
|
|
|
346
|
-
# Extract endpoints
|
|
419
|
+
# Extract endpoints and build skeleton graph
|
|
347
420
|
if verbose:
|
|
348
|
-
print("Extracting skeleton endpoints...")
|
|
349
|
-
kernel_points = self.select_kernel_points_topology(data, skeleton)
|
|
421
|
+
print("Extracting skeleton endpoints and building graph...")
|
|
422
|
+
kernel_points, skel_graph, coord_to_idx = self.select_kernel_points_topology(data, skeleton)
|
|
350
423
|
|
|
351
424
|
if verbose:
|
|
352
425
|
print(f"Found {len(kernel_points)} endpoints")
|
|
353
426
|
|
|
427
|
+
if len(kernel_points) == 0:
|
|
428
|
+
# No endpoints, return identity mapping
|
|
429
|
+
unique_labels = np.unique(labeled_skeleton[labeled_skeleton > 0])
|
|
430
|
+
return {int(label): int(label) for label in unique_labels}
|
|
431
|
+
|
|
354
432
|
# Group endpoints by vertex
|
|
355
433
|
if verbose:
|
|
356
434
|
print("Grouping endpoints by vertex...")
|
|
@@ -363,10 +441,25 @@ class VesselDenoiser:
|
|
|
363
441
|
|
|
364
442
|
# Extract features for each endpoint
|
|
365
443
|
if verbose:
|
|
366
|
-
print("Extracting endpoint features...")
|
|
444
|
+
print("Extracting endpoint features with topology-based directions...")
|
|
445
|
+
|
|
446
|
+
# Create reverse mapping: position -> node index in graph
|
|
447
|
+
skeleton_coords = np.argwhere(skeleton)
|
|
367
448
|
kernel_features = []
|
|
449
|
+
|
|
368
450
|
for pt in kernel_points:
|
|
369
|
-
|
|
451
|
+
# Find this endpoint in the graph
|
|
452
|
+
pt_tuple = tuple(pt)
|
|
453
|
+
endpoint_idx = coord_to_idx.get(pt_tuple)
|
|
454
|
+
|
|
455
|
+
if endpoint_idx is None:
|
|
456
|
+
# Shouldn't happen, but handle gracefully
|
|
457
|
+
print(f"Warning: Endpoint {pt} not found in graph")
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
feat = self.extract_kernel_features(
|
|
461
|
+
skeleton, distance_map, pt, skel_graph, coord_to_idx, endpoint_idx
|
|
462
|
+
)
|
|
370
463
|
kernel_features.append(feat)
|
|
371
464
|
|
|
372
465
|
# Connect vertices
|
|
@@ -377,7 +470,7 @@ class VesselDenoiser:
|
|
|
377
470
|
vertex_to_endpoints, verbose
|
|
378
471
|
)
|
|
379
472
|
|
|
380
|
-
# Compress label dictionary
|
|
473
|
+
# Compress label dictionary
|
|
381
474
|
if verbose:
|
|
382
475
|
print("\nCompressing label mappings...")
|
|
383
476
|
for label in list(label_dict.keys()):
|
|
@@ -395,31 +488,41 @@ class VesselDenoiser:
|
|
|
395
488
|
return label_dict
|
|
396
489
|
|
|
397
490
|
|
|
398
|
-
def trace(data, labeled_skeleton, verts, score_thresh=10, xy_scale
|
|
491
|
+
def trace(data, labeled_skeleton, verts, score_thresh=10, xy_scale=1, z_scale=1,
|
|
492
|
+
trace_length=10, verbose=False):
|
|
399
493
|
"""
|
|
400
|
-
Trace and unify skeleton labels using vertex-based endpoint grouping
|
|
494
|
+
Trace and unify skeleton labels using vertex-based endpoint grouping.
|
|
495
|
+
IMPROVED: Uses topology-based direction calculation.
|
|
496
|
+
|
|
497
|
+
Parameters:
|
|
498
|
+
-----------
|
|
499
|
+
trace_length : int
|
|
500
|
+
How many voxels to trace from each endpoint to determine direction
|
|
401
501
|
"""
|
|
402
|
-
skeleton =
|
|
502
|
+
skeleton = (labeled_skeleton > 0).astype(np.uint8)
|
|
403
503
|
|
|
404
|
-
# Create denoiser
|
|
405
|
-
denoiser = VesselDenoiser(
|
|
504
|
+
# Create denoiser with trace_length parameter
|
|
505
|
+
denoiser = VesselDenoiser(
|
|
506
|
+
score_thresh=score_thresh,
|
|
507
|
+
xy_scale=xy_scale,
|
|
508
|
+
z_scale=z_scale,
|
|
509
|
+
trace_length=trace_length
|
|
510
|
+
)
|
|
406
511
|
|
|
407
512
|
# Run label unification
|
|
408
513
|
label_dict = denoiser.denoise(data, skeleton, labeled_skeleton, verts, verbose=verbose)
|
|
409
514
|
|
|
410
|
-
# Apply unified labels
|
|
411
|
-
# Create lookup array: index by old label, get new label
|
|
515
|
+
# Apply unified labels
|
|
412
516
|
max_label = np.max(labeled_skeleton)
|
|
413
|
-
label_map = np.arange(max_label + 1)
|
|
517
|
+
label_map = np.arange(max_label + 1)
|
|
414
518
|
|
|
415
519
|
for old_label, new_label in label_dict.items():
|
|
416
520
|
label_map[old_label] = new_label
|
|
417
521
|
|
|
418
|
-
# Single array indexing operation
|
|
419
522
|
relabeled_skeleton = label_map[labeled_skeleton]
|
|
420
523
|
|
|
421
524
|
return relabeled_skeleton
|
|
422
525
|
|
|
423
526
|
|
|
424
527
|
if __name__ == "__main__":
|
|
425
|
-
print("
|
|
528
|
+
print("Improved branch stitcher with topology-based direction calculation")
|