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.
- neuro_sam/__init__.py +1 -0
- neuro_sam/brightest_path_lib/__init__.py +5 -0
- neuro_sam/brightest_path_lib/algorithm/__init__.py +3 -0
- neuro_sam/brightest_path_lib/algorithm/astar.py +586 -0
- neuro_sam/brightest_path_lib/algorithm/waypointastar.py +449 -0
- neuro_sam/brightest_path_lib/algorithm/waypointastar_speedup.py +1007 -0
- neuro_sam/brightest_path_lib/connected_componen.py +329 -0
- neuro_sam/brightest_path_lib/cost/__init__.py +8 -0
- neuro_sam/brightest_path_lib/cost/cost.py +33 -0
- neuro_sam/brightest_path_lib/cost/reciprocal.py +90 -0
- neuro_sam/brightest_path_lib/cost/reciprocal_transonic.py +86 -0
- neuro_sam/brightest_path_lib/heuristic/__init__.py +2 -0
- neuro_sam/brightest_path_lib/heuristic/euclidean.py +101 -0
- neuro_sam/brightest_path_lib/heuristic/heuristic.py +29 -0
- neuro_sam/brightest_path_lib/image/__init__.py +1 -0
- neuro_sam/brightest_path_lib/image/stats.py +197 -0
- neuro_sam/brightest_path_lib/input/__init__.py +1 -0
- neuro_sam/brightest_path_lib/input/inputs.py +14 -0
- neuro_sam/brightest_path_lib/node/__init__.py +2 -0
- neuro_sam/brightest_path_lib/node/bidirectional_node.py +240 -0
- neuro_sam/brightest_path_lib/node/node.py +125 -0
- neuro_sam/brightest_path_lib/visualization/__init__.py +4 -0
- neuro_sam/brightest_path_lib/visualization/flythrough.py +133 -0
- neuro_sam/brightest_path_lib/visualization/flythrough_all.py +394 -0
- neuro_sam/brightest_path_lib/visualization/tube_data.py +385 -0
- neuro_sam/brightest_path_lib/visualization/tube_flythrough.py +227 -0
- neuro_sam/napari_utils/anisotropic_scaling.py +503 -0
- neuro_sam/napari_utils/color_utils.py +135 -0
- neuro_sam/napari_utils/contrasting_color_system.py +169 -0
- neuro_sam/napari_utils/main_widget.py +1016 -0
- neuro_sam/napari_utils/path_tracing_module.py +1016 -0
- neuro_sam/napari_utils/punet_widget.py +424 -0
- neuro_sam/napari_utils/segmentation_model.py +769 -0
- neuro_sam/napari_utils/segmentation_module.py +649 -0
- neuro_sam/napari_utils/visualization_module.py +574 -0
- neuro_sam/plugin.py +260 -0
- neuro_sam/punet/__init__.py +0 -0
- neuro_sam/punet/deepd3_model.py +231 -0
- neuro_sam/punet/prob_unet_deepd3.py +431 -0
- neuro_sam/punet/prob_unet_with_tversky.py +375 -0
- neuro_sam/punet/punet_inference.py +236 -0
- neuro_sam/punet/run_inference.py +145 -0
- neuro_sam/punet/unet_blocks.py +81 -0
- neuro_sam/punet/utils.py +52 -0
- neuro_sam-0.1.0.dist-info/METADATA +269 -0
- neuro_sam-0.1.0.dist-info/RECORD +93 -0
- neuro_sam-0.1.0.dist-info/WHEEL +5 -0
- neuro_sam-0.1.0.dist-info/entry_points.txt +2 -0
- neuro_sam-0.1.0.dist-info/licenses/LICENSE +21 -0
- neuro_sam-0.1.0.dist-info/top_level.txt +2 -0
- sam2/__init__.py +11 -0
- sam2/automatic_mask_generator.py +454 -0
- sam2/benchmark.py +92 -0
- sam2/build_sam.py +174 -0
- sam2/configs/sam2/sam2_hiera_b+.yaml +113 -0
- sam2/configs/sam2/sam2_hiera_l.yaml +117 -0
- sam2/configs/sam2/sam2_hiera_s.yaml +116 -0
- sam2/configs/sam2/sam2_hiera_t.yaml +118 -0
- sam2/configs/sam2.1/sam2.1_hiera_b+.yaml +116 -0
- sam2/configs/sam2.1/sam2.1_hiera_l.yaml +120 -0
- sam2/configs/sam2.1/sam2.1_hiera_s.yaml +119 -0
- sam2/configs/sam2.1/sam2.1_hiera_t.yaml +121 -0
- sam2/configs/sam2.1_training/sam2.1_hiera_b+_MOSE_finetune.yaml +339 -0
- sam2/configs/train.yaml +335 -0
- sam2/modeling/__init__.py +5 -0
- sam2/modeling/backbones/__init__.py +5 -0
- sam2/modeling/backbones/hieradet.py +317 -0
- sam2/modeling/backbones/image_encoder.py +134 -0
- sam2/modeling/backbones/utils.py +93 -0
- sam2/modeling/memory_attention.py +169 -0
- sam2/modeling/memory_encoder.py +181 -0
- sam2/modeling/position_encoding.py +239 -0
- sam2/modeling/sam/__init__.py +5 -0
- sam2/modeling/sam/mask_decoder.py +295 -0
- sam2/modeling/sam/prompt_encoder.py +202 -0
- sam2/modeling/sam/transformer.py +311 -0
- sam2/modeling/sam2_base.py +911 -0
- sam2/modeling/sam2_utils.py +323 -0
- sam2/sam2.1_hiera_b+.yaml +116 -0
- sam2/sam2.1_hiera_l.yaml +120 -0
- sam2/sam2.1_hiera_s.yaml +119 -0
- sam2/sam2.1_hiera_t.yaml +121 -0
- sam2/sam2_hiera_b+.yaml +113 -0
- sam2/sam2_hiera_l.yaml +117 -0
- sam2/sam2_hiera_s.yaml +116 -0
- sam2/sam2_hiera_t.yaml +118 -0
- sam2/sam2_image_predictor.py +475 -0
- sam2/sam2_video_predictor.py +1222 -0
- sam2/sam2_video_predictor_legacy.py +1172 -0
- sam2/utils/__init__.py +5 -0
- sam2/utils/amg.py +348 -0
- sam2/utils/misc.py +349 -0
- sam2/utils/transforms.py +118 -0
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
import napari
|
|
2
|
+
import numpy as np
|
|
3
|
+
import uuid
|
|
4
|
+
from qtpy.QtWidgets import (
|
|
5
|
+
QWidget, QVBoxLayout, QPushButton, QLabel,
|
|
6
|
+
QHBoxLayout, QFrame, QCheckBox, QComboBox, QDoubleSpinBox
|
|
7
|
+
)
|
|
8
|
+
from qtpy.QtCore import Signal
|
|
9
|
+
# import sys
|
|
10
|
+
# sys.path.append('./brightest-path-lib/')
|
|
11
|
+
from neuro_sam.brightest_path_lib.algorithm.waypointastar_speedup import quick_accurate_optimized_search
|
|
12
|
+
from scipy.interpolate import splprep, splev
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PathSmoother:
|
|
16
|
+
"""B-spline based path smoothing for dendrite traces with scaling support"""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
def smooth_path(self, path_points, spacing_xyz=(1.0, 1.0, 1.0), smoothing_factor=None,
|
|
22
|
+
num_points=None, preserve_endpoints=True):
|
|
23
|
+
"""
|
|
24
|
+
Smooth a 3D path using B-spline interpolation
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
path_points: numpy array of shape (N, 3) with [z, y, x] coordinates
|
|
28
|
+
spacing_xyz: voxel spacing in (x, y, z) format for proper distance calculation
|
|
29
|
+
smoothing_factor: B-spline smoothing parameter (higher = more smooth)
|
|
30
|
+
num_points: number of points in smoothed path (None = same as input)
|
|
31
|
+
preserve_endpoints: whether to keep original start/end points
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Smoothed path as numpy array
|
|
35
|
+
"""
|
|
36
|
+
if len(path_points) < 3:
|
|
37
|
+
return path_points.copy()
|
|
38
|
+
|
|
39
|
+
# Store original endpoints
|
|
40
|
+
start_point = path_points[0].copy()
|
|
41
|
+
end_point = path_points[-1].copy()
|
|
42
|
+
|
|
43
|
+
# Apply B-spline smoothing
|
|
44
|
+
smoothed_path = self._bspline_smooth_anisotropic(
|
|
45
|
+
path_points, spacing_xyz, smoothing_factor, num_points
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Restore endpoints if requested
|
|
49
|
+
if preserve_endpoints and len(smoothed_path) > 0:
|
|
50
|
+
smoothed_path[0] = start_point
|
|
51
|
+
smoothed_path[-1] = end_point
|
|
52
|
+
|
|
53
|
+
return smoothed_path
|
|
54
|
+
|
|
55
|
+
def _bspline_smooth_anisotropic(self, path_points, spacing_xyz, smoothing_factor=None, num_points=None):
|
|
56
|
+
"""B-spline smoothingn"""
|
|
57
|
+
if smoothing_factor is None:
|
|
58
|
+
# Auto-determine smoothing factor based on path length
|
|
59
|
+
path_length = len(path_points)
|
|
60
|
+
anisotropy_factor = max(spacing_xyz) / min(spacing_xyz)
|
|
61
|
+
smoothing_factor = max(0, path_length - np.sqrt(2 * path_length)) * anisotropy_factor
|
|
62
|
+
|
|
63
|
+
if num_points is None:
|
|
64
|
+
num_points = len(path_points)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
# Scale coordinates by voxel spacing for proper distance calculation
|
|
68
|
+
# path_points are in [z, y, x] format, spacing is in (x, y, z) format
|
|
69
|
+
scaled_points = path_points.copy().astype(float)
|
|
70
|
+
scaled_points[:, 0] *= spacing_xyz[2] # Z scaling
|
|
71
|
+
scaled_points[:, 1] *= spacing_xyz[1] # Y scaling
|
|
72
|
+
scaled_points[:, 2] *= spacing_xyz[0] # X scaling
|
|
73
|
+
|
|
74
|
+
# Simplify path to remove grid jitter/staircasing which causes spline overshoots
|
|
75
|
+
# We only keep points that are a certain physical distance apart
|
|
76
|
+
simplified_points = [scaled_points[0]]
|
|
77
|
+
last_kept_idx = 0
|
|
78
|
+
min_dist_sq = 2.0 * 2.0 * min(spacing_xyz)**2 # roughly 2 pixels distance squared
|
|
79
|
+
|
|
80
|
+
for i in range(1, len(scaled_points) - 1):
|
|
81
|
+
# Calculate distance to last kept point
|
|
82
|
+
diff = scaled_points[i] - scaled_points[last_kept_idx]
|
|
83
|
+
dist_sq = np.sum(diff**2)
|
|
84
|
+
|
|
85
|
+
if dist_sq > min_dist_sq:
|
|
86
|
+
simplified_points.append(scaled_points[i])
|
|
87
|
+
last_kept_idx = i
|
|
88
|
+
|
|
89
|
+
# Handling the final point
|
|
90
|
+
# If the last kept point is too close to the end point, we replace it with the end point
|
|
91
|
+
# This prevents "dancing" or jitter at the end where two points are very close
|
|
92
|
+
last_added = simplified_points[-1]
|
|
93
|
+
end_point = scaled_points[-1]
|
|
94
|
+
diff = end_point - last_added
|
|
95
|
+
dist_sq = np.sum(diff**2)
|
|
96
|
+
|
|
97
|
+
if dist_sq < min_dist_sq and len(simplified_points) > 1:
|
|
98
|
+
# Replace the last added intermediate point with the true end point
|
|
99
|
+
simplified_points[-1] = end_point
|
|
100
|
+
else:
|
|
101
|
+
# Otherwise append as usual
|
|
102
|
+
simplified_points.append(end_point)
|
|
103
|
+
simplified_points = np.array(simplified_points)
|
|
104
|
+
|
|
105
|
+
# Prepare coordinates for spline fitting
|
|
106
|
+
if len(simplified_points) < 2:
|
|
107
|
+
return path_points.copy()
|
|
108
|
+
|
|
109
|
+
x = simplified_points[:, 2] # x coordinates (scaled)
|
|
110
|
+
y = simplified_points[:, 1] # y coordinates (scaled)
|
|
111
|
+
z = simplified_points[:, 0] # z coordinates (scaled)
|
|
112
|
+
|
|
113
|
+
# Fit B-spline (k=3 for cubic, k=min(3, len-1) for short paths)
|
|
114
|
+
# Use a smaller k if we simplified too much
|
|
115
|
+
k = min(3, len(simplified_points) - 1)
|
|
116
|
+
|
|
117
|
+
# If too few points for cubic spline, just use linear interpolation or original points
|
|
118
|
+
if k < 1:
|
|
119
|
+
return path_points.copy()
|
|
120
|
+
|
|
121
|
+
# Adjust smoothing factor for simplified path
|
|
122
|
+
# Since we have fewer points, we need less smoothing 's'
|
|
123
|
+
# Original s was based on full path length.
|
|
124
|
+
if smoothing_factor is None:
|
|
125
|
+
# Re-calculate s based on simplified length
|
|
126
|
+
path_length = len(simplified_points)
|
|
127
|
+
anisotropy_factor = max(spacing_xyz) / min(spacing_xyz)
|
|
128
|
+
smoothing_factor = max(0, path_length - np.sqrt(2 * path_length)) * anisotropy_factor
|
|
129
|
+
|
|
130
|
+
tck, u = splprep([x, y, z], s=smoothing_factor, k=k)
|
|
131
|
+
|
|
132
|
+
# Generate smoothed points
|
|
133
|
+
u_new = np.linspace(0, 1, num_points)
|
|
134
|
+
x_smooth, y_smooth, z_smooth = splev(u_new, tck)
|
|
135
|
+
|
|
136
|
+
# Scale back to voxel coordinates
|
|
137
|
+
x_smooth /= spacing_xyz[0] # Unscale X
|
|
138
|
+
y_smooth /= spacing_xyz[1] # Unscale Y
|
|
139
|
+
z_smooth /= spacing_xyz[2] # Unscale Z
|
|
140
|
+
|
|
141
|
+
# Combine back to [z, y, x] format
|
|
142
|
+
smoothed_path = np.column_stack([z_smooth, y_smooth, x_smooth])
|
|
143
|
+
|
|
144
|
+
return smoothed_path
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
print(f"B-spline smoothing failed: {e}, falling back to original path")
|
|
148
|
+
return path_points.copy()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class PathTracingWidget(QWidget):
|
|
152
|
+
"""Widget for tracing the brightest path ."""
|
|
153
|
+
|
|
154
|
+
# Define signals
|
|
155
|
+
path_created = Signal(str, str, object) # path_id, path_name, path_data
|
|
156
|
+
path_updated = Signal(str, str, object) # path_id, path_name, path_data
|
|
157
|
+
|
|
158
|
+
def __init__(self, viewer, image, state, scaler, scaling_update_callback):
|
|
159
|
+
"""Initialize the path tracing widget.
|
|
160
|
+
|
|
161
|
+
Parameters:
|
|
162
|
+
-----------
|
|
163
|
+
viewer : napari.Viewer
|
|
164
|
+
The napari viewer instance
|
|
165
|
+
image : numpy.ndarray
|
|
166
|
+
3D or higher-dimensional image data (potentially scaled)
|
|
167
|
+
state : dict
|
|
168
|
+
Shared state dictionary between modules
|
|
169
|
+
scaler : AnisotropicScaler
|
|
170
|
+
The scaler instance for coordinate conversion
|
|
171
|
+
scaling_update_callback : function
|
|
172
|
+
Callback function when scaling is updated
|
|
173
|
+
"""
|
|
174
|
+
super().__init__()
|
|
175
|
+
self.viewer = viewer
|
|
176
|
+
self.image = image
|
|
177
|
+
self.state = state
|
|
178
|
+
self.scaler = scaler
|
|
179
|
+
self.scaling_update_callback = scaling_update_callback
|
|
180
|
+
|
|
181
|
+
# List to store waypoints as they are clicked
|
|
182
|
+
self.clicked_points = []
|
|
183
|
+
|
|
184
|
+
# Settings for path finding
|
|
185
|
+
self.next_path_number = 1
|
|
186
|
+
self.color_idx = 0
|
|
187
|
+
|
|
188
|
+
# Flag to prevent recursive event handling
|
|
189
|
+
self.handling_event = False
|
|
190
|
+
|
|
191
|
+
# Initialize path smoother
|
|
192
|
+
self.path_smoother = PathSmoother()
|
|
193
|
+
|
|
194
|
+
# Setup UI
|
|
195
|
+
self.setup_ui()
|
|
196
|
+
|
|
197
|
+
def setup_ui(self):
|
|
198
|
+
"""Create the UI panel with controls"""
|
|
199
|
+
layout = QVBoxLayout()
|
|
200
|
+
layout.setSpacing(2)
|
|
201
|
+
layout.setContentsMargins(2, 2, 2, 2)
|
|
202
|
+
self.setLayout(layout)
|
|
203
|
+
|
|
204
|
+
# Main instruction
|
|
205
|
+
title = QLabel("<b>Path Tracing</b>")
|
|
206
|
+
layout.addWidget(title)
|
|
207
|
+
|
|
208
|
+
# Instructions section
|
|
209
|
+
instructions_section = QWidget()
|
|
210
|
+
instructions_layout = QVBoxLayout()
|
|
211
|
+
instructions_layout.setSpacing(2)
|
|
212
|
+
instructions_layout.setContentsMargins(2, 2, 2, 2)
|
|
213
|
+
instructions_section.setLayout(instructions_layout)
|
|
214
|
+
|
|
215
|
+
instructions = QLabel(
|
|
216
|
+
"<b>Step 1: Configure Voxel Spacing</b><br>"
|
|
217
|
+
"Set correct X, Y, Z spacing, then click 'Apply Scaling'<br><br>"
|
|
218
|
+
"<b>Step 2: Trace Paths</b><br>"
|
|
219
|
+
"1. Click points on dendrite structure<br>"
|
|
220
|
+
"2. Click 'Find Path' to trace<br>"
|
|
221
|
+
"3. Use 'Trace Another Path' for additional paths"
|
|
222
|
+
)
|
|
223
|
+
instructions.setWordWrap(True)
|
|
224
|
+
instructions_layout.addWidget(instructions)
|
|
225
|
+
|
|
226
|
+
layout.addWidget(instructions_section)
|
|
227
|
+
|
|
228
|
+
# Add separator
|
|
229
|
+
separator0 = QFrame()
|
|
230
|
+
separator0.setFrameShape(QFrame.HLine)
|
|
231
|
+
separator0.setFrameShadow(QFrame.Sunken)
|
|
232
|
+
layout.addWidget(separator0)
|
|
233
|
+
|
|
234
|
+
# Anisotropic scaling section
|
|
235
|
+
self._add_scaling_controls(layout)
|
|
236
|
+
|
|
237
|
+
# Add separator
|
|
238
|
+
separator1 = QFrame()
|
|
239
|
+
separator1.setFrameShape(QFrame.HLine)
|
|
240
|
+
separator1.setFrameShadow(QFrame.Sunken)
|
|
241
|
+
layout.addWidget(separator1)
|
|
242
|
+
|
|
243
|
+
# Current spacing display
|
|
244
|
+
spacing_info_section = QWidget()
|
|
245
|
+
spacing_info_layout = QVBoxLayout()
|
|
246
|
+
spacing_info_layout.setSpacing(2)
|
|
247
|
+
spacing_info_layout.setContentsMargins(2, 2, 2, 2)
|
|
248
|
+
spacing_info_section.setLayout(spacing_info_layout)
|
|
249
|
+
|
|
250
|
+
self.spacing_info_label = QLabel("Current voxel spacing: Not set")
|
|
251
|
+
self.spacing_info_label.setStyleSheet("font-weight: bold; color: #0066cc;")
|
|
252
|
+
spacing_info_layout.addWidget(self.spacing_info_label)
|
|
253
|
+
|
|
254
|
+
layout.addWidget(spacing_info_section)
|
|
255
|
+
|
|
256
|
+
# Update spacing display
|
|
257
|
+
self._update_spacing_display()
|
|
258
|
+
|
|
259
|
+
# Waypoint controls section
|
|
260
|
+
waypoints_section = QWidget()
|
|
261
|
+
waypoints_layout = QVBoxLayout()
|
|
262
|
+
waypoints_layout.setSpacing(2)
|
|
263
|
+
waypoints_layout.setContentsMargins(2, 2, 2, 2)
|
|
264
|
+
waypoints_section.setLayout(waypoints_layout)
|
|
265
|
+
|
|
266
|
+
self.select_waypoints_btn = QPushButton("Start Point Selection")
|
|
267
|
+
self.select_waypoints_btn.setFixedHeight(22)
|
|
268
|
+
self.select_waypoints_btn.clicked.connect(self.activate_waypoints_layer)
|
|
269
|
+
waypoints_layout.addWidget(self.select_waypoints_btn)
|
|
270
|
+
|
|
271
|
+
self.waypoints_status = QLabel("Status: Click to start selecting points")
|
|
272
|
+
waypoints_layout.addWidget(self.waypoints_status)
|
|
273
|
+
layout.addWidget(waypoints_section)
|
|
274
|
+
|
|
275
|
+
# Add separator
|
|
276
|
+
separator = QFrame()
|
|
277
|
+
separator.setFrameShape(QFrame.HLine)
|
|
278
|
+
separator.setFrameShadow(QFrame.Sunken)
|
|
279
|
+
layout.addWidget(separator)
|
|
280
|
+
|
|
281
|
+
# Fast algorithm settings
|
|
282
|
+
algorithm_section = QWidget()
|
|
283
|
+
algorithm_layout = QVBoxLayout()
|
|
284
|
+
algorithm_layout.setSpacing(2)
|
|
285
|
+
algorithm_layout.setContentsMargins(2, 2, 2, 2)
|
|
286
|
+
algorithm_section.setLayout(algorithm_layout)
|
|
287
|
+
|
|
288
|
+
# Parallel processing checkbox
|
|
289
|
+
self.enable_parallel_cb = QCheckBox("Enable Parallel Processing")
|
|
290
|
+
self.enable_parallel_cb.setChecked(True)
|
|
291
|
+
self.enable_parallel_cb.setToolTip("Use parallel processing for faster pathfinding")
|
|
292
|
+
algorithm_layout.addWidget(self.enable_parallel_cb)
|
|
293
|
+
|
|
294
|
+
# Weight heuristic parameter
|
|
295
|
+
weight_heuristic_layout = QHBoxLayout()
|
|
296
|
+
weight_heuristic_layout.setSpacing(2)
|
|
297
|
+
weight_heuristic_layout.addWidget(QLabel("Weight Heuristic:"))
|
|
298
|
+
self.weight_heuristic_spin = QDoubleSpinBox()
|
|
299
|
+
self.weight_heuristic_spin.setRange(0.1, 10.0)
|
|
300
|
+
self.weight_heuristic_spin.setSingleStep(0.1)
|
|
301
|
+
self.weight_heuristic_spin.setValue(2.0) # Default value
|
|
302
|
+
self.weight_heuristic_spin.setDecimals(1)
|
|
303
|
+
self.weight_heuristic_spin.setToolTip("Weight heuristic for A* search algorithm (higher = more heuristic-guided)")
|
|
304
|
+
weight_heuristic_layout.addWidget(self.weight_heuristic_spin)
|
|
305
|
+
algorithm_layout.addLayout(weight_heuristic_layout)
|
|
306
|
+
|
|
307
|
+
layout.addWidget(algorithm_section)
|
|
308
|
+
|
|
309
|
+
# Smoothing controls section
|
|
310
|
+
smoothing_section = QWidget()
|
|
311
|
+
smoothing_layout = QVBoxLayout()
|
|
312
|
+
smoothing_layout.setSpacing(2)
|
|
313
|
+
smoothing_layout.setContentsMargins(2, 2, 2, 2)
|
|
314
|
+
smoothing_section.setLayout(smoothing_layout)
|
|
315
|
+
|
|
316
|
+
# Smoothing checkbox
|
|
317
|
+
self.enable_smoothing_cb = QCheckBox("Enable B-spline Smoothing")
|
|
318
|
+
self.enable_smoothing_cb.setChecked(True)
|
|
319
|
+
self.enable_smoothing_cb.setToolTip("Apply B-spline smoothing that considers voxel spacing")
|
|
320
|
+
smoothing_layout.addWidget(self.enable_smoothing_cb)
|
|
321
|
+
|
|
322
|
+
# Smoothing factor
|
|
323
|
+
factor_layout = QHBoxLayout()
|
|
324
|
+
factor_layout.setSpacing(2)
|
|
325
|
+
factor_layout.addWidget(QLabel("Smoothing:"))
|
|
326
|
+
self.smoothing_factor_spin = QDoubleSpinBox()
|
|
327
|
+
self.smoothing_factor_spin.setRange(0.0, 10.0)
|
|
328
|
+
self.smoothing_factor_spin.setSingleStep(0.1)
|
|
329
|
+
self.smoothing_factor_spin.setValue(1.0)
|
|
330
|
+
self.smoothing_factor_spin.setToolTip("Higher values = more smoothing (0 = no smoothing)")
|
|
331
|
+
factor_layout.addWidget(self.smoothing_factor_spin)
|
|
332
|
+
smoothing_layout.addLayout(factor_layout)
|
|
333
|
+
|
|
334
|
+
layout.addWidget(smoothing_section)
|
|
335
|
+
|
|
336
|
+
# Add separator
|
|
337
|
+
separator2 = QFrame()
|
|
338
|
+
separator2.setFrameShape(QFrame.HLine)
|
|
339
|
+
separator2.setFrameShadow(QFrame.Sunken)
|
|
340
|
+
layout.addWidget(separator2)
|
|
341
|
+
|
|
342
|
+
# Action buttons
|
|
343
|
+
buttons_layout = QVBoxLayout()
|
|
344
|
+
buttons_layout.setSpacing(2)
|
|
345
|
+
|
|
346
|
+
# Main path finding button
|
|
347
|
+
self.find_path_btn = QPushButton("Find Path")
|
|
348
|
+
self.find_path_btn.setFixedHeight(26)
|
|
349
|
+
self.find_path_btn.setStyleSheet("font-weight: bold; background-color: #4CAF50; color: white;")
|
|
350
|
+
self.find_path_btn.clicked.connect(self.find_path)
|
|
351
|
+
self.find_path_btn.setEnabled(False)
|
|
352
|
+
buttons_layout.addWidget(self.find_path_btn)
|
|
353
|
+
|
|
354
|
+
layout.addLayout(buttons_layout)
|
|
355
|
+
|
|
356
|
+
# Add progress bar
|
|
357
|
+
from qtpy.QtWidgets import QProgressBar
|
|
358
|
+
self.progress_bar = QProgressBar()
|
|
359
|
+
self.progress_bar.setRange(0, 100)
|
|
360
|
+
self.progress_bar.setValue(0)
|
|
361
|
+
self.progress_bar.setTextVisible(True)
|
|
362
|
+
self.progress_bar.setVisible(False)
|
|
363
|
+
layout.addWidget(self.progress_bar)
|
|
364
|
+
|
|
365
|
+
# Management buttons
|
|
366
|
+
management_layout = QHBoxLayout()
|
|
367
|
+
management_layout.setSpacing(2)
|
|
368
|
+
|
|
369
|
+
self.trace_another_btn = QPushButton("Trace Another Path")
|
|
370
|
+
self.trace_another_btn.setFixedHeight(22)
|
|
371
|
+
self.trace_another_btn.clicked.connect(self.trace_another_path)
|
|
372
|
+
self.trace_another_btn.setEnabled(False)
|
|
373
|
+
management_layout.addWidget(self.trace_another_btn)
|
|
374
|
+
|
|
375
|
+
self.clear_points_btn = QPushButton("Clear All Points")
|
|
376
|
+
self.clear_points_btn.setFixedHeight(22)
|
|
377
|
+
self.clear_points_btn.clicked.connect(self.clear_points)
|
|
378
|
+
management_layout.addWidget(self.clear_points_btn)
|
|
379
|
+
|
|
380
|
+
layout.addLayout(management_layout)
|
|
381
|
+
|
|
382
|
+
# Status messages
|
|
383
|
+
self.status_label = QLabel("")
|
|
384
|
+
layout.addWidget(self.status_label)
|
|
385
|
+
|
|
386
|
+
self.error_status = QLabel("")
|
|
387
|
+
self.error_status.setStyleSheet("color: red;")
|
|
388
|
+
layout.addWidget(self.error_status)
|
|
389
|
+
|
|
390
|
+
def _add_scaling_controls(self, layout):
|
|
391
|
+
"""Add anisotropic scaling controls to the layout"""
|
|
392
|
+
from qtpy.QtWidgets import (QGroupBox, QDoubleSpinBox, QComboBox, QCheckBox)
|
|
393
|
+
|
|
394
|
+
# Scaling section
|
|
395
|
+
scaling_group = QGroupBox("Voxel Spacing")
|
|
396
|
+
scaling_layout = QVBoxLayout()
|
|
397
|
+
scaling_layout.setSpacing(2)
|
|
398
|
+
scaling_layout.setContentsMargins(5, 5, 5, 5)
|
|
399
|
+
|
|
400
|
+
# Instructions
|
|
401
|
+
info_label = QLabel("Set voxel spacing in nanometers (will reshape the dataset):")
|
|
402
|
+
info_label.setWordWrap(True)
|
|
403
|
+
scaling_layout.addWidget(info_label)
|
|
404
|
+
|
|
405
|
+
# X spacing
|
|
406
|
+
x_layout = QHBoxLayout()
|
|
407
|
+
x_layout.addWidget(QLabel("X spacing:"))
|
|
408
|
+
self.x_spacing_spin = QDoubleSpinBox()
|
|
409
|
+
self.x_spacing_spin.setRange(1.0, 10000.0)
|
|
410
|
+
self.x_spacing_spin.setSingleStep(1.0)
|
|
411
|
+
self.x_spacing_spin.setValue(self.scaler.current_spacing_xyz[0])
|
|
412
|
+
self.x_spacing_spin.setDecimals(1)
|
|
413
|
+
self.x_spacing_spin.setSuffix(" nm")
|
|
414
|
+
self.x_spacing_spin.setToolTip("X-axis voxel spacing in nanometers")
|
|
415
|
+
self.x_spacing_spin.valueChanged.connect(self._on_spacing_changed)
|
|
416
|
+
x_layout.addWidget(self.x_spacing_spin)
|
|
417
|
+
scaling_layout.addLayout(x_layout)
|
|
418
|
+
|
|
419
|
+
# Y spacing
|
|
420
|
+
y_layout = QHBoxLayout()
|
|
421
|
+
y_layout.addWidget(QLabel("Y spacing:"))
|
|
422
|
+
self.y_spacing_spin = QDoubleSpinBox()
|
|
423
|
+
self.y_spacing_spin.setRange(1.0, 10000.0)
|
|
424
|
+
self.y_spacing_spin.setSingleStep(1.0)
|
|
425
|
+
self.y_spacing_spin.setValue(self.scaler.current_spacing_xyz[1])
|
|
426
|
+
self.y_spacing_spin.setDecimals(1)
|
|
427
|
+
self.y_spacing_spin.setSuffix(" nm")
|
|
428
|
+
self.y_spacing_spin.setToolTip("Y-axis voxel spacing in nanometers")
|
|
429
|
+
self.y_spacing_spin.valueChanged.connect(self._on_spacing_changed)
|
|
430
|
+
y_layout.addWidget(self.y_spacing_spin)
|
|
431
|
+
scaling_layout.addLayout(y_layout)
|
|
432
|
+
|
|
433
|
+
# Z spacing
|
|
434
|
+
z_layout = QHBoxLayout()
|
|
435
|
+
z_layout.addWidget(QLabel("Z spacing:"))
|
|
436
|
+
self.z_spacing_spin = QDoubleSpinBox()
|
|
437
|
+
self.z_spacing_spin.setRange(1.0, 10000.0)
|
|
438
|
+
self.z_spacing_spin.setSingleStep(1.0)
|
|
439
|
+
self.z_spacing_spin.setValue(self.scaler.current_spacing_xyz[2])
|
|
440
|
+
self.z_spacing_spin.setDecimals(1)
|
|
441
|
+
self.z_spacing_spin.setSuffix(" nm")
|
|
442
|
+
self.z_spacing_spin.setToolTip("Z-axis voxel spacing in nanometers")
|
|
443
|
+
self.z_spacing_spin.valueChanged.connect(self._on_spacing_changed)
|
|
444
|
+
z_layout.addWidget(self.z_spacing_spin)
|
|
445
|
+
scaling_layout.addLayout(z_layout)
|
|
446
|
+
|
|
447
|
+
# Interpolation method
|
|
448
|
+
interp_layout = QHBoxLayout()
|
|
449
|
+
interp_layout.addWidget(QLabel("Interpolation:"))
|
|
450
|
+
self.interp_combo = QComboBox()
|
|
451
|
+
self.interp_combo.addItems(["Nearest", "Linear", "Cubic"])
|
|
452
|
+
self.interp_combo.setCurrentIndex(1) # Default to linear
|
|
453
|
+
self.interp_combo.setToolTip("Interpolation method for scaling")
|
|
454
|
+
interp_layout.addWidget(self.interp_combo)
|
|
455
|
+
scaling_layout.addLayout(interp_layout)
|
|
456
|
+
|
|
457
|
+
# Auto-update checkbox
|
|
458
|
+
self.auto_update_cb = QCheckBox("Auto-update on change")
|
|
459
|
+
self.auto_update_cb.setChecked(False)
|
|
460
|
+
self.auto_update_cb.setToolTip("Automatically apply scaling when values change")
|
|
461
|
+
scaling_layout.addWidget(self.auto_update_cb)
|
|
462
|
+
|
|
463
|
+
# Control buttons
|
|
464
|
+
button_layout = QHBoxLayout()
|
|
465
|
+
|
|
466
|
+
self.apply_scaling_btn = QPushButton("Apply Scaling")
|
|
467
|
+
self.apply_scaling_btn.setToolTip("Apply current scaling settings to reshape the image")
|
|
468
|
+
self.apply_scaling_btn.setFixedHeight(22)
|
|
469
|
+
self.apply_scaling_btn.clicked.connect(self._apply_scaling)
|
|
470
|
+
button_layout.addWidget(self.apply_scaling_btn)
|
|
471
|
+
|
|
472
|
+
self.reset_scaling_btn = QPushButton("Reset to Original")
|
|
473
|
+
self.reset_scaling_btn.setToolTip("Reset to original voxel spacing")
|
|
474
|
+
self.reset_scaling_btn.setFixedHeight(22)
|
|
475
|
+
self.reset_scaling_btn.clicked.connect(self._reset_scaling)
|
|
476
|
+
button_layout.addWidget(self.reset_scaling_btn)
|
|
477
|
+
|
|
478
|
+
scaling_layout.addLayout(button_layout)
|
|
479
|
+
|
|
480
|
+
# Status info
|
|
481
|
+
self.scaling_status = QLabel("Status: Original spacing")
|
|
482
|
+
self.scaling_status.setWordWrap(True)
|
|
483
|
+
self.scaling_status.setStyleSheet("font-weight: bold; color: #0066cc;")
|
|
484
|
+
scaling_layout.addWidget(self.scaling_status)
|
|
485
|
+
|
|
486
|
+
scaling_group.setLayout(scaling_layout)
|
|
487
|
+
layout.addWidget(scaling_group)
|
|
488
|
+
|
|
489
|
+
# Update initial status
|
|
490
|
+
self._update_status_only()
|
|
491
|
+
|
|
492
|
+
def _on_spacing_changed(self):
|
|
493
|
+
"""Handle when spacing values change"""
|
|
494
|
+
if self.auto_update_cb.isChecked():
|
|
495
|
+
self._apply_scaling()
|
|
496
|
+
else:
|
|
497
|
+
self._update_status_only()
|
|
498
|
+
|
|
499
|
+
def _update_status_only(self):
|
|
500
|
+
"""Update status without applying scaling"""
|
|
501
|
+
x_nm = self.x_spacing_spin.value()
|
|
502
|
+
y_nm = self.y_spacing_spin.value()
|
|
503
|
+
z_nm = self.z_spacing_spin.value()
|
|
504
|
+
|
|
505
|
+
# Calculate what the scale factors would be
|
|
506
|
+
temp_scale_factors = np.array([
|
|
507
|
+
self.scaler.original_spacing_xyz[2] / z_nm, # Z
|
|
508
|
+
self.scaler.original_spacing_xyz[1] / y_nm, # Y
|
|
509
|
+
self.scaler.original_spacing_xyz[0] / x_nm # X
|
|
510
|
+
])
|
|
511
|
+
|
|
512
|
+
self.scaling_status.setText(
|
|
513
|
+
f"Pending: X={x_nm:.1f}, Y={y_nm:.1f}, Z={z_nm:.1f} nm\n"
|
|
514
|
+
f"Scale factors (Z,Y,X): {temp_scale_factors[0]:.3f}, {temp_scale_factors[1]:.3f}, {temp_scale_factors[2]:.3f}"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
def _apply_scaling(self):
|
|
518
|
+
"""Apply current scaling settings"""
|
|
519
|
+
try:
|
|
520
|
+
x_nm = self.x_spacing_spin.value()
|
|
521
|
+
y_nm = self.y_spacing_spin.value()
|
|
522
|
+
z_nm = self.z_spacing_spin.value()
|
|
523
|
+
|
|
524
|
+
# Update scaler
|
|
525
|
+
self.scaler.set_spacing(x_nm, y_nm, z_nm)
|
|
526
|
+
|
|
527
|
+
# Get interpolation order
|
|
528
|
+
interp_order = self.interp_combo.currentIndex()
|
|
529
|
+
if interp_order == 0:
|
|
530
|
+
order = 0 # Nearest
|
|
531
|
+
elif interp_order == 1:
|
|
532
|
+
order = 1 # Linear
|
|
533
|
+
else:
|
|
534
|
+
order = 3 # Cubic
|
|
535
|
+
|
|
536
|
+
# Update status
|
|
537
|
+
volume_ratio = self.scaler.get_volume_ratio()
|
|
538
|
+
self.scaling_status.setText(
|
|
539
|
+
f"Applied: X={x_nm:.1f}, Y={y_nm:.1f}, Z={z_nm:.1f} nm\n"
|
|
540
|
+
f"Scale factors (Z,Y,X): {self.scaler.scale_factors[0]:.3f}, {self.scaler.scale_factors[1]:.3f}, {self.scaler.scale_factors[2]:.3f}\n"
|
|
541
|
+
f"Volume ratio: {volume_ratio:.3f}"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# Call the main widget's scaling update callback
|
|
545
|
+
if self.scaling_update_callback:
|
|
546
|
+
self.scaling_update_callback(order)
|
|
547
|
+
|
|
548
|
+
napari.utils.notifications.show_info(f"Applied scaling: X={x_nm:.1f}, Y={y_nm:.1f}, Z={z_nm:.1f} nm")
|
|
549
|
+
|
|
550
|
+
except Exception as e:
|
|
551
|
+
napari.utils.notifications.show_info(f"Error applying scaling: {str(e)}")
|
|
552
|
+
print(f"Scaling error: {str(e)}")
|
|
553
|
+
|
|
554
|
+
def _reset_scaling(self):
|
|
555
|
+
"""Reset to original scaling"""
|
|
556
|
+
self.scaler.reset_to_original()
|
|
557
|
+
|
|
558
|
+
# Update UI
|
|
559
|
+
self.x_spacing_spin.setValue(self.scaler.current_spacing_xyz[0])
|
|
560
|
+
self.y_spacing_spin.setValue(self.scaler.current_spacing_xyz[1])
|
|
561
|
+
self.z_spacing_spin.setValue(self.scaler.current_spacing_xyz[2])
|
|
562
|
+
|
|
563
|
+
self.scaling_status.setText("Status: Reset to original spacing")
|
|
564
|
+
|
|
565
|
+
# Call the main widget's scaling update callback
|
|
566
|
+
if self.scaling_update_callback:
|
|
567
|
+
self.scaling_update_callback(1) # Linear interpolation for reset
|
|
568
|
+
|
|
569
|
+
napari.utils.notifications.show_info("Reset to original voxel spacing")
|
|
570
|
+
|
|
571
|
+
def _update_spacing_display(self):
|
|
572
|
+
"""Update the spacing display with current values"""
|
|
573
|
+
try:
|
|
574
|
+
if 'current_spacing_xyz' in self.state:
|
|
575
|
+
spacing = self.state['current_spacing_xyz']
|
|
576
|
+
self.spacing_info_label.setText(
|
|
577
|
+
f"Current voxel spacing: X={spacing[0]:.1f}, Y={spacing[1]:.1f}, Z={spacing[2]:.1f} nm"
|
|
578
|
+
)
|
|
579
|
+
else:
|
|
580
|
+
self.spacing_info_label.setText("Current voxel spacing: Not configured")
|
|
581
|
+
except Exception as e:
|
|
582
|
+
self.spacing_info_label.setText("Current voxel spacing: Error reading values")
|
|
583
|
+
print(f"Error updating spacing display: {e}")
|
|
584
|
+
|
|
585
|
+
def activate_waypoints_layer(self):
|
|
586
|
+
"""Activate the waypoints layer for selecting points"""
|
|
587
|
+
if self.handling_event:
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
try:
|
|
591
|
+
self.handling_event = True
|
|
592
|
+
self.viewer.layers.selection.active = self.state['waypoints_layer']
|
|
593
|
+
self.error_status.setText("")
|
|
594
|
+
self.status_label.setText("Click points on the dendrite structure")
|
|
595
|
+
self._update_spacing_display() # Update spacing display
|
|
596
|
+
napari.utils.notifications.show_info("Click points on the dendrite")
|
|
597
|
+
except Exception as e:
|
|
598
|
+
error_msg = f"Error activating waypoints layer: {str(e)}"
|
|
599
|
+
napari.utils.notifications.show_info(error_msg)
|
|
600
|
+
self.error_status.setText(error_msg)
|
|
601
|
+
finally:
|
|
602
|
+
self.handling_event = False
|
|
603
|
+
|
|
604
|
+
def on_waypoints_changed(self, event=None):
|
|
605
|
+
"""Handle when waypoints are added or changed"""
|
|
606
|
+
if self.handling_event:
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
self.handling_event = True
|
|
611
|
+
|
|
612
|
+
waypoints_layer = self.state['waypoints_layer']
|
|
613
|
+
if len(waypoints_layer.data) > 0:
|
|
614
|
+
# Validate points are within image bounds
|
|
615
|
+
valid_points = []
|
|
616
|
+
for point in waypoints_layer.data:
|
|
617
|
+
valid = True
|
|
618
|
+
for i, coord in enumerate(point):
|
|
619
|
+
if coord < 0 or coord >= self.image.shape[i]:
|
|
620
|
+
valid = False
|
|
621
|
+
break
|
|
622
|
+
|
|
623
|
+
if valid:
|
|
624
|
+
valid_points.append(point)
|
|
625
|
+
|
|
626
|
+
# Update the waypoints layer with only valid points
|
|
627
|
+
if len(valid_points) != len(waypoints_layer.data):
|
|
628
|
+
waypoints_layer.data = np.array(valid_points)
|
|
629
|
+
napari.utils.notifications.show_info("Some points were outside image bounds and were removed.")
|
|
630
|
+
|
|
631
|
+
# Convert to integer coordinates and store
|
|
632
|
+
self.clicked_points = [point.astype(int) for point in valid_points]
|
|
633
|
+
|
|
634
|
+
# Update status
|
|
635
|
+
num_points = len(self.clicked_points)
|
|
636
|
+
self.waypoints_status.setText(f"Status: {num_points} points selected")
|
|
637
|
+
|
|
638
|
+
# Enable buttons if we have enough points
|
|
639
|
+
self.find_path_btn.setEnabled(num_points >= 2)
|
|
640
|
+
|
|
641
|
+
if num_points >= 2:
|
|
642
|
+
self.status_label.setText("Ready to find path!")
|
|
643
|
+
else:
|
|
644
|
+
self.status_label.setText(f"Need at least 2 points (currently have {num_points})")
|
|
645
|
+
else:
|
|
646
|
+
self.clicked_points = []
|
|
647
|
+
self.waypoints_status.setText("Status: Click to start selecting points")
|
|
648
|
+
self.find_path_btn.setEnabled(False)
|
|
649
|
+
self.status_label.setText("")
|
|
650
|
+
except Exception as e:
|
|
651
|
+
napari.utils.notifications.show_info(f"Error processing waypoints: {str(e)}")
|
|
652
|
+
print(f"Error details: {str(e)}")
|
|
653
|
+
finally:
|
|
654
|
+
self.handling_event = False
|
|
655
|
+
|
|
656
|
+
def find_path(self):
|
|
657
|
+
"""Find path using the custom A* algorithm"""
|
|
658
|
+
if self.handling_event:
|
|
659
|
+
return
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
if len(self.clicked_points) < 2:
|
|
663
|
+
napari.utils.notifications.show_info("Please select at least 2 points")
|
|
664
|
+
self.error_status.setText("Error: Please select at least 2 points")
|
|
665
|
+
return
|
|
666
|
+
|
|
667
|
+
# Lock UI
|
|
668
|
+
self.handling_event = True
|
|
669
|
+
self.find_path_btn.setEnabled(False)
|
|
670
|
+
self.progress_bar.setVisible(True)
|
|
671
|
+
self.progress_bar.setValue(0)
|
|
672
|
+
self.error_status.setText("")
|
|
673
|
+
|
|
674
|
+
# Prepare data
|
|
675
|
+
points_list = [point.tolist() for point in self.clicked_points]
|
|
676
|
+
enable_parallel = self.enable_parallel_cb.isChecked()
|
|
677
|
+
weight_heuristic = self.weight_heuristic_spin.value()
|
|
678
|
+
enable_smoothing = self.enable_smoothing_cb.isChecked()
|
|
679
|
+
smoothing_factor = self.smoothing_factor_spin.value()
|
|
680
|
+
spacing_xyz = self.state.get('current_spacing_xyz', (1.0, 1.0, 1.0))
|
|
681
|
+
|
|
682
|
+
from napari.qt.threading import thread_worker
|
|
683
|
+
|
|
684
|
+
@thread_worker
|
|
685
|
+
def path_worker():
|
|
686
|
+
yield (10, "Initializing search...")
|
|
687
|
+
|
|
688
|
+
# Run the search
|
|
689
|
+
yield (20, "Tracing bright path (A*)...")
|
|
690
|
+
# Note: quick_accurate_optimized_search is still blocking/long-running here
|
|
691
|
+
# but since we are in a thread, UI stays responsive-ish (indeterminate)
|
|
692
|
+
path = quick_accurate_optimized_search(
|
|
693
|
+
image=self.image,
|
|
694
|
+
points_list=points_list,
|
|
695
|
+
verbose=False,
|
|
696
|
+
enable_parallel=enable_parallel,
|
|
697
|
+
my_weight_heuristic=weight_heuristic
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
yield (80, "Path found. Post-processing...")
|
|
701
|
+
|
|
702
|
+
if path is not None and len(path) > 0:
|
|
703
|
+
path_data = np.array(path)
|
|
704
|
+
|
|
705
|
+
if enable_smoothing and len(path_data) >= 3 and smoothing_factor > 0:
|
|
706
|
+
yield (90, "Applying B-spline smoothing...")
|
|
707
|
+
path_data = self.path_smoother.smooth_path(
|
|
708
|
+
path_data,
|
|
709
|
+
spacing_xyz=spacing_xyz,
|
|
710
|
+
smoothing_factor=smoothing_factor,
|
|
711
|
+
preserve_endpoints=True
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
return path_data
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
def on_yield(data):
|
|
718
|
+
progress, status = data
|
|
719
|
+
self.progress_bar.setValue(progress)
|
|
720
|
+
self.status_label.setText(status)
|
|
721
|
+
|
|
722
|
+
def on_return(path_data):
|
|
723
|
+
self.progress_bar.setValue(100)
|
|
724
|
+
self.progress_bar.setVisible(False)
|
|
725
|
+
self.handling_event = False
|
|
726
|
+
self.find_path_btn.setEnabled(True)
|
|
727
|
+
|
|
728
|
+
if path_data is None:
|
|
729
|
+
self.status_label.setText("No path found.")
|
|
730
|
+
napari.utils.notifications.show_warning("No path found.")
|
|
731
|
+
else:
|
|
732
|
+
self.status_label.setText("Path complete!")
|
|
733
|
+
self._finalize_path(path_data)
|
|
734
|
+
|
|
735
|
+
def on_error(e):
|
|
736
|
+
self.handling_event = False
|
|
737
|
+
self.find_path_btn.setEnabled(True)
|
|
738
|
+
self.progress_bar.setVisible(False)
|
|
739
|
+
self.progress_bar.setValue(0)
|
|
740
|
+
self.status_label.setText("Error during tracing")
|
|
741
|
+
self.error_status.setText(f"Error: {str(e)}")
|
|
742
|
+
napari.utils.notifications.show_error(f"Tracing failed: {e}")
|
|
743
|
+
print(f"Tracing error: {e}")
|
|
744
|
+
|
|
745
|
+
# Start worker
|
|
746
|
+
worker = path_worker()
|
|
747
|
+
worker.yielded.connect(on_yield)
|
|
748
|
+
worker.returned.connect(on_return)
|
|
749
|
+
worker.errored.connect(on_error)
|
|
750
|
+
worker.start()
|
|
751
|
+
|
|
752
|
+
except Exception as e:
|
|
753
|
+
self.handling_event = False
|
|
754
|
+
self.find_path_btn.setEnabled(True)
|
|
755
|
+
self.progress_bar.setVisible(False)
|
|
756
|
+
print(f"Setup error: {e}")
|
|
757
|
+
|
|
758
|
+
def _finalize_path(self, path_data):
|
|
759
|
+
"""Handle successful path creation (moved from main logic)"""
|
|
760
|
+
try:
|
|
761
|
+
# Generate path name
|
|
762
|
+
path_name = f"Path {self.next_path_number}"
|
|
763
|
+
self.next_path_number += 1
|
|
764
|
+
|
|
765
|
+
# Get color for this path
|
|
766
|
+
path_color = self.get_next_color()
|
|
767
|
+
|
|
768
|
+
# Create a new layer for this path
|
|
769
|
+
path_layer = self.viewer.add_points(
|
|
770
|
+
path_data,
|
|
771
|
+
name=path_name,
|
|
772
|
+
size=3,
|
|
773
|
+
face_color=path_color,
|
|
774
|
+
opacity=0.8
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
# Update 3D visualization if applicable
|
|
778
|
+
if self.image.ndim > 2 and self.state['traced_path_layer'] is not None:
|
|
779
|
+
self._update_traced_path_visualization(path_data)
|
|
780
|
+
|
|
781
|
+
# Generate a unique ID for this path
|
|
782
|
+
path_id = str(uuid.uuid4())
|
|
783
|
+
|
|
784
|
+
# Store the path with enhanced metadata
|
|
785
|
+
current_spacing = self.state.get('current_spacing_xyz', (1.0, 1.0, 1.0))
|
|
786
|
+
|
|
787
|
+
# Retrieve current settings for metadata
|
|
788
|
+
enable_parallel = self.enable_parallel_cb.isChecked()
|
|
789
|
+
weight_heuristic = self.weight_heuristic_spin.value()
|
|
790
|
+
current_spacing = self.state.get('current_spacing_xyz', (1.0, 1.0, 1.0))
|
|
791
|
+
|
|
792
|
+
self.state['paths'][path_id] = {
|
|
793
|
+
'name': path_name,
|
|
794
|
+
'data': path_data,
|
|
795
|
+
'start': self.clicked_points[0].copy(),
|
|
796
|
+
'end': self.clicked_points[-1].copy(),
|
|
797
|
+
'waypoints': [point.copy() for point in self.clicked_points[1:-1]] if len(self.clicked_points) > 2 else [],
|
|
798
|
+
'visible': True,
|
|
799
|
+
'layer': path_layer,
|
|
800
|
+
'original_clicks': [point.copy() for point in self.clicked_points],
|
|
801
|
+
'smoothed': self.enable_smoothing_cb.isChecked() and self.smoothing_factor_spin.value() > 0,
|
|
802
|
+
'algorithm': 'waypoint_astar',
|
|
803
|
+
'parallel_processing': enable_parallel,
|
|
804
|
+
'weight_heuristic': weight_heuristic, # Store weight heuristic parameter
|
|
805
|
+
'voxel_spacing_xyz': current_spacing, # Store voxel spacing with path
|
|
806
|
+
'anisotropic_smoothing': self.enable_smoothing_cb.isChecked()
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
# Store reference to the layer
|
|
810
|
+
self.state['path_layers'][path_id] = path_layer
|
|
811
|
+
|
|
812
|
+
# Update UI
|
|
813
|
+
algorithm_info = f" (parallel, weight={weight_heuristic:.1f})" if enable_parallel else f" (sequential, weight={weight_heuristic:.1f})"
|
|
814
|
+
smoothing_msg = " (smoothing)" if self.state['paths'][path_id]['smoothed'] else ""
|
|
815
|
+
spacing_info = f" at {current_spacing[0]:.1f}, {current_spacing[1]:.1f}, {current_spacing[2]:.1f} nm"
|
|
816
|
+
|
|
817
|
+
msg = f"Path found: {len(path_data)} points {spacing_info}"
|
|
818
|
+
napari.utils.notifications.show_info(msg)
|
|
819
|
+
self.status_label.setText(f"Success: {path_name} created")
|
|
820
|
+
|
|
821
|
+
# Enable trace another path button
|
|
822
|
+
self.trace_another_btn.setEnabled(True)
|
|
823
|
+
|
|
824
|
+
# Store current path ID in state
|
|
825
|
+
self.state['current_path_id'] = path_id
|
|
826
|
+
|
|
827
|
+
# Emit signal that a new path was created
|
|
828
|
+
self.path_created.emit(path_id, path_name, path_data)
|
|
829
|
+
|
|
830
|
+
except Exception as e:
|
|
831
|
+
msg = f"Error finalizing path: {e}"
|
|
832
|
+
napari.utils.notifications.show_error(msg)
|
|
833
|
+
print(f"Finalize error: {e}")
|
|
834
|
+
|
|
835
|
+
def _update_traced_path_visualization(self, path):
|
|
836
|
+
"""Update the 3D traced path visualization"""
|
|
837
|
+
if self.state['traced_path_layer'] is None:
|
|
838
|
+
return
|
|
839
|
+
|
|
840
|
+
try:
|
|
841
|
+
# Get the z-range of the path
|
|
842
|
+
path_array = np.array(path)
|
|
843
|
+
z_values = [point[0] for point in path]
|
|
844
|
+
min_z = int(min(z_values))
|
|
845
|
+
max_z = int(max(z_values))
|
|
846
|
+
|
|
847
|
+
# Create a projection of the path onto every frame in the range
|
|
848
|
+
traced_points = []
|
|
849
|
+
for z in range(min_z, max_z + 1):
|
|
850
|
+
for point in path:
|
|
851
|
+
new_point = point.copy()
|
|
852
|
+
new_point[0] = z
|
|
853
|
+
traced_points.append(new_point)
|
|
854
|
+
|
|
855
|
+
# Update the traced path layer
|
|
856
|
+
if traced_points:
|
|
857
|
+
self.state['traced_path_layer'].data = np.array(traced_points)
|
|
858
|
+
self.state['traced_path_layer'].visible = True
|
|
859
|
+
self.viewer.dims.set_point(0, min_z)
|
|
860
|
+
except Exception as e:
|
|
861
|
+
print(f"Error updating traced path visualization: {e}")
|
|
862
|
+
|
|
863
|
+
def trace_another_path(self):
|
|
864
|
+
"""Reset for tracing a new path while preserving existing paths"""
|
|
865
|
+
# Clear current points
|
|
866
|
+
self.clicked_points = []
|
|
867
|
+
self.state['waypoints_layer'].data = np.empty((0, self.image.ndim))
|
|
868
|
+
|
|
869
|
+
# Reset UI for new path
|
|
870
|
+
self.waypoints_status.setText("Status: Click to start selecting points")
|
|
871
|
+
self.status_label.setText("Ready for new path - click points on dendrite")
|
|
872
|
+
self.find_path_btn.setEnabled(False)
|
|
873
|
+
self.trace_another_btn.setEnabled(False)
|
|
874
|
+
|
|
875
|
+
# Update spacing display
|
|
876
|
+
self._update_spacing_display()
|
|
877
|
+
|
|
878
|
+
# Activate the waypoints layer for the new path
|
|
879
|
+
self.viewer.layers.selection.active = self.state['waypoints_layer']
|
|
880
|
+
napari.utils.notifications.show_info("Ready to trace a new path. Click points on the dendrite.")
|
|
881
|
+
|
|
882
|
+
def clear_points(self):
|
|
883
|
+
"""Clear all waypoints and paths"""
|
|
884
|
+
self.clicked_points = []
|
|
885
|
+
self.state['waypoints_layer'].data = np.empty((0, self.image.ndim))
|
|
886
|
+
|
|
887
|
+
# Clear traced path layer if it exists
|
|
888
|
+
if self.state['traced_path_layer'] is not None:
|
|
889
|
+
self.state['traced_path_layer'].data = np.empty((0, self.image.ndim))
|
|
890
|
+
self.state['traced_path_layer'].visible = False
|
|
891
|
+
|
|
892
|
+
# Reset UI
|
|
893
|
+
self.waypoints_status.setText("Status: Click to start selecting points")
|
|
894
|
+
self.status_label.setText("")
|
|
895
|
+
self.error_status.setText("")
|
|
896
|
+
|
|
897
|
+
# Reset buttons
|
|
898
|
+
self.find_path_btn.setEnabled(False)
|
|
899
|
+
self.trace_another_btn.setEnabled(False)
|
|
900
|
+
|
|
901
|
+
# Update spacing display
|
|
902
|
+
self._update_spacing_display()
|
|
903
|
+
|
|
904
|
+
napari.utils.notifications.show_info("All points cleared. Ready to start over.")
|
|
905
|
+
|
|
906
|
+
def get_next_color(self):
|
|
907
|
+
"""Get the next color from the predefined list"""
|
|
908
|
+
colors = ['cyan', 'magenta', 'green', 'blue', 'orange',
|
|
909
|
+
'purple', 'teal', 'coral', 'gold', 'lavender']
|
|
910
|
+
|
|
911
|
+
color = colors[self.color_idx % len(colors)]
|
|
912
|
+
self.color_idx += 1
|
|
913
|
+
|
|
914
|
+
return color
|
|
915
|
+
|
|
916
|
+
def load_path_waypoints(self, path_id):
|
|
917
|
+
"""Load the waypoints for a specific path"""
|
|
918
|
+
if self.handling_event:
|
|
919
|
+
return
|
|
920
|
+
|
|
921
|
+
try:
|
|
922
|
+
self.handling_event = True
|
|
923
|
+
|
|
924
|
+
if path_id not in self.state['paths']:
|
|
925
|
+
return
|
|
926
|
+
|
|
927
|
+
path_data = self.state['paths'][path_id]
|
|
928
|
+
|
|
929
|
+
# Check if this path has original clicks stored
|
|
930
|
+
if ('original_clicks' in path_data and
|
|
931
|
+
len(path_data['original_clicks']) > 0):
|
|
932
|
+
# Load the original clicked points
|
|
933
|
+
self.clicked_points = [np.array(point) for point in path_data['original_clicks']]
|
|
934
|
+
|
|
935
|
+
# Update the waypoints layer
|
|
936
|
+
if self.clicked_points:
|
|
937
|
+
self.state['waypoints_layer'].data = np.array(self.clicked_points)
|
|
938
|
+
self.waypoints_status.setText(f"Status: {len(self.clicked_points)} points loaded")
|
|
939
|
+
elif ('original_clicks' in path_data and
|
|
940
|
+
len(path_data['original_clicks']) == 0):
|
|
941
|
+
# This is a connected path - show a subset of the path points as waypoints
|
|
942
|
+
path_points = path_data['data']
|
|
943
|
+
if len(path_points) > 10:
|
|
944
|
+
# Take every nth point to get about 10 waypoints
|
|
945
|
+
step = len(path_points) // 10
|
|
946
|
+
waypoint_indices = range(0, len(path_points), step)
|
|
947
|
+
new_waypoints = [path_points[i] for i in waypoint_indices]
|
|
948
|
+
else:
|
|
949
|
+
# Use all points if path is short
|
|
950
|
+
new_waypoints = path_points.copy()
|
|
951
|
+
|
|
952
|
+
self.clicked_points = new_waypoints
|
|
953
|
+
self.state['waypoints_layer'].data = np.array(new_waypoints)
|
|
954
|
+
self.waypoints_status.setText(f"Status: {len(new_waypoints)} waypoints from connected path")
|
|
955
|
+
else:
|
|
956
|
+
# Fallback - reconstruct from start, waypoints, and end
|
|
957
|
+
new_waypoints = []
|
|
958
|
+
if 'start' in path_data and path_data['start'] is not None:
|
|
959
|
+
new_waypoints.append(path_data['start'])
|
|
960
|
+
|
|
961
|
+
if 'waypoints' in path_data and path_data['waypoints']:
|
|
962
|
+
new_waypoints.extend(path_data['waypoints'])
|
|
963
|
+
|
|
964
|
+
if 'end' in path_data and path_data['end'] is not None:
|
|
965
|
+
new_waypoints.append(path_data['end'])
|
|
966
|
+
|
|
967
|
+
# Update the waypoints layer
|
|
968
|
+
if new_waypoints:
|
|
969
|
+
self.state['waypoints_layer'].data = np.array(new_waypoints)
|
|
970
|
+
self.clicked_points = new_waypoints
|
|
971
|
+
self.waypoints_status.setText(f"Status: {len(new_waypoints)} points loaded")
|
|
972
|
+
|
|
973
|
+
# Enable buttons
|
|
974
|
+
if len(self.clicked_points) >= 2:
|
|
975
|
+
self.find_path_btn.setEnabled(True)
|
|
976
|
+
self.trace_another_btn.setEnabled(True)
|
|
977
|
+
|
|
978
|
+
# Clear any error messages
|
|
979
|
+
self.error_status.setText("")
|
|
980
|
+
|
|
981
|
+
# Show path status including algorithm type, weight heuristic, and spacing info
|
|
982
|
+
path_type = ""
|
|
983
|
+
if path_data.get('algorithm') == 'waypoint_astar':
|
|
984
|
+
path_type = " (waypoint_astar"
|
|
985
|
+
if path_data.get('parallel_processing', False):
|
|
986
|
+
path_type += ", parallel"
|
|
987
|
+
# Add weight heuristic info if available
|
|
988
|
+
if 'weight_heuristic' in path_data:
|
|
989
|
+
path_type += f", weight={path_data['weight_heuristic']:.1f}"
|
|
990
|
+
path_type += ")"
|
|
991
|
+
if path_data.get('anisotropic_smoothing', False):
|
|
992
|
+
path_type += " (anisotropic smoothing)"
|
|
993
|
+
elif path_data.get('smoothed', False):
|
|
994
|
+
path_type = " (smoothed)"
|
|
995
|
+
elif ('original_clicks' in path_data and
|
|
996
|
+
len(path_data['original_clicks']) == 0):
|
|
997
|
+
path_type = " (connected)"
|
|
998
|
+
|
|
999
|
+
# Add spacing info if available
|
|
1000
|
+
if 'voxel_spacing_xyz' in path_data:
|
|
1001
|
+
spacing = path_data['voxel_spacing_xyz']
|
|
1002
|
+
spacing_info = f" [X={spacing[0]:.1f}, Y={spacing[1]:.1f}, Z={spacing[2]:.1f} nm]"
|
|
1003
|
+
path_type += spacing_info
|
|
1004
|
+
|
|
1005
|
+
self.status_label.setText(f"Loaded path: {path_data['name']}{path_type}")
|
|
1006
|
+
|
|
1007
|
+
# Update spacing display
|
|
1008
|
+
self._update_spacing_display()
|
|
1009
|
+
|
|
1010
|
+
napari.utils.notifications.show_info(f"Loaded {path_data['name']}{path_type}")
|
|
1011
|
+
except Exception as e:
|
|
1012
|
+
error_msg = f"Error loading path waypoints: {str(e)}"
|
|
1013
|
+
napari.utils.notifications.show_info(error_msg)
|
|
1014
|
+
self.error_status.setText(error_msg)
|
|
1015
|
+
finally:
|
|
1016
|
+
self.handling_event = False
|