neuro-sam 0.1.0__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.
Files changed (93) hide show
  1. neuro_sam/__init__.py +1 -0
  2. neuro_sam/brightest_path_lib/__init__.py +5 -0
  3. neuro_sam/brightest_path_lib/algorithm/__init__.py +3 -0
  4. neuro_sam/brightest_path_lib/algorithm/astar.py +586 -0
  5. neuro_sam/brightest_path_lib/algorithm/waypointastar.py +449 -0
  6. neuro_sam/brightest_path_lib/algorithm/waypointastar_speedup.py +1007 -0
  7. neuro_sam/brightest_path_lib/connected_componen.py +329 -0
  8. neuro_sam/brightest_path_lib/cost/__init__.py +8 -0
  9. neuro_sam/brightest_path_lib/cost/cost.py +33 -0
  10. neuro_sam/brightest_path_lib/cost/reciprocal.py +90 -0
  11. neuro_sam/brightest_path_lib/cost/reciprocal_transonic.py +86 -0
  12. neuro_sam/brightest_path_lib/heuristic/__init__.py +2 -0
  13. neuro_sam/brightest_path_lib/heuristic/euclidean.py +101 -0
  14. neuro_sam/brightest_path_lib/heuristic/heuristic.py +29 -0
  15. neuro_sam/brightest_path_lib/image/__init__.py +1 -0
  16. neuro_sam/brightest_path_lib/image/stats.py +197 -0
  17. neuro_sam/brightest_path_lib/input/__init__.py +1 -0
  18. neuro_sam/brightest_path_lib/input/inputs.py +14 -0
  19. neuro_sam/brightest_path_lib/node/__init__.py +2 -0
  20. neuro_sam/brightest_path_lib/node/bidirectional_node.py +240 -0
  21. neuro_sam/brightest_path_lib/node/node.py +125 -0
  22. neuro_sam/brightest_path_lib/visualization/__init__.py +4 -0
  23. neuro_sam/brightest_path_lib/visualization/flythrough.py +133 -0
  24. neuro_sam/brightest_path_lib/visualization/flythrough_all.py +394 -0
  25. neuro_sam/brightest_path_lib/visualization/tube_data.py +385 -0
  26. neuro_sam/brightest_path_lib/visualization/tube_flythrough.py +227 -0
  27. neuro_sam/napari_utils/anisotropic_scaling.py +503 -0
  28. neuro_sam/napari_utils/color_utils.py +135 -0
  29. neuro_sam/napari_utils/contrasting_color_system.py +169 -0
  30. neuro_sam/napari_utils/main_widget.py +1016 -0
  31. neuro_sam/napari_utils/path_tracing_module.py +1016 -0
  32. neuro_sam/napari_utils/punet_widget.py +424 -0
  33. neuro_sam/napari_utils/segmentation_model.py +769 -0
  34. neuro_sam/napari_utils/segmentation_module.py +649 -0
  35. neuro_sam/napari_utils/visualization_module.py +574 -0
  36. neuro_sam/plugin.py +260 -0
  37. neuro_sam/punet/__init__.py +0 -0
  38. neuro_sam/punet/deepd3_model.py +231 -0
  39. neuro_sam/punet/prob_unet_deepd3.py +431 -0
  40. neuro_sam/punet/prob_unet_with_tversky.py +375 -0
  41. neuro_sam/punet/punet_inference.py +236 -0
  42. neuro_sam/punet/run_inference.py +145 -0
  43. neuro_sam/punet/unet_blocks.py +81 -0
  44. neuro_sam/punet/utils.py +52 -0
  45. neuro_sam-0.1.0.dist-info/METADATA +269 -0
  46. neuro_sam-0.1.0.dist-info/RECORD +93 -0
  47. neuro_sam-0.1.0.dist-info/WHEEL +5 -0
  48. neuro_sam-0.1.0.dist-info/entry_points.txt +2 -0
  49. neuro_sam-0.1.0.dist-info/licenses/LICENSE +21 -0
  50. neuro_sam-0.1.0.dist-info/top_level.txt +2 -0
  51. sam2/__init__.py +11 -0
  52. sam2/automatic_mask_generator.py +454 -0
  53. sam2/benchmark.py +92 -0
  54. sam2/build_sam.py +174 -0
  55. sam2/configs/sam2/sam2_hiera_b+.yaml +113 -0
  56. sam2/configs/sam2/sam2_hiera_l.yaml +117 -0
  57. sam2/configs/sam2/sam2_hiera_s.yaml +116 -0
  58. sam2/configs/sam2/sam2_hiera_t.yaml +118 -0
  59. sam2/configs/sam2.1/sam2.1_hiera_b+.yaml +116 -0
  60. sam2/configs/sam2.1/sam2.1_hiera_l.yaml +120 -0
  61. sam2/configs/sam2.1/sam2.1_hiera_s.yaml +119 -0
  62. sam2/configs/sam2.1/sam2.1_hiera_t.yaml +121 -0
  63. sam2/configs/sam2.1_training/sam2.1_hiera_b+_MOSE_finetune.yaml +339 -0
  64. sam2/configs/train.yaml +335 -0
  65. sam2/modeling/__init__.py +5 -0
  66. sam2/modeling/backbones/__init__.py +5 -0
  67. sam2/modeling/backbones/hieradet.py +317 -0
  68. sam2/modeling/backbones/image_encoder.py +134 -0
  69. sam2/modeling/backbones/utils.py +93 -0
  70. sam2/modeling/memory_attention.py +169 -0
  71. sam2/modeling/memory_encoder.py +181 -0
  72. sam2/modeling/position_encoding.py +239 -0
  73. sam2/modeling/sam/__init__.py +5 -0
  74. sam2/modeling/sam/mask_decoder.py +295 -0
  75. sam2/modeling/sam/prompt_encoder.py +202 -0
  76. sam2/modeling/sam/transformer.py +311 -0
  77. sam2/modeling/sam2_base.py +911 -0
  78. sam2/modeling/sam2_utils.py +323 -0
  79. sam2/sam2.1_hiera_b+.yaml +116 -0
  80. sam2/sam2.1_hiera_l.yaml +120 -0
  81. sam2/sam2.1_hiera_s.yaml +119 -0
  82. sam2/sam2.1_hiera_t.yaml +121 -0
  83. sam2/sam2_hiera_b+.yaml +113 -0
  84. sam2/sam2_hiera_l.yaml +117 -0
  85. sam2/sam2_hiera_s.yaml +116 -0
  86. sam2/sam2_hiera_t.yaml +118 -0
  87. sam2/sam2_image_predictor.py +475 -0
  88. sam2/sam2_video_predictor.py +1222 -0
  89. sam2/sam2_video_predictor_legacy.py +1172 -0
  90. sam2/utils/__init__.py +5 -0
  91. sam2/utils/amg.py +348 -0
  92. sam2/utils/misc.py +349 -0
  93. sam2/utils/transforms.py +118 -0
