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.
@@ -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
- ENDPOINTS ONLY version: Returns only skeleton endpoints (degree=1 nodes)
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 skeleton_coords
31
+ return None, None
34
32
 
35
- # Map coord -> index
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 full 26-connected skeleton graph
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, None)
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
- # Get degree per voxel
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 = {i for i, d in deg.items() if d == 1}
72
+ endpoints = [i for i, d in deg.items() if d == 1]
62
73
 
63
- # Return endpoint coordinates
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
- def extract_kernel_features(self, skeleton, distance_map, kernel_pos, radius=5):
69
- """Extract geometric features for a kernel at a skeleton point"""
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
- # Local skeleton density (connectivity measure)
79
- z_min = max(0, z - radius)
80
- z_max = min(shape[0], z + radius + 1)
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
- # ALL kernels are endpoints in this version
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
- NO DISTANCE-BASED FEATURES - only radius and direction
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
- # Euclidean distance (for reference only, not used in scoring)
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
- # Direction alignment
173
- direction_vec = pos_diff / (features['distance'] + 1e-10)
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
- # Alignment with both local directions
176
- align_i = abs(np.dot(feat_i['direction'], direction_vec))
177
- align_j = abs(np.dot(feat_j['direction'], direction_vec))
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
- # Smoothness: how well does connection align with both local directions
181
- features['smoothness'] = min(align_i, align_j)
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
- # Density similarity
184
- features['density_diff'] = abs(feat_i['local_density'] - feat_j['local_density'])
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
- # HARD REJECT for definite forks/sharp turns
192
- if edge_features['smoothness'] < 0.5: # At least one endpoint pointing away
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'] * 10.0
197
- score += edge_features['alignment'] * 8.0
198
- score += edge_features['smoothness'] * 6.0
199
- score -= edge_features['density_diff'] * 0.5
200
-
201
- # PENALTY for poor directional alignment (punish forks!)
202
- # Alignment < 0.5 means vessels are pointing in different directions
203
- # This doesn't trigger that often so it might be redundant with the above step
204
- if edge_features['alignment'] < 0.5:
205
- penalty = (0.5 - edge_features['alignment']) * 15.0
206
- score -= penalty
207
-
208
- # ADDITIONAL PENALTY for sharp turns/forks --- no longer in use since we now hard reject these, but I left this in here to reverse it later potentially
209
- # Smoothness < 0.4 means at least one endpoint points away
210
- #if edge_features['smoothness'] < 0.4:
211
- # penalty = (0.4 - edge_features['smoothness']) * 20.0
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 (no skeleton needed, no distance penalty)
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: point larger label to smaller label
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
- feat = self.extract_kernel_features(skeleton, distance_map, pt)
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 (path compression for union-find)
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 = 1, z_scale = 1, verbose=False):
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 = n3d.binarize(labeled_skeleton)
502
+ skeleton = (labeled_skeleton > 0).astype(np.uint8)
403
503
 
404
- # Create denoiser
405
- denoiser = VesselDenoiser(score_thresh=score_thresh, xy_scale = xy_scale, z_scale = z_scale)
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 efficiently (SINGLE PASS)
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) # Identity mapping by default
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("Test area")
528
+ print("Improved branch stitcher with topology-based direction calculation")