nettracer3d 0.7.9__py3-none-any.whl → 0.8.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.
- nettracer3d/community_extractor.py +17 -26
- nettracer3d/nettracer.py +111 -30
- nettracer3d/nettracer_gui.py +914 -182
- nettracer3d/proximity.py +20 -6
- nettracer3d/segmenter.py +1 -1
- nettracer3d/segmenter_GPU.py +1 -1
- nettracer3d/simple_network.py +43 -25
- {nettracer3d-0.7.9.dist-info → nettracer3d-0.8.0.dist-info}/METADATA +8 -3
- nettracer3d-0.8.0.dist-info/RECORD +23 -0
- nettracer3d-0.7.9.dist-info/RECORD +0 -23
- {nettracer3d-0.7.9.dist-info → nettracer3d-0.8.0.dist-info}/WHEEL +0 -0
- {nettracer3d-0.7.9.dist-info → nettracer3d-0.8.0.dist-info}/entry_points.txt +0 -0
- {nettracer3d-0.7.9.dist-info → nettracer3d-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {nettracer3d-0.7.9.dist-info → nettracer3d-0.8.0.dist-info}/top_level.txt +0 -0
nettracer3d/nettracer_gui.py
CHANGED
|
@@ -30,6 +30,9 @@ try:
|
|
|
30
30
|
except:
|
|
31
31
|
pass
|
|
32
32
|
from nettracer3d import excelotron
|
|
33
|
+
import threading
|
|
34
|
+
import queue
|
|
35
|
+
from threading import Lock
|
|
33
36
|
|
|
34
37
|
|
|
35
38
|
|
|
@@ -44,6 +47,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
44
47
|
self.channel_visible = [False] * 4
|
|
45
48
|
self.current_slice = 0
|
|
46
49
|
self.active_channel = 0 # Initialize active channel
|
|
50
|
+
self.node_name = "Root_Nodes"
|
|
47
51
|
|
|
48
52
|
self.color_dictionary = {
|
|
49
53
|
# Reds
|
|
@@ -108,6 +112,9 @@ class ImageViewerWindow(QMainWindow):
|
|
|
108
112
|
self.selection_rect = None
|
|
109
113
|
self.click_start_time = None # Add this to track when click started
|
|
110
114
|
self.selection_threshold = 1.0 # Time in seconds before starting rectangle selection
|
|
115
|
+
self.background = None
|
|
116
|
+
self.last_update_time = 0
|
|
117
|
+
self.update_interval = 0.016 # 60 FPS
|
|
111
118
|
|
|
112
119
|
# Initialize zoom mode state
|
|
113
120
|
self.zoom_mode = False
|
|
@@ -119,6 +126,15 @@ class ImageViewerWindow(QMainWindow):
|
|
|
119
126
|
self.pan_mode = False
|
|
120
127
|
self.panning = False
|
|
121
128
|
self.pan_start = None
|
|
129
|
+
self.img_width = None
|
|
130
|
+
self.img_height = None
|
|
131
|
+
self.pre_pan_channel_state = None # Store which channels were visible before pan
|
|
132
|
+
self.pan_background = None # Store the static background image during pan
|
|
133
|
+
self.is_pan_preview = False # Track if we're in pan preview mode
|
|
134
|
+
self.pre_pan_channel_state = None # Store which channels were visible before pan
|
|
135
|
+
self.pan_background_image = None # Store the rendered composite image
|
|
136
|
+
self.pan_zoom_state = None # Store zoom state when pan began
|
|
137
|
+
self.is_pan_preview = False # Track if we're in pan preview mode
|
|
122
138
|
|
|
123
139
|
#For ML segmenting mode
|
|
124
140
|
self.brush_mode = False
|
|
@@ -157,7 +173,9 @@ class ImageViewerWindow(QMainWindow):
|
|
|
157
173
|
|
|
158
174
|
self.radii_dict = {
|
|
159
175
|
0: None,
|
|
160
|
-
1: None
|
|
176
|
+
1: None,
|
|
177
|
+
2: None,
|
|
178
|
+
3: None
|
|
161
179
|
}
|
|
162
180
|
|
|
163
181
|
self.original_shape = None #For undoing resamples
|
|
@@ -423,6 +441,25 @@ class ImageViewerWindow(QMainWindow):
|
|
|
423
441
|
self.excel_manager.data_received.connect(self.handle_excel_data)
|
|
424
442
|
self.prev_coms = None
|
|
425
443
|
|
|
444
|
+
self.paint_timer = QTimer()
|
|
445
|
+
self.paint_timer.timeout.connect(self.flush_paint_updates)
|
|
446
|
+
self.paint_timer.setSingleShot(True)
|
|
447
|
+
self.pending_paint_update = False
|
|
448
|
+
|
|
449
|
+
# Threading for paint operations
|
|
450
|
+
self.paint_queue = queue.Queue()
|
|
451
|
+
self.paint_lock = Lock()
|
|
452
|
+
self.paint_worker = threading.Thread(target=self.paint_worker_loop, daemon=True)
|
|
453
|
+
self.paint_worker.start()
|
|
454
|
+
|
|
455
|
+
# Background caching for blitting
|
|
456
|
+
self.paint_background = None
|
|
457
|
+
self.paint_session_active = False
|
|
458
|
+
|
|
459
|
+
# Batch paint operations
|
|
460
|
+
self.paint_batch = []
|
|
461
|
+
self.last_paint_pos = None
|
|
462
|
+
|
|
426
463
|
def start_left_scroll(self):
|
|
427
464
|
"""Start scrolling left when left arrow is pressed."""
|
|
428
465
|
# Single increment first
|
|
@@ -794,6 +831,9 @@ class ImageViewerWindow(QMainWindow):
|
|
|
794
831
|
try:
|
|
795
832
|
# Create context menu
|
|
796
833
|
context_menu = QMenu(self)
|
|
834
|
+
|
|
835
|
+
find = context_menu.addAction("Find Node/Edge")
|
|
836
|
+
find.triggered.connect(self.handle_find)
|
|
797
837
|
|
|
798
838
|
# Create "Show Neighbors" submenu
|
|
799
839
|
neighbors_menu = QMenu("Show Neighbors", self)
|
|
@@ -1450,6 +1490,109 @@ class ImageViewerWindow(QMainWindow):
|
|
|
1450
1490
|
except Exception as e:
|
|
1451
1491
|
print(f"Error showing identities: {e}")
|
|
1452
1492
|
|
|
1493
|
+
def handle_find(self):
|
|
1494
|
+
|
|
1495
|
+
class FindDialog(QDialog):
|
|
1496
|
+
def __init__(self, parent=None):
|
|
1497
|
+
super().__init__(parent)
|
|
1498
|
+
self.setWindowTitle("Find Node (or edge?)")
|
|
1499
|
+
self.setModal(True)
|
|
1500
|
+
|
|
1501
|
+
layout = QFormLayout(self)
|
|
1502
|
+
|
|
1503
|
+
self.targ = QLineEdit("")
|
|
1504
|
+
layout.addRow("Node/Edge ID:", self.targ)
|
|
1505
|
+
|
|
1506
|
+
run_button = QPushButton("Enter")
|
|
1507
|
+
run_button.clicked.connect(self.run)
|
|
1508
|
+
layout.addWidget(run_button)
|
|
1509
|
+
|
|
1510
|
+
def run(self):
|
|
1511
|
+
|
|
1512
|
+
try:
|
|
1513
|
+
|
|
1514
|
+
value = int(self.targ.text()) if self.targ.text().strip() else None
|
|
1515
|
+
|
|
1516
|
+
if value is None:
|
|
1517
|
+
return
|
|
1518
|
+
|
|
1519
|
+
if self.parent().active_channel == 1:
|
|
1520
|
+
|
|
1521
|
+
mode = 'edges'
|
|
1522
|
+
|
|
1523
|
+
if my_network.edge_centroids is None:
|
|
1524
|
+
self.parent().show_centroid_dialog()
|
|
1525
|
+
|
|
1526
|
+
num = (self.parent().channel_data[1].shape[0] * self.parent().channel_data[1].shape[1] * self.parent().channel_data[1].shape[2])
|
|
1527
|
+
|
|
1528
|
+
if value in my_network.edge_centroids:
|
|
1529
|
+
|
|
1530
|
+
# Get centroid coordinates (Z, Y, X)
|
|
1531
|
+
centroid = my_network.edge_centroids[value]
|
|
1532
|
+
# Set the active channel to edges (1)
|
|
1533
|
+
self.parent().set_active_channel(1)
|
|
1534
|
+
# Toggle on the edges channel if it's not already visible
|
|
1535
|
+
if not self.parent().channel_visible[1]:
|
|
1536
|
+
self.parent().channel_buttons[1].setChecked(True)
|
|
1537
|
+
self.parent().toggle_channel(1)
|
|
1538
|
+
# Navigate to the Z-slice
|
|
1539
|
+
self.parent().slice_slider.setValue(int(centroid[0]))
|
|
1540
|
+
print(f"Found edge {value} at Z-slice {centroid[0]}")
|
|
1541
|
+
|
|
1542
|
+
else:
|
|
1543
|
+
print(f"Edge {value} not found in centroids dictionary")
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
else:
|
|
1547
|
+
|
|
1548
|
+
mode = 'nodes'
|
|
1549
|
+
|
|
1550
|
+
if my_network.node_centroids is None:
|
|
1551
|
+
self.parent().show_centroid_dialog()
|
|
1552
|
+
|
|
1553
|
+
num = (self.parent().channel_data[0].shape[0] * self.parent().channel_data[0].shape[1] * self.parent().channel_data[0].shape[2])
|
|
1554
|
+
|
|
1555
|
+
if value in my_network.node_centroids:
|
|
1556
|
+
# Get centroid coordinates (Z, Y, X)
|
|
1557
|
+
centroid = my_network.node_centroids[value]
|
|
1558
|
+
# Set the active channel to nodes (0)
|
|
1559
|
+
self.parent().set_active_channel(0)
|
|
1560
|
+
# Toggle on the nodes channel if it's not already visible
|
|
1561
|
+
if not self.parent().channel_visible[0]:
|
|
1562
|
+
self.parent().channel_buttons[0].setChecked(True)
|
|
1563
|
+
self.parent().toggle_channel(0)
|
|
1564
|
+
# Navigate to the Z-slice
|
|
1565
|
+
self.parent().slice_slider.setValue(int(centroid[0]))
|
|
1566
|
+
print(f"Found node {value} at Z-slice {centroid[0]}")
|
|
1567
|
+
|
|
1568
|
+
else:
|
|
1569
|
+
print(f"Node {value} not found in centroids dictionary")
|
|
1570
|
+
|
|
1571
|
+
self.parent().clicked_values[mode] = [value]
|
|
1572
|
+
|
|
1573
|
+
if num > self.parent().mini_thresh:
|
|
1574
|
+
self.parent().mini_overlay = True
|
|
1575
|
+
self.parent().create_mini_overlay(node_indices = self.parent().clicked_values['nodes'], edge_indices = self.parent().clicked_values['edges'])
|
|
1576
|
+
else:
|
|
1577
|
+
self.parent().create_highlight_overlay(
|
|
1578
|
+
node_indices=self.parent().clicked_values['nodes'],
|
|
1579
|
+
edge_indices=self.parent().clicked_values['edges']
|
|
1580
|
+
)
|
|
1581
|
+
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
# Close the dialog after processing
|
|
1585
|
+
self.accept()
|
|
1586
|
+
|
|
1587
|
+
except Exception as e:
|
|
1588
|
+
|
|
1589
|
+
print(f"Error: {e}")
|
|
1590
|
+
|
|
1591
|
+
dialog = FindDialog(self)
|
|
1592
|
+
dialog.exec()
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
|
|
1453
1596
|
|
|
1454
1597
|
def handle_select_all(self, nodes = True, edges = False):
|
|
1455
1598
|
|
|
@@ -1630,63 +1773,32 @@ class ImageViewerWindow(QMainWindow):
|
|
|
1630
1773
|
except Exception as e:
|
|
1631
1774
|
print(f"An error has occured: {e}")
|
|
1632
1775
|
|
|
1633
|
-
def
|
|
1776
|
+
def separate_nontouching_objects(self, input_array, max_val=0):
|
|
1777
|
+
"""
|
|
1778
|
+
optimized version using advanced indexing.
|
|
1779
|
+
"""
|
|
1780
|
+
|
|
1781
|
+
print("Splitting nontouching objects")
|
|
1634
1782
|
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
# Step 1: Perform connected component labeling on the entire binary mask
|
|
1655
|
-
binary_mask = input_array > 0
|
|
1656
|
-
structure = np.ones((3,) * input_array.ndim, dtype=bool) # 3x3x3 connectivity for 3D or 3x3 for 2D
|
|
1657
|
-
labeled_array, num_features = ndi.label(binary_mask, structure=structure)
|
|
1658
|
-
|
|
1659
|
-
# Step 2: Map the original labels to the new connected components
|
|
1660
|
-
# Create a sparse matrix to efficiently store label mappings
|
|
1661
|
-
coords = np.nonzero(input_array)
|
|
1662
|
-
original_values = input_array[coords]
|
|
1663
|
-
new_labels = labeled_array[coords]
|
|
1664
|
-
|
|
1665
|
-
# Create a mapping of (original_label, new_connected_component) pairs
|
|
1666
|
-
label_mapping = {}
|
|
1667
|
-
for orig, new in zip(original_values, new_labels):
|
|
1668
|
-
if orig not in label_mapping:
|
|
1669
|
-
label_mapping[orig] = []
|
|
1670
|
-
if new not in label_mapping[orig]:
|
|
1671
|
-
label_mapping[orig].append(new)
|
|
1672
|
-
|
|
1673
|
-
# Step 3: Create a new output array with unique labels for each connected component
|
|
1674
|
-
output_array = np.zeros_like(input_array)
|
|
1675
|
-
next_label = 1 + max_val
|
|
1676
|
-
|
|
1677
|
-
# Map of (original_label, connected_component) -> new_unique_label
|
|
1678
|
-
unique_label_map = {}
|
|
1679
|
-
|
|
1680
|
-
for orig_label, cc_list in label_mapping.items():
|
|
1681
|
-
for cc in cc_list:
|
|
1682
|
-
unique_label_map[(orig_label, cc)] = next_label
|
|
1683
|
-
# Create a mask for this original label and connected component
|
|
1684
|
-
mask = (input_array == orig_label) & (labeled_array == cc)
|
|
1685
|
-
# Assign the new unique label
|
|
1686
|
-
output_array[mask] = next_label
|
|
1687
|
-
next_label += 1
|
|
1688
|
-
|
|
1689
|
-
return output_array
|
|
1783
|
+
binary_mask = input_array > 0
|
|
1784
|
+
labeled_array, _ = n3d.label_objects(binary_mask)
|
|
1785
|
+
|
|
1786
|
+
# Create a compound key for each (original_label, connected_component) pair
|
|
1787
|
+
# This avoids the need for explicit mapping
|
|
1788
|
+
mask = binary_mask
|
|
1789
|
+
compound_key = input_array[mask] * (labeled_array.max() + 1) + labeled_array[mask]
|
|
1790
|
+
|
|
1791
|
+
# Get unique compound keys and create new labels
|
|
1792
|
+
unique_keys, inverse_indices = np.unique(compound_key, return_inverse=True)
|
|
1793
|
+
new_labels = np.arange(max_val + 1, max_val + 1 + len(unique_keys))
|
|
1794
|
+
|
|
1795
|
+
# Create output array
|
|
1796
|
+
output_array = np.zeros_like(input_array)
|
|
1797
|
+
output_array[mask] = new_labels[inverse_indices]
|
|
1798
|
+
|
|
1799
|
+
return output_array
|
|
1800
|
+
|
|
1801
|
+
def handle_seperate(self):
|
|
1690
1802
|
|
|
1691
1803
|
try:
|
|
1692
1804
|
# Handle nodes
|
|
@@ -1708,7 +1820,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
1708
1820
|
max_val = np.max(non_highlighted)
|
|
1709
1821
|
|
|
1710
1822
|
# Process highlighted part
|
|
1711
|
-
processed_highlights = separate_nontouching_objects(highlighted_nodes, max_val)
|
|
1823
|
+
processed_highlights = self.separate_nontouching_objects(highlighted_nodes, max_val)
|
|
1712
1824
|
|
|
1713
1825
|
# Combine back with non-highlighted parts
|
|
1714
1826
|
my_network.nodes = non_highlighted + processed_highlights
|
|
@@ -1729,13 +1841,13 @@ class ImageViewerWindow(QMainWindow):
|
|
|
1729
1841
|
# Get non-highlighted part of the array
|
|
1730
1842
|
non_highlighted = my_network.edges * (~self.highlight_overlay)
|
|
1731
1843
|
|
|
1732
|
-
if (
|
|
1844
|
+
if (highlighted_edges==non_highlighted).all():
|
|
1733
1845
|
max_val = 0
|
|
1734
1846
|
else:
|
|
1735
1847
|
max_val = np.max(non_highlighted)
|
|
1736
1848
|
|
|
1737
1849
|
# Process highlighted part
|
|
1738
|
-
processed_highlights = separate_nontouching_objects(highlighted_edges, max_val)
|
|
1850
|
+
processed_highlights = self.separate_nontouching_objects(highlighted_edges, max_val)
|
|
1739
1851
|
|
|
1740
1852
|
# Combine back with non-highlighted parts
|
|
1741
1853
|
my_network.edges = non_highlighted + processed_highlights
|
|
@@ -2074,8 +2186,11 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2074
2186
|
|
|
2075
2187
|
self.update_display(preserve_zoom=(current_xlim, current_ylim))
|
|
2076
2188
|
|
|
2189
|
+
|
|
2077
2190
|
def keyPressEvent(self, event):
|
|
2078
2191
|
|
|
2192
|
+
"""Key press shortcuts for main class"""
|
|
2193
|
+
|
|
2079
2194
|
if event.key() == Qt.Key_Z and event.modifiers() & Qt.ControlModifier:
|
|
2080
2195
|
try:
|
|
2081
2196
|
self.load_channel(self.last_change[1], self.last_change[0], True)
|
|
@@ -2091,6 +2206,8 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2091
2206
|
self.machine_window.switch_foreground()
|
|
2092
2207
|
if event.key() == Qt.Key_X:
|
|
2093
2208
|
self.high_button.click()
|
|
2209
|
+
if event.key() == Qt.Key_F and event.modifiers() == Qt.ShiftModifier:
|
|
2210
|
+
self.handle_find()
|
|
2094
2211
|
if self.brush_mode and self.machine_window is None:
|
|
2095
2212
|
if event.key() == Qt.Key_F:
|
|
2096
2213
|
self.toggle_can()
|
|
@@ -2098,6 +2215,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2098
2215
|
self.toggle_threed()
|
|
2099
2216
|
|
|
2100
2217
|
|
|
2218
|
+
|
|
2101
2219
|
def update_brush_cursor(self):
|
|
2102
2220
|
"""Update the cursor to show brush size"""
|
|
2103
2221
|
if not self.brush_mode:
|
|
@@ -2158,7 +2276,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2158
2276
|
painter.end()
|
|
2159
2277
|
|
|
2160
2278
|
def get_line_points(self, x0, y0, x1, y1):
|
|
2161
|
-
"""Get all points in a line between (x0,y0) and (x1,y1) using Bresenham's algorithm"""
|
|
2279
|
+
"""Get all points in a line between (x0,y0) and (x1,y1) using Bresenham's algorithm."""
|
|
2162
2280
|
points = []
|
|
2163
2281
|
dx = abs(x1 - x0)
|
|
2164
2282
|
dy = abs(y1 - y0)
|
|
@@ -2211,7 +2329,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2211
2329
|
return data_coords[0], data_coords[1]
|
|
2212
2330
|
|
|
2213
2331
|
def on_mouse_press(self, event):
|
|
2214
|
-
"""Handle mouse press events."""
|
|
2332
|
+
"""Handle mouse press events - OPTIMIZED VERSION."""
|
|
2215
2333
|
if event.inaxes != self.ax:
|
|
2216
2334
|
return
|
|
2217
2335
|
|
|
@@ -2249,7 +2367,6 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2249
2367
|
new_xlim = [xdata - x_range, xdata + x_range]
|
|
2250
2368
|
new_ylim = [ydata - y_range, ydata + y_range]
|
|
2251
2369
|
|
|
2252
|
-
|
|
2253
2370
|
if (new_xlim[0] <= self.original_xlim[0] or
|
|
2254
2371
|
new_xlim[1] >= self.original_xlim[1] or
|
|
2255
2372
|
new_ylim[0] <= self.original_ylim[0] or
|
|
@@ -2271,22 +2388,31 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2271
2388
|
self.panning = True
|
|
2272
2389
|
self.pan_start = (event.xdata, event.ydata)
|
|
2273
2390
|
self.canvas.setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
2391
|
+
|
|
2392
|
+
# Store current channel visibility state
|
|
2393
|
+
self.pre_pan_channel_state = self.channel_visible.copy()
|
|
2394
|
+
|
|
2395
|
+
# Create static background from currently visible channels
|
|
2396
|
+
self.create_pan_background()
|
|
2397
|
+
|
|
2398
|
+
# Hide all channels and show only the background
|
|
2399
|
+
self.channel_visible = [False] * 4
|
|
2400
|
+
self.is_pan_preview = True
|
|
2401
|
+
|
|
2402
|
+
# Update display to show only background
|
|
2403
|
+
self.update_display_pan_mode()
|
|
2274
2404
|
|
|
2275
2405
|
elif self.brush_mode:
|
|
2276
2406
|
if event.inaxes != self.ax:
|
|
2277
2407
|
return
|
|
2278
2408
|
|
|
2279
|
-
|
|
2280
2409
|
if event.button == 1 or event.button == 3:
|
|
2281
|
-
|
|
2282
2410
|
x, y = int(event.xdata), int(event.ydata)
|
|
2283
2411
|
|
|
2284
|
-
|
|
2285
2412
|
if event.button == 1 and self.can:
|
|
2286
2413
|
self.handle_can(x, y)
|
|
2287
2414
|
return
|
|
2288
2415
|
|
|
2289
|
-
|
|
2290
2416
|
if event.button == 3:
|
|
2291
2417
|
self.erase = True
|
|
2292
2418
|
else:
|
|
@@ -2300,27 +2426,24 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2300
2426
|
else:
|
|
2301
2427
|
channel = 2
|
|
2302
2428
|
|
|
2303
|
-
#
|
|
2304
|
-
self.paint_at_position(x, y, self.erase, channel)
|
|
2305
|
-
|
|
2429
|
+
# Get current zoom to preserve it
|
|
2306
2430
|
current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
|
|
2307
2431
|
current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
|
|
2308
2432
|
|
|
2309
|
-
|
|
2433
|
+
# Paint at initial position
|
|
2434
|
+
self.paint_at_position(x, y, self.erase, channel)
|
|
2435
|
+
|
|
2310
2436
|
self.canvas.draw()
|
|
2311
|
-
#self.update_display(preserve_zoom=(current_xlim, current_ylim))
|
|
2312
|
-
self.restore_channels = []
|
|
2313
|
-
|
|
2314
2437
|
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
elif self.channel_data[i] is not None and self.channel_visible[i] == True:
|
|
2319
|
-
self.channel_visible[i] = False
|
|
2320
|
-
self.restore_channels.append(i)
|
|
2321
|
-
self.update_display(preserve_zoom = (current_xlim, current_ylim), begin_paint = True)
|
|
2322
|
-
self.update_display_slice(channel, preserve_zoom=(current_xlim, current_ylim))
|
|
2438
|
+
self.restore_channels = []
|
|
2439
|
+
if not self.channel_visible[channel]:
|
|
2440
|
+
self.channel_visible[channel] = True
|
|
2323
2441
|
|
|
2442
|
+
# No need to hide other channels or track restore_channels
|
|
2443
|
+
self.restore_channels = []
|
|
2444
|
+
|
|
2445
|
+
self.update_display(preserve_zoom=(current_xlim, current_ylim), begin_paint=True)
|
|
2446
|
+
self.update_display_slice_optimized(channel, preserve_zoom=(current_xlim, current_ylim))
|
|
2324
2447
|
|
|
2325
2448
|
elif event.button == 3: # Right click (for context menu)
|
|
2326
2449
|
self.create_context_menu(event)
|
|
@@ -2334,7 +2457,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2334
2457
|
"""Paint pixels within brush radius at given position"""
|
|
2335
2458
|
if self.channel_data[channel] is None:
|
|
2336
2459
|
return
|
|
2337
|
-
|
|
2460
|
+
|
|
2338
2461
|
if erase:
|
|
2339
2462
|
val = 0
|
|
2340
2463
|
elif self.machine_window is None:
|
|
@@ -2343,26 +2466,74 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2343
2466
|
val = 1
|
|
2344
2467
|
else:
|
|
2345
2468
|
val = 2
|
|
2346
|
-
|
|
2347
2469
|
height, width = self.channel_data[channel][self.current_slice].shape
|
|
2348
2470
|
radius = self.brush_size // 2
|
|
2349
|
-
|
|
2471
|
+
|
|
2350
2472
|
# Calculate brush area
|
|
2351
2473
|
for y in range(max(0, center_y - radius), min(height, center_y + radius + 1)):
|
|
2352
2474
|
for x in range(max(0, center_x - radius), min(width, center_x + radius + 1)):
|
|
2353
2475
|
# Check if point is within circular brush area
|
|
2354
|
-
if (x - center_x)
|
|
2355
|
-
|
|
2476
|
+
if (x - center_x) * 2 + (y - center_y) * 2 <= radius ** 2:
|
|
2356
2477
|
if self.threed and self.threedthresh > 1:
|
|
2357
2478
|
amount = (self.threedthresh - 1) / 2
|
|
2358
2479
|
low = max(0, self.current_slice - amount)
|
|
2359
2480
|
high = min(self.channel_data[channel].shape[0] - 1, self.current_slice + amount)
|
|
2360
|
-
|
|
2361
2481
|
for i in range(int(low), int(high + 1)):
|
|
2362
2482
|
self.channel_data[channel][i][y, x] = val
|
|
2363
2483
|
else:
|
|
2364
2484
|
self.channel_data[channel][self.current_slice][y, x] = val
|
|
2365
2485
|
|
|
2486
|
+
def paint_at_position_vectorized(self, center_x, center_y, erase=False, channel=2,
|
|
2487
|
+
slice_idx=None, brush_size=None, threed=False,
|
|
2488
|
+
threedthresh=1, foreground=True, machine_window=None):
|
|
2489
|
+
"""Vectorized paint operation for better performance."""
|
|
2490
|
+
if self.channel_data[channel] is None:
|
|
2491
|
+
return
|
|
2492
|
+
|
|
2493
|
+
# Use provided parameters or fall back to instance variables
|
|
2494
|
+
slice_idx = slice_idx if slice_idx is not None else self.current_slice
|
|
2495
|
+
brush_size = brush_size if brush_size is not None else getattr(self, 'brush_size', 5)
|
|
2496
|
+
|
|
2497
|
+
# Determine paint value
|
|
2498
|
+
if erase:
|
|
2499
|
+
val = 0
|
|
2500
|
+
elif machine_window is None:
|
|
2501
|
+
val = 255
|
|
2502
|
+
elif foreground:
|
|
2503
|
+
val = 1
|
|
2504
|
+
else:
|
|
2505
|
+
val = 2
|
|
2506
|
+
|
|
2507
|
+
height, width = self.channel_data[channel][slice_idx].shape
|
|
2508
|
+
radius = brush_size // 2
|
|
2509
|
+
|
|
2510
|
+
# Calculate affected region bounds
|
|
2511
|
+
y_min = max(0, center_y - radius)
|
|
2512
|
+
y_max = min(height, center_y + radius + 1)
|
|
2513
|
+
x_min = max(0, center_x - radius)
|
|
2514
|
+
x_max = min(width, center_x + radius + 1)
|
|
2515
|
+
|
|
2516
|
+
if y_min >= y_max or x_min >= x_max:
|
|
2517
|
+
return # No valid region to paint
|
|
2518
|
+
|
|
2519
|
+
# Create coordinate grids for the affected region
|
|
2520
|
+
y_coords, x_coords = np.mgrid[y_min:y_max, x_min:x_max]
|
|
2521
|
+
|
|
2522
|
+
# Calculate distances squared (avoid sqrt for performance)
|
|
2523
|
+
distances_sq = (x_coords - center_x) ** 2 + (y_coords - center_y) ** 2
|
|
2524
|
+
mask = distances_sq <= radius ** 2
|
|
2525
|
+
|
|
2526
|
+
# Apply paint to affected slices
|
|
2527
|
+
if threed and threedthresh > 1:
|
|
2528
|
+
amount = (threedthresh - 1) / 2
|
|
2529
|
+
low = max(0, int(slice_idx - amount))
|
|
2530
|
+
high = min(self.channel_data[channel].shape[0] - 1, int(slice_idx + amount))
|
|
2531
|
+
|
|
2532
|
+
for i in range(low, high + 1):
|
|
2533
|
+
self.channel_data[channel][i][y_min:y_max, x_min:x_max][mask] = val
|
|
2534
|
+
else:
|
|
2535
|
+
self.channel_data[channel][slice_idx][y_min:y_max, x_min:x_max][mask] = val
|
|
2536
|
+
|
|
2366
2537
|
def handle_can(self, x, y):
|
|
2367
2538
|
|
|
2368
2539
|
|
|
@@ -2425,33 +2596,42 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2425
2596
|
|
|
2426
2597
|
|
|
2427
2598
|
def on_mouse_move(self, event):
|
|
2428
|
-
|
|
2429
|
-
if event.inaxes != self.ax:
|
|
2599
|
+
if not event.inaxes or event.xdata is None or event.ydata is None:
|
|
2430
2600
|
return
|
|
2431
|
-
|
|
2601
|
+
|
|
2602
|
+
current_time = time.time()
|
|
2603
|
+
|
|
2432
2604
|
if self.selection_start and not self.selecting and not self.pan_mode and not self.zoom_mode and not self.brush_mode:
|
|
2433
|
-
# If mouse has moved more than a tiny amount while button is held, start selection
|
|
2434
2605
|
if (abs(event.xdata - self.selection_start[0]) > 1 or
|
|
2435
2606
|
abs(event.ydata - self.selection_start[1]) > 1):
|
|
2436
2607
|
self.selecting = True
|
|
2608
|
+
self.background = self.canvas.copy_from_bbox(self.ax.bbox)
|
|
2609
|
+
|
|
2437
2610
|
self.selection_rect = plt.Rectangle(
|
|
2438
2611
|
(self.selection_start[0], self.selection_start[1]), 0, 0,
|
|
2439
|
-
fill=False, color='white', linestyle='--'
|
|
2612
|
+
fill=False, color='white', linestyle='--', animated=True
|
|
2440
2613
|
)
|
|
2441
2614
|
self.ax.add_patch(self.selection_rect)
|
|
2442
2615
|
|
|
2443
2616
|
if self.selecting and self.selection_rect is not None:
|
|
2444
|
-
#
|
|
2445
|
-
|
|
2446
|
-
|
|
2617
|
+
# Throttle updates
|
|
2618
|
+
if current_time - self.last_update_time < self.update_interval:
|
|
2619
|
+
return
|
|
2620
|
+
self.last_update_time = current_time
|
|
2621
|
+
|
|
2622
|
+
# Use blitting
|
|
2623
|
+
self.canvas.restore_region(self.background)
|
|
2624
|
+
|
|
2625
|
+
x_min = min(self.selection_start[0], event.xdata)
|
|
2626
|
+
y_min = min(self.selection_start[1], event.ydata)
|
|
2447
2627
|
width = abs(event.xdata - self.selection_start[0])
|
|
2448
2628
|
height = abs(event.ydata - self.selection_start[1])
|
|
2449
2629
|
|
|
2450
|
-
self.selection_rect.set_bounds(
|
|
2451
|
-
self.
|
|
2630
|
+
self.selection_rect.set_bounds(x_min, y_min, width, height)
|
|
2631
|
+
self.ax.draw_artist(self.selection_rect)
|
|
2632
|
+
self.canvas.blit(self.ax.bbox)
|
|
2452
2633
|
|
|
2453
2634
|
elif self.panning and self.pan_start is not None:
|
|
2454
|
-
|
|
2455
2635
|
# Calculate the movement
|
|
2456
2636
|
dx = event.xdata - self.pan_start[0]
|
|
2457
2637
|
dy = event.ydata - self.pan_start[1]
|
|
@@ -2464,25 +2644,23 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2464
2644
|
new_xlim = [xlim[0] - dx, xlim[1] - dx]
|
|
2465
2645
|
new_ylim = [ylim[0] - dy, ylim[1] - dy]
|
|
2466
2646
|
|
|
2467
|
-
# Get image bounds
|
|
2468
|
-
if self.
|
|
2469
|
-
img_height, img_width = self.channel_data[0][self.current_slice].shape
|
|
2470
|
-
|
|
2647
|
+
# Get image bounds using cached dimensions
|
|
2648
|
+
if self.img_width is not None: # Changed from self.channel_data[0] check
|
|
2471
2649
|
# Ensure new limits don't go beyond image bounds
|
|
2472
2650
|
if new_xlim[0] < 0:
|
|
2473
2651
|
new_xlim = [0, xlim[1] - xlim[0]]
|
|
2474
|
-
elif new_xlim[1] > img_width:
|
|
2475
|
-
new_xlim = [img_width - (xlim[1] - xlim[0]), img_width]
|
|
2652
|
+
elif new_xlim[1] > self.img_width: # Changed from img_width variable lookup
|
|
2653
|
+
new_xlim = [self.img_width - (xlim[1] - xlim[0]), self.img_width]
|
|
2476
2654
|
|
|
2477
2655
|
if new_ylim[0] < 0:
|
|
2478
2656
|
new_ylim = [0, ylim[1] - ylim[0]]
|
|
2479
|
-
elif new_ylim[1] > img_height:
|
|
2480
|
-
new_ylim = [img_height - (ylim[1] - ylim[0]), img_height]
|
|
2657
|
+
elif new_ylim[1] > self.img_height: # Changed from img_height variable lookup
|
|
2658
|
+
new_ylim = [self.img_height - (ylim[1] - ylim[0]), self.img_height]
|
|
2481
2659
|
|
|
2482
2660
|
# Apply new limits
|
|
2483
2661
|
self.ax.set_xlim(new_xlim)
|
|
2484
2662
|
self.ax.set_ylim(new_ylim)
|
|
2485
|
-
self.canvas.draw()
|
|
2663
|
+
self.canvas.draw_idle() # Changed from draw() to draw_idle()
|
|
2486
2664
|
|
|
2487
2665
|
# Update pan start position
|
|
2488
2666
|
self.pan_start = (event.xdata, event.ydata)
|
|
@@ -2490,43 +2668,365 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2490
2668
|
elif self.painting and self.brush_mode:
|
|
2491
2669
|
if event.inaxes != self.ax:
|
|
2492
2670
|
return
|
|
2493
|
-
|
|
2494
|
-
|
|
2671
|
+
|
|
2672
|
+
# OPTIMIZED: Queue paint operation instead of immediate execution
|
|
2673
|
+
self.queue_paint_operation(event)
|
|
2674
|
+
|
|
2675
|
+
# OPTIMIZED: Schedule display update at controlled frequency
|
|
2676
|
+
if not self.pending_paint_update:
|
|
2677
|
+
self.pending_paint_update = True
|
|
2678
|
+
self.paint_timer.start(16) # ~60fps max update rate
|
|
2495
2679
|
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
channel = 2
|
|
2680
|
+
def queue_paint_operation(self, event):
|
|
2681
|
+
"""Queue a paint operation for background processing."""
|
|
2682
|
+
x, y = int(event.xdata), int(event.ydata)
|
|
2500
2683
|
|
|
2684
|
+
if self.pen_button.isChecked():
|
|
2685
|
+
channel = self.active_channel
|
|
2686
|
+
else:
|
|
2687
|
+
channel = 2
|
|
2688
|
+
|
|
2689
|
+
if self.channel_data[channel] is not None:
|
|
2690
|
+
# Prepare paint session if needed
|
|
2691
|
+
if not self.paint_session_active:
|
|
2692
|
+
self.prepare_paint_session(channel)
|
|
2693
|
+
|
|
2694
|
+
# Create paint operation
|
|
2695
|
+
paint_op = {
|
|
2696
|
+
'type': 'stroke',
|
|
2697
|
+
'x': x,
|
|
2698
|
+
'y': y,
|
|
2699
|
+
'last_pos': getattr(self, 'last_paint_pos', None),
|
|
2700
|
+
'brush_size': self.brush_size,
|
|
2701
|
+
'erase': self.erase,
|
|
2702
|
+
'channel': channel,
|
|
2703
|
+
'slice': self.current_slice,
|
|
2704
|
+
'threed': getattr(self, 'threed', False),
|
|
2705
|
+
'threedthresh': getattr(self, 'threedthresh', 1),
|
|
2706
|
+
'foreground': getattr(self, 'foreground', True),
|
|
2707
|
+
'machine_window': getattr(self, 'machine_window', None)
|
|
2708
|
+
}
|
|
2501
2709
|
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2710
|
+
# Queue the operation
|
|
2711
|
+
try:
|
|
2712
|
+
self.paint_queue.put_nowait(paint_op)
|
|
2713
|
+
except queue.Full:
|
|
2714
|
+
pass # Skip if queue is full to avoid blocking
|
|
2715
|
+
|
|
2716
|
+
self.last_paint_pos = (x, y)
|
|
2717
|
+
|
|
2718
|
+
def prepare_paint_session(self, channel):
|
|
2719
|
+
"""Prepare optimized background for blitting during paint session."""
|
|
2720
|
+
if self.paint_session_active:
|
|
2721
|
+
return
|
|
2722
|
+
|
|
2723
|
+
# IMPORTANT: Don't capture background here - let the main display update handle it
|
|
2724
|
+
# We'll capture the background after the proper channel visibility setup
|
|
2725
|
+
self.paint_session_active = True
|
|
2726
|
+
|
|
2727
|
+
def end_paint_session(self):
|
|
2728
|
+
"""Clean up after paint session."""
|
|
2729
|
+
self.paint_session_active = False
|
|
2730
|
+
self.paint_background = None
|
|
2731
|
+
self.last_paint_pos = None
|
|
2732
|
+
|
|
2733
|
+
def paint_worker_loop(self):
|
|
2734
|
+
"""Background thread for processing paint operations."""
|
|
2735
|
+
while True:
|
|
2736
|
+
try:
|
|
2737
|
+
paint_op = self.paint_queue.get(timeout=1.0)
|
|
2738
|
+
if paint_op is None: # Shutdown signal
|
|
2739
|
+
break
|
|
2506
2740
|
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
points = self.get_line_points(last_x, last_y, x, y)
|
|
2741
|
+
with self.paint_lock:
|
|
2742
|
+
self.execute_paint_operation(paint_op)
|
|
2510
2743
|
|
|
2511
|
-
|
|
2744
|
+
except queue.Empty:
|
|
2745
|
+
continue
|
|
2512
2746
|
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2747
|
+
def shutdown(self):
|
|
2748
|
+
"""Clean shutdown of worker thread."""
|
|
2749
|
+
self.paint_queue.put(None) # Signal worker to stop
|
|
2750
|
+
if hasattr(self, 'paint_worker'):
|
|
2751
|
+
self.paint_worker.join(timeout=1.0)
|
|
2752
|
+
|
|
2753
|
+
def execute_paint_operation(self, paint_op):
|
|
2754
|
+
"""Execute a single paint operation on the data arrays."""
|
|
2755
|
+
if paint_op['type'] == 'stroke':
|
|
2756
|
+
channel = paint_op['channel']
|
|
2757
|
+
x, y = paint_op['x'], paint_op['y']
|
|
2758
|
+
last_pos = paint_op['last_pos']
|
|
2759
|
+
|
|
2760
|
+
if last_pos is not None:
|
|
2761
|
+
# Paint line from last position to current
|
|
2762
|
+
points = self.get_line_points(last_pos[0], last_pos[1], x, y)
|
|
2763
|
+
for px, py in points:
|
|
2764
|
+
height, width = self.channel_data[channel][paint_op['slice']].shape
|
|
2765
|
+
if 0 <= px < width and 0 <= py < height:
|
|
2766
|
+
self.paint_at_position_vectorized(
|
|
2767
|
+
px, py, paint_op['erase'], paint_op['channel'],
|
|
2768
|
+
paint_op['slice'], paint_op['brush_size'],
|
|
2769
|
+
paint_op['threed'], paint_op['threedthresh'],
|
|
2770
|
+
paint_op['foreground'], paint_op['machine_window']
|
|
2771
|
+
)
|
|
2772
|
+
else:
|
|
2773
|
+
# Single point paint
|
|
2774
|
+
height, width = self.channel_data[channel][paint_op['slice']].shape
|
|
2775
|
+
if 0 <= x < width and 0 <= y < height:
|
|
2776
|
+
self.paint_at_position_vectorized(
|
|
2777
|
+
x, y, paint_op['erase'], paint_op['channel'],
|
|
2778
|
+
paint_op['slice'], paint_op['brush_size'],
|
|
2779
|
+
paint_op['threed'], paint_op['threedthresh'],
|
|
2780
|
+
paint_op['foreground'], paint_op['machine_window']
|
|
2781
|
+
)
|
|
2782
|
+
|
|
2783
|
+
def flush_paint_updates(self):
|
|
2784
|
+
"""Update the display with batched paint changes."""
|
|
2785
|
+
self.pending_paint_update = False
|
|
2786
|
+
|
|
2787
|
+
# Determine which channel to update
|
|
2788
|
+
channel = self.active_channel if hasattr(self, 'pen_button') and self.pen_button.isChecked() else 2
|
|
2789
|
+
|
|
2790
|
+
# Get current zoom to preserve it
|
|
2791
|
+
current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
|
|
2792
|
+
current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
|
|
2793
|
+
|
|
2794
|
+
# Update display
|
|
2795
|
+
self.update_display_slice_optimized(channel, preserve_zoom=(current_xlim, current_ylim))
|
|
2796
|
+
|
|
2797
|
+
def create_pan_background(self):
|
|
2798
|
+
"""Create a static background image from currently visible channels with proper rendering"""
|
|
2799
|
+
# Store current zoom state
|
|
2800
|
+
current_xlim = self.ax.get_xlim()
|
|
2801
|
+
current_ylim = self.ax.get_ylim()
|
|
2802
|
+
|
|
2803
|
+
# Render all visible channels with proper colors/brightness into a single composite
|
|
2804
|
+
self.pan_background_image = self.create_composite_for_pan()
|
|
2805
|
+
self.pan_zoom_state = (current_xlim, current_ylim)
|
|
2806
|
+
|
|
2807
|
+
def create_composite_for_pan(self):
|
|
2808
|
+
"""Create a properly rendered composite image for panning"""
|
|
2809
|
+
# Get active channels and dimensions (copied from update_display)
|
|
2810
|
+
active_channels = [i for i in range(4) if self.channel_data[i] is not None]
|
|
2811
|
+
if active_channels:
|
|
2812
|
+
dims = [(self.channel_data[i].shape[1:3] if len(self.channel_data[i].shape) >= 3 else
|
|
2813
|
+
self.channel_data[i].shape) for i in active_channels]
|
|
2814
|
+
min_height = min(d[0] for d in dims)
|
|
2815
|
+
min_width = min(d[1] for d in dims)
|
|
2816
|
+
else:
|
|
2817
|
+
return None
|
|
2818
|
+
|
|
2819
|
+
# Create a blank RGBA composite to accumulate all channels
|
|
2820
|
+
composite = np.zeros((min_height, min_width, 4), dtype=np.float32)
|
|
2821
|
+
|
|
2822
|
+
# Process each visible channel exactly like update_display does
|
|
2823
|
+
for channel in range(4):
|
|
2824
|
+
if (self.channel_visible[channel] and
|
|
2825
|
+
self.channel_data[channel] is not None):
|
|
2516
2826
|
|
|
2517
|
-
|
|
2827
|
+
# Get current slice data (same logic as update_display)
|
|
2828
|
+
is_rgb = len(self.channel_data[channel].shape) == 4 and (self.channel_data[channel].shape[-1] == 3 or self.channel_data[channel].shape[-1] == 4)
|
|
2518
2829
|
|
|
2519
|
-
self.
|
|
2520
|
-
|
|
2521
|
-
|
|
2830
|
+
if len(self.channel_data[channel].shape) == 3 and not is_rgb:
|
|
2831
|
+
current_image = self.channel_data[channel][self.current_slice, :, :]
|
|
2832
|
+
elif is_rgb:
|
|
2833
|
+
current_image = self.channel_data[channel][self.current_slice]
|
|
2834
|
+
else:
|
|
2835
|
+
current_image = self.channel_data[channel]
|
|
2836
|
+
|
|
2837
|
+
if is_rgb and self.channel_data[channel].shape[-1] == 3:
|
|
2838
|
+
# RGB image - convert to RGBA and blend
|
|
2839
|
+
rgb_alpha = np.ones((*current_image.shape[:2], 4), dtype=np.float32)
|
|
2840
|
+
rgb_alpha[:, :, :3] = current_image.astype(np.float32) / 255.0
|
|
2841
|
+
rgb_alpha[:, :, 3] = 0.7 # Same alpha as update_display
|
|
2842
|
+
composite = self.blend_layers(composite, rgb_alpha)
|
|
2843
|
+
|
|
2844
|
+
elif is_rgb and self.channel_data[channel].shape[-1] == 4:
|
|
2845
|
+
# RGBA image - blend directly
|
|
2846
|
+
rgba_image = current_image.astype(np.float32) / 255.0
|
|
2847
|
+
composite = self.blend_layers(composite, rgba_image)
|
|
2848
|
+
|
|
2849
|
+
else:
|
|
2850
|
+
# Regular channel processing (same logic as update_display)
|
|
2851
|
+
if self.min_max[channel][0] == None:
|
|
2852
|
+
self.min_max[channel][0] = np.min(current_image)
|
|
2853
|
+
if self.min_max[channel][1] == None:
|
|
2854
|
+
self.min_max[channel][1] = np.max(current_image)
|
|
2855
|
+
|
|
2856
|
+
img_min = self.min_max[channel][0]
|
|
2857
|
+
img_max = self.min_max[channel][1]
|
|
2858
|
+
|
|
2859
|
+
if img_min == img_max:
|
|
2860
|
+
vmin = img_min
|
|
2861
|
+
vmax = img_min + 1
|
|
2862
|
+
else:
|
|
2863
|
+
vmin = img_min + (img_max - img_min) * self.channel_brightness[channel]['min']
|
|
2864
|
+
vmax = img_min + (img_max - img_min) * self.channel_brightness[channel]['max']
|
|
2865
|
+
|
|
2866
|
+
# Normalize the image
|
|
2867
|
+
if vmin == vmax:
|
|
2868
|
+
normalized_image = np.zeros_like(current_image)
|
|
2869
|
+
else:
|
|
2870
|
+
normalized_image = np.clip((current_image - vmin) / (vmax - vmin), 0, 1)
|
|
2871
|
+
|
|
2872
|
+
# Apply channel color and alpha
|
|
2873
|
+
if channel == 2 and self.machine_window is not None:
|
|
2874
|
+
# Special case for machine window channel 2
|
|
2875
|
+
channel_rgba = self.apply_machine_colormap(current_image)
|
|
2876
|
+
else:
|
|
2877
|
+
# Regular channel with custom color
|
|
2878
|
+
color = self.base_colors[channel]
|
|
2879
|
+
channel_rgba = np.zeros((*normalized_image.shape, 4), dtype=np.float32)
|
|
2880
|
+
channel_rgba[:, :, 0] = normalized_image * color[0] # R
|
|
2881
|
+
channel_rgba[:, :, 1] = normalized_image * color[1] # G
|
|
2882
|
+
channel_rgba[:, :, 2] = normalized_image * color[2] # B
|
|
2883
|
+
channel_rgba[:, :, 3] = normalized_image * 0.7 # A (same alpha as update_display)
|
|
2884
|
+
|
|
2885
|
+
# Blend this channel into the composite
|
|
2886
|
+
composite = self.blend_layers(composite, channel_rgba)
|
|
2887
|
+
|
|
2888
|
+
# Add highlight overlays if they exist (same logic as update_display)
|
|
2889
|
+
if self.mini_overlay and self.highlight and self.machine_window is None:
|
|
2890
|
+
highlight_rgba = self.create_highlight_rgba(self.mini_overlay_data, yellow=True)
|
|
2891
|
+
composite = self.blend_layers(composite, highlight_rgba)
|
|
2892
|
+
elif self.highlight_overlay is not None and self.highlight:
|
|
2893
|
+
highlight_slice = self.highlight_overlay[self.current_slice]
|
|
2894
|
+
if self.machine_window is None:
|
|
2895
|
+
highlight_rgba = self.create_highlight_rgba(highlight_slice, yellow=True)
|
|
2896
|
+
else:
|
|
2897
|
+
highlight_rgba = self.create_highlight_rgba(highlight_slice, yellow=False)
|
|
2898
|
+
composite = self.blend_layers(composite, highlight_rgba)
|
|
2899
|
+
|
|
2900
|
+
# Convert to 0-255 range for display
|
|
2901
|
+
return (composite * 255).astype(np.uint8)
|
|
2902
|
+
|
|
2903
|
+
def apply_machine_colormap(self, image):
|
|
2904
|
+
"""Apply the special machine window colormap for channel 2"""
|
|
2905
|
+
rgba = np.zeros((*image.shape, 4), dtype=np.float32)
|
|
2906
|
+
|
|
2907
|
+
# Transparent for 0
|
|
2908
|
+
mask_0 = (image == 0)
|
|
2909
|
+
rgba[mask_0] = [0, 0, 0, 0]
|
|
2910
|
+
|
|
2911
|
+
# Light green for 1
|
|
2912
|
+
mask_1 = (image == 1)
|
|
2913
|
+
rgba[mask_1] = [0.5, 1, 0.5, 0.7]
|
|
2914
|
+
|
|
2915
|
+
# Light red for 2
|
|
2916
|
+
mask_2 = (image == 2)
|
|
2917
|
+
rgba[mask_2] = [1, 0.5, 0.5, 0.7]
|
|
2918
|
+
|
|
2919
|
+
return rgba
|
|
2522
2920
|
|
|
2921
|
+
def create_highlight_rgba(self, highlight_data, yellow=True):
|
|
2922
|
+
"""Create RGBA highlight overlay"""
|
|
2923
|
+
rgba = np.zeros((*highlight_data.shape, 4), dtype=np.float32)
|
|
2924
|
+
|
|
2925
|
+
if yellow:
|
|
2926
|
+
# Yellow highlight
|
|
2927
|
+
mask = highlight_data > 0
|
|
2928
|
+
rgba[mask] = [1, 1, 0, 0.5] # Yellow with alpha 0.5
|
|
2929
|
+
else:
|
|
2930
|
+
# Multi-color highlight for machine window
|
|
2931
|
+
mask_1 = (highlight_data == 1)
|
|
2932
|
+
mask_2 = (highlight_data == 2)
|
|
2933
|
+
rgba[mask_1] = [1, 1, 0, 0.5] # Yellow for 1
|
|
2934
|
+
rgba[mask_2] = [0, 0.7, 1, 0.5] # Blue for 2
|
|
2935
|
+
|
|
2936
|
+
return rgba
|
|
2937
|
+
|
|
2938
|
+
def blend_layers(self, base, overlay):
|
|
2939
|
+
"""Alpha blend two RGBA layers"""
|
|
2940
|
+
# Standard alpha blending formula
|
|
2941
|
+
alpha_overlay = overlay[:, :, 3:4]
|
|
2942
|
+
alpha_base = base[:, :, 3:4]
|
|
2943
|
+
|
|
2944
|
+
# Calculate output alpha
|
|
2945
|
+
alpha_out = alpha_overlay + alpha_base * (1 - alpha_overlay)
|
|
2946
|
+
|
|
2947
|
+
# Calculate output RGB
|
|
2948
|
+
rgb_out = np.zeros_like(base[:, :, :3])
|
|
2949
|
+
mask = alpha_out[:, :, 0] > 0
|
|
2950
|
+
|
|
2951
|
+
rgb_out[mask] = (overlay[mask, :3] * alpha_overlay[mask] +
|
|
2952
|
+
base[mask, :3] * alpha_base[mask] * (1 - alpha_overlay[mask])) / alpha_out[mask]
|
|
2953
|
+
|
|
2954
|
+
# Combine RGB and alpha
|
|
2955
|
+
result = np.zeros_like(base)
|
|
2956
|
+
result[:, :, :3] = rgb_out
|
|
2957
|
+
result[:, :, 3:4] = alpha_out
|
|
2958
|
+
|
|
2959
|
+
return result
|
|
2960
|
+
|
|
2961
|
+
def update_display_pan_mode(self):
|
|
2962
|
+
"""Lightweight display update for pan preview mode"""
|
|
2963
|
+
if self.is_pan_preview and self.pan_background_image is not None:
|
|
2964
|
+
# Clear and setup axes
|
|
2965
|
+
self.ax.clear()
|
|
2966
|
+
self.ax.set_facecolor('black')
|
|
2967
|
+
|
|
2968
|
+
# Get dimensions
|
|
2969
|
+
height, width = self.pan_background_image.shape[:2]
|
|
2970
|
+
|
|
2971
|
+
# Display the composite background with preserved zoom
|
|
2972
|
+
self.ax.imshow(self.pan_background_image,
|
|
2973
|
+
extent=(-0.5, width-0.5, height-0.5, -0.5),
|
|
2974
|
+
aspect='equal')
|
|
2975
|
+
|
|
2976
|
+
# Restore the zoom state from when pan began
|
|
2977
|
+
if hasattr(self, 'pan_zoom_state'):
|
|
2978
|
+
self.ax.set_xlim(self.pan_zoom_state[0])
|
|
2979
|
+
self.ax.set_ylim(self.pan_zoom_state[1])
|
|
2980
|
+
|
|
2981
|
+
# Style the axes (same as update_display)
|
|
2982
|
+
self.ax.set_xlabel('X')
|
|
2983
|
+
self.ax.set_ylabel('Y')
|
|
2984
|
+
self.ax.set_title(f'Slice {self.current_slice}')
|
|
2985
|
+
self.ax.xaxis.label.set_color('black')
|
|
2986
|
+
self.ax.yaxis.label.set_color('black')
|
|
2987
|
+
self.ax.title.set_color('black')
|
|
2988
|
+
self.ax.tick_params(colors='black')
|
|
2989
|
+
for spine in self.ax.spines.values():
|
|
2990
|
+
spine.set_color('black')
|
|
2991
|
+
|
|
2992
|
+
# Add measurement points if they exist (same as update_display)
|
|
2993
|
+
for point in self.measurement_points:
|
|
2994
|
+
x1, y1, z1 = point['point1']
|
|
2995
|
+
x2, y2, z2 = point['point2']
|
|
2996
|
+
pair_idx = point['pair_index']
|
|
2997
|
+
|
|
2998
|
+
if z1 == self.current_slice:
|
|
2999
|
+
self.ax.plot(x1, y1, 'yo', markersize=8)
|
|
3000
|
+
self.ax.text(x1, y1+5, str(pair_idx),
|
|
3001
|
+
color='white', ha='center', va='bottom')
|
|
3002
|
+
if z2 == self.current_slice:
|
|
3003
|
+
self.ax.plot(x2, y2, 'yo', markersize=8)
|
|
3004
|
+
self.ax.text(x2, y2+5, str(pair_idx),
|
|
3005
|
+
color='white', ha='center', va='bottom')
|
|
3006
|
+
|
|
3007
|
+
if z1 == z2 == self.current_slice:
|
|
3008
|
+
self.ax.plot([x1, x2], [y1, y2], 'r--', alpha=0.5)
|
|
3009
|
+
|
|
3010
|
+
self.canvas.draw_idle()
|
|
2523
3011
|
|
|
2524
3012
|
def on_mouse_release(self, event):
|
|
2525
|
-
"""Handle mouse release events."""
|
|
3013
|
+
"""Handle mouse release events - OPTIMIZED VERSION."""
|
|
2526
3014
|
if self.pan_mode:
|
|
3015
|
+
# Get current view limits before restoring channels
|
|
3016
|
+
current_xlim = self.ax.get_xlim()
|
|
3017
|
+
current_ylim = self.ax.get_ylim()
|
|
3018
|
+
|
|
2527
3019
|
self.panning = False
|
|
2528
3020
|
self.pan_start = None
|
|
2529
3021
|
self.canvas.setCursor(Qt.CursorShape.OpenHandCursor)
|
|
3022
|
+
|
|
3023
|
+
# Restore previously visible channels
|
|
3024
|
+
self.channel_visible = self.pre_pan_channel_state.copy()
|
|
3025
|
+
self.is_pan_preview = False
|
|
3026
|
+
self.pan_background = None
|
|
3027
|
+
|
|
3028
|
+
# Update display with preserved zoom at new position
|
|
3029
|
+
self.update_display(preserve_zoom=(current_xlim, current_ylim))
|
|
2530
3030
|
elif event.button == 1: # Left button release
|
|
2531
3031
|
if self.selecting and self.selection_rect is not None:
|
|
2532
3032
|
# Get the rectangle bounds
|
|
@@ -2585,18 +3085,15 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2585
3085
|
# Try to highlight the last selected value in tables
|
|
2586
3086
|
if self.clicked_values['edges']:
|
|
2587
3087
|
self.highlight_value_in_tables(self.clicked_values['edges'][-1])
|
|
2588
|
-
|
|
2589
3088
|
|
|
2590
3089
|
elif not self.selecting and self.selection_start: # If we had a click but never started selection
|
|
2591
3090
|
# Handle as a normal click
|
|
2592
3091
|
self.on_mouse_click(event)
|
|
2593
|
-
|
|
2594
3092
|
|
|
2595
|
-
# Clean up
|
|
3093
|
+
# Clean up selection
|
|
2596
3094
|
self.selection_start = None
|
|
2597
3095
|
self.selecting = False
|
|
2598
3096
|
|
|
2599
|
-
|
|
2600
3097
|
if self.selection_rect is not None:
|
|
2601
3098
|
try:
|
|
2602
3099
|
self.selection_rect.remove()
|
|
@@ -2605,13 +3102,28 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2605
3102
|
self.selection_rect = None
|
|
2606
3103
|
self.canvas.draw()
|
|
2607
3104
|
|
|
2608
|
-
|
|
3105
|
+
# OPTIMIZED: Handle brush mode cleanup with paint session management
|
|
3106
|
+
if self.brush_mode and hasattr(self, 'painting') and self.painting:
|
|
2609
3107
|
self.painting = False
|
|
3108
|
+
|
|
3109
|
+
# Restore hidden channels
|
|
2610
3110
|
try:
|
|
2611
3111
|
for i in self.restore_channels:
|
|
2612
3112
|
self.channel_visible[i] = True
|
|
3113
|
+
self.restore_channels = []
|
|
2613
3114
|
except:
|
|
2614
3115
|
pass
|
|
3116
|
+
|
|
3117
|
+
# OPTIMIZED: End paint session and ensure all operations complete
|
|
3118
|
+
self.end_paint_session()
|
|
3119
|
+
|
|
3120
|
+
# OPTIMIZED: Stop timer and process any pending paint operations
|
|
3121
|
+
if hasattr(self, 'paint_timer'):
|
|
3122
|
+
self.paint_timer.stop()
|
|
3123
|
+
if hasattr(self, 'pending_paint_update') and self.pending_paint_update:
|
|
3124
|
+
self.flush_paint_updates()
|
|
3125
|
+
|
|
3126
|
+
# Get current zoom and do final display update
|
|
2615
3127
|
current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
|
|
2616
3128
|
current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
|
|
2617
3129
|
self.update_display(preserve_zoom=(current_xlim, current_ylim))
|
|
@@ -2886,6 +3398,8 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2886
3398
|
load_action.triggered.connect(lambda: self.load_misc('Edge Centroids'))
|
|
2887
3399
|
load_action = misc_menu.addAction("Merge Nodes")
|
|
2888
3400
|
load_action.triggered.connect(lambda: self.load_misc('Merge Nodes'))
|
|
3401
|
+
load_action = misc_menu.addAction("Merge Node IDs from Images")
|
|
3402
|
+
load_action.triggered.connect(self.show_merge_node_id_dialog)
|
|
2889
3403
|
|
|
2890
3404
|
|
|
2891
3405
|
# Analysis menu
|
|
@@ -3267,6 +3781,11 @@ class ImageViewerWindow(QMainWindow):
|
|
|
3267
3781
|
except:
|
|
3268
3782
|
pass
|
|
3269
3783
|
|
|
3784
|
+
def show_merge_node_id_dialog(self):
|
|
3785
|
+
|
|
3786
|
+
dialog = MergeNodeIdDialog(self)
|
|
3787
|
+
dialog.exec()
|
|
3788
|
+
|
|
3270
3789
|
|
|
3271
3790
|
def show_watershed_dialog(self):
|
|
3272
3791
|
"""Show the watershed parameter dialog."""
|
|
@@ -3585,7 +4104,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
3585
4104
|
f"Failed to load {sort}: {str(e)}"
|
|
3586
4105
|
)
|
|
3587
4106
|
|
|
3588
|
-
|
|
4107
|
+
elif sort == 'Merge Nodes':
|
|
3589
4108
|
try:
|
|
3590
4109
|
|
|
3591
4110
|
if len(np.unique(my_network.nodes)) < 3:
|
|
@@ -3623,7 +4142,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
3623
4142
|
if dialog.exec() == QFileDialog.DialogCode.Accepted:
|
|
3624
4143
|
selected_path = dialog.directory().absolutePath()
|
|
3625
4144
|
|
|
3626
|
-
my_network.merge_nodes(selected_path)
|
|
4145
|
+
my_network.merge_nodes(selected_path, root_id = self.node_name)
|
|
3627
4146
|
self.load_channel(0, my_network.nodes, True)
|
|
3628
4147
|
|
|
3629
4148
|
|
|
@@ -3641,6 +4160,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
3641
4160
|
)
|
|
3642
4161
|
|
|
3643
4162
|
|
|
4163
|
+
|
|
3644
4164
|
# Modify load_from_network_obj method
|
|
3645
4165
|
def load_from_network_obj(self):
|
|
3646
4166
|
try:
|
|
@@ -3952,11 +4472,15 @@ class ImageViewerWindow(QMainWindow):
|
|
|
3952
4472
|
return
|
|
3953
4473
|
|
|
3954
4474
|
file_extension = filename.lower().split('.')[-1]
|
|
4475
|
+
|
|
4476
|
+
if channel_index == 0:
|
|
4477
|
+
self.node_name = filename
|
|
3955
4478
|
|
|
3956
4479
|
try:
|
|
3957
4480
|
if file_extension in ['tif', 'tiff']:
|
|
3958
4481
|
import tifffile
|
|
3959
4482
|
self.channel_data[channel_index] = tifffile.imread(filename)
|
|
4483
|
+
|
|
3960
4484
|
|
|
3961
4485
|
elif file_extension == 'nii':
|
|
3962
4486
|
import nibabel as nib
|
|
@@ -4094,6 +4618,9 @@ class ImageViewerWindow(QMainWindow):
|
|
|
4094
4618
|
|
|
4095
4619
|
self.shape = self.channel_data[channel_index].shape
|
|
4096
4620
|
|
|
4621
|
+
self.img_height, self.img_width = self.shape[1], self.shape[2]
|
|
4622
|
+
|
|
4623
|
+
|
|
4097
4624
|
self.update_display(reset_resize = reset_resize)
|
|
4098
4625
|
|
|
4099
4626
|
|
|
@@ -4554,36 +5081,55 @@ class ImageViewerWindow(QMainWindow):
|
|
|
4554
5081
|
import traceback
|
|
4555
5082
|
print(traceback.format_exc())
|
|
4556
5083
|
|
|
4557
|
-
def
|
|
4558
|
-
"""Ultra minimal update that only changes the paint channel's data"""
|
|
5084
|
+
def update_display_slice_optimized(self, channel, preserve_zoom=None):
|
|
5085
|
+
"""Ultra minimal update that only changes the paint channel's data - OPTIMIZED VERSION"""
|
|
4559
5086
|
if not self.channel_visible[channel]:
|
|
4560
5087
|
return
|
|
4561
|
-
|
|
5088
|
+
|
|
4562
5089
|
if preserve_zoom:
|
|
4563
5090
|
current_xlim, current_ylim = preserve_zoom
|
|
4564
5091
|
if current_xlim is not None and current_ylim is not None:
|
|
4565
5092
|
self.ax.set_xlim(current_xlim)
|
|
4566
5093
|
self.ax.set_ylim(current_ylim)
|
|
4567
|
-
|
|
4568
|
-
|
|
5094
|
+
|
|
4569
5095
|
# Find the existing image for channel (paint channel)
|
|
4570
5096
|
channel_image = None
|
|
4571
5097
|
for img in self.ax.images:
|
|
4572
5098
|
if img.cmap.name == f'custom_{channel}':
|
|
4573
5099
|
channel_image = img
|
|
4574
5100
|
break
|
|
4575
|
-
|
|
5101
|
+
|
|
4576
5102
|
if channel_image is not None:
|
|
4577
|
-
# Update the data of the existing image
|
|
4578
|
-
|
|
5103
|
+
# Update the data of the existing image with thread safety
|
|
5104
|
+
with self.paint_lock:
|
|
5105
|
+
channel_image.set_array(self.channel_data[channel][self.current_slice])
|
|
4579
5106
|
|
|
4580
5107
|
# Restore the static background (all other channels) at current zoom level
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
5108
|
+
# This is the key - use static_background from update_display, not paint_background
|
|
5109
|
+
if hasattr(self, 'static_background') and self.static_background is not None:
|
|
5110
|
+
self.canvas.restore_region(self.static_background)
|
|
5111
|
+
# Draw just our paint channel
|
|
5112
|
+
self.ax.draw_artist(channel_image)
|
|
5113
|
+
# Blit everything
|
|
5114
|
+
self.canvas.blit(self.ax.bbox)
|
|
5115
|
+
self.canvas.flush_events()
|
|
5116
|
+
else:
|
|
5117
|
+
# Fallback to full draw if no static background
|
|
5118
|
+
self.canvas.draw()
|
|
5119
|
+
else:
|
|
5120
|
+
# Fallback if channel image not found
|
|
5121
|
+
self.canvas.draw()
|
|
5122
|
+
|
|
5123
|
+
def get_channel_image(self, channel):
|
|
5124
|
+
"""Find the matplotlib image object for a specific channel."""
|
|
5125
|
+
if not hasattr(self.ax, 'images'):
|
|
5126
|
+
return None
|
|
5127
|
+
|
|
5128
|
+
for img in self.ax.images:
|
|
5129
|
+
if hasattr(img, 'cmap') and hasattr(img.cmap, 'name'):
|
|
5130
|
+
if img.cmap.name == f'custom_{channel}':
|
|
5131
|
+
return img
|
|
5132
|
+
return None
|
|
4587
5133
|
|
|
4588
5134
|
def show_netshow_dialog(self):
|
|
4589
5135
|
dialog = NetShowDialog(self)
|
|
@@ -5480,6 +6026,10 @@ class PropertiesDialog(QDialog):
|
|
|
5480
6026
|
run_button.clicked.connect(self.run_properties)
|
|
5481
6027
|
layout.addWidget(run_button)
|
|
5482
6028
|
|
|
6029
|
+
report_button = QPushButton("Report Properties (Show in Top Right Tables)")
|
|
6030
|
+
report_button.clicked.connect(self.report)
|
|
6031
|
+
layout.addWidget(report_button)
|
|
6032
|
+
|
|
5483
6033
|
def check_checked(self, ques):
|
|
5484
6034
|
|
|
5485
6035
|
if ques is None:
|
|
@@ -5517,6 +6067,30 @@ class PropertiesDialog(QDialog):
|
|
|
5517
6067
|
except Exception as e:
|
|
5518
6068
|
print(f"Error: {e}")
|
|
5519
6069
|
|
|
6070
|
+
def report(self):
|
|
6071
|
+
|
|
6072
|
+
try:
|
|
6073
|
+
|
|
6074
|
+
self.parent().format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity', 'Node Identities')
|
|
6075
|
+
except:
|
|
6076
|
+
pass
|
|
6077
|
+
try:
|
|
6078
|
+
|
|
6079
|
+
self.parent().format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
|
|
6080
|
+
except:
|
|
6081
|
+
pass
|
|
6082
|
+
|
|
6083
|
+
try:
|
|
6084
|
+
self.parent().format_for_upperright_table(my_network.edge_centroids, 'EdgeID', ['Z', 'Y', 'X'], 'Edge Centroids')
|
|
6085
|
+
except:
|
|
6086
|
+
pass
|
|
6087
|
+
try:
|
|
6088
|
+
self.parent().format_for_upperright_table(my_network.communities, 'NodeID', 'Community', 'Node Communities')
|
|
6089
|
+
except:
|
|
6090
|
+
pass
|
|
6091
|
+
|
|
6092
|
+
|
|
6093
|
+
|
|
5520
6094
|
class BrightnessContrastDialog(QDialog):
|
|
5521
6095
|
def __init__(self, parent=None):
|
|
5522
6096
|
super().__init__(parent)
|
|
@@ -5896,6 +6470,72 @@ class ArbitraryDialog(QDialog):
|
|
|
5896
6470
|
except Exception as e:
|
|
5897
6471
|
QMessageBox.critical(self, "Error", f"Error processing selections: {str(e)}")
|
|
5898
6472
|
|
|
6473
|
+
class MergeNodeIdDialog(QDialog):
|
|
6474
|
+
|
|
6475
|
+
def __init__(self, parent=None):
|
|
6476
|
+
super().__init__(parent)
|
|
6477
|
+
self.setWindowTitle("Merging Node Identities From Folder Dialog.\nNote that you should prelabel or prewatershed your current node objects before doing this. (See Process -> Image) It does not label them for you.")
|
|
6478
|
+
self.setModal(True)
|
|
6479
|
+
|
|
6480
|
+
layout = QFormLayout(self)
|
|
6481
|
+
|
|
6482
|
+
self.search = QLineEdit("")
|
|
6483
|
+
layout.addRow("Step-out distance (from current nodes image - ignore if you dilated them previously or don't want):", self.search)
|
|
6484
|
+
|
|
6485
|
+
self.xy_scale = QLineEdit(f"{my_network.xy_scale}")
|
|
6486
|
+
layout.addRow("xy_scale:", self.xy_scale)
|
|
6487
|
+
|
|
6488
|
+
self.z_scale = QLineEdit(f"{my_network.z_scale}")
|
|
6489
|
+
layout.addRow("z_scale:", self.z_scale)
|
|
6490
|
+
|
|
6491
|
+
# Add Run button
|
|
6492
|
+
run_button = QPushButton("Get Directory")
|
|
6493
|
+
run_button.clicked.connect(self.run)
|
|
6494
|
+
layout.addWidget(run_button)
|
|
6495
|
+
|
|
6496
|
+
def run(self):
|
|
6497
|
+
|
|
6498
|
+
try:
|
|
6499
|
+
|
|
6500
|
+
search = float(self.search.text()) if self.search.text().strip() else 0
|
|
6501
|
+
xy_scale = float(self.xy_scale.text()) if self.xy_scale.text().strip() else 1
|
|
6502
|
+
z_scale = float(self.z_scale.text()) if self.z_scale.text().strip() else 1
|
|
6503
|
+
|
|
6504
|
+
|
|
6505
|
+
data = self.parent().channel_data[0]
|
|
6506
|
+
|
|
6507
|
+
if data is None:
|
|
6508
|
+
return
|
|
6509
|
+
|
|
6510
|
+
|
|
6511
|
+
|
|
6512
|
+
dialog = QFileDialog(self)
|
|
6513
|
+
dialog.setOption(QFileDialog.Option.DontUseNativeDialog)
|
|
6514
|
+
dialog.setOption(QFileDialog.Option.ReadOnly)
|
|
6515
|
+
dialog.setFileMode(QFileDialog.FileMode.Directory)
|
|
6516
|
+
dialog.setViewMode(QFileDialog.ViewMode.Detail)
|
|
6517
|
+
|
|
6518
|
+
if dialog.exec() == QFileDialog.DialogCode.Accepted:
|
|
6519
|
+
selected_path = dialog.directory().absolutePath()
|
|
6520
|
+
|
|
6521
|
+
if search > 0:
|
|
6522
|
+
data = sdl.smart_dilate(data, 1, 1, GPU = False, fast_dil = False, use_dt_dil_amount = search, xy_scale = xy_scale, z_scale = z_scale)
|
|
6523
|
+
|
|
6524
|
+
my_network.merge_node_ids(selected_path, data)
|
|
6525
|
+
|
|
6526
|
+
self.parent().format_for_upperright_table(my_network.node_identities, 'NodeID', 'Identity')
|
|
6527
|
+
|
|
6528
|
+
QMessageBox.critical(
|
|
6529
|
+
self,
|
|
6530
|
+
"Success",
|
|
6531
|
+
"Node Identities Merged. New IDs represent presence of corresponding img foreground with +, absence with -. Please save your new identities as csv, then use File -> Load -> Load From Excel Helper to bulk search and rename desired combinations. (Press Help [above] for more info)"
|
|
6532
|
+
)
|
|
6533
|
+
|
|
6534
|
+
self.accept()
|
|
6535
|
+
|
|
6536
|
+
except Exception as e:
|
|
6537
|
+
print(f"Error: {e}")
|
|
6538
|
+
|
|
5899
6539
|
|
|
5900
6540
|
class Show3dDialog(QDialog):
|
|
5901
6541
|
def __init__(self, parent=None):
|
|
@@ -7018,6 +7658,10 @@ class RadDialog(QDialog):
|
|
|
7018
7658
|
self.parent().radii_dict[0] = radii
|
|
7019
7659
|
elif self.parent().active_channel == 1:
|
|
7020
7660
|
self.parent().radii_dict[1] = radii
|
|
7661
|
+
elif self.parent().active_channel == 2:
|
|
7662
|
+
self.parent().radii_dict[2] = radii
|
|
7663
|
+
elif self.parent().active_channel == 3:
|
|
7664
|
+
self.parent().radii_dict[3] = radii
|
|
7021
7665
|
|
|
7022
7666
|
self.parent().format_for_upperright_table(radii, title = 'Largest Radii of Objects', metric='ObjectID', value='Largest Radius (Scaled)')
|
|
7023
7667
|
|
|
@@ -7964,7 +8608,7 @@ class ThresholdDialog(QDialog):
|
|
|
7964
8608
|
|
|
7965
8609
|
# Add mode selection dropdown
|
|
7966
8610
|
self.mode_selector = QComboBox()
|
|
7967
|
-
self.mode_selector.addItems(["Using Label/Brightness", "Using Volumes"])
|
|
8611
|
+
self.mode_selector.addItems(["Using Label/Brightness", "Using Volumes", "Using Radii", "Using Node Degree"])
|
|
7968
8612
|
self.mode_selector.setCurrentIndex(0) # Default to Mode 1
|
|
7969
8613
|
layout.addRow("Execution Mode:", self.mode_selector)
|
|
7970
8614
|
|
|
@@ -7997,6 +8641,22 @@ class ThresholdDialog(QDialog):
|
|
|
7997
8641
|
if self.parent().volume_dict[self.parent().active_channel] is None:
|
|
7998
8642
|
self.parent().volumes()
|
|
7999
8643
|
|
|
8644
|
+
elif accepted_mode == 2:
|
|
8645
|
+
if len(np.unique(self.parent().channel_data[self.parent().active_channel])) < 3:
|
|
8646
|
+
self.parent().show_label_dialog()
|
|
8647
|
+
|
|
8648
|
+
if self.parent().radii_dict[self.parent().active_channel] is None:
|
|
8649
|
+
self.parent().show_rad_dialog()
|
|
8650
|
+
|
|
8651
|
+
if self.parent().radii_dict[self.parent().active_channel] is None:
|
|
8652
|
+
return
|
|
8653
|
+
|
|
8654
|
+
elif accepted_mode == 3:
|
|
8655
|
+
|
|
8656
|
+
if my_network.nodes is None or my_network.network is None:
|
|
8657
|
+
print("Error - please calculate network first")
|
|
8658
|
+
return
|
|
8659
|
+
|
|
8000
8660
|
if self.parent().mini_overlay_data is not None:
|
|
8001
8661
|
self.parent().mini_overlay_data = None
|
|
8002
8662
|
|
|
@@ -8005,6 +8665,8 @@ class ThresholdDialog(QDialog):
|
|
|
8005
8665
|
self.highlight_overlay = None
|
|
8006
8666
|
self.accept()
|
|
8007
8667
|
except:
|
|
8668
|
+
import traceback
|
|
8669
|
+
traceback.print_exc()
|
|
8008
8670
|
pass
|
|
8009
8671
|
|
|
8010
8672
|
def start_ml(self, GPU = False):
|
|
@@ -8696,7 +9358,11 @@ class MachineWindow(QMainWindow):
|
|
|
8696
9358
|
|
|
8697
9359
|
print("Segmenting entire volume with model...")
|
|
8698
9360
|
#foreground_coords, background_coords = self.segmenter.segment_volume(array = self.parent().highlight_overlay)
|
|
8699
|
-
|
|
9361
|
+
try:
|
|
9362
|
+
self.parent().highlight_overlay = self.segmenter.segment_volume(array = self.parent().highlight_overlay)
|
|
9363
|
+
except Exception as e:
|
|
9364
|
+
print(f"Error segmenting (Perhaps retrain the model...): {e}")
|
|
9365
|
+
return
|
|
8700
9366
|
|
|
8701
9367
|
# Clean up when done
|
|
8702
9368
|
self.segmenter.cleanup()
|
|
@@ -8717,23 +9383,27 @@ class MachineWindow(QMainWindow):
|
|
|
8717
9383
|
print("Finished segmentation moved to Overlay 2. Use File -> Save(As) for disk saving.")
|
|
8718
9384
|
|
|
8719
9385
|
def closeEvent(self, event):
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
|
|
8723
|
-
if self.
|
|
8724
|
-
|
|
8725
|
-
self.
|
|
8726
|
-
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
9386
|
+
|
|
9387
|
+
try:
|
|
9388
|
+
if self.parent().isVisible():
|
|
9389
|
+
if self.confirm_close_dialog():
|
|
9390
|
+
# Clean up resources before closing
|
|
9391
|
+
if self.brush_button.isChecked():
|
|
9392
|
+
self.silence_button()
|
|
9393
|
+
self.toggle_brush_mode()
|
|
9394
|
+
|
|
9395
|
+
self.parent().pen_button.setEnabled(True)
|
|
9396
|
+
self.parent().brush_mode = False
|
|
9397
|
+
|
|
9398
|
+
# Kill the segmentation thread and wait for it to finish
|
|
9399
|
+
self.kill_segmentation()
|
|
9400
|
+
time.sleep(0.2) # Give additional time for cleanup
|
|
9401
|
+
|
|
9402
|
+
self.parent().machine_window = None
|
|
9403
|
+
else:
|
|
9404
|
+
event.ignore()
|
|
9405
|
+
except:
|
|
9406
|
+
pass
|
|
8737
9407
|
|
|
8738
9408
|
|
|
8739
9409
|
|
|
@@ -8846,6 +9516,8 @@ class ThresholdWindow(QMainWindow):
|
|
|
8846
9516
|
def __init__(self, parent=None, accepted_mode=0):
|
|
8847
9517
|
super().__init__(parent)
|
|
8848
9518
|
self.setWindowTitle("Threshold")
|
|
9519
|
+
|
|
9520
|
+
self.accepted_mode = accepted_mode
|
|
8849
9521
|
|
|
8850
9522
|
# Create central widget and layout
|
|
8851
9523
|
central_widget = QWidget()
|
|
@@ -8857,6 +9529,27 @@ class ThresholdWindow(QMainWindow):
|
|
|
8857
9529
|
self.histo_list = list(self.parent().volume_dict[self.parent().active_channel].values())
|
|
8858
9530
|
self.bounds = False
|
|
8859
9531
|
self.parent().bounds = False
|
|
9532
|
+
elif accepted_mode == 2:
|
|
9533
|
+
self.histo_list = list(self.parent().radii_dict[self.parent().active_channel].values())
|
|
9534
|
+
self.bounds = False
|
|
9535
|
+
self.parent().bounds = False
|
|
9536
|
+
elif accepted_mode == 3:
|
|
9537
|
+
self.parent().degree_dict = {}
|
|
9538
|
+
self.parent().set_active_channel(0)
|
|
9539
|
+
nodes = list(my_network.network.nodes())
|
|
9540
|
+
img_nodes = list(np.unique(my_network.nodes))
|
|
9541
|
+
if 0 in img_nodes:
|
|
9542
|
+
del img_nodes[0]
|
|
9543
|
+
for node in img_nodes:
|
|
9544
|
+
if node in nodes:
|
|
9545
|
+
self.parent().degree_dict[int(node)] = my_network.network.degree(node)
|
|
9546
|
+
else:
|
|
9547
|
+
self.parent().degree_dict[int(node)] = 0
|
|
9548
|
+
|
|
9549
|
+
self.histo_list = list(self.parent().degree_dict.values())
|
|
9550
|
+
self.bounds = False
|
|
9551
|
+
self.parent().bounds = False
|
|
9552
|
+
|
|
8860
9553
|
elif accepted_mode == 0:
|
|
8861
9554
|
targ_shape = self.parent().channel_data[self.parent().active_channel].shape
|
|
8862
9555
|
if (targ_shape[0] + targ_shape[1] + targ_shape[2]) > 2500: #Take a simpler histogram on big arrays
|
|
@@ -8976,17 +9669,35 @@ class ThresholdWindow(QMainWindow):
|
|
|
8976
9669
|
|
|
8977
9670
|
def get_values_in_range_all_vols(self, chan, min_val, max_val):
|
|
8978
9671
|
output = []
|
|
8979
|
-
|
|
8980
|
-
|
|
8981
|
-
|
|
9672
|
+
if self.accepted_mode == 1:
|
|
9673
|
+
for node, vol in self.parent().volume_dict[chan].items():
|
|
9674
|
+
if min_val <= vol <= max_val:
|
|
9675
|
+
output.append(node)
|
|
9676
|
+
elif self.accepted_mode == 2:
|
|
9677
|
+
for node, vol in self.parent().radii_dict[chan].items():
|
|
9678
|
+
if min_val <= vol <= max_val:
|
|
9679
|
+
output.append(node)
|
|
9680
|
+
elif self.accepted_mode == 3:
|
|
9681
|
+
for node, vol in self.parent().degree_dict.items():
|
|
9682
|
+
if min_val <= vol <= max_val:
|
|
9683
|
+
output.append(node)
|
|
8982
9684
|
return output
|
|
8983
9685
|
|
|
8984
9686
|
def get_values_in_range(self, lst, min_val, max_val):
|
|
8985
9687
|
values = [x for x in lst if min_val <= x <= max_val]
|
|
8986
9688
|
output = []
|
|
8987
|
-
|
|
8988
|
-
|
|
8989
|
-
|
|
9689
|
+
if self.accepted_mode == 1:
|
|
9690
|
+
for item in self.parent().volume_dict[self.parent().active_channel]:
|
|
9691
|
+
if self.parent().volume_dict[self.parent().active_channel][item] in values:
|
|
9692
|
+
output.append(item)
|
|
9693
|
+
elif self.accepted_mode == 2:
|
|
9694
|
+
for item in self.parent().radii_dict[self.parent().active_channel]:
|
|
9695
|
+
if self.parent().radii_dict[self.parent().active_channel][item] in values:
|
|
9696
|
+
output.append(item)
|
|
9697
|
+
elif self.accepted_mode == 3:
|
|
9698
|
+
for item in self.parent().degree_dict:
|
|
9699
|
+
if self.parent().degree_dict[item] in values:
|
|
9700
|
+
output.append(item)
|
|
8990
9701
|
return output
|
|
8991
9702
|
|
|
8992
9703
|
|
|
@@ -9421,15 +10132,20 @@ class HoleDialog(QDialog):
|
|
|
9421
10132
|
# auto checkbox (default True)
|
|
9422
10133
|
self.headon = QPushButton("Head-on")
|
|
9423
10134
|
self.headon.setCheckable(True)
|
|
9424
|
-
self.headon.setChecked(
|
|
10135
|
+
self.headon.setChecked(True)
|
|
9425
10136
|
layout.addRow("Only Use 2D Slicing Dimension:", self.headon)
|
|
9426
10137
|
|
|
9427
10138
|
# auto checkbox (default True)
|
|
9428
10139
|
self.borders = QPushButton("Borders")
|
|
9429
10140
|
self.borders.setCheckable(True)
|
|
9430
|
-
self.borders.setChecked(
|
|
10141
|
+
self.borders.setChecked(False)
|
|
9431
10142
|
layout.addRow("Fill Small Holes Along Borders:", self.borders)
|
|
9432
10143
|
|
|
10144
|
+
self.sep_holes = QPushButton("Seperate Hole Mask")
|
|
10145
|
+
self.sep_holes.setCheckable(True)
|
|
10146
|
+
self.sep_holes.setChecked(False)
|
|
10147
|
+
layout.addRow("Place Hole Mask in Overlay 2 (Instead of Filling):", self.sep_holes)
|
|
10148
|
+
|
|
9433
10149
|
# Add Run button
|
|
9434
10150
|
run_button = QPushButton("Run Fill Holes")
|
|
9435
10151
|
run_button.clicked.connect(self.run_holes)
|
|
@@ -9446,6 +10162,7 @@ class HoleDialog(QDialog):
|
|
|
9446
10162
|
|
|
9447
10163
|
borders = self.borders.isChecked()
|
|
9448
10164
|
headon = self.headon.isChecked()
|
|
10165
|
+
sep_holes = self.sep_holes.isChecked()
|
|
9449
10166
|
|
|
9450
10167
|
# Call dilate method with parameters
|
|
9451
10168
|
result = n3d.fill_holes_3d(
|
|
@@ -9454,7 +10171,11 @@ class HoleDialog(QDialog):
|
|
|
9454
10171
|
fill_borders = borders
|
|
9455
10172
|
)
|
|
9456
10173
|
|
|
9457
|
-
|
|
10174
|
+
if not sep_holes:
|
|
10175
|
+
self.parent().load_channel(self.parent().active_channel, result, True)
|
|
10176
|
+
else:
|
|
10177
|
+
self.parent().load_channel(3, active_data - result, True)
|
|
10178
|
+
|
|
9458
10179
|
|
|
9459
10180
|
self.parent().update_display()
|
|
9460
10181
|
self.accept()
|
|
@@ -10037,7 +10758,7 @@ class CentroidNodeDialog(QDialog):
|
|
|
10037
10758
|
|
|
10038
10759
|
else:
|
|
10039
10760
|
|
|
10040
|
-
my_network.nodes, my_network.
|
|
10761
|
+
my_network.nodes, my_network.node_centroids = my_network.centroid_array(clip = True)
|
|
10041
10762
|
|
|
10042
10763
|
self.parent().format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
|
|
10043
10764
|
|
|
@@ -10315,6 +11036,12 @@ class BranchDialog(QDialog):
|
|
|
10315
11036
|
self.fix2.setChecked(True)
|
|
10316
11037
|
correction_layout.addWidget(QLabel("Auto-Correct Branches by Collapsing Internal Labels: "), 3, 0)
|
|
10317
11038
|
correction_layout.addWidget(self.fix2, 3, 1)
|
|
11039
|
+
|
|
11040
|
+
self.fix3 = QPushButton("Split Nontouching Branches?")
|
|
11041
|
+
self.fix3.setCheckable(True)
|
|
11042
|
+
self.fix3.setChecked(True)
|
|
11043
|
+
correction_layout.addWidget(QLabel("Split Nontouching Branches? (Useful if branch pruning - may want to threshold out small, split branches after): "), 4, 0)
|
|
11044
|
+
correction_layout.addWidget(self.fix3, 4, 1)
|
|
10318
11045
|
|
|
10319
11046
|
correction_group.setLayout(correction_layout)
|
|
10320
11047
|
main_layout.addWidget(correction_group)
|
|
@@ -10385,6 +11112,7 @@ class BranchDialog(QDialog):
|
|
|
10385
11112
|
cubic = self.cubic.isChecked()
|
|
10386
11113
|
fix = self.fix.isChecked()
|
|
10387
11114
|
fix2 = self.fix2.isChecked()
|
|
11115
|
+
fix3 = self.fix3.isChecked()
|
|
10388
11116
|
fix_val = float(self.fix_val.text()) if self.fix_val.text() else None
|
|
10389
11117
|
seed = int(self.seed.text()) if self.seed.text() else None
|
|
10390
11118
|
|
|
@@ -10434,6 +11162,10 @@ class BranchDialog(QDialog):
|
|
|
10434
11162
|
|
|
10435
11163
|
output = temp_network.nodes
|
|
10436
11164
|
|
|
11165
|
+
if fix3:
|
|
11166
|
+
|
|
11167
|
+
output = self.parent().separate_nontouching_objects(output, max_val=np.max(output))
|
|
11168
|
+
|
|
10437
11169
|
|
|
10438
11170
|
if down_factor is not None:
|
|
10439
11171
|
|
|
@@ -10946,11 +11678,11 @@ class CalcAllDialog(QDialog):
|
|
|
10946
11678
|
|
|
10947
11679
|
self.search = QLineEdit(self.prev_search)
|
|
10948
11680
|
self.search.setPlaceholderText("Leave empty for None")
|
|
10949
|
-
important_layout.addRow("Node Search (float):", self.search)
|
|
11681
|
+
important_layout.addRow("Node Search (float - Does not merge nodes):", self.search)
|
|
10950
11682
|
|
|
10951
11683
|
self.diledge = QLineEdit(self.prev_diledge)
|
|
10952
11684
|
self.diledge.setPlaceholderText("Leave empty for None")
|
|
10953
|
-
important_layout.addRow("Edge
|
|
11685
|
+
important_layout.addRow("Edge Search (float - Note that edges that find each other will merge):", self.diledge)
|
|
10954
11686
|
|
|
10955
11687
|
self.label_nodes = QPushButton("Label")
|
|
10956
11688
|
self.label_nodes.setCheckable(True)
|