microlive 1.0.12__py3-none-any.whl → 1.0.19__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.
- microlive/__init__.py +1 -1
- microlive/data/models/spot_detection_cnn.pth +0 -0
- microlive/gui/app.py +1412 -58
- microlive/imports.py +5 -1
- microlive/microscopy.py +51 -9
- microlive/pipelines/pipeline_FRAP.py +212 -23
- microlive/pipelines/pipeline_folding_efficiency.py +29 -25
- microlive/pipelines/pipeline_particle_tracking.py +616 -176
- microlive/utils/__init__.py +11 -0
- microlive/utils/model_downloader.py +293 -0
- {microlive-1.0.12.dist-info → microlive-1.0.19.dist-info}/METADATA +2 -1
- microlive-1.0.19.dist-info/RECORD +27 -0
- microlive/pipelines/pipeline_spot_detection_no_tracking.py +0 -368
- microlive-1.0.12.dist-info/RECORD +0 -26
- {microlive-1.0.12.dist-info → microlive-1.0.19.dist-info}/WHEEL +0 -0
- {microlive-1.0.12.dist-info → microlive-1.0.19.dist-info}/entry_points.txt +0 -0
- {microlive-1.0.12.dist-info → microlive-1.0.19.dist-info}/licenses/LICENSE +0 -0
microlive/gui/app.py
CHANGED
|
@@ -101,6 +101,7 @@ from matplotlib.backends.backend_qt5agg import (
|
|
|
101
101
|
FigureCanvasQTAgg as FigureCanvas,
|
|
102
102
|
NavigationToolbar2QT as NavigationToolbar,)
|
|
103
103
|
from mpl_toolkits.axes_grid1.inset_locator import inset_axes
|
|
104
|
+
from mpl_toolkits.mplot3d import Axes3D # For 3D intensity profile visualization
|
|
104
105
|
from functools import partial
|
|
105
106
|
from scipy.optimize import curve_fit
|
|
106
107
|
from scipy.ndimage import gaussian_filter, label, center_of_mass, distance_transform_edt
|
|
@@ -2841,12 +2842,21 @@ class GUI(QMainWindow):
|
|
|
2841
2842
|
if hasattr(self, 'time_slider_display'):
|
|
2842
2843
|
self.time_slider_display.setEnabled(False)
|
|
2843
2844
|
self.time_slider_display.setValue(0)
|
|
2845
|
+
self.time_slider_display.setMaximum(0)
|
|
2846
|
+
if hasattr(self, 'frame_label_display'):
|
|
2847
|
+
self.frame_label_display.setText("0/0")
|
|
2844
2848
|
if hasattr(self, 'play_button_display'):
|
|
2845
2849
|
self.play_button_display.setEnabled(False)
|
|
2846
2850
|
if hasattr(self, 'time_slider_tracking'):
|
|
2847
2851
|
self.time_slider_tracking.setValue(0)
|
|
2852
|
+
self.time_slider_tracking.setMaximum(0)
|
|
2853
|
+
if hasattr(self, 'frame_label_tracking'):
|
|
2854
|
+
self.frame_label_tracking.setText("0/0")
|
|
2848
2855
|
if hasattr(self, 'time_slider_tracking_vis'):
|
|
2849
2856
|
self.time_slider_tracking_vis.setValue(0)
|
|
2857
|
+
self.time_slider_tracking_vis.setMaximum(0)
|
|
2858
|
+
if hasattr(self, 'frame_label_tracking_vis'):
|
|
2859
|
+
self.frame_label_tracking_vis.setText("0/0")
|
|
2850
2860
|
|
|
2851
2861
|
# Stop any playing timers
|
|
2852
2862
|
self.stop_all_playback()
|
|
@@ -2937,12 +2947,21 @@ class GUI(QMainWindow):
|
|
|
2937
2947
|
if hasattr(self, 'time_slider_display'):
|
|
2938
2948
|
self.time_slider_display.setEnabled(False)
|
|
2939
2949
|
self.time_slider_display.setValue(0)
|
|
2950
|
+
self.time_slider_display.setMaximum(0)
|
|
2951
|
+
if hasattr(self, 'frame_label_display'):
|
|
2952
|
+
self.frame_label_display.setText("0/0")
|
|
2940
2953
|
if hasattr(self, 'play_button_display'):
|
|
2941
2954
|
self.play_button_display.setEnabled(False)
|
|
2942
2955
|
if hasattr(self, 'time_slider_tracking'):
|
|
2943
2956
|
self.time_slider_tracking.setValue(0)
|
|
2957
|
+
self.time_slider_tracking.setMaximum(0)
|
|
2958
|
+
if hasattr(self, 'frame_label_tracking'):
|
|
2959
|
+
self.frame_label_tracking.setText("0/0")
|
|
2944
2960
|
if hasattr(self, 'time_slider_tracking_vis'):
|
|
2945
2961
|
self.time_slider_tracking_vis.setValue(0)
|
|
2962
|
+
self.time_slider_tracking_vis.setMaximum(0)
|
|
2963
|
+
if hasattr(self, 'frame_label_tracking_vis'):
|
|
2964
|
+
self.frame_label_tracking_vis.setText("0/0")
|
|
2946
2965
|
|
|
2947
2966
|
# Stop any playing timers
|
|
2948
2967
|
self.stop_all_playback()
|
|
@@ -4164,6 +4183,19 @@ class GUI(QMainWindow):
|
|
|
4164
4183
|
if hasattr(self, 'label_nuc_mask_status'):
|
|
4165
4184
|
self.label_nuc_mask_status.setText("No nucleus mask loaded")
|
|
4166
4185
|
self.label_nuc_mask_status.setStyleSheet("color: gray;")
|
|
4186
|
+
# Reset Improve Segmentation checkboxes
|
|
4187
|
+
if hasattr(self, 'chk_remove_border_cells'):
|
|
4188
|
+
self.chk_remove_border_cells.blockSignals(True)
|
|
4189
|
+
self.chk_remove_border_cells.setChecked(False)
|
|
4190
|
+
self.chk_remove_border_cells.blockSignals(False)
|
|
4191
|
+
if hasattr(self, 'chk_remove_unpaired_cells'):
|
|
4192
|
+
self.chk_remove_unpaired_cells.blockSignals(True)
|
|
4193
|
+
self.chk_remove_unpaired_cells.setChecked(False)
|
|
4194
|
+
self.chk_remove_unpaired_cells.blockSignals(False)
|
|
4195
|
+
if hasattr(self, 'chk_keep_center_cell'):
|
|
4196
|
+
self.chk_keep_center_cell.blockSignals(True)
|
|
4197
|
+
self.chk_keep_center_cell.setChecked(False)
|
|
4198
|
+
self.chk_keep_center_cell.blockSignals(False)
|
|
4167
4199
|
self.plot_cellpose_results()
|
|
4168
4200
|
|
|
4169
4201
|
def clear_imported_masks(self):
|
|
@@ -4581,6 +4613,142 @@ class GUI(QMainWindow):
|
|
|
4581
4613
|
new_masks[masks == old_id] = new_id
|
|
4582
4614
|
return new_masks
|
|
4583
4615
|
|
|
4616
|
+
def get_closest_cell_to_center(self, mask):
|
|
4617
|
+
"""Find the cell ID whose centroid is closest to the image center.
|
|
4618
|
+
|
|
4619
|
+
Args:
|
|
4620
|
+
mask: 2D array [Y, X] with cell labels (0=background, 1,2,3...=cells)
|
|
4621
|
+
|
|
4622
|
+
Returns:
|
|
4623
|
+
int: Cell ID closest to center, or None if no cells
|
|
4624
|
+
"""
|
|
4625
|
+
if mask is None or np.max(mask) == 0:
|
|
4626
|
+
return None
|
|
4627
|
+
|
|
4628
|
+
# Get image center
|
|
4629
|
+
center_y, center_x = mask.shape[0] / 2, mask.shape[1] / 2
|
|
4630
|
+
|
|
4631
|
+
# Get unique cell IDs (exclude background)
|
|
4632
|
+
cell_ids = np.unique(mask)
|
|
4633
|
+
cell_ids = cell_ids[cell_ids > 0]
|
|
4634
|
+
|
|
4635
|
+
if len(cell_ids) == 0:
|
|
4636
|
+
return None
|
|
4637
|
+
|
|
4638
|
+
min_distance = float('inf')
|
|
4639
|
+
closest_cell_id = None
|
|
4640
|
+
|
|
4641
|
+
for cell_id in cell_ids:
|
|
4642
|
+
# Find centroid of this cell
|
|
4643
|
+
coords = np.argwhere(mask == cell_id)
|
|
4644
|
+
centroid_y = np.mean(coords[:, 0])
|
|
4645
|
+
centroid_x = np.mean(coords[:, 1])
|
|
4646
|
+
|
|
4647
|
+
# Calculate distance to image center
|
|
4648
|
+
distance = np.sqrt((centroid_y - center_y)**2 + (centroid_x - center_x)**2)
|
|
4649
|
+
|
|
4650
|
+
if distance < min_distance:
|
|
4651
|
+
min_distance = distance
|
|
4652
|
+
closest_cell_id = cell_id
|
|
4653
|
+
|
|
4654
|
+
return closest_cell_id
|
|
4655
|
+
|
|
4656
|
+
def on_keep_center_cell_changed(self, state):
|
|
4657
|
+
"""Handle checkbox state change for keeping only the center cell.
|
|
4658
|
+
|
|
4659
|
+
For TYX masks: Uses the FIRST frame to identify the center cell,
|
|
4660
|
+
then keeps that cell ID across ALL frames for consistent tracking.
|
|
4661
|
+
"""
|
|
4662
|
+
from PyQt5.QtCore import Qt
|
|
4663
|
+
|
|
4664
|
+
if state == Qt.Checked:
|
|
4665
|
+
# Check if we have any masks
|
|
4666
|
+
has_cyto = False
|
|
4667
|
+
has_nuc = False
|
|
4668
|
+
|
|
4669
|
+
if getattr(self, 'use_tyx_masks', False):
|
|
4670
|
+
has_cyto = self.cellpose_masks_cyto_tyx is not None and np.max(self.cellpose_masks_cyto_tyx) > 0
|
|
4671
|
+
has_nuc = self.cellpose_masks_nuc_tyx is not None and np.max(self.cellpose_masks_nuc_tyx) > 0
|
|
4672
|
+
else:
|
|
4673
|
+
has_cyto = self.cellpose_masks_cyto is not None and np.max(self.cellpose_masks_cyto) > 0
|
|
4674
|
+
has_nuc = self.cellpose_masks_nuc is not None and np.max(self.cellpose_masks_nuc) > 0
|
|
4675
|
+
|
|
4676
|
+
if not has_cyto and not has_nuc:
|
|
4677
|
+
QMessageBox.warning(self, "No Masks",
|
|
4678
|
+
"No segmentation masks available to filter.")
|
|
4679
|
+
self.chk_keep_center_cell.blockSignals(True)
|
|
4680
|
+
self.chk_keep_center_cell.setChecked(False)
|
|
4681
|
+
self.chk_keep_center_cell.blockSignals(False)
|
|
4682
|
+
return
|
|
4683
|
+
|
|
4684
|
+
# Determine which mask to use for finding center cell
|
|
4685
|
+
# Prefer cytosol mask, fall back to nucleus
|
|
4686
|
+
if getattr(self, 'use_tyx_masks', False):
|
|
4687
|
+
# TYX mode: use first frame to find center cell
|
|
4688
|
+
if has_cyto:
|
|
4689
|
+
reference_mask = self.cellpose_masks_cyto_tyx[0]
|
|
4690
|
+
else:
|
|
4691
|
+
reference_mask = self.cellpose_masks_nuc_tyx[0]
|
|
4692
|
+
else:
|
|
4693
|
+
# Standard YX mode
|
|
4694
|
+
if has_cyto:
|
|
4695
|
+
reference_mask = self.cellpose_masks_cyto
|
|
4696
|
+
else:
|
|
4697
|
+
reference_mask = self.cellpose_masks_nuc
|
|
4698
|
+
|
|
4699
|
+
# Find the cell closest to center
|
|
4700
|
+
center_cell_id = self.get_closest_cell_to_center(reference_mask)
|
|
4701
|
+
|
|
4702
|
+
if center_cell_id is None:
|
|
4703
|
+
QMessageBox.warning(self, "No Cells",
|
|
4704
|
+
"Could not find any cells in the mask.")
|
|
4705
|
+
self.chk_keep_center_cell.blockSignals(True)
|
|
4706
|
+
self.chk_keep_center_cell.setChecked(False)
|
|
4707
|
+
self.chk_keep_center_cell.blockSignals(False)
|
|
4708
|
+
return
|
|
4709
|
+
|
|
4710
|
+
# Remove all cells except the center cell
|
|
4711
|
+
if getattr(self, 'use_tyx_masks', False):
|
|
4712
|
+
# TYX mode: remove from all frames
|
|
4713
|
+
if self.cellpose_masks_cyto_tyx is not None:
|
|
4714
|
+
all_cyto_ids = set(np.unique(self.cellpose_masks_cyto_tyx))
|
|
4715
|
+
all_cyto_ids.discard(0)
|
|
4716
|
+
ids_to_remove = all_cyto_ids - {center_cell_id}
|
|
4717
|
+
if ids_to_remove:
|
|
4718
|
+
self.cellpose_masks_cyto_tyx = self._remove_labels_from_tyx(
|
|
4719
|
+
self.cellpose_masks_cyto_tyx, ids_to_remove)
|
|
4720
|
+
# Update current frame YX mask
|
|
4721
|
+
self.cellpose_masks_cyto = self.cellpose_masks_cyto_tyx[self.segmentation_current_frame]
|
|
4722
|
+
|
|
4723
|
+
if self.cellpose_masks_nuc_tyx is not None:
|
|
4724
|
+
all_nuc_ids = set(np.unique(self.cellpose_masks_nuc_tyx))
|
|
4725
|
+
all_nuc_ids.discard(0)
|
|
4726
|
+
ids_to_remove = all_nuc_ids - {center_cell_id}
|
|
4727
|
+
if ids_to_remove:
|
|
4728
|
+
self.cellpose_masks_nuc_tyx = self._remove_labels_from_tyx(
|
|
4729
|
+
self.cellpose_masks_nuc_tyx, ids_to_remove)
|
|
4730
|
+
# Update current frame YX mask
|
|
4731
|
+
self.cellpose_masks_nuc = self.cellpose_masks_nuc_tyx[self.segmentation_current_frame]
|
|
4732
|
+
else:
|
|
4733
|
+
# Standard YX mode
|
|
4734
|
+
if self.cellpose_masks_cyto is not None:
|
|
4735
|
+
all_cyto_ids = set(np.unique(self.cellpose_masks_cyto))
|
|
4736
|
+
all_cyto_ids.discard(0)
|
|
4737
|
+
ids_to_remove = all_cyto_ids - {center_cell_id}
|
|
4738
|
+
if ids_to_remove:
|
|
4739
|
+
self.cellpose_masks_cyto = self.remove_labels_and_reindex(
|
|
4740
|
+
self.cellpose_masks_cyto, ids_to_remove)
|
|
4741
|
+
|
|
4742
|
+
if self.cellpose_masks_nuc is not None:
|
|
4743
|
+
all_nuc_ids = set(np.unique(self.cellpose_masks_nuc))
|
|
4744
|
+
all_nuc_ids.discard(0)
|
|
4745
|
+
ids_to_remove = all_nuc_ids - {center_cell_id}
|
|
4746
|
+
if ids_to_remove:
|
|
4747
|
+
self.cellpose_masks_nuc = self.remove_labels_and_reindex(
|
|
4748
|
+
self.cellpose_masks_nuc, ids_to_remove)
|
|
4749
|
+
|
|
4750
|
+
self.plot_cellpose_results()
|
|
4751
|
+
|
|
4584
4752
|
def plot_cellpose_results(self):
|
|
4585
4753
|
"""Plot Cellpose segmentation results on the shared segmentation canvas."""
|
|
4586
4754
|
if self.image_stack is None:
|
|
@@ -4702,21 +4870,43 @@ class GUI(QMainWindow):
|
|
|
4702
4870
|
self.segmentation_channel_buttons.append(btn)
|
|
4703
4871
|
|
|
4704
4872
|
def update_segmentation_channel(self, channel_index):
|
|
4873
|
+
# Don't clear mask when in Edit mode (we're editing, not creating new)
|
|
4874
|
+
current_subtab = getattr(self, 'segmentation_method_tabs', None)
|
|
4875
|
+
if current_subtab is not None and current_subtab.currentIndex() == 4:
|
|
4876
|
+
# Just update channel for viewing, don't clear mask
|
|
4877
|
+
self.segmentation_current_channel = channel_index
|
|
4878
|
+
self.plot_edit_mode()
|
|
4879
|
+
return
|
|
4880
|
+
|
|
4705
4881
|
# Clear old mask when changing channel
|
|
4706
4882
|
self.segmentation_mask = None
|
|
4707
4883
|
self.segmentation_current_channel = channel_index
|
|
4708
4884
|
|
|
4709
4885
|
# Refresh display based on active sub-tab
|
|
4710
4886
|
if hasattr(self, 'segmentation_method_tabs'):
|
|
4711
|
-
|
|
4712
|
-
if
|
|
4887
|
+
current_index = self.segmentation_method_tabs.currentIndex()
|
|
4888
|
+
if current_index == 1 or current_index == 3: # Cellpose or Import sub-tab
|
|
4713
4889
|
self.plot_cellpose_results()
|
|
4890
|
+
elif current_index == 4: # Edit sub-tab
|
|
4891
|
+
self.plot_edit_mode()
|
|
4714
4892
|
else:
|
|
4715
4893
|
self.plot_segmentation()
|
|
4716
4894
|
else:
|
|
4717
4895
|
self.plot_segmentation()
|
|
4718
4896
|
|
|
4719
4897
|
def update_segmentation_frame(self, value):
|
|
4898
|
+
# Don't clear mask when in Edit mode (we're editing, not creating new)
|
|
4899
|
+
current_subtab = getattr(self, 'segmentation_method_tabs', None)
|
|
4900
|
+
if current_subtab is not None and current_subtab.currentIndex() == 4:
|
|
4901
|
+
# Just update frame for viewing, don't clear mask
|
|
4902
|
+
self.segmentation_current_frame = value
|
|
4903
|
+
# Update frame label
|
|
4904
|
+
total_frames = getattr(self, 'total_frames', 1)
|
|
4905
|
+
if hasattr(self, 'frame_label_segmentation'):
|
|
4906
|
+
self.frame_label_segmentation.setText(f"{value}/{total_frames - 1}")
|
|
4907
|
+
self.plot_edit_mode()
|
|
4908
|
+
return
|
|
4909
|
+
|
|
4720
4910
|
# Clear old manual/watershed mask when changing frame
|
|
4721
4911
|
self.segmentation_mask = None
|
|
4722
4912
|
self.segmentation_current_frame = value
|
|
@@ -4735,9 +4925,11 @@ class GUI(QMainWindow):
|
|
|
4735
4925
|
|
|
4736
4926
|
# Refresh display based on active sub-tab
|
|
4737
4927
|
if hasattr(self, 'segmentation_method_tabs'):
|
|
4738
|
-
|
|
4739
|
-
if
|
|
4928
|
+
current_index = self.segmentation_method_tabs.currentIndex()
|
|
4929
|
+
if current_index == 1 or current_index == 3: # Cellpose or Import sub-tab
|
|
4740
4930
|
self.plot_cellpose_results()
|
|
4931
|
+
elif current_index == 4: # Edit sub-tab
|
|
4932
|
+
self.plot_edit_mode()
|
|
4741
4933
|
else:
|
|
4742
4934
|
self.plot_segmentation()
|
|
4743
4935
|
else:
|
|
@@ -5683,7 +5875,7 @@ class GUI(QMainWindow):
|
|
|
5683
5875
|
cellpose_cyto_* and cellpose_nuc_* inputs (model, channel, diameter)
|
|
5684
5876
|
btn_run_cyto, btn_run_nuc (QPushButton)
|
|
5685
5877
|
num_masks_slider, min_frames_slider, cell_expansion_slider, cell_shrink_slider
|
|
5686
|
-
chk_remove_border_cells, chk_remove_unpaired_cells (QCheckBox)
|
|
5878
|
+
chk_remove_border_cells, chk_remove_unpaired_cells, chk_keep_center_cell (QCheckBox)
|
|
5687
5879
|
btn_clear_cellpose (QPushButton)
|
|
5688
5880
|
cellpose_masks_cyto, cellpose_masks_nuc (Optional[np.ndarray])
|
|
5689
5881
|
cellpose_masks_cyto_tyx, cellpose_masks_nuc_tyx (Optional[np.ndarray])
|
|
@@ -5703,7 +5895,7 @@ class GUI(QMainWindow):
|
|
|
5703
5895
|
run_cellpose_cyto, run_cellpose_nuc, clear_cellpose_masks
|
|
5704
5896
|
_on_num_masks_slider_changed, _on_min_frames_slider_changed
|
|
5705
5897
|
_on_expansion_slider_changed, _on_shrink_slider_changed
|
|
5706
|
-
on_remove_border_cells_changed, on_remove_unpaired_cells_changed
|
|
5898
|
+
on_remove_border_cells_changed, on_remove_unpaired_cells_changed, on_keep_center_cell_changed
|
|
5707
5899
|
"""
|
|
5708
5900
|
|
|
5709
5901
|
self.segmentation_current_frame = 0
|
|
@@ -5719,7 +5911,7 @@ class GUI(QMainWindow):
|
|
|
5719
5911
|
main_layout = QHBoxLayout(self.segmentation_tab)
|
|
5720
5912
|
# LEFT PANEL: Segmentation Figure & Controls
|
|
5721
5913
|
left_layout = QVBoxLayout()
|
|
5722
|
-
main_layout.addLayout(left_layout, stretch=
|
|
5914
|
+
main_layout.addLayout(left_layout, stretch=5)
|
|
5723
5915
|
|
|
5724
5916
|
# Create canvas + Z-slider layout (horizontal: canvas on left, Z-slider on right)
|
|
5725
5917
|
canvas_z_layout = QHBoxLayout()
|
|
@@ -5802,12 +5994,15 @@ class GUI(QMainWindow):
|
|
|
5802
5994
|
|
|
5803
5995
|
# RIGHT PANEL: Segmentation Methods & Source Toggle
|
|
5804
5996
|
right_layout = QVBoxLayout()
|
|
5805
|
-
main_layout.addLayout(right_layout, stretch=
|
|
5997
|
+
main_layout.addLayout(right_layout, stretch=2)
|
|
5806
5998
|
|
|
5807
5999
|
# =====================================================================
|
|
5808
6000
|
# SEGMENTATION METHOD SUB-TABS
|
|
5809
6001
|
# =====================================================================
|
|
5810
6002
|
self.segmentation_method_tabs = QTabWidget()
|
|
6003
|
+
# Enable scrollable tabs so all tabs are accessible when panel is narrow
|
|
6004
|
+
self.segmentation_method_tabs.setUsesScrollButtons(True)
|
|
6005
|
+
self.segmentation_method_tabs.setElideMode(Qt.ElideNone)
|
|
5811
6006
|
|
|
5812
6007
|
# --- Manual Segmentation Tab ---
|
|
5813
6008
|
manual_tab = QWidget()
|
|
@@ -6145,6 +6340,16 @@ class GUI(QMainWindow):
|
|
|
6145
6340
|
self.chk_remove_unpaired_cells.stateChanged.connect(self.on_remove_unpaired_cells_changed)
|
|
6146
6341
|
improve_layout.addRow(self.chk_remove_unpaired_cells)
|
|
6147
6342
|
|
|
6343
|
+
self.chk_keep_center_cell = QCheckBox("Keep only center cell")
|
|
6344
|
+
self.chk_keep_center_cell.setChecked(False)
|
|
6345
|
+
self.chk_keep_center_cell.setToolTip(
|
|
6346
|
+
"Keep only the cell whose centroid is closest to the image center.\n"
|
|
6347
|
+
"Useful for single-cell analysis when multiple cells are detected.\n"
|
|
6348
|
+
"Note: This filter is applied after 'Remove border' and 'Remove unpaired' filters."
|
|
6349
|
+
)
|
|
6350
|
+
self.chk_keep_center_cell.stateChanged.connect(self.on_keep_center_cell_changed)
|
|
6351
|
+
improve_layout.addRow(self.chk_keep_center_cell)
|
|
6352
|
+
|
|
6148
6353
|
improve_group.setLayout(improve_layout)
|
|
6149
6354
|
cellpose_layout.addWidget(improve_group)
|
|
6150
6355
|
|
|
@@ -6215,9 +6420,154 @@ class GUI(QMainWindow):
|
|
|
6215
6420
|
# Add Manual tab (Index 2) before Import
|
|
6216
6421
|
self.segmentation_method_tabs.addTab(manual_tab, "Manual")
|
|
6217
6422
|
|
|
6218
|
-
# Add Import tab
|
|
6423
|
+
# Add Import tab (Index 3)
|
|
6219
6424
|
self.segmentation_method_tabs.addTab(import_tab, "Import")
|
|
6220
6425
|
|
|
6426
|
+
# =====================================================================
|
|
6427
|
+
# EDIT TAB (Index 4) - Edit existing masks from any method
|
|
6428
|
+
# =====================================================================
|
|
6429
|
+
edit_tab = QWidget()
|
|
6430
|
+
edit_tab_layout = QVBoxLayout(edit_tab)
|
|
6431
|
+
edit_tab_layout.setContentsMargins(10, 10, 10, 10)
|
|
6432
|
+
edit_tab_layout.setSpacing(8)
|
|
6433
|
+
|
|
6434
|
+
# Create scrollable area for edit controls
|
|
6435
|
+
edit_scroll = QScrollArea()
|
|
6436
|
+
edit_scroll.setWidgetResizable(True)
|
|
6437
|
+
edit_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
6438
|
+
edit_scroll_widget = QWidget()
|
|
6439
|
+
edit_scroll_layout = QVBoxLayout(edit_scroll_widget)
|
|
6440
|
+
edit_scroll_layout.setSpacing(8)
|
|
6441
|
+
|
|
6442
|
+
# Instructions label
|
|
6443
|
+
edit_instructions = QLabel(
|
|
6444
|
+
"Edit existing masks from any segmentation method.\n"
|
|
6445
|
+
"Select a mask and tool, then draw on the image.\n"
|
|
6446
|
+
"Cell labels are preserved (erasing sets pixels to background)."
|
|
6447
|
+
)
|
|
6448
|
+
edit_instructions.setWordWrap(True)
|
|
6449
|
+
edit_instructions.setStyleSheet("color: gray; font-size: 11px;")
|
|
6450
|
+
edit_scroll_layout.addWidget(edit_instructions)
|
|
6451
|
+
|
|
6452
|
+
# --- Mask to Edit Group ---
|
|
6453
|
+
mask_select_group = QGroupBox("Mask to Edit")
|
|
6454
|
+
mask_select_layout = QVBoxLayout()
|
|
6455
|
+
|
|
6456
|
+
self.edit_mask_selector = QComboBox()
|
|
6457
|
+
self.edit_mask_selector.addItem("-- Select Mask --")
|
|
6458
|
+
self.edit_mask_selector.currentIndexChanged.connect(self.on_edit_mask_selector_changed)
|
|
6459
|
+
mask_select_layout.addWidget(self.edit_mask_selector)
|
|
6460
|
+
|
|
6461
|
+
self.edit_mask_info_label = QLabel("No mask selected")
|
|
6462
|
+
self.edit_mask_info_label.setStyleSheet("color: gray; font-size: 10px;")
|
|
6463
|
+
mask_select_layout.addWidget(self.edit_mask_info_label)
|
|
6464
|
+
|
|
6465
|
+
mask_select_group.setLayout(mask_select_layout)
|
|
6466
|
+
edit_scroll_layout.addWidget(mask_select_group)
|
|
6467
|
+
|
|
6468
|
+
# --- Instructions Panel (shown when mask is selected) ---
|
|
6469
|
+
self.edit_instructions_group = QGroupBox("How to Edit")
|
|
6470
|
+
instructions_layout = QVBoxLayout()
|
|
6471
|
+
|
|
6472
|
+
self.edit_instructions_label = QLabel(
|
|
6473
|
+
"🖱️ Click and drag on the mask to erase regions.\n"
|
|
6474
|
+
" Erased areas become background (0).\n\n"
|
|
6475
|
+
"💾 Click 'Apply & Save' when finished."
|
|
6476
|
+
)
|
|
6477
|
+
self.edit_instructions_label.setWordWrap(True)
|
|
6478
|
+
self.edit_instructions_label.setStyleSheet("color: #88ccff; font-size: 11px;")
|
|
6479
|
+
instructions_layout.addWidget(self.edit_instructions_label)
|
|
6480
|
+
|
|
6481
|
+
self.edit_instructions_group.setLayout(instructions_layout)
|
|
6482
|
+
self.edit_instructions_group.setVisible(False) # Hidden until mask selected
|
|
6483
|
+
edit_scroll_layout.addWidget(self.edit_instructions_group)
|
|
6484
|
+
|
|
6485
|
+
# --- TYX Info Panel (shown for time-varying masks) ---
|
|
6486
|
+
self.edit_tyx_info_group = QGroupBox("Time-Varying Mask")
|
|
6487
|
+
tyx_info_layout = QVBoxLayout()
|
|
6488
|
+
|
|
6489
|
+
self.edit_tyx_info_label = QLabel(
|
|
6490
|
+
"ℹ️ Editing combined view (max projection).\n"
|
|
6491
|
+
"Edits will apply to ALL frames when saved."
|
|
6492
|
+
)
|
|
6493
|
+
self.edit_tyx_info_label.setWordWrap(True)
|
|
6494
|
+
self.edit_tyx_info_label.setStyleSheet("color: #6699ff; font-size: 10px;")
|
|
6495
|
+
tyx_info_layout.addWidget(self.edit_tyx_info_label)
|
|
6496
|
+
|
|
6497
|
+
self.edit_tyx_info_group.setLayout(tyx_info_layout)
|
|
6498
|
+
self.edit_tyx_info_group.setVisible(False) # Hidden until TYX mask selected
|
|
6499
|
+
edit_scroll_layout.addWidget(self.edit_tyx_info_group)
|
|
6500
|
+
|
|
6501
|
+
# --- Action Buttons Group ---
|
|
6502
|
+
action_group = QGroupBox("Actions")
|
|
6503
|
+
action_layout = QVBoxLayout()
|
|
6504
|
+
|
|
6505
|
+
action_btn_layout = QHBoxLayout()
|
|
6506
|
+
self.btn_undo_edit = QPushButton("Undo")
|
|
6507
|
+
self.btn_undo_edit.setToolTip("Restore mask to state before last edit (max 10 steps)")
|
|
6508
|
+
self.btn_undo_edit.clicked.connect(self.undo_edit)
|
|
6509
|
+
self.btn_undo_edit.setEnabled(False)
|
|
6510
|
+
action_btn_layout.addWidget(self.btn_undo_edit)
|
|
6511
|
+
|
|
6512
|
+
self.btn_reset_edits = QPushButton("Reset")
|
|
6513
|
+
self.btn_reset_edits.setToolTip("Discard all edits and restore the mask to its original state")
|
|
6514
|
+
self.btn_reset_edits.clicked.connect(self.reset_edits)
|
|
6515
|
+
self.btn_reset_edits.setEnabled(False)
|
|
6516
|
+
action_btn_layout.addWidget(self.btn_reset_edits)
|
|
6517
|
+
action_layout.addLayout(action_btn_layout)
|
|
6518
|
+
|
|
6519
|
+
self.btn_apply_edits = QPushButton("Apply && Save")
|
|
6520
|
+
self.btn_apply_edits.setToolTip("Apply edits to the active mask and use for downstream analysis")
|
|
6521
|
+
self.btn_apply_edits.setStyleSheet("background-color: #336633; font-weight: bold;")
|
|
6522
|
+
self.btn_apply_edits.clicked.connect(self.apply_and_save_edits)
|
|
6523
|
+
self.btn_apply_edits.setEnabled(False)
|
|
6524
|
+
action_layout.addWidget(self.btn_apply_edits)
|
|
6525
|
+
|
|
6526
|
+
action_group.setLayout(action_layout)
|
|
6527
|
+
edit_scroll_layout.addWidget(action_group)
|
|
6528
|
+
|
|
6529
|
+
# --- Status Panel ---
|
|
6530
|
+
status_group = QGroupBox("Edit Status")
|
|
6531
|
+
status_layout = QVBoxLayout()
|
|
6532
|
+
|
|
6533
|
+
self.edit_status_label = QLabel("⚠ Select a mask to begin editing")
|
|
6534
|
+
self.edit_status_label.setWordWrap(True)
|
|
6535
|
+
self.edit_status_label.setStyleSheet("color: #ffcc00;")
|
|
6536
|
+
status_layout.addWidget(self.edit_status_label)
|
|
6537
|
+
|
|
6538
|
+
status_group.setLayout(status_layout)
|
|
6539
|
+
edit_scroll_layout.addWidget(status_group)
|
|
6540
|
+
|
|
6541
|
+
edit_scroll_layout.addStretch()
|
|
6542
|
+
edit_scroll.setWidget(edit_scroll_widget)
|
|
6543
|
+
edit_tab_layout.addWidget(edit_scroll)
|
|
6544
|
+
|
|
6545
|
+
# Add Edit tab (Index 4)
|
|
6546
|
+
self.segmentation_method_tabs.addTab(edit_tab, "Edit")
|
|
6547
|
+
|
|
6548
|
+
# =====================================================================
|
|
6549
|
+
# EDIT MODE STATE VARIABLES
|
|
6550
|
+
# =====================================================================
|
|
6551
|
+
# Edit mode state
|
|
6552
|
+
self.edit_mode_active = False
|
|
6553
|
+
self.edit_current_mask_key = None # 'watershed', 'cellpose_cyto', 'cellpose_nuc'
|
|
6554
|
+
self.edit_working_mask = None # 2D working copy (Y, X) - always 2D for editing
|
|
6555
|
+
self.edit_original_mask = None # Original mask before edits (for reset)
|
|
6556
|
+
self.edit_original_2d = None # Max projection of original (for computing diffs)
|
|
6557
|
+
self.edit_source_is_tyx = False # True if source mask is TYX (3D)
|
|
6558
|
+
|
|
6559
|
+
# Undo system
|
|
6560
|
+
self.edit_undo_stack = []
|
|
6561
|
+
self.EDIT_MAX_UNDO_DEPTH = 10
|
|
6562
|
+
|
|
6563
|
+
# Eraser brush state (only tool)
|
|
6564
|
+
self.edit_brush_size = 10
|
|
6565
|
+
self.edit_brush_shape = 'circle'
|
|
6566
|
+
|
|
6567
|
+
# Mouse state for drawing
|
|
6568
|
+
self.edit_last_mouse_pos = None
|
|
6569
|
+
self.edit_is_drawing = False
|
|
6570
|
+
|
|
6221
6571
|
# Initialize Cellpose/imported mask state variables
|
|
6222
6572
|
# NOTE: Uses segmentation_current_frame and segmentation_current_channel (shared)
|
|
6223
6573
|
self.cellpose_masks_cyto = None
|
|
@@ -6256,21 +6606,625 @@ class GUI(QMainWindow):
|
|
|
6256
6606
|
self.plot_segmentation()
|
|
6257
6607
|
|
|
6258
6608
|
def _on_segmentation_subtab_changed(self, index):
|
|
6259
|
-
"""Handle switching between segmentation method sub-tabs
|
|
6609
|
+
"""Handle switching between segmentation method sub-tabs.
|
|
6260
6610
|
|
|
6261
6611
|
Tab indices:
|
|
6262
6612
|
0 = Watershed
|
|
6263
6613
|
1 = Cellpose
|
|
6264
6614
|
2 = Manual
|
|
6265
6615
|
3 = Import (uses Cellpose-style display)
|
|
6616
|
+
4 = Edit (edit existing masks)
|
|
6266
6617
|
"""
|
|
6267
|
-
if index ==
|
|
6268
|
-
|
|
6618
|
+
if index == 4: # Edit sub-tab
|
|
6619
|
+
self.enter_edit_mode()
|
|
6620
|
+
elif index == 1 or index == 3: # Cellpose or Import sub-tab
|
|
6621
|
+
self.exit_edit_mode()
|
|
6269
6622
|
self.plot_cellpose_results()
|
|
6270
6623
|
else:
|
|
6271
|
-
|
|
6624
|
+
self.exit_edit_mode()
|
|
6272
6625
|
self.plot_segmentation()
|
|
6273
6626
|
|
|
6627
|
+
# =========================================================================
|
|
6628
|
+
# EDIT TAB SIGNAL HANDLERS
|
|
6629
|
+
# =========================================================================
|
|
6630
|
+
|
|
6631
|
+
def enter_edit_mode(self):
|
|
6632
|
+
"""Initialize edit mode when Edit tab is selected."""
|
|
6633
|
+
self.edit_mode_active = True
|
|
6634
|
+
|
|
6635
|
+
# Connect mouse events for editing
|
|
6636
|
+
self._connect_edit_mouse_events()
|
|
6637
|
+
|
|
6638
|
+
# Populate mask selector with available masks
|
|
6639
|
+
self._refresh_edit_mask_selector()
|
|
6640
|
+
|
|
6641
|
+
# If a mask is already selected, load it
|
|
6642
|
+
if self.edit_mask_selector.currentIndex() > 0:
|
|
6643
|
+
self.on_edit_mask_selector_changed(self.edit_mask_selector.currentIndex())
|
|
6644
|
+
else:
|
|
6645
|
+
# Just show the current segmentation view
|
|
6646
|
+
self.plot_edit_mode()
|
|
6647
|
+
|
|
6648
|
+
def exit_edit_mode(self):
|
|
6649
|
+
"""Clean up edit mode when leaving Edit tab."""
|
|
6650
|
+
if not self.edit_mode_active:
|
|
6651
|
+
return
|
|
6652
|
+
|
|
6653
|
+
# Check for unsaved edits
|
|
6654
|
+
if self.edit_working_mask is not None and self.edit_undo_stack:
|
|
6655
|
+
reply = QMessageBox.question(
|
|
6656
|
+
self, "Unsaved Edits",
|
|
6657
|
+
"You have unsaved edits. Discard them?",
|
|
6658
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
6659
|
+
QMessageBox.No
|
|
6660
|
+
)
|
|
6661
|
+
if reply == QMessageBox.No:
|
|
6662
|
+
# Switch back to Edit tab
|
|
6663
|
+
self.segmentation_method_tabs.blockSignals(True)
|
|
6664
|
+
self.segmentation_method_tabs.setCurrentIndex(4)
|
|
6665
|
+
self.segmentation_method_tabs.blockSignals(False)
|
|
6666
|
+
return
|
|
6667
|
+
|
|
6668
|
+
# Clear edit state
|
|
6669
|
+
self.edit_mode_active = False
|
|
6670
|
+
self.edit_working_mask = None
|
|
6671
|
+
self.edit_original_mask = None
|
|
6672
|
+
self.edit_undo_stack = []
|
|
6673
|
+
|
|
6674
|
+
# Disconnect mouse events
|
|
6675
|
+
self._disconnect_edit_mouse_events()
|
|
6676
|
+
|
|
6677
|
+
def _refresh_edit_mask_selector(self):
|
|
6678
|
+
"""Populate the mask selector dropdown with available masks."""
|
|
6679
|
+
self.edit_mask_selector.blockSignals(True)
|
|
6680
|
+
self.edit_mask_selector.clear()
|
|
6681
|
+
self.edit_mask_selector.addItem("-- Select Mask --")
|
|
6682
|
+
|
|
6683
|
+
# Check for Watershed/Manual mask
|
|
6684
|
+
if self.segmentation_mask is not None:
|
|
6685
|
+
n_cells = len(np.unique(self.segmentation_mask)) - 1 # Exclude 0
|
|
6686
|
+
shape = self.segmentation_mask.shape
|
|
6687
|
+
self.edit_mask_selector.addItem(
|
|
6688
|
+
f"Watershed Mask ({n_cells} cells, {shape[1]}×{shape[0]})",
|
|
6689
|
+
"watershed"
|
|
6690
|
+
)
|
|
6691
|
+
|
|
6692
|
+
# Check for Cellpose cytosol mask
|
|
6693
|
+
if self.cellpose_masks_cyto is not None:
|
|
6694
|
+
mask = self.cellpose_masks_cyto
|
|
6695
|
+
n_cells = len(np.unique(mask)) - 1
|
|
6696
|
+
if mask.ndim == 3:
|
|
6697
|
+
shape_str = f"TYX: {mask.shape[0]}×{mask.shape[2]}×{mask.shape[1]}"
|
|
6698
|
+
else:
|
|
6699
|
+
shape_str = f"{mask.shape[1]}×{mask.shape[0]}"
|
|
6700
|
+
self.edit_mask_selector.addItem(
|
|
6701
|
+
f"Cellpose Cytosol ({n_cells} cells, {shape_str})",
|
|
6702
|
+
"cellpose_cyto"
|
|
6703
|
+
)
|
|
6704
|
+
|
|
6705
|
+
# Check for Cellpose nucleus mask
|
|
6706
|
+
if self.cellpose_masks_nuc is not None:
|
|
6707
|
+
mask = self.cellpose_masks_nuc
|
|
6708
|
+
n_cells = len(np.unique(mask)) - 1
|
|
6709
|
+
if mask.ndim == 3:
|
|
6710
|
+
shape_str = f"TYX: {mask.shape[0]}×{mask.shape[2]}×{mask.shape[1]}"
|
|
6711
|
+
else:
|
|
6712
|
+
shape_str = f"{mask.shape[1]}×{mask.shape[0]}"
|
|
6713
|
+
self.edit_mask_selector.addItem(
|
|
6714
|
+
f"Cellpose Nucleus ({n_cells} cells, {shape_str})",
|
|
6715
|
+
"cellpose_nuc"
|
|
6716
|
+
)
|
|
6717
|
+
|
|
6718
|
+
self.edit_mask_selector.blockSignals(False)
|
|
6719
|
+
|
|
6720
|
+
# Update status based on availability
|
|
6721
|
+
if self.edit_mask_selector.count() <= 1:
|
|
6722
|
+
self.edit_status_label.setText("⚠ No masks available - run segmentation first")
|
|
6723
|
+
self.edit_status_label.setStyleSheet("color: #ff6666;")
|
|
6724
|
+
else:
|
|
6725
|
+
self.edit_status_label.setText("⚠ Select a mask to begin editing")
|
|
6726
|
+
self.edit_status_label.setStyleSheet("color: #ffcc00;")
|
|
6727
|
+
|
|
6728
|
+
def on_edit_mask_selector_changed(self, index):
|
|
6729
|
+
"""Handle switching between masks to edit."""
|
|
6730
|
+
if index <= 0:
|
|
6731
|
+
# No mask selected
|
|
6732
|
+
self.edit_working_mask = None
|
|
6733
|
+
self.edit_original_mask = None
|
|
6734
|
+
self.edit_current_mask_key = None
|
|
6735
|
+
self.edit_mask_info_label.setText("No mask selected")
|
|
6736
|
+
self.edit_tyx_info_group.setVisible(False)
|
|
6737
|
+
self.edit_instructions_group.setVisible(False) # Hide instructions
|
|
6738
|
+
self._update_edit_buttons_state()
|
|
6739
|
+
self.plot_edit_mode()
|
|
6740
|
+
return
|
|
6741
|
+
|
|
6742
|
+
# Get the mask key from the item data
|
|
6743
|
+
mask_key = self.edit_mask_selector.itemData(index)
|
|
6744
|
+
|
|
6745
|
+
# Load the selected mask
|
|
6746
|
+
if mask_key == "watershed":
|
|
6747
|
+
source_mask = self.segmentation_mask
|
|
6748
|
+
elif mask_key == "cellpose_cyto":
|
|
6749
|
+
source_mask = self.cellpose_masks_cyto
|
|
6750
|
+
elif mask_key == "cellpose_nuc":
|
|
6751
|
+
source_mask = self.cellpose_masks_nuc
|
|
6752
|
+
else:
|
|
6753
|
+
return
|
|
6754
|
+
|
|
6755
|
+
# Create working copy
|
|
6756
|
+
self.edit_working_mask, self.edit_original_2d, self.edit_source_is_tyx = \
|
|
6757
|
+
self._create_edit_working_copy(source_mask)
|
|
6758
|
+
self.edit_original_mask = source_mask.copy()
|
|
6759
|
+
self.edit_current_mask_key = mask_key
|
|
6760
|
+
|
|
6761
|
+
# Clear undo stack for new mask
|
|
6762
|
+
self.edit_undo_stack = []
|
|
6763
|
+
|
|
6764
|
+
# Update info label
|
|
6765
|
+
n_cells = len(np.unique(self.edit_working_mask)) - 1
|
|
6766
|
+
shape = self.edit_working_mask.shape
|
|
6767
|
+
self.edit_mask_info_label.setText(f"{n_cells} cells, {shape[1]}×{shape[0]} px")
|
|
6768
|
+
self.edit_mask_info_label.setStyleSheet("color: #88ff88; font-size: 10px;")
|
|
6769
|
+
|
|
6770
|
+
# Show TYX info if applicable
|
|
6771
|
+
self.edit_tyx_info_group.setVisible(self.edit_source_is_tyx)
|
|
6772
|
+
if self.edit_source_is_tyx:
|
|
6773
|
+
n_frames = source_mask.shape[0]
|
|
6774
|
+
self.edit_tyx_info_label.setText(
|
|
6775
|
+
f"ℹ️ Editing combined view of {n_frames} frames.\n"
|
|
6776
|
+
"Edits will apply to ALL frames when saved."
|
|
6777
|
+
)
|
|
6778
|
+
|
|
6779
|
+
# Show instructions panel
|
|
6780
|
+
self.edit_instructions_group.setVisible(True)
|
|
6781
|
+
|
|
6782
|
+
# Update buttons
|
|
6783
|
+
self._update_edit_buttons_state()
|
|
6784
|
+
|
|
6785
|
+
# Update status
|
|
6786
|
+
self.edit_status_label.setText(f"✓ Ready to edit {mask_key.replace('_', ' ').title()}")
|
|
6787
|
+
self.edit_status_label.setStyleSheet("color: #88ff88;")
|
|
6788
|
+
|
|
6789
|
+
# Update display
|
|
6790
|
+
self.plot_edit_mode()
|
|
6791
|
+
|
|
6792
|
+
def _create_edit_working_copy(self, source_mask):
|
|
6793
|
+
"""Create a 2D working copy for editing.
|
|
6794
|
+
|
|
6795
|
+
Args:
|
|
6796
|
+
source_mask: The source mask array (YX or TYX)
|
|
6797
|
+
|
|
6798
|
+
Returns:
|
|
6799
|
+
tuple: (working_mask_2d, original_2d, is_tyx)
|
|
6800
|
+
"""
|
|
6801
|
+
if source_mask.ndim == 3: # TYX
|
|
6802
|
+
# Max projection for editing
|
|
6803
|
+
working_mask = np.max(source_mask, axis=0).copy()
|
|
6804
|
+
original_2d = working_mask.copy()
|
|
6805
|
+
is_tyx = True
|
|
6806
|
+
else: # YX
|
|
6807
|
+
working_mask = source_mask.copy()
|
|
6808
|
+
original_2d = working_mask.copy()
|
|
6809
|
+
is_tyx = False
|
|
6810
|
+
|
|
6811
|
+
return working_mask, original_2d, is_tyx
|
|
6812
|
+
|
|
6813
|
+
|
|
6814
|
+
|
|
6815
|
+
def _update_edit_buttons_state(self):
|
|
6816
|
+
"""Update enabled state of edit action buttons."""
|
|
6817
|
+
has_mask = self.edit_working_mask is not None
|
|
6818
|
+
has_edits = len(self.edit_undo_stack) > 0
|
|
6819
|
+
|
|
6820
|
+
self.btn_undo_edit.setEnabled(has_edits)
|
|
6821
|
+
self.btn_reset_edits.setEnabled(has_edits)
|
|
6822
|
+
self.btn_apply_edits.setEnabled(has_mask)
|
|
6823
|
+
|
|
6824
|
+
# =========================================================================
|
|
6825
|
+
# UNDO/RESET/APPLY
|
|
6826
|
+
# =========================================================================
|
|
6827
|
+
|
|
6828
|
+
def _push_undo(self, description="Edit"):
|
|
6829
|
+
"""Push current mask state to undo stack."""
|
|
6830
|
+
if self.edit_working_mask is None:
|
|
6831
|
+
return
|
|
6832
|
+
|
|
6833
|
+
self.edit_undo_stack.append((self.edit_working_mask.copy(), description))
|
|
6834
|
+
|
|
6835
|
+
# Limit stack size
|
|
6836
|
+
if len(self.edit_undo_stack) > self.EDIT_MAX_UNDO_DEPTH:
|
|
6837
|
+
self.edit_undo_stack.pop(0)
|
|
6838
|
+
|
|
6839
|
+
def undo_edit(self):
|
|
6840
|
+
"""Pop last state from undo stack and restore."""
|
|
6841
|
+
if not self.edit_undo_stack:
|
|
6842
|
+
self.edit_status_label.setText("Nothing to undo")
|
|
6843
|
+
self.edit_status_label.setStyleSheet("color: gray;")
|
|
6844
|
+
return
|
|
6845
|
+
|
|
6846
|
+
# Pop the last state
|
|
6847
|
+
mask, description = self.edit_undo_stack.pop()
|
|
6848
|
+
self.edit_working_mask = mask
|
|
6849
|
+
|
|
6850
|
+
self.edit_status_label.setText(f"↩ Undid: {description}")
|
|
6851
|
+
self.edit_status_label.setStyleSheet("color: #88ff88;")
|
|
6852
|
+
|
|
6853
|
+
# Refresh dropdown
|
|
6854
|
+
self._refresh_cell_label_dropdown()
|
|
6855
|
+
|
|
6856
|
+
# Update buttons and display
|
|
6857
|
+
self._update_edit_buttons_state()
|
|
6858
|
+
self.plot_edit_mode()
|
|
6859
|
+
|
|
6860
|
+
def reset_edits(self):
|
|
6861
|
+
"""Restore mask to original state before editing began."""
|
|
6862
|
+
if self.edit_original_mask is None:
|
|
6863
|
+
return
|
|
6864
|
+
|
|
6865
|
+
# Confirm if many edits
|
|
6866
|
+
if len(self.edit_undo_stack) > 3:
|
|
6867
|
+
reply = QMessageBox.question(
|
|
6868
|
+
self, "Reset All Edits",
|
|
6869
|
+
f"This will discard {len(self.edit_undo_stack)} edits. Continue?",
|
|
6870
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
6871
|
+
QMessageBox.No
|
|
6872
|
+
)
|
|
6873
|
+
if reply == QMessageBox.No:
|
|
6874
|
+
return
|
|
6875
|
+
|
|
6876
|
+
# Restore original
|
|
6877
|
+
self.edit_working_mask, self.edit_original_2d, _ = \
|
|
6878
|
+
self._create_edit_working_copy(self.edit_original_mask)
|
|
6879
|
+
|
|
6880
|
+
# Clear undo stack
|
|
6881
|
+
self.edit_undo_stack = []
|
|
6882
|
+
|
|
6883
|
+
self.edit_status_label.setText("↺ Reset to original mask")
|
|
6884
|
+
self.edit_status_label.setStyleSheet("color: #88ff88;")
|
|
6885
|
+
|
|
6886
|
+
# Refresh dropdown
|
|
6887
|
+
self._refresh_cell_label_dropdown()
|
|
6888
|
+
|
|
6889
|
+
# Update buttons and display
|
|
6890
|
+
self._update_edit_buttons_state()
|
|
6891
|
+
self.plot_edit_mode()
|
|
6892
|
+
|
|
6893
|
+
def apply_and_save_edits(self):
|
|
6894
|
+
"""Apply edits back to the source mask and set as active for downstream workflow."""
|
|
6895
|
+
if self.edit_working_mask is None or self.edit_current_mask_key is None:
|
|
6896
|
+
return
|
|
6897
|
+
|
|
6898
|
+
source_key = self.edit_current_mask_key
|
|
6899
|
+
|
|
6900
|
+
if source_key == 'watershed':
|
|
6901
|
+
# Watershed is always YX
|
|
6902
|
+
self.segmentation_mask = self.edit_working_mask.copy()
|
|
6903
|
+
self._active_mask_source = 'segmentation'
|
|
6904
|
+
|
|
6905
|
+
elif source_key == 'cellpose_cyto':
|
|
6906
|
+
if self.edit_source_is_tyx:
|
|
6907
|
+
# Apply 2D edits to all frames
|
|
6908
|
+
self.cellpose_masks_cyto = self._apply_edits_to_tyx(
|
|
6909
|
+
self.edit_original_mask,
|
|
6910
|
+
self.edit_working_mask,
|
|
6911
|
+
self.edit_original_2d
|
|
6912
|
+
)
|
|
6913
|
+
else:
|
|
6914
|
+
self.cellpose_masks_cyto = self.edit_working_mask.copy()
|
|
6915
|
+
self._active_mask_source = 'cellpose'
|
|
6916
|
+
|
|
6917
|
+
elif source_key == 'cellpose_nuc':
|
|
6918
|
+
if self.edit_source_is_tyx:
|
|
6919
|
+
self.cellpose_masks_nuc = self._apply_edits_to_tyx(
|
|
6920
|
+
self.edit_original_mask,
|
|
6921
|
+
self.edit_working_mask,
|
|
6922
|
+
self.edit_original_2d
|
|
6923
|
+
)
|
|
6924
|
+
else:
|
|
6925
|
+
self.cellpose_masks_nuc = self.edit_working_mask.copy()
|
|
6926
|
+
self._active_mask_source = 'cellpose'
|
|
6927
|
+
|
|
6928
|
+
# Invalidate downstream calculations
|
|
6929
|
+
self.corrected_image = None
|
|
6930
|
+
self.photobleaching_calculated = False
|
|
6931
|
+
if hasattr(self, 'df_tracking'):
|
|
6932
|
+
self.df_tracking = pd.DataFrame()
|
|
6933
|
+
if hasattr(self, 'multi_channel_tracking_data'):
|
|
6934
|
+
self.multi_channel_tracking_data = {}
|
|
6935
|
+
|
|
6936
|
+
# Clear edit state
|
|
6937
|
+
self.edit_undo_stack = []
|
|
6938
|
+
self.edit_original_mask = self.edit_working_mask.copy()
|
|
6939
|
+
self.edit_original_2d = self.edit_working_mask.copy()
|
|
6940
|
+
|
|
6941
|
+
# Update status
|
|
6942
|
+
self.edit_status_label.setText(f"✓ Saved! Re-run Photobleaching/Tracking if needed.")
|
|
6943
|
+
self.edit_status_label.setStyleSheet("color: #88ff88; font-weight: bold;")
|
|
6944
|
+
self.statusBar().showMessage(f"✓ Edited mask saved ({source_key})")
|
|
6945
|
+
|
|
6946
|
+
# Update mask selector to reflect changes
|
|
6947
|
+
self._refresh_edit_mask_selector()
|
|
6948
|
+
# Re-select the mask we just edited
|
|
6949
|
+
for i in range(self.edit_mask_selector.count()):
|
|
6950
|
+
if self.edit_mask_selector.itemData(i) == source_key:
|
|
6951
|
+
self.edit_mask_selector.blockSignals(True)
|
|
6952
|
+
self.edit_mask_selector.setCurrentIndex(i)
|
|
6953
|
+
self.edit_mask_selector.blockSignals(False)
|
|
6954
|
+
break
|
|
6955
|
+
|
|
6956
|
+
self._update_edit_buttons_state()
|
|
6957
|
+
self.plot_edit_mode()
|
|
6958
|
+
|
|
6959
|
+
def _apply_edits_to_tyx(self, original_tyx, edited_2d, original_2d):
|
|
6960
|
+
"""Apply 2D edits to all frames of a TYX mask.
|
|
6961
|
+
|
|
6962
|
+
Args:
|
|
6963
|
+
original_tyx: Original TYX mask (T, Y, X)
|
|
6964
|
+
edited_2d: Edited 2D mask (Y, X)
|
|
6965
|
+
original_2d: Original max projection (Y, X)
|
|
6966
|
+
|
|
6967
|
+
Returns:
|
|
6968
|
+
Modified TYX mask with edits applied to all frames
|
|
6969
|
+
"""
|
|
6970
|
+
result = original_tyx.copy()
|
|
6971
|
+
|
|
6972
|
+
# Find erased pixels (were non-zero in original, now zero)
|
|
6973
|
+
erased = (original_2d > 0) & (edited_2d == 0)
|
|
6974
|
+
|
|
6975
|
+
# Find painted pixels (changed label)
|
|
6976
|
+
painted = (edited_2d > 0) & (edited_2d != original_2d)
|
|
6977
|
+
|
|
6978
|
+
# Apply to all frames
|
|
6979
|
+
for t in range(result.shape[0]):
|
|
6980
|
+
# Erase: set to 0 where erased in 2D
|
|
6981
|
+
result[t][erased] = 0
|
|
6982
|
+
|
|
6983
|
+
# Paint: set to new label where painted in 2D
|
|
6984
|
+
result[t][painted] = edited_2d[painted]
|
|
6985
|
+
|
|
6986
|
+
return result
|
|
6987
|
+
|
|
6988
|
+
# =========================================================================
|
|
6989
|
+
# EDIT MODE DISPLAY
|
|
6990
|
+
# =========================================================================
|
|
6991
|
+
|
|
6992
|
+
def plot_edit_mode(self):
|
|
6993
|
+
"""Display the mask being edited overlaid on the selected channel.
|
|
6994
|
+
|
|
6995
|
+
Uses the same display logic as plot_segmentation() to ensure
|
|
6996
|
+
consistent appearance with channel colors and display parameters.
|
|
6997
|
+
"""
|
|
6998
|
+
# Use the figure's subplot instead of clearing the whole figure
|
|
6999
|
+
# to avoid issues with the figure layout
|
|
7000
|
+
self.figure_segmentation.clear()
|
|
7001
|
+
self.ax_segmentation = self.figure_segmentation.add_subplot(111)
|
|
7002
|
+
self.ax_segmentation.set_facecolor('black')
|
|
7003
|
+
|
|
7004
|
+
if self.image_stack is None:
|
|
7005
|
+
self.ax_segmentation.text(
|
|
7006
|
+
0.5, 0.5, 'No image loaded.',
|
|
7007
|
+
horizontalalignment='center', verticalalignment='center',
|
|
7008
|
+
fontsize=12, color='white', transform=self.ax_segmentation.transAxes
|
|
7009
|
+
)
|
|
7010
|
+
self.ax_segmentation.axis('off')
|
|
7011
|
+
self.canvas_segmentation.draw()
|
|
7012
|
+
return
|
|
7013
|
+
|
|
7014
|
+
# Get current channel and frame
|
|
7015
|
+
ch = self.segmentation_current_channel
|
|
7016
|
+
|
|
7017
|
+
# Use registered image if available (same as plot_segmentation)
|
|
7018
|
+
image_to_use = self.get_current_image_source()
|
|
7019
|
+
|
|
7020
|
+
# Choose image to display (temporal max projection vs current frame, then Z selection)
|
|
7021
|
+
if self.use_max_proj_for_segmentation and self.segmentation_maxproj is not None:
|
|
7022
|
+
# Temporal max projection (already max over T and Z)
|
|
7023
|
+
image_to_display = self.segmentation_maxproj[..., ch]
|
|
7024
|
+
else:
|
|
7025
|
+
# Get current time frame's Z-stack for this channel
|
|
7026
|
+
image_channel = image_to_use[self.segmentation_current_frame, :, :, :, ch]
|
|
7027
|
+
# Apply Z selection
|
|
7028
|
+
if getattr(self, 'segmentation_current_z', -1) == -1:
|
|
7029
|
+
# Max Z-projection (default)
|
|
7030
|
+
image_to_display = np.max(image_channel, axis=0)
|
|
7031
|
+
else:
|
|
7032
|
+
# Specific Z-slice
|
|
7033
|
+
z_idx = min(self.segmentation_current_z, image_channel.shape[0] - 1)
|
|
7034
|
+
image_to_display = image_channel[z_idx, :, :]
|
|
7035
|
+
|
|
7036
|
+
# Get display parameters for channel (same as plot_segmentation)
|
|
7037
|
+
params = self.channelDisplayParams.get(ch, {
|
|
7038
|
+
'min_percentile': self.display_min_percentile,
|
|
7039
|
+
'max_percentile': self.display_max_percentile,
|
|
7040
|
+
'sigma': self.display_sigma,
|
|
7041
|
+
'low_sigma': self.low_display_sigma
|
|
7042
|
+
})
|
|
7043
|
+
|
|
7044
|
+
# Convert using per-channel percentiles
|
|
7045
|
+
rescaled_image = mi.Utilities().convert_to_int8(
|
|
7046
|
+
image_to_display,
|
|
7047
|
+
rescale=True,
|
|
7048
|
+
min_percentile=params['min_percentile'],
|
|
7049
|
+
max_percentile=params['max_percentile']
|
|
7050
|
+
)
|
|
7051
|
+
if params['low_sigma'] > 0:
|
|
7052
|
+
rescaled_image = gaussian_filter(rescaled_image, sigma=params['low_sigma'])
|
|
7053
|
+
if params['sigma'] > 0:
|
|
7054
|
+
rescaled_image = gaussian_filter(rescaled_image, sigma=params['sigma'])
|
|
7055
|
+
rescaled_image = mi.Utilities().convert_to_int8(rescaled_image, rescale=False)
|
|
7056
|
+
normalized_image = rescaled_image.astype(np.float32) / 255.0
|
|
7057
|
+
|
|
7058
|
+
# Use channel colormap (same as plot_segmentation)
|
|
7059
|
+
cmap_used = cmap_list_imagej[ch % len(cmap_list_imagej)]
|
|
7060
|
+
self.ax_segmentation.imshow(normalized_image[..., 0], cmap=cmap_used, vmin=0, vmax=1)
|
|
7061
|
+
|
|
7062
|
+
# Overlay the working mask with contours
|
|
7063
|
+
if self.edit_working_mask is not None:
|
|
7064
|
+
mask = self.edit_working_mask
|
|
7065
|
+
unique_labels = np.unique(mask)
|
|
7066
|
+
unique_labels = unique_labels[unique_labels > 0]
|
|
7067
|
+
|
|
7068
|
+
if len(unique_labels) > 0:
|
|
7069
|
+
# Create semi-transparent colored overlay for cells
|
|
7070
|
+
overlay = np.zeros((*mask.shape, 4))
|
|
7071
|
+
|
|
7072
|
+
# Use tab10 colormap for different cell labels
|
|
7073
|
+
colors = plt.cm.tab10.colors
|
|
7074
|
+
|
|
7075
|
+
for i, label in enumerate(unique_labels):
|
|
7076
|
+
color = colors[i % len(colors)]
|
|
7077
|
+
cell_mask = mask == label
|
|
7078
|
+
overlay[cell_mask, :3] = color[:3]
|
|
7079
|
+
overlay[cell_mask, 3] = 0.3 # Alpha (slightly transparent)
|
|
7080
|
+
|
|
7081
|
+
self.ax_segmentation.imshow(overlay)
|
|
7082
|
+
|
|
7083
|
+
# Draw white contours around each cell
|
|
7084
|
+
self.ax_segmentation.contour(mask, levels=[0.5], colors='white', linewidths=1.5)
|
|
7085
|
+
|
|
7086
|
+
# Add axis labels in pixels (same as plot_segmentation)
|
|
7087
|
+
height, width = image_to_display.shape[:2]
|
|
7088
|
+
self.ax_segmentation.set_xlabel('X (pixels)', color='white', fontsize=10)
|
|
7089
|
+
self.ax_segmentation.set_ylabel('Y (pixels)', color='white', fontsize=10)
|
|
7090
|
+
|
|
7091
|
+
num_x_ticks = 5
|
|
7092
|
+
x_tick_positions = np.linspace(0, width - 1, num_x_ticks)
|
|
7093
|
+
self.ax_segmentation.set_xticks(x_tick_positions)
|
|
7094
|
+
self.ax_segmentation.set_xticklabels([f'{int(pos)}' for pos in x_tick_positions], color='white', fontsize=8)
|
|
7095
|
+
|
|
7096
|
+
num_y_ticks = 5
|
|
7097
|
+
y_tick_positions = np.linspace(0, height - 1, num_y_ticks)
|
|
7098
|
+
self.ax_segmentation.set_yticks(y_tick_positions)
|
|
7099
|
+
self.ax_segmentation.set_yticklabels([f'{int(pos)}' for pos in y_tick_positions], color='white', fontsize=8)
|
|
7100
|
+
|
|
7101
|
+
self.ax_segmentation.tick_params(axis='both', colors='white', direction='out', length=4)
|
|
7102
|
+
self.ax_segmentation.spines['bottom'].set_color('white')
|
|
7103
|
+
self.ax_segmentation.spines['left'].set_color('white')
|
|
7104
|
+
self.ax_segmentation.spines['top'].set_visible(False)
|
|
7105
|
+
self.ax_segmentation.spines['right'].set_visible(False)
|
|
7106
|
+
|
|
7107
|
+
# Add subtle grid lines
|
|
7108
|
+
self.ax_segmentation.grid(True, linewidth=0.3, alpha=0.3, color='white')
|
|
7109
|
+
|
|
7110
|
+
# Set title
|
|
7111
|
+
if self.edit_current_mask_key:
|
|
7112
|
+
title = f"Editing: {self.edit_current_mask_key.replace('_', ' ').title()}"
|
|
7113
|
+
if self.edit_source_is_tyx:
|
|
7114
|
+
title += " (Max Projection)"
|
|
7115
|
+
else:
|
|
7116
|
+
title = "Select a mask to edit"
|
|
7117
|
+
self.ax_segmentation.set_title(title, color='white', fontsize=10)
|
|
7118
|
+
|
|
7119
|
+
self.figure_segmentation.tight_layout()
|
|
7120
|
+
self.canvas_segmentation.draw()
|
|
7121
|
+
|
|
7122
|
+
def _connect_edit_mouse_events(self):
|
|
7123
|
+
"""Connect mouse event handlers for edit mode."""
|
|
7124
|
+
# Disconnect any existing handlers
|
|
7125
|
+
self._disconnect_edit_mouse_events()
|
|
7126
|
+
|
|
7127
|
+
# Connect press, release, and motion events
|
|
7128
|
+
self._edit_cid_press = self.canvas_segmentation.mpl_connect(
|
|
7129
|
+
'button_press_event', self._on_edit_mouse_press)
|
|
7130
|
+
self._edit_cid_release = self.canvas_segmentation.mpl_connect(
|
|
7131
|
+
'button_release_event', self._on_edit_mouse_release)
|
|
7132
|
+
self._edit_cid_motion = self.canvas_segmentation.mpl_connect(
|
|
7133
|
+
'motion_notify_event', self._on_edit_mouse_motion)
|
|
7134
|
+
|
|
7135
|
+
def _disconnect_edit_mouse_events(self):
|
|
7136
|
+
"""Disconnect mouse event handlers for edit mode."""
|
|
7137
|
+
if hasattr(self, '_edit_cid_press') and self._edit_cid_press:
|
|
7138
|
+
self.canvas_segmentation.mpl_disconnect(self._edit_cid_press)
|
|
7139
|
+
self._edit_cid_press = None
|
|
7140
|
+
if hasattr(self, '_edit_cid_release') and self._edit_cid_release:
|
|
7141
|
+
self.canvas_segmentation.mpl_disconnect(self._edit_cid_release)
|
|
7142
|
+
self._edit_cid_release = None
|
|
7143
|
+
if hasattr(self, '_edit_cid_motion') and self._edit_cid_motion:
|
|
7144
|
+
self.canvas_segmentation.mpl_disconnect(self._edit_cid_motion)
|
|
7145
|
+
self._edit_cid_motion = None
|
|
7146
|
+
|
|
7147
|
+
def _on_edit_mouse_press(self, event):
|
|
7148
|
+
"""Handle mouse button press in edit mode (eraser brush only)."""
|
|
7149
|
+
if event.inaxes != self.ax_segmentation:
|
|
7150
|
+
return
|
|
7151
|
+
if event.xdata is None or event.ydata is None:
|
|
7152
|
+
return
|
|
7153
|
+
if not self.edit_mode_active or self.edit_working_mask is None:
|
|
7154
|
+
return
|
|
7155
|
+
|
|
7156
|
+
x, y = int(event.xdata), int(event.ydata)
|
|
7157
|
+
|
|
7158
|
+
# Start erasing
|
|
7159
|
+
self.edit_is_drawing = True
|
|
7160
|
+
self.edit_last_mouse_pos = (x, y)
|
|
7161
|
+
|
|
7162
|
+
# Push undo state at start of stroke
|
|
7163
|
+
self._push_undo("Erase")
|
|
7164
|
+
|
|
7165
|
+
# Apply first point
|
|
7166
|
+
self._apply_eraser_at(x, y)
|
|
7167
|
+
self.plot_edit_mode()
|
|
7168
|
+
|
|
7169
|
+
def _on_edit_mouse_release(self, event):
|
|
7170
|
+
"""Handle mouse button release in edit mode."""
|
|
7171
|
+
if self.edit_is_drawing:
|
|
7172
|
+
self.edit_is_drawing = False
|
|
7173
|
+
self.edit_last_mouse_pos = None
|
|
7174
|
+
self._update_edit_buttons_state()
|
|
7175
|
+
|
|
7176
|
+
def _on_edit_mouse_motion(self, event):
|
|
7177
|
+
"""Handle mouse motion in edit mode (for eraser drawing)."""
|
|
7178
|
+
if not self.edit_is_drawing:
|
|
7179
|
+
return
|
|
7180
|
+
if event.inaxes != self.ax_segmentation:
|
|
7181
|
+
return
|
|
7182
|
+
if event.xdata is None or event.ydata is None:
|
|
7183
|
+
return
|
|
7184
|
+
if self.edit_working_mask is None:
|
|
7185
|
+
return
|
|
7186
|
+
|
|
7187
|
+
x, y = int(event.xdata), int(event.ydata)
|
|
7188
|
+
|
|
7189
|
+
# Apply eraser at new position
|
|
7190
|
+
self._apply_eraser_at(x, y)
|
|
7191
|
+
|
|
7192
|
+
# Draw line between last position and current for smooth strokes
|
|
7193
|
+
if self.edit_last_mouse_pos:
|
|
7194
|
+
self._apply_eraser_line(self.edit_last_mouse_pos[0], self.edit_last_mouse_pos[1], x, y)
|
|
7195
|
+
|
|
7196
|
+
self.edit_last_mouse_pos = (x, y)
|
|
7197
|
+
self.plot_edit_mode()
|
|
7198
|
+
|
|
7199
|
+
def _apply_eraser_at(self, x, y):
|
|
7200
|
+
"""Apply eraser brush at given position (sets pixels to background 0).
|
|
7201
|
+
|
|
7202
|
+
Uses a fixed 10px circular brush.
|
|
7203
|
+
"""
|
|
7204
|
+
if self.edit_working_mask is None:
|
|
7205
|
+
return
|
|
7206
|
+
|
|
7207
|
+
mask = self.edit_working_mask
|
|
7208
|
+
h, w = mask.shape
|
|
7209
|
+
size = 10 # Fixed brush size
|
|
7210
|
+
|
|
7211
|
+
# Create circular brush mask
|
|
7212
|
+
yy, xx = np.ogrid[:h, :w]
|
|
7213
|
+
brush_mask = ((xx - x) ** 2 + (yy - y) ** 2) <= (size / 2) ** 2
|
|
7214
|
+
|
|
7215
|
+
# Erase: set pixels to background (0)
|
|
7216
|
+
mask[brush_mask] = 0
|
|
7217
|
+
|
|
7218
|
+
def _apply_eraser_line(self, x0, y0, x1, y1):
|
|
7219
|
+
"""Apply eraser along a line between two points for smooth strokes."""
|
|
7220
|
+
from skimage.draw import line
|
|
7221
|
+
|
|
7222
|
+
rr, cc = line(y0, x0, y1, x1)
|
|
7223
|
+
|
|
7224
|
+
for r, c in zip(rr, cc):
|
|
7225
|
+
self._apply_eraser_at(c, r)
|
|
7226
|
+
|
|
7227
|
+
|
|
6274
7228
|
# =============================================================================
|
|
6275
7229
|
# =============================================================================
|
|
6276
7230
|
# PHOTOBLEACHING TAB
|
|
@@ -9054,7 +10008,7 @@ class GUI(QMainWindow):
|
|
|
9054
10008
|
# Cluster radius
|
|
9055
10009
|
self.cluster_radius_input = QSpinBox()
|
|
9056
10010
|
self.cluster_radius_input.setMinimum(100)
|
|
9057
|
-
self.cluster_radius_input.setMaximum(
|
|
10011
|
+
self.cluster_radius_input.setMaximum(6000)
|
|
9058
10012
|
self.cluster_radius_input.setValue(self.cluster_radius_nm)
|
|
9059
10013
|
self.cluster_radius_input.valueChanged.connect(self.update_cluster_radius)
|
|
9060
10014
|
params_layout.addRow("Cluster radius (nm):", self.cluster_radius_input)
|
|
@@ -10691,7 +11645,7 @@ class GUI(QMainWindow):
|
|
|
10691
11645
|
}
|
|
10692
11646
|
|
|
10693
11647
|
if display_crop is not None and display_flags is not None:
|
|
10694
|
-
self.display_colocalization_results(display_crop, crop_size, display_flags, ch1, ch2)
|
|
11648
|
+
self.display_colocalization_results(display_crop, crop_size, display_flags, ch1, ch2, auto_columns=True)
|
|
10695
11649
|
self.extract_colocalization_data(save_df=False)
|
|
10696
11650
|
|
|
10697
11651
|
def _compute_coloc_flags(self, mean_crop, crop_size, ch2, method, threshold):
|
|
@@ -10776,20 +11730,34 @@ class GUI(QMainWindow):
|
|
|
10776
11730
|
|
|
10777
11731
|
|
|
10778
11732
|
|
|
10779
|
-
def display_colocalization_results(self, mean_crop, crop_size, flag_vector, ch1, ch2):
|
|
10780
|
-
"""Display the colocalization result using provided crop data.
|
|
11733
|
+
def display_colocalization_results(self, mean_crop, crop_size, flag_vector, ch1, ch2, auto_columns=False):
|
|
11734
|
+
"""Display the colocalization result using provided crop data.
|
|
11735
|
+
|
|
11736
|
+
Args:
|
|
11737
|
+
mean_crop: Cropped spot images
|
|
11738
|
+
crop_size: Size of each crop
|
|
11739
|
+
flag_vector: Boolean vector indicating colocalization status
|
|
11740
|
+
ch1: Reference channel index
|
|
11741
|
+
ch2: Colocalize channel index
|
|
11742
|
+
auto_columns: If True, auto-calculate optimal columns and update spinbox.
|
|
11743
|
+
If False (default), use current spinbox value.
|
|
11744
|
+
"""
|
|
10781
11745
|
self.figure_colocalization.clear()
|
|
10782
11746
|
title = f"Colocalization: {self.colocalization_results['colocalization_percentage']:.2f}%"
|
|
10783
11747
|
|
|
10784
|
-
#
|
|
10785
|
-
|
|
10786
|
-
|
|
10787
|
-
|
|
10788
|
-
|
|
10789
|
-
|
|
10790
|
-
self
|
|
10791
|
-
|
|
10792
|
-
|
|
11748
|
+
# Determine number of columns to use
|
|
11749
|
+
if auto_columns:
|
|
11750
|
+
# Auto-calculate optimal number of columns based on spot count
|
|
11751
|
+
n_spots = len(flag_vector) if flag_vector is not None else 0
|
|
11752
|
+
num_cols = self._calculate_optimal_coloc_columns(n_spots)
|
|
11753
|
+
# Update the spinbox to reflect the auto-calculated value
|
|
11754
|
+
if hasattr(self, 'columns_spinbox'):
|
|
11755
|
+
self.columns_spinbox.blockSignals(True)
|
|
11756
|
+
self.columns_spinbox.setValue(num_cols)
|
|
11757
|
+
self.columns_spinbox.blockSignals(False)
|
|
11758
|
+
else:
|
|
11759
|
+
# Use user's current spinbox value
|
|
11760
|
+
num_cols = self.columns_spinbox.value() if hasattr(self, 'columns_spinbox') else 20
|
|
10793
11761
|
|
|
10794
11762
|
self.plots.plot_matrix_pair_crops(
|
|
10795
11763
|
mean_crop=mean_crop,
|
|
@@ -10798,7 +11766,7 @@ class GUI(QMainWindow):
|
|
|
10798
11766
|
selected_channels=(ch1, ch2),
|
|
10799
11767
|
figure=self.figure_colocalization,
|
|
10800
11768
|
crop_spacing=5,
|
|
10801
|
-
number_columns=
|
|
11769
|
+
number_columns=num_cols,
|
|
10802
11770
|
plot_title=title
|
|
10803
11771
|
)
|
|
10804
11772
|
try:
|
|
@@ -10845,6 +11813,26 @@ class GUI(QMainWindow):
|
|
|
10845
11813
|
optimal = min(200, 120 + int((n_spots - 5000) * 80 / 10000))
|
|
10846
11814
|
|
|
10847
11815
|
return optimal
|
|
11816
|
+
|
|
11817
|
+
def _on_coloc_columns_changed(self, value):
|
|
11818
|
+
"""Handle user change to the Crop Columns spinbox.
|
|
11819
|
+
|
|
11820
|
+
Redraws the colocalization display with the new column count.
|
|
11821
|
+
Only triggers if colocalization results exist.
|
|
11822
|
+
"""
|
|
11823
|
+
if not self.colocalization_results:
|
|
11824
|
+
return
|
|
11825
|
+
|
|
11826
|
+
# Redraw with user's selected column count (auto_columns=False by default)
|
|
11827
|
+
self.display_colocalization_results(
|
|
11828
|
+
self.colocalization_results['mean_crop_filtered'],
|
|
11829
|
+
self.colocalization_results['crop_size'],
|
|
11830
|
+
self.colocalization_results['flag_vector'],
|
|
11831
|
+
self.colocalization_results['ch1_index'],
|
|
11832
|
+
self.colocalization_results['ch2_index'],
|
|
11833
|
+
auto_columns=False
|
|
11834
|
+
)
|
|
11835
|
+
self.canvas_colocalization.draw()
|
|
10848
11836
|
|
|
10849
11837
|
|
|
10850
11838
|
# Note: display_colocalization_manual() removed - replaced by separate
|
|
@@ -11376,8 +12364,9 @@ class GUI(QMainWindow):
|
|
|
11376
12364
|
columnsLayout.addWidget(QLabel("Columns:"))
|
|
11377
12365
|
self.columns_spinbox = QSpinBox()
|
|
11378
12366
|
self.columns_spinbox.setRange(4, 200)
|
|
11379
|
-
self.columns_spinbox.setValue(20) # Auto-adjusted
|
|
11380
|
-
self.columns_spinbox.setToolTip("
|
|
12367
|
+
self.columns_spinbox.setValue(20) # Auto-adjusted on first run, then user-controlled
|
|
12368
|
+
self.columns_spinbox.setToolTip("Adjust number of columns in crop display. Auto-set on first run.")
|
|
12369
|
+
self.columns_spinbox.valueChanged.connect(self._on_coloc_columns_changed)
|
|
11381
12370
|
columnsLayout.addWidget(self.columns_spinbox)
|
|
11382
12371
|
top_layout.addWidget(columnsGroup)
|
|
11383
12372
|
actionsGroup = QGroupBox("Actions")
|
|
@@ -11701,9 +12690,13 @@ class GUI(QMainWindow):
|
|
|
11701
12690
|
layout = QVBoxLayout(self.coloc_verify_distance_widget)
|
|
11702
12691
|
layout.setContentsMargins(10, 5, 10, 5)
|
|
11703
12692
|
|
|
11704
|
-
# Info label
|
|
11705
|
-
info_label = QLabel(
|
|
12693
|
+
# Info label explaining what is displayed
|
|
12694
|
+
info_label = QLabel(
|
|
12695
|
+
"Review unique particle tracks. Each row shows a time-averaged crop. "
|
|
12696
|
+
"A track is marked colocalized (✓) if ANY frame is within the distance threshold."
|
|
12697
|
+
)
|
|
11706
12698
|
info_label.setStyleSheet("font-style: italic; color: #999;")
|
|
12699
|
+
info_label.setWordWrap(True)
|
|
11707
12700
|
layout.addWidget(info_label)
|
|
11708
12701
|
|
|
11709
12702
|
# Top bar with stats and buttons
|
|
@@ -12553,6 +13546,9 @@ class GUI(QMainWindow):
|
|
|
12553
13546
|
channels=(ch1, ch2)
|
|
12554
13547
|
)
|
|
12555
13548
|
|
|
13549
|
+
# Reset sorted flag so Sort button can be used
|
|
13550
|
+
self._verify_visual_sorted = False
|
|
13551
|
+
|
|
12556
13552
|
# Update stats label
|
|
12557
13553
|
self._update_verify_visual_stats()
|
|
12558
13554
|
|
|
@@ -12570,20 +13566,76 @@ class GUI(QMainWindow):
|
|
|
12570
13566
|
)
|
|
12571
13567
|
|
|
12572
13568
|
def sort_verify_visual(self):
|
|
12573
|
-
"""Sort Verify Visual results by prediction value (lowest to highest)."""
|
|
13569
|
+
"""Sort Verify Visual results by prediction value (lowest to highest for review)."""
|
|
12574
13570
|
if not hasattr(self, 'verify_visual_checkboxes') or len(self.verify_visual_checkboxes) == 0:
|
|
13571
|
+
QMessageBox.information(self, "No Data", "No spots to sort. Please click Populate first.")
|
|
12575
13572
|
return
|
|
12576
13573
|
|
|
12577
|
-
|
|
13574
|
+
if not hasattr(self, 'colocalization_results') or not self.colocalization_results:
|
|
13575
|
+
QMessageBox.warning(self, "No Results", "No colocalization results available.")
|
|
13576
|
+
return
|
|
13577
|
+
|
|
13578
|
+
results = self.colocalization_results
|
|
13579
|
+
values = results.get('prediction_values_vector')
|
|
13580
|
+
mean_crop = results.get('mean_crop_filtered')
|
|
13581
|
+
crop_size = results.get('crop_size', 15)
|
|
13582
|
+
flag_vector = results.get('flag_vector')
|
|
13583
|
+
ch1 = results.get('ch1_index', 0)
|
|
13584
|
+
ch2 = results.get('ch2_index', 1)
|
|
13585
|
+
|
|
12578
13586
|
if values is None or len(values) == 0:
|
|
12579
13587
|
QMessageBox.information(self, "Cannot Sort", "No prediction values available for sorting.")
|
|
12580
13588
|
return
|
|
12581
13589
|
|
|
12582
|
-
|
|
12583
|
-
|
|
12584
|
-
|
|
12585
|
-
|
|
12586
|
-
|
|
13590
|
+
if mean_crop is None:
|
|
13591
|
+
QMessageBox.warning(self, "No Data", "Crop data not available for sorting.")
|
|
13592
|
+
return
|
|
13593
|
+
|
|
13594
|
+
# Check if already sorted (compare to original order)
|
|
13595
|
+
if hasattr(self, '_verify_visual_sorted') and self._verify_visual_sorted:
|
|
13596
|
+
QMessageBox.information(self, "Already Sorted", "Spots are already sorted by prediction value.")
|
|
13597
|
+
return
|
|
13598
|
+
|
|
13599
|
+
# Get current checkbox states before sorting
|
|
13600
|
+
current_states = [chk.isChecked() for chk in self.verify_visual_checkboxes]
|
|
13601
|
+
|
|
13602
|
+
# Create sorted indices (ascending by prediction value - uncertain first)
|
|
13603
|
+
num_spots = len(values)
|
|
13604
|
+
sorted_indices = np.argsort(values)
|
|
13605
|
+
|
|
13606
|
+
# Re-order checkbox states to match new sort order
|
|
13607
|
+
sorted_states = [current_states[i] if i < len(current_states) else False for i in sorted_indices]
|
|
13608
|
+
|
|
13609
|
+
# Re-order crops - each spot is crop_size rows in the mean_crop array
|
|
13610
|
+
num_crop_spots = mean_crop.shape[0] // crop_size
|
|
13611
|
+
if num_crop_spots < num_spots:
|
|
13612
|
+
num_spots = num_crop_spots
|
|
13613
|
+
sorted_indices = sorted_indices[:num_spots]
|
|
13614
|
+
|
|
13615
|
+
sorted_crop = np.zeros_like(mean_crop[:num_spots*crop_size])
|
|
13616
|
+
for new_idx, old_idx in enumerate(sorted_indices[:num_spots]):
|
|
13617
|
+
if old_idx < num_crop_spots:
|
|
13618
|
+
sorted_crop[new_idx*crop_size:(new_idx+1)*crop_size] = \
|
|
13619
|
+
mean_crop[old_idx*crop_size:(old_idx+1)*crop_size]
|
|
13620
|
+
|
|
13621
|
+
# Re-create verification crops with sorted data
|
|
13622
|
+
self._create_verification_crops(
|
|
13623
|
+
scroll_area=self.verify_visual_scroll_area,
|
|
13624
|
+
checkboxes_list_attr='verify_visual_checkboxes',
|
|
13625
|
+
mean_crop=sorted_crop,
|
|
13626
|
+
crop_size=crop_size,
|
|
13627
|
+
flag_vector=sorted_states, # Use previously checked states after reorder
|
|
13628
|
+
stats_label=self.verify_visual_stats_label,
|
|
13629
|
+
num_channels=2,
|
|
13630
|
+
channels=(ch1, ch2)
|
|
13631
|
+
)
|
|
13632
|
+
|
|
13633
|
+
# Mark as sorted
|
|
13634
|
+
self._verify_visual_sorted = True
|
|
13635
|
+
self._verify_visual_sort_indices = sorted_indices
|
|
13636
|
+
|
|
13637
|
+
# Update stats
|
|
13638
|
+
self._update_verify_visual_stats()
|
|
12587
13639
|
|
|
12588
13640
|
def cleanup_verify_visual(self):
|
|
12589
13641
|
"""Clear all checkboxes in Verify Visual subtab."""
|
|
@@ -12630,7 +13682,11 @@ class GUI(QMainWindow):
|
|
|
12630
13682
|
# === Verify Distance Subtab Methods ===
|
|
12631
13683
|
|
|
12632
13684
|
def populate_verify_distance(self):
|
|
12633
|
-
"""Populate the Verify Distance subtab with Distance colocalization results.
|
|
13685
|
+
"""Populate the Verify Distance subtab with Distance colocalization results.
|
|
13686
|
+
|
|
13687
|
+
Calculates and stores the minimum distance from each reference channel spot
|
|
13688
|
+
to its nearest partner in the target channel for sorting purposes.
|
|
13689
|
+
"""
|
|
12634
13690
|
if not hasattr(self, 'distance_coloc_results') or not self.distance_coloc_results:
|
|
12635
13691
|
QMessageBox.warning(self, "No Results",
|
|
12636
13692
|
"Please run Distance colocalization first.")
|
|
@@ -12641,8 +13697,10 @@ class GUI(QMainWindow):
|
|
|
12641
13697
|
ch0 = results.get('channel_0', 0)
|
|
12642
13698
|
ch1 = results.get('channel_1', 1)
|
|
12643
13699
|
df_coloc = results.get('df_colocalized', pd.DataFrame())
|
|
13700
|
+
df_ch1_all = results.get('df_ch1_all', pd.DataFrame())
|
|
12644
13701
|
threshold_px = results.get('threshold_distance_px', 2.0)
|
|
12645
13702
|
threshold_nm = results.get('threshold_distance_nm', 130.0)
|
|
13703
|
+
use_3d = results.get('use_3d', False)
|
|
12646
13704
|
|
|
12647
13705
|
# We need to create crops from tracking data
|
|
12648
13706
|
if not hasattr(self, 'df_tracking') or self.df_tracking.empty:
|
|
@@ -12684,28 +13742,129 @@ class GUI(QMainWindow):
|
|
|
12684
13742
|
|
|
12685
13743
|
num_spots = mean_crop.shape[0] // crop_size
|
|
12686
13744
|
|
|
12687
|
-
#
|
|
12688
|
-
#
|
|
12689
|
-
|
|
13745
|
+
# Build set of colocalized coordinates for matching
|
|
13746
|
+
# Use a tolerance-based approach instead of exact coordinate matching
|
|
13747
|
+
coloc_coords_array = np.empty((0, 4)) # z, y, x, cell_id
|
|
12690
13748
|
if not df_coloc.empty:
|
|
12691
|
-
|
|
12692
|
-
|
|
12693
|
-
|
|
12694
|
-
|
|
13749
|
+
if 'z' in df_coloc.columns and use_3d:
|
|
13750
|
+
coloc_coords_array = df_coloc[['z', 'y', 'x', 'cell_id']].values
|
|
13751
|
+
else:
|
|
13752
|
+
# Add dummy z=0 for 2D matching
|
|
13753
|
+
coloc_coords_array = np.column_stack([
|
|
13754
|
+
np.zeros(len(df_coloc)),
|
|
13755
|
+
df_coloc['y'].values,
|
|
13756
|
+
df_coloc['x'].values,
|
|
13757
|
+
df_coloc['cell_id'].values
|
|
13758
|
+
])
|
|
13759
|
+
|
|
13760
|
+
# Calculate minimum distances for each spot in ch0 to nearest spot in ch1
|
|
13761
|
+
# This will be used for sorting (ascending = closest to threshold = most uncertain)
|
|
13762
|
+
distance_values = []
|
|
12695
13763
|
flag_vector = []
|
|
12696
|
-
|
|
13764
|
+
|
|
13765
|
+
# Get ch1 coordinates for distance calculation
|
|
13766
|
+
ch1_coords = None
|
|
13767
|
+
if not df_ch1_all.empty and 'x' in df_ch1_all.columns and 'y' in df_ch1_all.columns:
|
|
13768
|
+
if use_3d and 'z' in df_ch1_all.columns:
|
|
13769
|
+
ch1_coords = df_ch1_all[['z', 'y', 'x']].values
|
|
13770
|
+
else:
|
|
13771
|
+
ch1_coords = df_ch1_all[['y', 'x']].values
|
|
13772
|
+
|
|
13773
|
+
# Get anisotropic scaling for 3D
|
|
13774
|
+
voxel_z_nm = results.get('voxel_z_nm', 300.0)
|
|
13775
|
+
voxel_xy_nm = results.get('voxel_xy_nm', 130.0)
|
|
13776
|
+
z_scale = voxel_z_nm / voxel_xy_nm if use_3d and voxel_xy_nm > 0 else 1.0
|
|
13777
|
+
|
|
13778
|
+
# Use the same particle column identification as CropArray
|
|
13779
|
+
# This ensures our iteration matches the crop order
|
|
13780
|
+
df_ch0_copy = df_ch0.copy()
|
|
13781
|
+
if 'unique_particle' in df_ch0_copy.columns:
|
|
13782
|
+
particle_col = 'unique_particle'
|
|
13783
|
+
elif 'cell_id' in df_ch0_copy.columns:
|
|
13784
|
+
if 'spot_type' in df_ch0_copy.columns:
|
|
13785
|
+
df_ch0_copy['unique_particle'] = (
|
|
13786
|
+
df_ch0_copy['cell_id'].astype(str) + '_' +
|
|
13787
|
+
df_ch0_copy['spot_type'].astype(str) + '_' +
|
|
13788
|
+
df_ch0_copy['particle'].astype(str)
|
|
13789
|
+
)
|
|
13790
|
+
else:
|
|
13791
|
+
df_ch0_copy['unique_particle'] = (
|
|
13792
|
+
df_ch0_copy['cell_id'].astype(str) + '_' +
|
|
13793
|
+
df_ch0_copy['particle'].astype(str)
|
|
13794
|
+
)
|
|
13795
|
+
particle_col = 'unique_particle'
|
|
13796
|
+
else:
|
|
13797
|
+
particle_col = 'particle'
|
|
13798
|
+
|
|
13799
|
+
# Helper function to check if a spot coordinate is in the colocalized set
|
|
13800
|
+
def is_coord_colocalized(z, y, x, cell_id, coloc_arr, tolerance=1.0):
|
|
13801
|
+
"""Check if a spot is in the colocalized set using coordinate tolerance."""
|
|
13802
|
+
if len(coloc_arr) == 0:
|
|
13803
|
+
return False
|
|
13804
|
+
# Filter by cell_id first for efficiency
|
|
13805
|
+
cell_mask = coloc_arr[:, 3].astype(int) == int(cell_id)
|
|
13806
|
+
cell_coloc = coloc_arr[cell_mask]
|
|
13807
|
+
if len(cell_coloc) == 0:
|
|
13808
|
+
return False
|
|
13809
|
+
# Check distance to each colocalized spot
|
|
13810
|
+
for cz, cy, cx, _ in cell_coloc:
|
|
13811
|
+
dist_xy = np.sqrt((x - cx)**2 + (y - cy)**2)
|
|
13812
|
+
dist_z = abs(z - cz) if use_3d else 0
|
|
13813
|
+
if dist_xy <= tolerance and dist_z <= tolerance:
|
|
13814
|
+
return True
|
|
13815
|
+
return False
|
|
13816
|
+
|
|
13817
|
+
# Iterate unique particles in the same order as CropArray
|
|
13818
|
+
unique_particles = df_ch0_copy[particle_col].unique()
|
|
13819
|
+
|
|
13820
|
+
for i, particle_id in enumerate(unique_particles):
|
|
12697
13821
|
if i >= num_spots:
|
|
12698
13822
|
break
|
|
12699
|
-
|
|
12700
|
-
|
|
13823
|
+
|
|
13824
|
+
df_particle = df_ch0_copy[df_ch0_copy[particle_col] == particle_id]
|
|
13825
|
+
|
|
13826
|
+
# Check if ANY observation of this particle is colocalized
|
|
13827
|
+
is_coloc = False
|
|
13828
|
+
min_dist_all = threshold_px * 10.0 # Large default
|
|
13829
|
+
|
|
13830
|
+
for _, row in df_particle.iterrows():
|
|
13831
|
+
x_val, y_val = row['x'], row['y']
|
|
13832
|
+
z_val = row.get('z', 0)
|
|
13833
|
+
cell_id = row.get('cell_id', 0)
|
|
13834
|
+
|
|
13835
|
+
# Check if this observation is in the colocalized set
|
|
13836
|
+
if len(coloc_coords_array) > 0:
|
|
13837
|
+
if is_coord_colocalized(z_val, y_val, x_val, cell_id, coloc_coords_array, tolerance=1.0):
|
|
13838
|
+
is_coloc = True
|
|
13839
|
+
|
|
13840
|
+
# Calculate minimum distance to any ch1 spot for this observation
|
|
13841
|
+
if ch1_coords is not None and len(ch1_coords) > 0:
|
|
13842
|
+
if use_3d and ch1_coords.shape[1] == 3:
|
|
13843
|
+
spot_coord = np.array([[z_val * z_scale, y_val, x_val]])
|
|
13844
|
+
ch1_scaled = ch1_coords.copy().astype(float)
|
|
13845
|
+
ch1_scaled[:, 0] = ch1_scaled[:, 0] * z_scale # Scale Z
|
|
13846
|
+
else:
|
|
13847
|
+
spot_coord = np.array([[y_val, x_val]])
|
|
13848
|
+
ch1_scaled = ch1_coords
|
|
13849
|
+
|
|
13850
|
+
from scipy.spatial.distance import cdist
|
|
13851
|
+
distances = cdist(spot_coord, ch1_scaled, metric='euclidean')
|
|
13852
|
+
obs_min_dist = float(np.min(distances))
|
|
13853
|
+
if obs_min_dist < min_dist_all:
|
|
13854
|
+
min_dist_all = obs_min_dist
|
|
13855
|
+
|
|
13856
|
+
flag_vector.append(is_coloc)
|
|
13857
|
+
distance_values.append(min_dist_all)
|
|
12701
13858
|
|
|
12702
|
-
# Pad
|
|
13859
|
+
# Pad vectors if needed (shouldn't happen, but just in case)
|
|
12703
13860
|
while len(flag_vector) < num_spots:
|
|
12704
13861
|
flag_vector.append(False)
|
|
13862
|
+
distance_values.append(threshold_px * 10.0)
|
|
12705
13863
|
|
|
12706
|
-
# Store for later use
|
|
13864
|
+
# Store for later use (sorting, etc.)
|
|
12707
13865
|
self.verify_distance_mean_crop = mean_crop
|
|
12708
13866
|
self.verify_distance_crop_size = crop_size
|
|
13867
|
+
self.verify_distance_values = np.array(distance_values) # For sorting by distance
|
|
12709
13868
|
|
|
12710
13869
|
# Create spot crops with checkboxes
|
|
12711
13870
|
self._create_verification_crops(
|
|
@@ -12719,6 +13878,9 @@ class GUI(QMainWindow):
|
|
|
12719
13878
|
channels=(ch0, ch1)
|
|
12720
13879
|
)
|
|
12721
13880
|
|
|
13881
|
+
# Reset sorted flag so Sort button can be used
|
|
13882
|
+
self._verify_distance_sorted = False
|
|
13883
|
+
|
|
12722
13884
|
# Update stats label
|
|
12723
13885
|
self._update_verify_distance_stats()
|
|
12724
13886
|
|
|
@@ -12743,9 +13905,88 @@ class GUI(QMainWindow):
|
|
|
12743
13905
|
)
|
|
12744
13906
|
|
|
12745
13907
|
def sort_verify_distance(self):
|
|
12746
|
-
"""Sort Verify Distance results
|
|
12747
|
-
|
|
12748
|
-
|
|
13908
|
+
"""Sort Verify Distance results by distance value (ascending - closest to threshold first).
|
|
13909
|
+
|
|
13910
|
+
Similar to Visual method's certainty-based sorting, but uses the measured
|
|
13911
|
+
distance to nearest partner. Spots with distances closest to the colocalization
|
|
13912
|
+
threshold are shown first as they represent the most uncertain classifications.
|
|
13913
|
+
"""
|
|
13914
|
+
if not hasattr(self, 'verify_distance_checkboxes') or len(self.verify_distance_checkboxes) == 0:
|
|
13915
|
+
QMessageBox.information(self, "No Data", "No spots to sort. Please click Populate first.")
|
|
13916
|
+
return
|
|
13917
|
+
|
|
13918
|
+
if not hasattr(self, 'verify_distance_mean_crop') or self.verify_distance_mean_crop is None:
|
|
13919
|
+
QMessageBox.warning(self, "No Data", "Crop data not available for sorting.")
|
|
13920
|
+
return
|
|
13921
|
+
|
|
13922
|
+
# Check if distance values are available
|
|
13923
|
+
if not hasattr(self, 'verify_distance_values') or self.verify_distance_values is None:
|
|
13924
|
+
QMessageBox.warning(self, "No Distance Data",
|
|
13925
|
+
"Distance values not available. Please re-run Populate.")
|
|
13926
|
+
return
|
|
13927
|
+
|
|
13928
|
+
# Check if already sorted
|
|
13929
|
+
if hasattr(self, '_verify_distance_sorted') and self._verify_distance_sorted:
|
|
13930
|
+
QMessageBox.information(self, "Already Sorted", "Spots are already sorted by distance value.")
|
|
13931
|
+
return
|
|
13932
|
+
|
|
13933
|
+
mean_crop = self.verify_distance_mean_crop
|
|
13934
|
+
crop_size = self.verify_distance_crop_size
|
|
13935
|
+
distance_values = self.verify_distance_values
|
|
13936
|
+
|
|
13937
|
+
# Get current checkbox states before sorting
|
|
13938
|
+
current_states = [chk.isChecked() for chk in self.verify_distance_checkboxes]
|
|
13939
|
+
num_spots = len(current_states)
|
|
13940
|
+
|
|
13941
|
+
# Sort ascending by distance (closest to threshold = most uncertain first)
|
|
13942
|
+
# This matches the Visual method's approach of showing uncertain cases first
|
|
13943
|
+
sorted_indices = np.argsort(distance_values)
|
|
13944
|
+
|
|
13945
|
+
# Re-order states and distances
|
|
13946
|
+
sorted_states = [current_states[i] if i < len(current_states) else False for i in sorted_indices]
|
|
13947
|
+
sorted_distances = distance_values[sorted_indices]
|
|
13948
|
+
|
|
13949
|
+
# Re-order crops
|
|
13950
|
+
num_crop_spots = mean_crop.shape[0] // crop_size
|
|
13951
|
+
if num_crop_spots < num_spots:
|
|
13952
|
+
num_spots = num_crop_spots
|
|
13953
|
+
sorted_indices = sorted_indices[:num_spots]
|
|
13954
|
+
|
|
13955
|
+
sorted_crop = np.zeros_like(mean_crop[:num_spots*crop_size])
|
|
13956
|
+
for new_idx, old_idx in enumerate(sorted_indices[:num_spots]):
|
|
13957
|
+
if old_idx < num_crop_spots:
|
|
13958
|
+
sorted_crop[new_idx*crop_size:(new_idx+1)*crop_size] = \
|
|
13959
|
+
mean_crop[old_idx*crop_size:(old_idx+1)*crop_size]
|
|
13960
|
+
|
|
13961
|
+
# Get channels from distance results
|
|
13962
|
+
results = self.distance_coloc_results if hasattr(self, 'distance_coloc_results') else {}
|
|
13963
|
+
ch0 = results.get('channel_0', 0)
|
|
13964
|
+
ch1 = results.get('channel_1', 1)
|
|
13965
|
+
image = self.corrected_image if self.corrected_image is not None else self.image_stack
|
|
13966
|
+
num_channels = image.shape[-1] if image is not None and image.ndim == 5 else 1
|
|
13967
|
+
|
|
13968
|
+
# Re-create verification crops with sorted data
|
|
13969
|
+
self._create_verification_crops(
|
|
13970
|
+
scroll_area=self.verify_distance_scroll_area,
|
|
13971
|
+
checkboxes_list_attr='verify_distance_checkboxes',
|
|
13972
|
+
mean_crop=sorted_crop,
|
|
13973
|
+
crop_size=crop_size,
|
|
13974
|
+
flag_vector=sorted_states,
|
|
13975
|
+
stats_label=self.verify_distance_stats_label,
|
|
13976
|
+
num_channels=num_channels,
|
|
13977
|
+
channels=(ch0, ch1)
|
|
13978
|
+
)
|
|
13979
|
+
|
|
13980
|
+
# Update stored data after sorting for consistency
|
|
13981
|
+
self.verify_distance_mean_crop = sorted_crop
|
|
13982
|
+
self.verify_distance_values = sorted_distances
|
|
13983
|
+
self._verify_distance_sort_indices = sorted_indices # Store for reference
|
|
13984
|
+
|
|
13985
|
+
# Mark as sorted
|
|
13986
|
+
self._verify_distance_sorted = True
|
|
13987
|
+
|
|
13988
|
+
# Update stats
|
|
13989
|
+
self._update_verify_distance_stats()
|
|
12749
13990
|
|
|
12750
13991
|
def cleanup_verify_distance(self):
|
|
12751
13992
|
"""Clear all checkboxes in Verify Distance subtab."""
|
|
@@ -12833,7 +14074,12 @@ class GUI(QMainWindow):
|
|
|
12833
14074
|
|
|
12834
14075
|
# Checkbox
|
|
12835
14076
|
chk = QCheckBox(f"Spot {i+1}")
|
|
12836
|
-
|
|
14077
|
+
# Safely get the flag value (handle numpy arrays, lists, etc.)
|
|
14078
|
+
try:
|
|
14079
|
+
flag_val = bool(flag_vector[i]) if i < len(flag_vector) else False
|
|
14080
|
+
except (TypeError, IndexError):
|
|
14081
|
+
flag_val = False
|
|
14082
|
+
chk.setChecked(flag_val)
|
|
12837
14083
|
chk.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
|
12838
14084
|
|
|
12839
14085
|
# Connect to stats update
|
|
@@ -12897,7 +14143,18 @@ class GUI(QMainWindow):
|
|
|
12897
14143
|
spot_coord = (int(dfm.iloc[0]['y']), int(dfm.iloc[0]['x']))
|
|
12898
14144
|
found_spot = True
|
|
12899
14145
|
else:
|
|
12900
|
-
|
|
14146
|
+
# Particle not in current frame - jump to first valid frame for this particle
|
|
14147
|
+
df_particle = self.df_tracking[self.df_tracking[particle_col] == pid]
|
|
14148
|
+
if not df_particle.empty:
|
|
14149
|
+
first_frame = int(df_particle['frame'].min())
|
|
14150
|
+
self.current_frame = first_frame
|
|
14151
|
+
if hasattr(self, 'time_slider_tracking_vis'):
|
|
14152
|
+
self.time_slider_tracking_vis.setValue(first_frame)
|
|
14153
|
+
dfm = df_particle[df_particle['frame'] == first_frame]
|
|
14154
|
+
spot_coord = (int(dfm.iloc[0]['y']), int(dfm.iloc[0]['x']))
|
|
14155
|
+
found_spot = True
|
|
14156
|
+
else:
|
|
14157
|
+
spot_coord = (0, 0)
|
|
12901
14158
|
else:
|
|
12902
14159
|
spot_coord = (0, 0)
|
|
12903
14160
|
else:
|
|
@@ -12956,7 +14213,12 @@ class GUI(QMainWindow):
|
|
|
12956
14213
|
else:
|
|
12957
14214
|
main_img = norm_stack[selected_channelIndex]
|
|
12958
14215
|
main_cmap = cmap_list_imagej[selected_channelIndex % len(cmap_list_imagej)]
|
|
12959
|
-
|
|
14216
|
+
|
|
14217
|
+
# Always show 3D intensity profile for spot quality assessment
|
|
14218
|
+
show_3d_profile = True
|
|
14219
|
+
|
|
14220
|
+
# 3-column layout: main image + 2D crops + 3D surfaces
|
|
14221
|
+
gs = fig.add_gridspec(1, 3, width_ratios=[3, 1, 1.5], hspace=0.1, wspace=0.15)
|
|
12960
14222
|
ax_main = fig.add_subplot(gs[0, 0])
|
|
12961
14223
|
|
|
12962
14224
|
# Store main axes reference and recreate RectangleSelector
|
|
@@ -12972,8 +14234,15 @@ class GUI(QMainWindow):
|
|
|
12972
14234
|
props=dict(facecolor='cyan', edgecolor='white', alpha=0.3, linewidth=2)
|
|
12973
14235
|
)
|
|
12974
14236
|
|
|
14237
|
+
# 2D crop subgrid
|
|
12975
14238
|
gs2 = gs[0, 1].subgridspec(C, 1, hspace=0.1)
|
|
12976
|
-
axes_zoom = [fig.add_subplot(gs2[i, 0]) for i in range(C)]
|
|
14239
|
+
axes_zoom = [fig.add_subplot(gs2[i, 0]) for i in range(C)]
|
|
14240
|
+
|
|
14241
|
+
# 3D profile subgrid (only create if enabled)
|
|
14242
|
+
axes_3d = []
|
|
14243
|
+
if show_3d_profile:
|
|
14244
|
+
gs3 = gs[0, 2].subgridspec(C, 1, hspace=0.15)
|
|
14245
|
+
axes_3d = [fig.add_subplot(gs3[i, 0], projection='3d') for i in range(C)]
|
|
12977
14246
|
# remove background if requested
|
|
12978
14247
|
if hasattr(self, 'checkbox_remove_bg') and self.checkbox_remove_bg.isChecked():
|
|
12979
14248
|
seg_mask = getattr(self, 'segmentation_mask', None)
|
|
@@ -13027,6 +14296,15 @@ class GUI(QMainWindow):
|
|
|
13027
14296
|
rect = patches.Rectangle((x0, y0), crop_sz, crop_sz, edgecolor='white', facecolor='none', linewidth=2)
|
|
13028
14297
|
ax_main.add_patch(rect)
|
|
13029
14298
|
ax_main.axis('off')
|
|
14299
|
+
|
|
14300
|
+
# Add thin border to show image boundaries (matching Tracking tab style)
|
|
14301
|
+
# Use -0.5 origin and full size to place border outside the image
|
|
14302
|
+
if self.image_stack is not None:
|
|
14303
|
+
img_H, img_W = main_img.shape[:2]
|
|
14304
|
+
img_border = patches.Rectangle((-0.5, -0.5), img_W, img_H, linewidth=0.8,
|
|
14305
|
+
edgecolor='#555555', facecolor='none', linestyle='-')
|
|
14306
|
+
ax_main.add_patch(img_border)
|
|
14307
|
+
|
|
13030
14308
|
for ci, ax in enumerate(axes_zoom):
|
|
13031
14309
|
if found_spot:
|
|
13032
14310
|
crop = norm_stack[ci, y0:y1, x0:x1]
|
|
@@ -13034,6 +14312,59 @@ class GUI(QMainWindow):
|
|
|
13034
14312
|
crop = np.zeros((crop_sz, crop_sz))
|
|
13035
14313
|
ax.imshow(crop, cmap=cmap_list_imagej[ci % len(cmap_list_imagej)], interpolation='nearest', vmin=0, vmax=1)
|
|
13036
14314
|
ax.axis('off')
|
|
14315
|
+
# Add gray frame border to 2D crops using Rectangle (spines hidden by axis('off'))
|
|
14316
|
+
# Use -0.5 origin and full size to place border outside the image (matplotlib centers pixels at integers)
|
|
14317
|
+
crop_h, crop_w = crop.shape[:2]
|
|
14318
|
+
crop_border = patches.Rectangle((-0.5, -0.5), crop_w, crop_h, linewidth=0.8,
|
|
14319
|
+
edgecolor='#555555', facecolor='none', linestyle='-')
|
|
14320
|
+
ax.add_patch(crop_border)
|
|
14321
|
+
|
|
14322
|
+
# Render 3D intensity profiles if enabled
|
|
14323
|
+
if show_3d_profile and axes_3d:
|
|
14324
|
+
# Create meshgrid for surface plot (only once, using crop dimensions)
|
|
14325
|
+
crop_example = norm_stack[0, y0:y1, x0:x1] if found_spot else np.zeros((crop_sz, crop_sz))
|
|
14326
|
+
Y_grid, X_grid = np.meshgrid(np.arange(crop_example.shape[0]),
|
|
14327
|
+
np.arange(crop_example.shape[1]), indexing='ij')
|
|
14328
|
+
|
|
14329
|
+
for ci, ax3d in enumerate(axes_3d):
|
|
14330
|
+
if found_spot:
|
|
14331
|
+
crop = norm_stack[ci, y0:y1, x0:x1]
|
|
14332
|
+
else:
|
|
14333
|
+
crop = np.zeros((crop_sz, crop_sz))
|
|
14334
|
+
|
|
14335
|
+
# Get channel colormap
|
|
14336
|
+
cmap = cmap_list_imagej[ci % len(cmap_list_imagej)]
|
|
14337
|
+
|
|
14338
|
+
# Plot surface with matching colormap
|
|
14339
|
+
ax3d.plot_surface(X_grid, Y_grid, crop, cmap=cmap,
|
|
14340
|
+
edgecolor='none', alpha=0.9, antialiased=True)
|
|
14341
|
+
|
|
14342
|
+
# Style the 3D axes for dark theme
|
|
14343
|
+
ax3d.set_facecolor('black')
|
|
14344
|
+
ax3d.set_xlabel('X', fontsize=7, color='white', labelpad=-2)
|
|
14345
|
+
ax3d.set_ylabel('Y', fontsize=7, color='white', labelpad=-2)
|
|
14346
|
+
ax3d.set_zlabel('I', fontsize=7, color='white', labelpad=-2)
|
|
14347
|
+
ax3d.tick_params(axis='both', which='major', labelsize=5, colors='white', pad=0)
|
|
14348
|
+
ax3d.tick_params(axis='z', which='major', labelsize=5, colors='white', pad=0)
|
|
14349
|
+
|
|
14350
|
+
# Set consistent Z limits for comparison across channels
|
|
14351
|
+
ax3d.set_zlim(0, 1)
|
|
14352
|
+
|
|
14353
|
+
# Make pane and grid styling match dark theme
|
|
14354
|
+
ax3d.xaxis.pane.fill = False
|
|
14355
|
+
ax3d.yaxis.pane.fill = False
|
|
14356
|
+
ax3d.zaxis.pane.fill = False
|
|
14357
|
+
ax3d.xaxis.pane.set_edgecolor('gray')
|
|
14358
|
+
ax3d.yaxis.pane.set_edgecolor('gray')
|
|
14359
|
+
ax3d.zaxis.pane.set_edgecolor('gray')
|
|
14360
|
+
ax3d.grid(True, alpha=0.3, color='gray')
|
|
14361
|
+
|
|
14362
|
+
# Add channel label
|
|
14363
|
+
ax3d.set_title(f'Ch{ci}', fontsize=8, color='white', pad=-5)
|
|
14364
|
+
|
|
14365
|
+
# Set viewing angle for nice perspective
|
|
14366
|
+
ax3d.view_init(elev=25, azim=-45)
|
|
14367
|
+
|
|
13037
14368
|
fig.tight_layout()
|
|
13038
14369
|
|
|
13039
14370
|
# Add thin white frame border to main image
|
|
@@ -14771,6 +16102,21 @@ class GUI(QMainWindow):
|
|
|
14771
16102
|
self.watershed_size_slider.blockSignals(False)
|
|
14772
16103
|
if hasattr(self, 'watershed_size_label'):
|
|
14773
16104
|
self.watershed_size_label.setText("0")
|
|
16105
|
+
|
|
16106
|
+
# Reset Edit tab state
|
|
16107
|
+
self.edit_mode_active = False
|
|
16108
|
+
self.edit_working_mask = None
|
|
16109
|
+
self.edit_original_mask = None
|
|
16110
|
+
self.edit_current_mask_key = None
|
|
16111
|
+
self.edit_undo_stack = []
|
|
16112
|
+
if hasattr(self, '_disconnect_edit_mouse_events'):
|
|
16113
|
+
self._disconnect_edit_mouse_events()
|
|
16114
|
+
if hasattr(self, 'edit_mask_selector'):
|
|
16115
|
+
self.edit_mask_selector.blockSignals(True)
|
|
16116
|
+
self.edit_mask_selector.setCurrentIndex(0)
|
|
16117
|
+
self.edit_mask_selector.blockSignals(False)
|
|
16118
|
+
if hasattr(self, 'edit_instructions_group'):
|
|
16119
|
+
self.edit_instructions_group.setVisible(False)
|
|
14774
16120
|
|
|
14775
16121
|
def reset_photobleaching_tab(self):
|
|
14776
16122
|
self.figure_photobleaching.clear()
|
|
@@ -15475,6 +16821,7 @@ class GUI(QMainWindow):
|
|
|
15475
16821
|
self.verify_visual_checkboxes = []
|
|
15476
16822
|
if hasattr(self, 'verify_visual_stats_label'):
|
|
15477
16823
|
self.verify_visual_stats_label.setText("Run Visual colocalization first, then click Populate")
|
|
16824
|
+
self._verify_visual_sorted = False
|
|
15478
16825
|
|
|
15479
16826
|
# Reset Verify Distance
|
|
15480
16827
|
if hasattr(self, 'verify_distance_scroll_area'):
|
|
@@ -15483,6 +16830,11 @@ class GUI(QMainWindow):
|
|
|
15483
16830
|
self.verify_distance_checkboxes = []
|
|
15484
16831
|
if hasattr(self, 'verify_distance_stats_label'):
|
|
15485
16832
|
self.verify_distance_stats_label.setText("Run Distance colocalization first, then click Populate")
|
|
16833
|
+
# Reset stored distance data for sorting
|
|
16834
|
+
self.verify_distance_mean_crop = None
|
|
16835
|
+
self.verify_distance_crop_size = None
|
|
16836
|
+
self.verify_distance_values = None
|
|
16837
|
+
self._verify_distance_sorted = False
|
|
15486
16838
|
|
|
15487
16839
|
def reset_cellpose_tab(self):
|
|
15488
16840
|
"""Reset Cellpose tab state, masks, and UI controls to defaults."""
|
|
@@ -15544,6 +16896,8 @@ class GUI(QMainWindow):
|
|
|
15544
16896
|
self.chk_remove_border_cells.setChecked(False)
|
|
15545
16897
|
if hasattr(self, 'chk_remove_unpaired_cells'):
|
|
15546
16898
|
self.chk_remove_unpaired_cells.setChecked(False)
|
|
16899
|
+
if hasattr(self, 'chk_keep_center_cell'):
|
|
16900
|
+
self.chk_keep_center_cell.setChecked(False)
|
|
15547
16901
|
|
|
15548
16902
|
# Refresh the shared segmentation display
|
|
15549
16903
|
# (Cellpose now shares the segmentation canvas, so just refresh it)
|