nettracer3d 0.7.8__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 +920 -185
- 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.8.dist-info → nettracer3d-0.8.0.dist-info}/METADATA +9 -12
- nettracer3d-0.8.0.dist-info/RECORD +23 -0
- nettracer3d-0.7.8.dist-info/RECORD +0 -23
- {nettracer3d-0.7.8.dist-info → nettracer3d-0.8.0.dist-info}/WHEEL +0 -0
- {nettracer3d-0.7.8.dist-info → nettracer3d-0.8.0.dist-info}/entry_points.txt +0 -0
- {nettracer3d-0.7.8.dist-info → nettracer3d-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {nettracer3d-0.7.8.dist-info → nettracer3d-0.8.0.dist-info}/top_level.txt +0 -0
nettracer3d/nettracer_gui.py
CHANGED
|
@@ -25,8 +25,14 @@ import multiprocessing as mp
|
|
|
25
25
|
from concurrent.futures import ThreadPoolExecutor
|
|
26
26
|
from functools import partial
|
|
27
27
|
from nettracer3d import segmenter
|
|
28
|
-
|
|
28
|
+
try:
|
|
29
|
+
from nettracer3d import segmenter_GPU as seg_GPU
|
|
30
|
+
except:
|
|
31
|
+
pass
|
|
29
32
|
from nettracer3d import excelotron
|
|
33
|
+
import threading
|
|
34
|
+
import queue
|
|
35
|
+
from threading import Lock
|
|
30
36
|
|
|
31
37
|
|
|
32
38
|
|
|
@@ -41,6 +47,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
41
47
|
self.channel_visible = [False] * 4
|
|
42
48
|
self.current_slice = 0
|
|
43
49
|
self.active_channel = 0 # Initialize active channel
|
|
50
|
+
self.node_name = "Root_Nodes"
|
|
44
51
|
|
|
45
52
|
self.color_dictionary = {
|
|
46
53
|
# Reds
|
|
@@ -105,6 +112,9 @@ class ImageViewerWindow(QMainWindow):
|
|
|
105
112
|
self.selection_rect = None
|
|
106
113
|
self.click_start_time = None # Add this to track when click started
|
|
107
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
|
|
108
118
|
|
|
109
119
|
# Initialize zoom mode state
|
|
110
120
|
self.zoom_mode = False
|
|
@@ -116,6 +126,15 @@ class ImageViewerWindow(QMainWindow):
|
|
|
116
126
|
self.pan_mode = False
|
|
117
127
|
self.panning = False
|
|
118
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
|
|
119
138
|
|
|
120
139
|
#For ML segmenting mode
|
|
121
140
|
self.brush_mode = False
|
|
@@ -154,7 +173,9 @@ class ImageViewerWindow(QMainWindow):
|
|
|
154
173
|
|
|
155
174
|
self.radii_dict = {
|
|
156
175
|
0: None,
|
|
157
|
-
1: None
|
|
176
|
+
1: None,
|
|
177
|
+
2: None,
|
|
178
|
+
3: None
|
|
158
179
|
}
|
|
159
180
|
|
|
160
181
|
self.original_shape = None #For undoing resamples
|
|
@@ -420,6 +441,25 @@ class ImageViewerWindow(QMainWindow):
|
|
|
420
441
|
self.excel_manager.data_received.connect(self.handle_excel_data)
|
|
421
442
|
self.prev_coms = None
|
|
422
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
|
+
|
|
423
463
|
def start_left_scroll(self):
|
|
424
464
|
"""Start scrolling left when left arrow is pressed."""
|
|
425
465
|
# Single increment first
|
|
@@ -791,6 +831,9 @@ class ImageViewerWindow(QMainWindow):
|
|
|
791
831
|
try:
|
|
792
832
|
# Create context menu
|
|
793
833
|
context_menu = QMenu(self)
|
|
834
|
+
|
|
835
|
+
find = context_menu.addAction("Find Node/Edge")
|
|
836
|
+
find.triggered.connect(self.handle_find)
|
|
794
837
|
|
|
795
838
|
# Create "Show Neighbors" submenu
|
|
796
839
|
neighbors_menu = QMenu("Show Neighbors", self)
|
|
@@ -1447,6 +1490,109 @@ class ImageViewerWindow(QMainWindow):
|
|
|
1447
1490
|
except Exception as e:
|
|
1448
1491
|
print(f"Error showing identities: {e}")
|
|
1449
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
|
+
|
|
1450
1596
|
|
|
1451
1597
|
def handle_select_all(self, nodes = True, edges = False):
|
|
1452
1598
|
|
|
@@ -1627,63 +1773,32 @@ class ImageViewerWindow(QMainWindow):
|
|
|
1627
1773
|
except Exception as e:
|
|
1628
1774
|
print(f"An error has occured: {e}")
|
|
1629
1775
|
|
|
1630
|
-
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")
|
|
1631
1782
|
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
# Step 1: Perform connected component labeling on the entire binary mask
|
|
1652
|
-
binary_mask = input_array > 0
|
|
1653
|
-
structure = np.ones((3,) * input_array.ndim, dtype=bool) # 3x3x3 connectivity for 3D or 3x3 for 2D
|
|
1654
|
-
labeled_array, num_features = ndi.label(binary_mask, structure=structure)
|
|
1655
|
-
|
|
1656
|
-
# Step 2: Map the original labels to the new connected components
|
|
1657
|
-
# Create a sparse matrix to efficiently store label mappings
|
|
1658
|
-
coords = np.nonzero(input_array)
|
|
1659
|
-
original_values = input_array[coords]
|
|
1660
|
-
new_labels = labeled_array[coords]
|
|
1661
|
-
|
|
1662
|
-
# Create a mapping of (original_label, new_connected_component) pairs
|
|
1663
|
-
label_mapping = {}
|
|
1664
|
-
for orig, new in zip(original_values, new_labels):
|
|
1665
|
-
if orig not in label_mapping:
|
|
1666
|
-
label_mapping[orig] = []
|
|
1667
|
-
if new not in label_mapping[orig]:
|
|
1668
|
-
label_mapping[orig].append(new)
|
|
1669
|
-
|
|
1670
|
-
# Step 3: Create a new output array with unique labels for each connected component
|
|
1671
|
-
output_array = np.zeros_like(input_array)
|
|
1672
|
-
next_label = 1 + max_val
|
|
1673
|
-
|
|
1674
|
-
# Map of (original_label, connected_component) -> new_unique_label
|
|
1675
|
-
unique_label_map = {}
|
|
1676
|
-
|
|
1677
|
-
for orig_label, cc_list in label_mapping.items():
|
|
1678
|
-
for cc in cc_list:
|
|
1679
|
-
unique_label_map[(orig_label, cc)] = next_label
|
|
1680
|
-
# Create a mask for this original label and connected component
|
|
1681
|
-
mask = (input_array == orig_label) & (labeled_array == cc)
|
|
1682
|
-
# Assign the new unique label
|
|
1683
|
-
output_array[mask] = next_label
|
|
1684
|
-
next_label += 1
|
|
1685
|
-
|
|
1686
|
-
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):
|
|
1687
1802
|
|
|
1688
1803
|
try:
|
|
1689
1804
|
# Handle nodes
|
|
@@ -1705,7 +1820,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
1705
1820
|
max_val = np.max(non_highlighted)
|
|
1706
1821
|
|
|
1707
1822
|
# Process highlighted part
|
|
1708
|
-
processed_highlights = separate_nontouching_objects(highlighted_nodes, max_val)
|
|
1823
|
+
processed_highlights = self.separate_nontouching_objects(highlighted_nodes, max_val)
|
|
1709
1824
|
|
|
1710
1825
|
# Combine back with non-highlighted parts
|
|
1711
1826
|
my_network.nodes = non_highlighted + processed_highlights
|
|
@@ -1726,13 +1841,13 @@ class ImageViewerWindow(QMainWindow):
|
|
|
1726
1841
|
# Get non-highlighted part of the array
|
|
1727
1842
|
non_highlighted = my_network.edges * (~self.highlight_overlay)
|
|
1728
1843
|
|
|
1729
|
-
if (
|
|
1844
|
+
if (highlighted_edges==non_highlighted).all():
|
|
1730
1845
|
max_val = 0
|
|
1731
1846
|
else:
|
|
1732
1847
|
max_val = np.max(non_highlighted)
|
|
1733
1848
|
|
|
1734
1849
|
# Process highlighted part
|
|
1735
|
-
processed_highlights = separate_nontouching_objects(highlighted_edges, max_val)
|
|
1850
|
+
processed_highlights = self.separate_nontouching_objects(highlighted_edges, max_val)
|
|
1736
1851
|
|
|
1737
1852
|
# Combine back with non-highlighted parts
|
|
1738
1853
|
my_network.edges = non_highlighted + processed_highlights
|
|
@@ -2071,8 +2186,11 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2071
2186
|
|
|
2072
2187
|
self.update_display(preserve_zoom=(current_xlim, current_ylim))
|
|
2073
2188
|
|
|
2189
|
+
|
|
2074
2190
|
def keyPressEvent(self, event):
|
|
2075
2191
|
|
|
2192
|
+
"""Key press shortcuts for main class"""
|
|
2193
|
+
|
|
2076
2194
|
if event.key() == Qt.Key_Z and event.modifiers() & Qt.ControlModifier:
|
|
2077
2195
|
try:
|
|
2078
2196
|
self.load_channel(self.last_change[1], self.last_change[0], True)
|
|
@@ -2088,6 +2206,8 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2088
2206
|
self.machine_window.switch_foreground()
|
|
2089
2207
|
if event.key() == Qt.Key_X:
|
|
2090
2208
|
self.high_button.click()
|
|
2209
|
+
if event.key() == Qt.Key_F and event.modifiers() == Qt.ShiftModifier:
|
|
2210
|
+
self.handle_find()
|
|
2091
2211
|
if self.brush_mode and self.machine_window is None:
|
|
2092
2212
|
if event.key() == Qt.Key_F:
|
|
2093
2213
|
self.toggle_can()
|
|
@@ -2095,6 +2215,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2095
2215
|
self.toggle_threed()
|
|
2096
2216
|
|
|
2097
2217
|
|
|
2218
|
+
|
|
2098
2219
|
def update_brush_cursor(self):
|
|
2099
2220
|
"""Update the cursor to show brush size"""
|
|
2100
2221
|
if not self.brush_mode:
|
|
@@ -2155,7 +2276,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2155
2276
|
painter.end()
|
|
2156
2277
|
|
|
2157
2278
|
def get_line_points(self, x0, y0, x1, y1):
|
|
2158
|
-
"""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."""
|
|
2159
2280
|
points = []
|
|
2160
2281
|
dx = abs(x1 - x0)
|
|
2161
2282
|
dy = abs(y1 - y0)
|
|
@@ -2208,7 +2329,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2208
2329
|
return data_coords[0], data_coords[1]
|
|
2209
2330
|
|
|
2210
2331
|
def on_mouse_press(self, event):
|
|
2211
|
-
"""Handle mouse press events."""
|
|
2332
|
+
"""Handle mouse press events - OPTIMIZED VERSION."""
|
|
2212
2333
|
if event.inaxes != self.ax:
|
|
2213
2334
|
return
|
|
2214
2335
|
|
|
@@ -2246,7 +2367,6 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2246
2367
|
new_xlim = [xdata - x_range, xdata + x_range]
|
|
2247
2368
|
new_ylim = [ydata - y_range, ydata + y_range]
|
|
2248
2369
|
|
|
2249
|
-
|
|
2250
2370
|
if (new_xlim[0] <= self.original_xlim[0] or
|
|
2251
2371
|
new_xlim[1] >= self.original_xlim[1] or
|
|
2252
2372
|
new_ylim[0] <= self.original_ylim[0] or
|
|
@@ -2268,22 +2388,31 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2268
2388
|
self.panning = True
|
|
2269
2389
|
self.pan_start = (event.xdata, event.ydata)
|
|
2270
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()
|
|
2271
2404
|
|
|
2272
2405
|
elif self.brush_mode:
|
|
2273
2406
|
if event.inaxes != self.ax:
|
|
2274
2407
|
return
|
|
2275
2408
|
|
|
2276
|
-
|
|
2277
2409
|
if event.button == 1 or event.button == 3:
|
|
2278
|
-
|
|
2279
2410
|
x, y = int(event.xdata), int(event.ydata)
|
|
2280
2411
|
|
|
2281
|
-
|
|
2282
2412
|
if event.button == 1 and self.can:
|
|
2283
2413
|
self.handle_can(x, y)
|
|
2284
2414
|
return
|
|
2285
2415
|
|
|
2286
|
-
|
|
2287
2416
|
if event.button == 3:
|
|
2288
2417
|
self.erase = True
|
|
2289
2418
|
else:
|
|
@@ -2297,27 +2426,24 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2297
2426
|
else:
|
|
2298
2427
|
channel = 2
|
|
2299
2428
|
|
|
2300
|
-
#
|
|
2301
|
-
self.paint_at_position(x, y, self.erase, channel)
|
|
2302
|
-
|
|
2429
|
+
# Get current zoom to preserve it
|
|
2303
2430
|
current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
|
|
2304
2431
|
current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
|
|
2305
2432
|
|
|
2306
|
-
|
|
2433
|
+
# Paint at initial position
|
|
2434
|
+
self.paint_at_position(x, y, self.erase, channel)
|
|
2435
|
+
|
|
2307
2436
|
self.canvas.draw()
|
|
2308
|
-
#self.update_display(preserve_zoom=(current_xlim, current_ylim))
|
|
2309
|
-
self.restore_channels = []
|
|
2310
|
-
|
|
2311
2437
|
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
elif self.channel_data[i] is not None and self.channel_visible[i] == True:
|
|
2316
|
-
self.channel_visible[i] = False
|
|
2317
|
-
self.restore_channels.append(i)
|
|
2318
|
-
self.update_display(preserve_zoom = (current_xlim, current_ylim), begin_paint = True)
|
|
2319
|
-
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
|
|
2320
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))
|
|
2321
2447
|
|
|
2322
2448
|
elif event.button == 3: # Right click (for context menu)
|
|
2323
2449
|
self.create_context_menu(event)
|
|
@@ -2331,7 +2457,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2331
2457
|
"""Paint pixels within brush radius at given position"""
|
|
2332
2458
|
if self.channel_data[channel] is None:
|
|
2333
2459
|
return
|
|
2334
|
-
|
|
2460
|
+
|
|
2335
2461
|
if erase:
|
|
2336
2462
|
val = 0
|
|
2337
2463
|
elif self.machine_window is None:
|
|
@@ -2340,26 +2466,74 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2340
2466
|
val = 1
|
|
2341
2467
|
else:
|
|
2342
2468
|
val = 2
|
|
2343
|
-
|
|
2344
2469
|
height, width = self.channel_data[channel][self.current_slice].shape
|
|
2345
2470
|
radius = self.brush_size // 2
|
|
2346
|
-
|
|
2471
|
+
|
|
2347
2472
|
# Calculate brush area
|
|
2348
2473
|
for y in range(max(0, center_y - radius), min(height, center_y + radius + 1)):
|
|
2349
2474
|
for x in range(max(0, center_x - radius), min(width, center_x + radius + 1)):
|
|
2350
2475
|
# Check if point is within circular brush area
|
|
2351
|
-
if (x - center_x)
|
|
2352
|
-
|
|
2476
|
+
if (x - center_x) * 2 + (y - center_y) * 2 <= radius ** 2:
|
|
2353
2477
|
if self.threed and self.threedthresh > 1:
|
|
2354
2478
|
amount = (self.threedthresh - 1) / 2
|
|
2355
2479
|
low = max(0, self.current_slice - amount)
|
|
2356
2480
|
high = min(self.channel_data[channel].shape[0] - 1, self.current_slice + amount)
|
|
2357
|
-
|
|
2358
2481
|
for i in range(int(low), int(high + 1)):
|
|
2359
2482
|
self.channel_data[channel][i][y, x] = val
|
|
2360
2483
|
else:
|
|
2361
2484
|
self.channel_data[channel][self.current_slice][y, x] = val
|
|
2362
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
|
+
|
|
2363
2537
|
def handle_can(self, x, y):
|
|
2364
2538
|
|
|
2365
2539
|
|
|
@@ -2422,33 +2596,42 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2422
2596
|
|
|
2423
2597
|
|
|
2424
2598
|
def on_mouse_move(self, event):
|
|
2425
|
-
|
|
2426
|
-
if event.inaxes != self.ax:
|
|
2599
|
+
if not event.inaxes or event.xdata is None or event.ydata is None:
|
|
2427
2600
|
return
|
|
2428
|
-
|
|
2601
|
+
|
|
2602
|
+
current_time = time.time()
|
|
2603
|
+
|
|
2429
2604
|
if self.selection_start and not self.selecting and not self.pan_mode and not self.zoom_mode and not self.brush_mode:
|
|
2430
|
-
# If mouse has moved more than a tiny amount while button is held, start selection
|
|
2431
2605
|
if (abs(event.xdata - self.selection_start[0]) > 1 or
|
|
2432
2606
|
abs(event.ydata - self.selection_start[1]) > 1):
|
|
2433
2607
|
self.selecting = True
|
|
2608
|
+
self.background = self.canvas.copy_from_bbox(self.ax.bbox)
|
|
2609
|
+
|
|
2434
2610
|
self.selection_rect = plt.Rectangle(
|
|
2435
2611
|
(self.selection_start[0], self.selection_start[1]), 0, 0,
|
|
2436
|
-
fill=False, color='white', linestyle='--'
|
|
2612
|
+
fill=False, color='white', linestyle='--', animated=True
|
|
2437
2613
|
)
|
|
2438
2614
|
self.ax.add_patch(self.selection_rect)
|
|
2439
2615
|
|
|
2440
2616
|
if self.selecting and self.selection_rect is not None:
|
|
2441
|
-
#
|
|
2442
|
-
|
|
2443
|
-
|
|
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)
|
|
2444
2627
|
width = abs(event.xdata - self.selection_start[0])
|
|
2445
2628
|
height = abs(event.ydata - self.selection_start[1])
|
|
2446
2629
|
|
|
2447
|
-
self.selection_rect.set_bounds(
|
|
2448
|
-
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)
|
|
2449
2633
|
|
|
2450
2634
|
elif self.panning and self.pan_start is not None:
|
|
2451
|
-
|
|
2452
2635
|
# Calculate the movement
|
|
2453
2636
|
dx = event.xdata - self.pan_start[0]
|
|
2454
2637
|
dy = event.ydata - self.pan_start[1]
|
|
@@ -2461,25 +2644,23 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2461
2644
|
new_xlim = [xlim[0] - dx, xlim[1] - dx]
|
|
2462
2645
|
new_ylim = [ylim[0] - dy, ylim[1] - dy]
|
|
2463
2646
|
|
|
2464
|
-
# Get image bounds
|
|
2465
|
-
if self.
|
|
2466
|
-
img_height, img_width = self.channel_data[0][self.current_slice].shape
|
|
2467
|
-
|
|
2647
|
+
# Get image bounds using cached dimensions
|
|
2648
|
+
if self.img_width is not None: # Changed from self.channel_data[0] check
|
|
2468
2649
|
# Ensure new limits don't go beyond image bounds
|
|
2469
2650
|
if new_xlim[0] < 0:
|
|
2470
2651
|
new_xlim = [0, xlim[1] - xlim[0]]
|
|
2471
|
-
elif new_xlim[1] > img_width:
|
|
2472
|
-
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]
|
|
2473
2654
|
|
|
2474
2655
|
if new_ylim[0] < 0:
|
|
2475
2656
|
new_ylim = [0, ylim[1] - ylim[0]]
|
|
2476
|
-
elif new_ylim[1] > img_height:
|
|
2477
|
-
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]
|
|
2478
2659
|
|
|
2479
2660
|
# Apply new limits
|
|
2480
2661
|
self.ax.set_xlim(new_xlim)
|
|
2481
2662
|
self.ax.set_ylim(new_ylim)
|
|
2482
|
-
self.canvas.draw()
|
|
2663
|
+
self.canvas.draw_idle() # Changed from draw() to draw_idle()
|
|
2483
2664
|
|
|
2484
2665
|
# Update pan start position
|
|
2485
2666
|
self.pan_start = (event.xdata, event.ydata)
|
|
@@ -2487,43 +2668,365 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2487
2668
|
elif self.painting and self.brush_mode:
|
|
2488
2669
|
if event.inaxes != self.ax:
|
|
2489
2670
|
return
|
|
2490
|
-
|
|
2491
|
-
|
|
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
|
|
2492
2679
|
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
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)
|
|
2497
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
|
+
}
|
|
2498
2709
|
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
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
|
|
2503
2740
|
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
points = self.get_line_points(last_x, last_y, x, y)
|
|
2741
|
+
with self.paint_lock:
|
|
2742
|
+
self.execute_paint_operation(paint_op)
|
|
2507
2743
|
|
|
2508
|
-
|
|
2744
|
+
except queue.Empty:
|
|
2745
|
+
continue
|
|
2509
2746
|
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
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):
|
|
2513
2826
|
|
|
2514
|
-
|
|
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)
|
|
2515
2829
|
|
|
2516
|
-
self.
|
|
2517
|
-
|
|
2518
|
-
|
|
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
|
|
2519
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()
|
|
2520
3011
|
|
|
2521
3012
|
def on_mouse_release(self, event):
|
|
2522
|
-
"""Handle mouse release events."""
|
|
3013
|
+
"""Handle mouse release events - OPTIMIZED VERSION."""
|
|
2523
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
|
+
|
|
2524
3019
|
self.panning = False
|
|
2525
3020
|
self.pan_start = None
|
|
2526
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))
|
|
2527
3030
|
elif event.button == 1: # Left button release
|
|
2528
3031
|
if self.selecting and self.selection_rect is not None:
|
|
2529
3032
|
# Get the rectangle bounds
|
|
@@ -2582,18 +3085,15 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2582
3085
|
# Try to highlight the last selected value in tables
|
|
2583
3086
|
if self.clicked_values['edges']:
|
|
2584
3087
|
self.highlight_value_in_tables(self.clicked_values['edges'][-1])
|
|
2585
|
-
|
|
2586
3088
|
|
|
2587
3089
|
elif not self.selecting and self.selection_start: # If we had a click but never started selection
|
|
2588
3090
|
# Handle as a normal click
|
|
2589
3091
|
self.on_mouse_click(event)
|
|
2590
|
-
|
|
2591
3092
|
|
|
2592
|
-
# Clean up
|
|
3093
|
+
# Clean up selection
|
|
2593
3094
|
self.selection_start = None
|
|
2594
3095
|
self.selecting = False
|
|
2595
3096
|
|
|
2596
|
-
|
|
2597
3097
|
if self.selection_rect is not None:
|
|
2598
3098
|
try:
|
|
2599
3099
|
self.selection_rect.remove()
|
|
@@ -2602,13 +3102,28 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2602
3102
|
self.selection_rect = None
|
|
2603
3103
|
self.canvas.draw()
|
|
2604
3104
|
|
|
2605
|
-
|
|
3105
|
+
# OPTIMIZED: Handle brush mode cleanup with paint session management
|
|
3106
|
+
if self.brush_mode and hasattr(self, 'painting') and self.painting:
|
|
2606
3107
|
self.painting = False
|
|
3108
|
+
|
|
3109
|
+
# Restore hidden channels
|
|
2607
3110
|
try:
|
|
2608
3111
|
for i in self.restore_channels:
|
|
2609
3112
|
self.channel_visible[i] = True
|
|
3113
|
+
self.restore_channels = []
|
|
2610
3114
|
except:
|
|
2611
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
|
|
2612
3127
|
current_xlim = self.ax.get_xlim() if hasattr(self, 'ax') and self.ax.get_xlim() != (0, 1) else None
|
|
2613
3128
|
current_ylim = self.ax.get_ylim() if hasattr(self, 'ax') and self.ax.get_ylim() != (0, 1) else None
|
|
2614
3129
|
self.update_display(preserve_zoom=(current_xlim, current_ylim))
|
|
@@ -2883,6 +3398,8 @@ class ImageViewerWindow(QMainWindow):
|
|
|
2883
3398
|
load_action.triggered.connect(lambda: self.load_misc('Edge Centroids'))
|
|
2884
3399
|
load_action = misc_menu.addAction("Merge Nodes")
|
|
2885
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)
|
|
2886
3403
|
|
|
2887
3404
|
|
|
2888
3405
|
# Analysis menu
|
|
@@ -3264,6 +3781,11 @@ class ImageViewerWindow(QMainWindow):
|
|
|
3264
3781
|
except:
|
|
3265
3782
|
pass
|
|
3266
3783
|
|
|
3784
|
+
def show_merge_node_id_dialog(self):
|
|
3785
|
+
|
|
3786
|
+
dialog = MergeNodeIdDialog(self)
|
|
3787
|
+
dialog.exec()
|
|
3788
|
+
|
|
3267
3789
|
|
|
3268
3790
|
def show_watershed_dialog(self):
|
|
3269
3791
|
"""Show the watershed parameter dialog."""
|
|
@@ -3582,7 +4104,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
3582
4104
|
f"Failed to load {sort}: {str(e)}"
|
|
3583
4105
|
)
|
|
3584
4106
|
|
|
3585
|
-
|
|
4107
|
+
elif sort == 'Merge Nodes':
|
|
3586
4108
|
try:
|
|
3587
4109
|
|
|
3588
4110
|
if len(np.unique(my_network.nodes)) < 3:
|
|
@@ -3620,7 +4142,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
3620
4142
|
if dialog.exec() == QFileDialog.DialogCode.Accepted:
|
|
3621
4143
|
selected_path = dialog.directory().absolutePath()
|
|
3622
4144
|
|
|
3623
|
-
my_network.merge_nodes(selected_path)
|
|
4145
|
+
my_network.merge_nodes(selected_path, root_id = self.node_name)
|
|
3624
4146
|
self.load_channel(0, my_network.nodes, True)
|
|
3625
4147
|
|
|
3626
4148
|
|
|
@@ -3638,6 +4160,7 @@ class ImageViewerWindow(QMainWindow):
|
|
|
3638
4160
|
)
|
|
3639
4161
|
|
|
3640
4162
|
|
|
4163
|
+
|
|
3641
4164
|
# Modify load_from_network_obj method
|
|
3642
4165
|
def load_from_network_obj(self):
|
|
3643
4166
|
try:
|
|
@@ -3949,11 +4472,15 @@ class ImageViewerWindow(QMainWindow):
|
|
|
3949
4472
|
return
|
|
3950
4473
|
|
|
3951
4474
|
file_extension = filename.lower().split('.')[-1]
|
|
4475
|
+
|
|
4476
|
+
if channel_index == 0:
|
|
4477
|
+
self.node_name = filename
|
|
3952
4478
|
|
|
3953
4479
|
try:
|
|
3954
4480
|
if file_extension in ['tif', 'tiff']:
|
|
3955
4481
|
import tifffile
|
|
3956
4482
|
self.channel_data[channel_index] = tifffile.imread(filename)
|
|
4483
|
+
|
|
3957
4484
|
|
|
3958
4485
|
elif file_extension == 'nii':
|
|
3959
4486
|
import nibabel as nib
|
|
@@ -4091,6 +4618,9 @@ class ImageViewerWindow(QMainWindow):
|
|
|
4091
4618
|
|
|
4092
4619
|
self.shape = self.channel_data[channel_index].shape
|
|
4093
4620
|
|
|
4621
|
+
self.img_height, self.img_width = self.shape[1], self.shape[2]
|
|
4622
|
+
|
|
4623
|
+
|
|
4094
4624
|
self.update_display(reset_resize = reset_resize)
|
|
4095
4625
|
|
|
4096
4626
|
|
|
@@ -4551,36 +5081,55 @@ class ImageViewerWindow(QMainWindow):
|
|
|
4551
5081
|
import traceback
|
|
4552
5082
|
print(traceback.format_exc())
|
|
4553
5083
|
|
|
4554
|
-
def
|
|
4555
|
-
"""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"""
|
|
4556
5086
|
if not self.channel_visible[channel]:
|
|
4557
5087
|
return
|
|
4558
|
-
|
|
5088
|
+
|
|
4559
5089
|
if preserve_zoom:
|
|
4560
5090
|
current_xlim, current_ylim = preserve_zoom
|
|
4561
5091
|
if current_xlim is not None and current_ylim is not None:
|
|
4562
5092
|
self.ax.set_xlim(current_xlim)
|
|
4563
5093
|
self.ax.set_ylim(current_ylim)
|
|
4564
|
-
|
|
4565
|
-
|
|
5094
|
+
|
|
4566
5095
|
# Find the existing image for channel (paint channel)
|
|
4567
5096
|
channel_image = None
|
|
4568
5097
|
for img in self.ax.images:
|
|
4569
5098
|
if img.cmap.name == f'custom_{channel}':
|
|
4570
5099
|
channel_image = img
|
|
4571
5100
|
break
|
|
4572
|
-
|
|
5101
|
+
|
|
4573
5102
|
if channel_image is not None:
|
|
4574
|
-
# Update the data of the existing image
|
|
4575
|
-
|
|
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])
|
|
4576
5106
|
|
|
4577
5107
|
# Restore the static background (all other channels) at current zoom level
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
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
|
|
4584
5133
|
|
|
4585
5134
|
def show_netshow_dialog(self):
|
|
4586
5135
|
dialog = NetShowDialog(self)
|
|
@@ -5477,6 +6026,10 @@ class PropertiesDialog(QDialog):
|
|
|
5477
6026
|
run_button.clicked.connect(self.run_properties)
|
|
5478
6027
|
layout.addWidget(run_button)
|
|
5479
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
|
+
|
|
5480
6033
|
def check_checked(self, ques):
|
|
5481
6034
|
|
|
5482
6035
|
if ques is None:
|
|
@@ -5514,6 +6067,30 @@ class PropertiesDialog(QDialog):
|
|
|
5514
6067
|
except Exception as e:
|
|
5515
6068
|
print(f"Error: {e}")
|
|
5516
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
|
+
|
|
5517
6094
|
class BrightnessContrastDialog(QDialog):
|
|
5518
6095
|
def __init__(self, parent=None):
|
|
5519
6096
|
super().__init__(parent)
|
|
@@ -5893,6 +6470,72 @@ class ArbitraryDialog(QDialog):
|
|
|
5893
6470
|
except Exception as e:
|
|
5894
6471
|
QMessageBox.critical(self, "Error", f"Error processing selections: {str(e)}")
|
|
5895
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
|
+
|
|
5896
6539
|
|
|
5897
6540
|
class Show3dDialog(QDialog):
|
|
5898
6541
|
def __init__(self, parent=None):
|
|
@@ -7015,6 +7658,10 @@ class RadDialog(QDialog):
|
|
|
7015
7658
|
self.parent().radii_dict[0] = radii
|
|
7016
7659
|
elif self.parent().active_channel == 1:
|
|
7017
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
|
|
7018
7665
|
|
|
7019
7666
|
self.parent().format_for_upperright_table(radii, title = 'Largest Radii of Objects', metric='ObjectID', value='Largest Radius (Scaled)')
|
|
7020
7667
|
|
|
@@ -7961,7 +8608,7 @@ class ThresholdDialog(QDialog):
|
|
|
7961
8608
|
|
|
7962
8609
|
# Add mode selection dropdown
|
|
7963
8610
|
self.mode_selector = QComboBox()
|
|
7964
|
-
self.mode_selector.addItems(["Using Label/Brightness", "Using Volumes"])
|
|
8611
|
+
self.mode_selector.addItems(["Using Label/Brightness", "Using Volumes", "Using Radii", "Using Node Degree"])
|
|
7965
8612
|
self.mode_selector.setCurrentIndex(0) # Default to Mode 1
|
|
7966
8613
|
layout.addRow("Execution Mode:", self.mode_selector)
|
|
7967
8614
|
|
|
@@ -7994,6 +8641,22 @@ class ThresholdDialog(QDialog):
|
|
|
7994
8641
|
if self.parent().volume_dict[self.parent().active_channel] is None:
|
|
7995
8642
|
self.parent().volumes()
|
|
7996
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
|
+
|
|
7997
8660
|
if self.parent().mini_overlay_data is not None:
|
|
7998
8661
|
self.parent().mini_overlay_data = None
|
|
7999
8662
|
|
|
@@ -8002,6 +8665,8 @@ class ThresholdDialog(QDialog):
|
|
|
8002
8665
|
self.highlight_overlay = None
|
|
8003
8666
|
self.accept()
|
|
8004
8667
|
except:
|
|
8668
|
+
import traceback
|
|
8669
|
+
traceback.print_exc()
|
|
8005
8670
|
pass
|
|
8006
8671
|
|
|
8007
8672
|
def start_ml(self, GPU = False):
|
|
@@ -8290,7 +8955,7 @@ class MachineWindow(QMainWindow):
|
|
|
8290
8955
|
if not GPU:
|
|
8291
8956
|
self.segmenter = segmenter.InteractiveSegmenter(active_data, use_gpu=False)
|
|
8292
8957
|
else:
|
|
8293
|
-
self.segmenter =
|
|
8958
|
+
self.segmenter = seg_GPU.InteractiveSegmenter(active_data)
|
|
8294
8959
|
|
|
8295
8960
|
self.segmentation_worker = None
|
|
8296
8961
|
|
|
@@ -8396,7 +9061,7 @@ class MachineWindow(QMainWindow):
|
|
|
8396
9061
|
if self.GPU.isChecked():
|
|
8397
9062
|
|
|
8398
9063
|
try:
|
|
8399
|
-
self.segmenter =
|
|
9064
|
+
self.segmenter = seg_GPU.InteractiveSegmenter(active_data)
|
|
8400
9065
|
print("Using GPU")
|
|
8401
9066
|
except:
|
|
8402
9067
|
self.GPU.setChecked(False)
|
|
@@ -8693,7 +9358,11 @@ class MachineWindow(QMainWindow):
|
|
|
8693
9358
|
|
|
8694
9359
|
print("Segmenting entire volume with model...")
|
|
8695
9360
|
#foreground_coords, background_coords = self.segmenter.segment_volume(array = self.parent().highlight_overlay)
|
|
8696
|
-
|
|
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
|
|
8697
9366
|
|
|
8698
9367
|
# Clean up when done
|
|
8699
9368
|
self.segmenter.cleanup()
|
|
@@ -8714,23 +9383,27 @@ class MachineWindow(QMainWindow):
|
|
|
8714
9383
|
print("Finished segmentation moved to Overlay 2. Use File -> Save(As) for disk saving.")
|
|
8715
9384
|
|
|
8716
9385
|
def closeEvent(self, event):
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
if self.
|
|
8721
|
-
|
|
8722
|
-
self.
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
|
|
8726
|
-
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
|
|
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
|
|
8734
9407
|
|
|
8735
9408
|
|
|
8736
9409
|
|
|
@@ -8843,6 +9516,8 @@ class ThresholdWindow(QMainWindow):
|
|
|
8843
9516
|
def __init__(self, parent=None, accepted_mode=0):
|
|
8844
9517
|
super().__init__(parent)
|
|
8845
9518
|
self.setWindowTitle("Threshold")
|
|
9519
|
+
|
|
9520
|
+
self.accepted_mode = accepted_mode
|
|
8846
9521
|
|
|
8847
9522
|
# Create central widget and layout
|
|
8848
9523
|
central_widget = QWidget()
|
|
@@ -8854,6 +9529,27 @@ class ThresholdWindow(QMainWindow):
|
|
|
8854
9529
|
self.histo_list = list(self.parent().volume_dict[self.parent().active_channel].values())
|
|
8855
9530
|
self.bounds = False
|
|
8856
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
|
+
|
|
8857
9553
|
elif accepted_mode == 0:
|
|
8858
9554
|
targ_shape = self.parent().channel_data[self.parent().active_channel].shape
|
|
8859
9555
|
if (targ_shape[0] + targ_shape[1] + targ_shape[2]) > 2500: #Take a simpler histogram on big arrays
|
|
@@ -8973,17 +9669,35 @@ class ThresholdWindow(QMainWindow):
|
|
|
8973
9669
|
|
|
8974
9670
|
def get_values_in_range_all_vols(self, chan, min_val, max_val):
|
|
8975
9671
|
output = []
|
|
8976
|
-
|
|
8977
|
-
|
|
8978
|
-
|
|
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)
|
|
8979
9684
|
return output
|
|
8980
9685
|
|
|
8981
9686
|
def get_values_in_range(self, lst, min_val, max_val):
|
|
8982
9687
|
values = [x for x in lst if min_val <= x <= max_val]
|
|
8983
9688
|
output = []
|
|
8984
|
-
|
|
8985
|
-
|
|
8986
|
-
|
|
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)
|
|
8987
9701
|
return output
|
|
8988
9702
|
|
|
8989
9703
|
|
|
@@ -9418,15 +10132,20 @@ class HoleDialog(QDialog):
|
|
|
9418
10132
|
# auto checkbox (default True)
|
|
9419
10133
|
self.headon = QPushButton("Head-on")
|
|
9420
10134
|
self.headon.setCheckable(True)
|
|
9421
|
-
self.headon.setChecked(
|
|
10135
|
+
self.headon.setChecked(True)
|
|
9422
10136
|
layout.addRow("Only Use 2D Slicing Dimension:", self.headon)
|
|
9423
10137
|
|
|
9424
10138
|
# auto checkbox (default True)
|
|
9425
10139
|
self.borders = QPushButton("Borders")
|
|
9426
10140
|
self.borders.setCheckable(True)
|
|
9427
|
-
self.borders.setChecked(
|
|
10141
|
+
self.borders.setChecked(False)
|
|
9428
10142
|
layout.addRow("Fill Small Holes Along Borders:", self.borders)
|
|
9429
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
|
+
|
|
9430
10149
|
# Add Run button
|
|
9431
10150
|
run_button = QPushButton("Run Fill Holes")
|
|
9432
10151
|
run_button.clicked.connect(self.run_holes)
|
|
@@ -9443,6 +10162,7 @@ class HoleDialog(QDialog):
|
|
|
9443
10162
|
|
|
9444
10163
|
borders = self.borders.isChecked()
|
|
9445
10164
|
headon = self.headon.isChecked()
|
|
10165
|
+
sep_holes = self.sep_holes.isChecked()
|
|
9446
10166
|
|
|
9447
10167
|
# Call dilate method with parameters
|
|
9448
10168
|
result = n3d.fill_holes_3d(
|
|
@@ -9451,7 +10171,11 @@ class HoleDialog(QDialog):
|
|
|
9451
10171
|
fill_borders = borders
|
|
9452
10172
|
)
|
|
9453
10173
|
|
|
9454
|
-
|
|
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
|
+
|
|
9455
10179
|
|
|
9456
10180
|
self.parent().update_display()
|
|
9457
10181
|
self.accept()
|
|
@@ -10034,7 +10758,7 @@ class CentroidNodeDialog(QDialog):
|
|
|
10034
10758
|
|
|
10035
10759
|
else:
|
|
10036
10760
|
|
|
10037
|
-
my_network.nodes, my_network.
|
|
10761
|
+
my_network.nodes, my_network.node_centroids = my_network.centroid_array(clip = True)
|
|
10038
10762
|
|
|
10039
10763
|
self.parent().format_for_upperright_table(my_network.node_centroids, 'NodeID', ['Z', 'Y', 'X'], 'Node Centroids')
|
|
10040
10764
|
|
|
@@ -10312,6 +11036,12 @@ class BranchDialog(QDialog):
|
|
|
10312
11036
|
self.fix2.setChecked(True)
|
|
10313
11037
|
correction_layout.addWidget(QLabel("Auto-Correct Branches by Collapsing Internal Labels: "), 3, 0)
|
|
10314
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)
|
|
10315
11045
|
|
|
10316
11046
|
correction_group.setLayout(correction_layout)
|
|
10317
11047
|
main_layout.addWidget(correction_group)
|
|
@@ -10382,6 +11112,7 @@ class BranchDialog(QDialog):
|
|
|
10382
11112
|
cubic = self.cubic.isChecked()
|
|
10383
11113
|
fix = self.fix.isChecked()
|
|
10384
11114
|
fix2 = self.fix2.isChecked()
|
|
11115
|
+
fix3 = self.fix3.isChecked()
|
|
10385
11116
|
fix_val = float(self.fix_val.text()) if self.fix_val.text() else None
|
|
10386
11117
|
seed = int(self.seed.text()) if self.seed.text() else None
|
|
10387
11118
|
|
|
@@ -10431,6 +11162,10 @@ class BranchDialog(QDialog):
|
|
|
10431
11162
|
|
|
10432
11163
|
output = temp_network.nodes
|
|
10433
11164
|
|
|
11165
|
+
if fix3:
|
|
11166
|
+
|
|
11167
|
+
output = self.parent().separate_nontouching_objects(output, max_val=np.max(output))
|
|
11168
|
+
|
|
10434
11169
|
|
|
10435
11170
|
if down_factor is not None:
|
|
10436
11171
|
|
|
@@ -10943,11 +11678,11 @@ class CalcAllDialog(QDialog):
|
|
|
10943
11678
|
|
|
10944
11679
|
self.search = QLineEdit(self.prev_search)
|
|
10945
11680
|
self.search.setPlaceholderText("Leave empty for None")
|
|
10946
|
-
important_layout.addRow("Node Search (float):", self.search)
|
|
11681
|
+
important_layout.addRow("Node Search (float - Does not merge nodes):", self.search)
|
|
10947
11682
|
|
|
10948
11683
|
self.diledge = QLineEdit(self.prev_diledge)
|
|
10949
11684
|
self.diledge.setPlaceholderText("Leave empty for None")
|
|
10950
|
-
important_layout.addRow("Edge
|
|
11685
|
+
important_layout.addRow("Edge Search (float - Note that edges that find each other will merge):", self.diledge)
|
|
10951
11686
|
|
|
10952
11687
|
self.label_nodes = QPushButton("Label")
|
|
10953
11688
|
self.label_nodes.setCheckable(True)
|