@@ -0,0 +1,574 @@
1
+ import napari
2
+ import numpy as np
3
+ from qtpy.QtWidgets import (
4
+ QWidget, QVBoxLayout, QPushButton, QLabel,
5
+ QHBoxLayout, QFrame, QListWidget, QListWidgetItem,
6
+ QFileDialog
7
+ )
8
+ from qtpy.QtCore import Signal
9
+
10
+ class PathVisualizationWidget(QWidget):
11
+ """Widget for managing and visualizing multiple paths"""
12
+
13
+ # Define signals
14
+ path_selected = Signal(str) # path_id
15
+ path_deleted = Signal(str) # path_id
16
+
17
+ def __init__(self, viewer, image, state):
18
+ """Initialize the path visualization widget.
19
+
20
+ Parameters:
21
+ -----------
22
+ viewer : napari.Viewer
23
+ The napari viewer instance
24
+ image : numpy.ndarray
25
+ 3D or higher-dimensional image data
26
+ state : dict
27
+ Shared state dictionary between modules
28
+ """
29
+ super().__init__()
30
+ self.viewer = viewer
31
+ self.image = image
32
+ self.state = state
33
+
34
+ self.xy_spacing_nm = self.state.get('xy_spacing_nm', 94.0)
35
+
36
+ # Flag to prevent recursive event handling
37
+ self.handling_event = False
38
+
39
+ # Setup UI
40
+ self.setup_ui()
41
+
42
+ def update_pixel_spacing(self, new_spacing):
43
+ """Update pixel spacing for visualization module"""
44
+ self.pixel_spacing_nm = new_spacing
45
+ print(f"Visualization: Updated pixel spacing to {new_spacing:.1f} nm/pixel")
46
+ # Visualization module typically doesn't need parameter updates
47
+ # but spacing info could be used for distance calculations in path analysis
48
+
49
+
50
+ def setup_ui(self):
51
+ """Create the UI panel with controls"""
52
+ layout = QVBoxLayout()
53
+ layout.setSpacing(2)
54
+ layout.setContentsMargins(2, 2, 2, 2)
55
+ self.setLayout(layout)
56
+
57
+ # Path list with instructions
58
+ layout.addWidget(QLabel("Saved Paths (select to view or manipulate):"))
59
+ self.path_list = QListWidget()
60
+ self.path_list.setFixedHeight(120)
61
+ self.path_list.setSelectionMode(QListWidget.ExtendedSelection)
62
+ self.path_list.itemSelectionChanged.connect(self.on_path_selection_changed)
63
+ layout.addWidget(self.path_list)
64
+
65
+ # Path management buttons
66
+ path_buttons_layout = QHBoxLayout()
67
+ path_buttons_layout.setSpacing(2)
68
+ path_buttons_layout.setContentsMargins(2, 2, 2, 2)
69
+
70
+ self.view_path_btn = QPushButton("View Selected Path")
71
+ self.view_path_btn.setFixedHeight(22)
72
+ self.view_path_btn.clicked.connect(self.view_selected_path)
73
+ self.view_path_btn.setEnabled(False)
74
+ path_buttons_layout.addWidget(self.view_path_btn)
75
+
76
+ self.delete_path_btn = QPushButton("Delete Selected Path(s)")
77
+ self.delete_path_btn.setFixedHeight(22)
78
+ self.delete_path_btn.clicked.connect(self.delete_selected_paths)
79
+ self.delete_path_btn.setEnabled(False)
80
+ path_buttons_layout.addWidget(self.delete_path_btn)
81
+
82
+ layout.addLayout(path_buttons_layout)
83
+
84
+ # Add separator
85
+ separator = QFrame()
86
+ separator.setFrameShape(QFrame.HLine)
87
+ separator.setFrameShadow(QFrame.Sunken)
88
+ layout.addWidget(separator)
89
+
90
+ # Path connection button
91
+ self.connect_paths_btn = QPushButton("Connect Selected Paths")
92
+ self.connect_paths_btn.setFixedHeight(22)
93
+ self.connect_paths_btn.setToolTip("Select exactly 2 paths to connect them")
94
+ self.connect_paths_btn.clicked.connect(self.connect_selected_paths)
95
+ self.connect_paths_btn.setEnabled(False)
96
+ layout.addWidget(self.connect_paths_btn)
97
+
98
+ # Path visibility options
99
+ visibility_layout = QHBoxLayout()
100
+ visibility_layout.setSpacing(2)
101
+ visibility_layout.setContentsMargins(2, 2, 2, 2)
102
+
103
+ self.show_all_btn = QPushButton("Show All Paths")
104
+ self.show_all_btn.setFixedHeight(22)
105
+ self.show_all_btn.clicked.connect(lambda: self.set_paths_visibility(True))
106
+ visibility_layout.addWidget(self.show_all_btn)
107
+
108
+ self.hide_all_btn = QPushButton("Hide All Paths")
109
+ self.hide_all_btn.setFixedHeight(22)
110
+ self.hide_all_btn.clicked.connect(lambda: self.set_paths_visibility(False))
111
+ visibility_layout.addWidget(self.hide_all_btn)
112
+
113
+ layout.addLayout(visibility_layout)
114
+
115
+ # Export button
116
+ self.export_all_btn = QPushButton("Export All Paths")
117
+ self.export_all_btn.setFixedHeight(22)
118
+ self.export_all_btn.clicked.connect(self.export_all_paths)
119
+ self.export_all_btn.setEnabled(False)
120
+ layout.addWidget(self.export_all_btn)
121
+
122
+ # Status message
123
+ self.status_label = QLabel("")
124
+ layout.addWidget(self.status_label)
125
+
126
+ def update_path_list(self):
127
+ """Update the path list with current paths"""
128
+ if self.handling_event:
129
+ return
130
+
131
+ try:
132
+ self.handling_event = True
133
+
134
+ # Clear current list
135
+ self.path_list.clear()
136
+
137
+ # Add paths to list
138
+ for path_id, path_data in self.state['paths'].items():
139
+ item = QListWidgetItem(path_data['name'])
140
+ item.setData(100, path_id) # Store path ID as custom data
141
+ self.path_list.addItem(item)
142
+
143
+ # Enable/disable export button
144
+ self.export_all_btn.setEnabled(len(self.state['paths']) > 0)
145
+ except Exception as e:
146
+ napari.utils.notifications.show_info(f"Error updating path list: {str(e)}")
147
+ self.status_label.setText(f"Error: {str(e)}")
148
+ finally:
149
+ self.handling_event = False
150
+
151
+ def on_path_selection_changed(self):
152
+ """Handle when path selection changes in the list"""
153
+ # Prevent processing during updates
154
+ if self.handling_event:
155
+ return
156
+
157
+ try:
158
+ self.handling_event = True
159
+
160
+ selected_items = self.path_list.selectedItems()
161
+ num_selected = len(selected_items)
162
+
163
+ # Enable/disable buttons based on selection
164
+ self.delete_path_btn.setEnabled(num_selected > 0)
165
+ self.view_path_btn.setEnabled(num_selected == 1)
166
+ self.connect_paths_btn.setEnabled(num_selected == 2)
167
+ except Exception as e:
168
+ napari.utils.notifications.show_info(f"Error handling selection change: {str(e)}")
169
+ finally:
170
+ self.handling_event = False
171
+
172
+ def view_selected_path(self):
173
+ """View the selected path from the list"""
174
+ if self.handling_event:
175
+ return
176
+
177
+ try:
178
+ self.handling_event = True
179
+
180
+ selected_items = self.path_list.selectedItems()
181
+ if len(selected_items) != 1:
182
+ return
183
+
184
+ item = selected_items[0]
185
+ path_id = item.data(100)
186
+
187
+ # Emit signal that path is selected
188
+ self.path_selected.emit(path_id)
189
+
190
+ # Ensure the selected path's layer is visible
191
+ if path_id in self.state['path_layers']:
192
+ self.state['path_layers'][path_id].visible = True
193
+
194
+ napari.utils.notifications.show_info(f"Viewing {self.state['paths'][path_id]['name']}")
195
+ except Exception as e:
196
+ napari.utils.notifications.show_info(f"Error viewing path: {str(e)}")
197
+ self.status_label.setText(f"Error: {str(e)}")
198
+ finally:
199
+ self.handling_event = False
200
+
201
+ def delete_selected_paths(self):
202
+ """Delete the currently selected paths"""
203
+ selected_items = self.path_list.selectedItems()
204
+ if not selected_items:
205
+ napari.utils.notifications.show_info("No paths selected")
206
+ return
207
+
208
+ paths_deleted = []
209
+
210
+ for item in selected_items:
211
+ # Get the path ID
212
+ path_id = item.data(100)
213
+
214
+ if path_id in self.state['paths']:
215
+ path_name = self.state['paths'][path_id]['name']
216
+
217
+ # Remove the path layer from viewer
218
+ if path_id in self.state['path_layers']:
219
+ self.viewer.layers.remove(self.state['path_layers'][path_id])
220
+ del self.state['path_layers'][path_id]
221
+
222
+ # Remove corresponding segmentation layer if it exists
223
+ seg_layer_name = f"Segmentation - {path_name}"
224
+ for layer in list(self.viewer.layers): # Create a copy of the list to safely modify during iteration
225
+ if layer.name == seg_layer_name:
226
+ self.viewer.layers.remove(layer)
227
+ if (
228
+ 'segmentation_layer' in self.state and
229
+ self.state['segmentation_layer'] is not None and
230
+ self.state['segmentation_layer'].name == seg_layer_name
231
+ ):
232
+ self.state['segmentation_layer'] = None
233
+ napari.utils.notifications.show_info(f"Removed segmentation layer for {path_name}")
234
+ break
235
+
236
+ # Remove corresponding spine layer if it exists
237
+ spine_layer_name = f"Spines - {path_name}"
238
+ for layer in list(self.viewer.layers):
239
+ if layer.name == spine_layer_name:
240
+ self.viewer.layers.remove(layer)
241
+ if path_id in self.state['spine_layers']:
242
+ del self.state['spine_layers'][path_id]
243
+ napari.utils.notifications.show_info(f"Removed spine layer for {path_name}")
244
+ break
245
+
246
+ # Remove from dictionary
247
+ del self.state['paths'][path_id]
248
+
249
+ # Track deleted path
250
+ paths_deleted.append(path_id)
251
+
252
+ napari.utils.notifications.show_info(f"Deleted {path_name}")
253
+
254
+ # Update traced path visualization if any paths were deleted
255
+ if paths_deleted and self.image.ndim > 2 and self.state['traced_path_layer'] is not None:
256
+ self._update_traced_path_visualization()
257
+
258
+ # Update path list
259
+ self.update_path_list()
260
+
261
+ # Emit signal for each deleted path
262
+ for path_id in paths_deleted:
263
+ self.path_deleted.emit(path_id)
264
+
265
+ def _update_traced_path_visualization(self):
266
+ """Update the traced path visualization to reflect current paths"""
267
+ if 'traced_path_layer' not in self.state or self.state['traced_path_layer'] is None:
268
+ return
269
+
270
+ if not self.state['paths']:
271
+ # If no paths remain, clear the traced path layer
272
+ self.state['traced_path_layer'].data = np.empty((0, self.image.ndim))
273
+ self.state['traced_path_layer'].visible = False
274
+ return
275
+
276
+ # Create a comprehensive visualization of all paths in the traced layer
277
+ all_traced_points = []
278
+
279
+ # First, determine the full z-range for all paths
280
+ min_z = float('inf')
281
+ max_z = float('-inf')
282
+
283
+ for path_id, path_data in self.state['paths'].items():
284
+ if len(path_data['data']) > 0 and path_data['visible']:
285
+ z_values = [point[0] for point in path_data['data']]
286
+ path_min_z = int(min(z_values))
287
+ path_max_z = int(max(z_values))
288
+
289
+ min_z = min(min_z, path_min_z)
290
+ max_z = max(max_z, path_max_z)
291
+
292
+ # If we have valid z-range
293
+ if min_z != float('inf') and max_z != float('-inf'):
294
+ # For each frame in the full range
295
+ for z in range(min_z, max_z + 1):
296
+ # Add all paths to this frame
297
+ for path_id, path_data in self.state['paths'].items():
298
+ if path_data['visible']:
299
+ for point in path_data['data']:
300
+ # Create a new point with the current frame's z-coordinate
301
+ new_point = point.copy()
302
+ new_point[0] = z # Set the z-coordinate to the current frame
303
+ all_traced_points.append(new_point)
304
+
305
+ # Update the traced path layer
306
+ if all_traced_points:
307
+ self.state['traced_path_layer'].data = np.array(all_traced_points)
308
+ self.state['traced_path_layer'].visible = True
309
+ else:
310
+ self.state['traced_path_layer'].data = np.empty((0, self.image.ndim))
311
+ self.state['traced_path_layer'].visible = False
312
+
313
+ def set_paths_visibility(self, visible):
314
+ """Set visibility of all saved path layers and update traced path visualization"""
315
+ if self.handling_event:
316
+ return
317
+
318
+ try:
319
+ self.handling_event = True
320
+
321
+ # Show/hide individual path layers
322
+ for path_id, layer in self.state['path_layers'].items():
323
+ layer.visible = visible
324
+ self.state['paths'][path_id]['visible'] = visible
325
+
326
+ # Update traced path visualization only if we have a traced path layer
327
+ if self.image.ndim > 2 and self.state['traced_path_layer'] is not None:
328
+ if visible:
329
+ self._update_traced_path_visualization()
330
+ else:
331
+ # Hide traced path layer when hiding all paths
332
+ self.state['traced_path_layer'].data = np.empty((0, self.image.ndim))
333
+ self.state['traced_path_layer'].visible = False
334
+
335
+ action = "shown" if visible else "hidden"
336
+ napari.utils.notifications.show_info(f"All paths {action}")
337
+ except Exception as e:
338
+ napari.utils.notifications.show_info(f"Error updating path visibility: {str(e)}")
339
+ self.status_label.setText(f"Error: {str(e)}")
340
+ finally:
341
+ self.handling_event = False
342
+
343
+ def connect_selected_paths(self):
344
+ """Connect two selected paths"""
345
+ if self.handling_event:
346
+ return
347
+
348
+ try:
349
+ self.handling_event = True
350
+
351
+ selected_items = self.path_list.selectedItems()
352
+
353
+ if len(selected_items) != 2:
354
+ napari.utils.notifications.show_info("Please select exactly two paths to connect")
355
+ return
356
+
357
+ # Get the path IDs
358
+ path_id1 = selected_items[0].data(100)
359
+ path_id2 = selected_items[1].data(100)
360
+
361
+ if path_id1 not in self.state['paths'] or path_id2 not in self.state['paths']:
362
+ napari.utils.notifications.show_info("Invalid path selection")
363
+ return
364
+
365
+ # Get the path data
366
+ path1 = self.state['paths'][path_id1]
367
+ path2 = self.state['paths'][path_id2]
368
+
369
+ # Check if paths have start/end points
370
+ if path1['start'] is None or path2['end'] is None:
371
+ napari.utils.notifications.show_info("Both paths must have start and end points to connect them")
372
+ return
373
+
374
+ # Get start of path1 and end of path2
375
+ start_point = path1['start']
376
+ end_point = path2['end']
377
+
378
+ napari.utils.notifications.show_info(f"Connecting {path1['name']} to {path2['name']}...")
379
+
380
+ # Determine if we're doing 2D or 3D search
381
+ is_same_frame = True
382
+ if self.image.ndim > 2:
383
+ is_same_frame = start_point[0] == end_point[0]
384
+
385
+ # Import necessary classes
386
+ import sys
387
+ sys.path.append('../path_tracing/brightest-path-lib')
388
+ from neuro_sam.brightest_path_lib.algorithm import BidirectionalAStarSearch
389
+
390
+ # Prepare points format based on 2D or 3D
391
+ if is_same_frame and self.image.ndim > 2:
392
+ # 2D case: use [y, x] format (ignore z)
393
+ search_start = start_point[1:3] # [y, x]
394
+ search_end = end_point[1:3] # [y, x]
395
+ search_image = self.image[int(start_point[0])]
396
+ napari.utils.notifications.show_info(f"Using 2D path search on frame {int(start_point[0])}")
397
+ else:
398
+ # 3D case or already 2D image: use full coordinates
399
+ search_start = start_point
400
+ search_end = end_point
401
+ search_image = self.image
402
+ napari.utils.notifications.show_info("Using 3D path search across frames")
403
+
404
+ # Search for connecting path
405
+ search_algorithm = BidirectionalAStarSearch(
406
+ search_image,
407
+ start_point=search_start,
408
+ goal_point=search_end
409
+ )
410
+
411
+ connecting_path = search_algorithm.search()
412
+
413
+ # If path found, create combined path
414
+ if connecting_path is not None and len(connecting_path) > 0:
415
+ # Fix coordinates if needed (2D case)
416
+ if is_same_frame and self.image.ndim > 2:
417
+ z_val = start_point[0]
418
+ fixed_connecting_path = []
419
+ for point in connecting_path:
420
+ if len(point) == 2: # [y, x]
421
+ fixed_connecting_path.append([z_val, point[0], point[1]])
422
+ else:
423
+ fixed_connecting_path.append(point)
424
+ connecting_path = fixed_connecting_path
425
+
426
+ # Convert to numpy arrays
427
+ path1_data = path1['data']
428
+ path2_data = path2['data']
429
+ connecting_data = np.array(connecting_path)
430
+
431
+ # Create combined path
432
+ combined_path = np.vstack([path1_data, connecting_data, path2_data])
433
+
434
+ # Create a name for the combined path
435
+ combined_name = f"{path1['name']} + {path2['name']}"
436
+
437
+ # Get a color
438
+ colors = ['cyan', 'magenta', 'green', 'blue', 'orange',
439
+ 'purple', 'teal', 'coral', 'gold', 'lavender']
440
+ color_idx = len(self.state['paths']) % len(colors)
441
+ combined_color = colors[color_idx]
442
+
443
+ # Create a new layer
444
+ combined_layer = self.viewer.add_points(
445
+ combined_path,
446
+ name=combined_name,
447
+ size=3,
448
+ face_color=combined_color,
449
+ opacity=0.7
450
+ )
451
+
452
+ # Generate a unique ID for this path
453
+ import uuid
454
+ path_id = str(uuid.uuid4())
455
+
456
+ # Combine waypoints from both paths
457
+ combined_waypoints = []
458
+ if 'waypoints' in path1 and path1['waypoints']:
459
+ combined_waypoints.extend(path1['waypoints'])
460
+ if 'waypoints' in path2 and path2['waypoints']:
461
+ combined_waypoints.extend(path2['waypoints'])
462
+
463
+ # Store the combined path
464
+ self.state['paths'][path_id] = {
465
+ 'name': combined_name,
466
+ 'data': combined_path,
467
+ 'start': path1['start'].copy(),
468
+ 'end': path2['end'].copy(),
469
+ 'waypoints': combined_waypoints,
470
+ 'visible': True,
471
+ 'layer': combined_layer,
472
+ 'original_clicks': [], # Empty since this is a generated path
473
+ 'smoothed': False # Combined paths are not smoothed
474
+ }
475
+
476
+ # Store reference to the layer
477
+ self.state['path_layers'][path_id] = combined_layer
478
+
479
+ # Update the path list
480
+ self.update_path_list()
481
+
482
+ # Update traced path visualization
483
+ if self.image.ndim > 2 and self.state['traced_path_layer'] is not None:
484
+ self._update_traced_path_visualization()
485
+
486
+ # Set the new combined path as the current path
487
+ self.state['current_path_id'] = path_id
488
+
489
+ # Select the new path in the list
490
+ for i in range(self.path_list.count()):
491
+ item = self.path_list.item(i)
492
+ if item.data(100) == path_id:
493
+ self.path_list.setCurrentItem(item)
494
+ break
495
+
496
+ # Update UI
497
+ msg = f"Connected {path1['name']} to {path2['name']} successfully"
498
+ napari.utils.notifications.show_info(msg)
499
+ self.status_label.setText(msg)
500
+
501
+ # Import the signal from the path tracing widget to properly notify other modules
502
+ from qtpy.QtCore import QTimer
503
+
504
+ # Emit path_created signal to update other modules
505
+ def emit_delayed_signal():
506
+ # Find the path tracing widget and emit its signal
507
+ parent_widget = self.parent()
508
+ while parent_widget:
509
+ if hasattr(parent_widget, 'path_tracing_widget'):
510
+ parent_widget.path_tracing_widget.path_created.emit(path_id, combined_name, combined_path)
511
+ break
512
+ parent_widget = parent_widget.parent()
513
+
514
+ # Also emit our own signal
515
+ self.path_selected.emit(path_id)
516
+
517
+ # Use a timer to emit the signal after the UI has updated
518
+ QTimer.singleShot(100, emit_delayed_signal)
519
+ else:
520
+ error_msg = f"Could not find a path connecting {path1['name']} to {path2['name']}"
521
+ napari.utils.notifications.show_info(error_msg)
522
+ self.status_label.setText(error_msg)
523
+ except Exception as e:
524
+ error_msg = f"Error connecting paths: {str(e)}"
525
+ napari.utils.notifications.show_info(error_msg)
526
+ self.status_label.setText(error_msg)
527
+ print(f"Error details: {str(e)}")
528
+ finally:
529
+ self.handling_event = False
530
+
531
+ def export_all_paths(self):
532
+ """Export all paths to a file"""
533
+ if not self.state['paths']:
534
+ napari.utils.notifications.show_info("No paths to export")
535
+ return
536
+
537
+ # Get path to save file
538
+ filepath, _ = QFileDialog.getSaveFileName(
539
+ self, "Save All Paths", "", "NumPy Files (*.npz)"
540
+ )
541
+
542
+ if not filepath:
543
+ return
544
+
545
+ try:
546
+ # Prepare data for export
547
+ path_data = {}
548
+ for path_id, path_info in self.state['paths'].items():
549
+ export_data = {
550
+ 'points': path_info['data'],
551
+ 'start': path_info['start'] if 'start' in path_info and path_info['start'] is not None else np.array([]),
552
+ 'end': path_info['end'] if 'end' in path_info and path_info['end'] is not None else np.array([]),
553
+ }
554
+
555
+ # Include waypoints if available
556
+ if 'waypoints' in path_info and path_info['waypoints']:
557
+ export_data['waypoints'] = np.array(path_info['waypoints'])
558
+
559
+ path_data[path_info['name']] = export_data
560
+
561
+ # Save as NumPy archive
562
+ np.savez(filepath, paths=path_data)
563
+
564
+ napari.utils.notifications.show_info(f"All paths saved to {filepath}")
565
+ self.status_label.setText(f"Paths exported to {filepath}")
566
+
567
+ except Exception as e:
568
+ napari.utils.notifications.show_info(f"Error saving paths: {e}")
569
+ self.status_label.setText(f"Error: {str(e)}")
570
+
571
+ def update_path_visualization(self):
572
+ """Update the visualization of paths after modifications"""
573
+ if self.state['traced_path_layer'] is not None:
574
+ self._update_traced_path_visualization()