celldetective 1.4.2__py3-none-any.whl → 1.5.0b1__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.
- celldetective/__init__.py +25 -0
- celldetective/__main__.py +62 -43
- celldetective/_version.py +1 -1
- celldetective/extra_properties.py +477 -399
- celldetective/filters.py +192 -97
- celldetective/gui/InitWindow.py +541 -411
- celldetective/gui/__init__.py +0 -15
- celldetective/gui/about.py +44 -39
- celldetective/gui/analyze_block.py +120 -84
- celldetective/gui/base/__init__.py +0 -0
- celldetective/gui/base/channel_norm_generator.py +335 -0
- celldetective/gui/base/components.py +249 -0
- celldetective/gui/base/feature_choice.py +92 -0
- celldetective/gui/base/figure_canvas.py +52 -0
- celldetective/gui/base/list_widget.py +133 -0
- celldetective/gui/{styles.py → base/styles.py} +92 -36
- celldetective/gui/base/utils.py +33 -0
- celldetective/gui/base_annotator.py +900 -767
- celldetective/gui/classifier_widget.py +6 -22
- celldetective/gui/configure_new_exp.py +777 -671
- celldetective/gui/control_panel.py +635 -524
- celldetective/gui/dynamic_progress.py +449 -0
- celldetective/gui/event_annotator.py +2023 -1662
- celldetective/gui/generic_signal_plot.py +1292 -944
- celldetective/gui/gui_utils.py +899 -1289
- celldetective/gui/interactions_block.py +658 -0
- celldetective/gui/interactive_timeseries_viewer.py +447 -0
- celldetective/gui/json_readers.py +48 -15
- celldetective/gui/layouts/__init__.py +5 -0
- celldetective/gui/layouts/background_model_free_layout.py +537 -0
- celldetective/gui/layouts/channel_offset_layout.py +134 -0
- celldetective/gui/layouts/local_correction_layout.py +91 -0
- celldetective/gui/layouts/model_fit_layout.py +372 -0
- celldetective/gui/layouts/operation_layout.py +68 -0
- celldetective/gui/layouts/protocol_designer_layout.py +96 -0
- celldetective/gui/pair_event_annotator.py +3130 -2435
- celldetective/gui/plot_measurements.py +586 -267
- celldetective/gui/plot_signals_ui.py +724 -506
- celldetective/gui/preprocessing_block.py +395 -0
- celldetective/gui/process_block.py +1678 -1831
- celldetective/gui/seg_model_loader.py +580 -473
- celldetective/gui/settings/__init__.py +0 -7
- celldetective/gui/settings/_cellpose_model_params.py +181 -0
- celldetective/gui/settings/_event_detection_model_params.py +95 -0
- celldetective/gui/settings/_segmentation_model_params.py +159 -0
- celldetective/gui/settings/_settings_base.py +77 -65
- celldetective/gui/settings/_settings_event_model_training.py +752 -526
- celldetective/gui/settings/_settings_measurements.py +1133 -964
- celldetective/gui/settings/_settings_neighborhood.py +574 -488
- celldetective/gui/settings/_settings_segmentation_model_training.py +779 -564
- celldetective/gui/settings/_settings_signal_annotator.py +329 -305
- celldetective/gui/settings/_settings_tracking.py +1304 -1094
- celldetective/gui/settings/_stardist_model_params.py +98 -0
- celldetective/gui/survival_ui.py +422 -312
- celldetective/gui/tableUI.py +1665 -1701
- celldetective/gui/table_ops/_maths.py +295 -0
- celldetective/gui/table_ops/_merge_groups.py +140 -0
- celldetective/gui/table_ops/_merge_one_hot.py +95 -0
- celldetective/gui/table_ops/_query_table.py +43 -0
- celldetective/gui/table_ops/_rename_col.py +44 -0
- celldetective/gui/thresholds_gui.py +382 -179
- celldetective/gui/viewers/__init__.py +0 -0
- celldetective/gui/viewers/base_viewer.py +700 -0
- celldetective/gui/viewers/channel_offset_viewer.py +331 -0
- celldetective/gui/viewers/contour_viewer.py +394 -0
- celldetective/gui/viewers/size_viewer.py +153 -0
- celldetective/gui/viewers/spot_detection_viewer.py +341 -0
- celldetective/gui/viewers/threshold_viewer.py +309 -0
- celldetective/gui/workers.py +403 -126
- celldetective/log_manager.py +92 -0
- celldetective/measure.py +1895 -1478
- celldetective/napari/__init__.py +0 -0
- celldetective/napari/utils.py +1025 -0
- celldetective/neighborhood.py +1914 -1448
- celldetective/preprocessing.py +1620 -1220
- celldetective/processes/__init__.py +0 -0
- celldetective/processes/background_correction.py +271 -0
- celldetective/processes/compute_neighborhood.py +894 -0
- celldetective/processes/detect_events.py +246 -0
- celldetective/processes/downloader.py +137 -0
- celldetective/processes/measure_cells.py +565 -0
- celldetective/processes/segment_cells.py +760 -0
- celldetective/processes/track_cells.py +435 -0
- celldetective/processes/train_segmentation_model.py +694 -0
- celldetective/processes/train_signal_model.py +265 -0
- celldetective/processes/unified_process.py +292 -0
- celldetective/regionprops/_regionprops.py +358 -317
- celldetective/relative_measurements.py +987 -710
- celldetective/scripts/measure_cells.py +313 -212
- celldetective/scripts/measure_relative.py +90 -46
- celldetective/scripts/segment_cells.py +165 -104
- celldetective/scripts/segment_cells_thresholds.py +96 -68
- celldetective/scripts/track_cells.py +198 -149
- celldetective/scripts/train_segmentation_model.py +324 -201
- celldetective/scripts/train_signal_model.py +87 -45
- celldetective/segmentation.py +844 -749
- celldetective/signals.py +3514 -2861
- celldetective/tracking.py +30 -15
- celldetective/utils/__init__.py +0 -0
- celldetective/utils/cellpose_utils/__init__.py +133 -0
- celldetective/utils/color_mappings.py +42 -0
- celldetective/utils/data_cleaning.py +630 -0
- celldetective/utils/data_loaders.py +450 -0
- celldetective/utils/dataset_helpers.py +207 -0
- celldetective/utils/downloaders.py +235 -0
- celldetective/utils/event_detection/__init__.py +8 -0
- celldetective/utils/experiment.py +1782 -0
- celldetective/utils/image_augmenters.py +308 -0
- celldetective/utils/image_cleaning.py +74 -0
- celldetective/utils/image_loaders.py +926 -0
- celldetective/utils/image_transforms.py +335 -0
- celldetective/utils/io.py +62 -0
- celldetective/utils/mask_cleaning.py +348 -0
- celldetective/utils/mask_transforms.py +5 -0
- celldetective/utils/masks.py +184 -0
- celldetective/utils/maths.py +351 -0
- celldetective/utils/model_getters.py +325 -0
- celldetective/utils/model_loaders.py +296 -0
- celldetective/utils/normalization.py +380 -0
- celldetective/utils/parsing.py +465 -0
- celldetective/utils/plots/__init__.py +0 -0
- celldetective/utils/plots/regression.py +53 -0
- celldetective/utils/resources.py +34 -0
- celldetective/utils/stardist_utils/__init__.py +104 -0
- celldetective/utils/stats.py +90 -0
- celldetective/utils/types.py +21 -0
- {celldetective-1.4.2.dist-info → celldetective-1.5.0b1.dist-info}/METADATA +1 -1
- celldetective-1.5.0b1.dist-info/RECORD +187 -0
- {celldetective-1.4.2.dist-info → celldetective-1.5.0b1.dist-info}/WHEEL +1 -1
- tests/gui/test_new_project.py +129 -117
- tests/gui/test_project.py +127 -79
- tests/test_filters.py +39 -15
- tests/test_notebooks.py +8 -0
- tests/test_tracking.py +232 -13
- tests/test_utils.py +123 -77
- celldetective/gui/base_components.py +0 -23
- celldetective/gui/layouts.py +0 -1602
- celldetective/gui/processes/compute_neighborhood.py +0 -594
- celldetective/gui/processes/downloader.py +0 -111
- celldetective/gui/processes/measure_cells.py +0 -360
- celldetective/gui/processes/segment_cells.py +0 -499
- celldetective/gui/processes/track_cells.py +0 -303
- celldetective/gui/processes/train_segmentation_model.py +0 -270
- celldetective/gui/processes/train_signal_model.py +0 -108
- celldetective/gui/table_ops/merge_groups.py +0 -118
- celldetective/gui/viewers.py +0 -1354
- celldetective/io.py +0 -3663
- celldetective/utils.py +0 -3108
- celldetective-1.4.2.dist-info/RECORD +0 -123
- {celldetective-1.4.2.dist-info → celldetective-1.5.0b1.dist-info}/entry_points.txt +0 -0
- {celldetective-1.4.2.dist-info → celldetective-1.5.0b1.dist-info}/licenses/LICENSE +0 -0
- {celldetective-1.4.2.dist-info → celldetective-1.5.0b1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from glob import glob
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from PyQt5.QtCore import QSize, Qt
|
|
7
|
+
from PyQt5.QtWidgets import (
|
|
8
|
+
QListWidget,
|
|
9
|
+
QLineEdit,
|
|
10
|
+
QMessageBox,
|
|
11
|
+
QHBoxLayout,
|
|
12
|
+
QPushButton,
|
|
13
|
+
QLabel,
|
|
14
|
+
)
|
|
15
|
+
from fonticon_mdi6 import MDI6
|
|
16
|
+
from natsort import natsorted
|
|
17
|
+
from superqt import QLabeledSlider, QLabeledDoubleSlider, QLabeledRangeSlider
|
|
18
|
+
from superqt.fonticon import icon
|
|
19
|
+
from collections import OrderedDict
|
|
20
|
+
|
|
21
|
+
from celldetective.gui.gui_utils import QuickSliderLayout
|
|
22
|
+
from celldetective.gui.viewers.base_viewer import StackVisualizer
|
|
23
|
+
from celldetective import get_logger
|
|
24
|
+
from tifffile import imread
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CellEdgeVisualizer(StackVisualizer):
|
|
31
|
+
"""
|
|
32
|
+
A widget for visualizing cell edges with interactive sliders and channel selection.
|
|
33
|
+
|
|
34
|
+
Parameters:
|
|
35
|
+
- cell_type (str): Type of cells ('effectors' by default).
|
|
36
|
+
- edge_range (tuple): Range of edge sizes (-30, 30) by default.
|
|
37
|
+
- invert (bool): Flag to invert the edge size (False by default).
|
|
38
|
+
- parent_list_widget: The parent QListWidget instance to add edge measurements.
|
|
39
|
+
- parent_le: The parent QLineEdit instance to set the edge size.
|
|
40
|
+
- labels (array or None): Array of labels for cell segmentation.
|
|
41
|
+
- initial_edge (int): Initial edge size (5 by default).
|
|
42
|
+
- initial_mask_alpha (float): Initial mask opacity value (0.5 by default).
|
|
43
|
+
- args, kwargs: Additional arguments to pass to the parent class constructor.
|
|
44
|
+
|
|
45
|
+
Methods:
|
|
46
|
+
- load_labels(): Load the cell labels.
|
|
47
|
+
- locate_labels_virtual(): Locate virtual labels.
|
|
48
|
+
- generate_add_to_list_btn(): Generate the add to list button.
|
|
49
|
+
- generate_add_to_le_btn(): Generate the set measurement button for QLineEdit.
|
|
50
|
+
- set_measurement_in_parent_le(): Set the edge size in the parent QLineEdit.
|
|
51
|
+
- set_measurement_in_parent_list(): Add the edge size to the parent QListWidget.
|
|
52
|
+
- generate_label_imshow(): Generate the label imshow.
|
|
53
|
+
- generate_edge_slider(): Generate the edge size slider.
|
|
54
|
+
- generate_opacity_slider(): Generate the opacity slider for the mask.
|
|
55
|
+
- change_mask_opacity(value): Change the opacity of the mask.
|
|
56
|
+
- change_edge_size(value): Change the edge size.
|
|
57
|
+
- change_frame(value): Change the displayed frame and update the edge labels.
|
|
58
|
+
- compute_edge_labels(): Compute the edge labels.
|
|
59
|
+
|
|
60
|
+
Notes:
|
|
61
|
+
- This class extends the functionality of StackVisualizer to visualize cell edges
|
|
62
|
+
with interactive sliders for edge size adjustment and mask opacity control.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
cell_type="effectors",
|
|
68
|
+
edge_range=(-30, 30),
|
|
69
|
+
invert=False,
|
|
70
|
+
parent_list_widget=None,
|
|
71
|
+
parent_le=None,
|
|
72
|
+
labels=None,
|
|
73
|
+
initial_edge=5,
|
|
74
|
+
initial_mask_alpha=0.5,
|
|
75
|
+
*args,
|
|
76
|
+
**kwargs,
|
|
77
|
+
):
|
|
78
|
+
|
|
79
|
+
# Initialize the widget and its attributes
|
|
80
|
+
super().__init__(*args, **kwargs)
|
|
81
|
+
self.edge_size = initial_edge
|
|
82
|
+
self.mask_alpha = initial_mask_alpha
|
|
83
|
+
self.cell_type = cell_type
|
|
84
|
+
self.labels = labels
|
|
85
|
+
self.edge_range = edge_range
|
|
86
|
+
self.invert = invert
|
|
87
|
+
self.parent_list_widget = parent_list_widget
|
|
88
|
+
self.parent_le = parent_le
|
|
89
|
+
|
|
90
|
+
# SDF cache (stores label + dist_in + dist_out + voronoi)
|
|
91
|
+
self.sdf_cache = OrderedDict()
|
|
92
|
+
self.max_sdf_cache_size = 128
|
|
93
|
+
|
|
94
|
+
self.load_labels()
|
|
95
|
+
self.generate_label_imshow()
|
|
96
|
+
self.generate_edge_slider()
|
|
97
|
+
self.generate_opacity_slider()
|
|
98
|
+
if isinstance(self.parent_list_widget, QListWidget):
|
|
99
|
+
self.generate_add_to_list_btn()
|
|
100
|
+
if isinstance(self.parent_le, QLineEdit):
|
|
101
|
+
self.generate_add_to_le_btn()
|
|
102
|
+
|
|
103
|
+
def closeEvent(self, event):
|
|
104
|
+
if hasattr(self, "sdf_cache") and isinstance(self.sdf_cache, OrderedDict):
|
|
105
|
+
self.sdf_cache.clear()
|
|
106
|
+
super().closeEvent(event)
|
|
107
|
+
|
|
108
|
+
def load_labels(self):
|
|
109
|
+
# Load the cell labels
|
|
110
|
+
|
|
111
|
+
if self.labels is not None:
|
|
112
|
+
|
|
113
|
+
if isinstance(self.labels, list):
|
|
114
|
+
self.labels = np.array(self.labels)
|
|
115
|
+
|
|
116
|
+
assert (
|
|
117
|
+
self.labels.ndim == 3
|
|
118
|
+
), "Wrong dimensions for the provided labels, expect TXY"
|
|
119
|
+
assert len(self.labels) == self.stack_length
|
|
120
|
+
|
|
121
|
+
self.mode = "direct"
|
|
122
|
+
self.init_label = self.labels[self.mid_time, :, :]
|
|
123
|
+
else:
|
|
124
|
+
self.mode = "virtual"
|
|
125
|
+
assert isinstance(self.stack_path, str)
|
|
126
|
+
assert self.stack_path.endswith(".tif")
|
|
127
|
+
self.locate_labels_virtual()
|
|
128
|
+
|
|
129
|
+
self.compute_edge_labels()
|
|
130
|
+
|
|
131
|
+
def locate_labels_virtual(self):
|
|
132
|
+
# Locate virtual labels
|
|
133
|
+
|
|
134
|
+
labels_path = (
|
|
135
|
+
str(Path(self.stack_path).parent.parent)
|
|
136
|
+
+ os.sep
|
|
137
|
+
+ f"labels_{self.cell_type}"
|
|
138
|
+
+ os.sep
|
|
139
|
+
)
|
|
140
|
+
self.mask_paths = natsorted(glob(labels_path + "*.tif"))
|
|
141
|
+
|
|
142
|
+
if len(self.mask_paths) == 0:
|
|
143
|
+
|
|
144
|
+
msgBox = QMessageBox()
|
|
145
|
+
msgBox.setIcon(QMessageBox.Critical)
|
|
146
|
+
msgBox.setText("No labels were found for the selected cells. Abort.")
|
|
147
|
+
msgBox.setWindowTitle("Critical")
|
|
148
|
+
msgBox.setStandardButtons(QMessageBox.Ok)
|
|
149
|
+
returnValue = msgBox.exec()
|
|
150
|
+
self.close()
|
|
151
|
+
|
|
152
|
+
self.label_map = {}
|
|
153
|
+
for path in self.mask_paths:
|
|
154
|
+
# Find the last number in the filename
|
|
155
|
+
match = re.findall(r"(\d+)", Path(path).stem)
|
|
156
|
+
if match:
|
|
157
|
+
# Assume the last number is the frame index (Use direct mapping: 0000 -> 0)
|
|
158
|
+
frame_num = int(match[-1])
|
|
159
|
+
self.label_map[frame_num] = path
|
|
160
|
+
|
|
161
|
+
current_frame = self.frame_slider.value()
|
|
162
|
+
if current_frame in self.label_map:
|
|
163
|
+
self.init_label = imread(self.label_map[current_frame])
|
|
164
|
+
else:
|
|
165
|
+
if hasattr(self, "init_frame"):
|
|
166
|
+
self.init_label = np.zeros_like(self.init_frame)
|
|
167
|
+
else:
|
|
168
|
+
self.init_label = imread(self.mask_paths[0])
|
|
169
|
+
|
|
170
|
+
def generate_add_to_list_btn(self):
|
|
171
|
+
# Generate the add to list button
|
|
172
|
+
|
|
173
|
+
add_hbox = QHBoxLayout()
|
|
174
|
+
self.add_measurement_btn = QPushButton("Add measurement")
|
|
175
|
+
self.add_measurement_btn.clicked.connect(self.set_measurement_in_parent_list)
|
|
176
|
+
self.add_measurement_btn.setIcon(icon(MDI6.plus, color="white"))
|
|
177
|
+
self.add_measurement_btn.setIconSize(QSize(20, 20))
|
|
178
|
+
self.add_measurement_btn.setStyleSheet(self.button_style_sheet)
|
|
179
|
+
add_hbox.addWidget(QLabel(""), 33)
|
|
180
|
+
add_hbox.addWidget(self.add_measurement_btn, 33)
|
|
181
|
+
add_hbox.addWidget(QLabel(""), 33)
|
|
182
|
+
self.canvas.layout.addLayout(add_hbox)
|
|
183
|
+
|
|
184
|
+
def generate_add_to_le_btn(self):
|
|
185
|
+
# Generate the set measurement button for QLineEdit
|
|
186
|
+
|
|
187
|
+
add_hbox = QHBoxLayout()
|
|
188
|
+
self.set_measurement_btn = QPushButton("Set")
|
|
189
|
+
self.set_measurement_btn.clicked.connect(self.set_measurement_in_parent_le)
|
|
190
|
+
self.set_measurement_btn.setStyleSheet(self.button_style_sheet)
|
|
191
|
+
add_hbox.addWidget(QLabel(""), 33)
|
|
192
|
+
add_hbox.addWidget(self.set_measurement_btn, 33)
|
|
193
|
+
add_hbox.addWidget(QLabel(""), 33)
|
|
194
|
+
self.canvas.layout.addLayout(add_hbox)
|
|
195
|
+
|
|
196
|
+
def set_measurement_in_parent_le(self):
|
|
197
|
+
# Set the edge size in the parent QLineEdit
|
|
198
|
+
|
|
199
|
+
self.parent_le.setText(str(int(self.edge_slider.value())))
|
|
200
|
+
self.close()
|
|
201
|
+
|
|
202
|
+
def set_measurement_in_parent_list(self):
|
|
203
|
+
# Add the edge size to the parent QListWidget
|
|
204
|
+
|
|
205
|
+
self.parent_list_widget.addItems([str(self.edge_slider.value())])
|
|
206
|
+
self.close()
|
|
207
|
+
|
|
208
|
+
def generate_label_imshow(self):
|
|
209
|
+
# Generate the label imshow
|
|
210
|
+
|
|
211
|
+
self.im_mask = self.ax.imshow(
|
|
212
|
+
np.ma.masked_where(self.edge_labels == 0, self.edge_labels),
|
|
213
|
+
alpha=self.mask_alpha,
|
|
214
|
+
interpolation="none",
|
|
215
|
+
cmap="viridis",
|
|
216
|
+
)
|
|
217
|
+
self.canvas.canvas.draw()
|
|
218
|
+
|
|
219
|
+
def generate_edge_slider(self):
|
|
220
|
+
# Generate the edge size slider using a Range Slider
|
|
221
|
+
|
|
222
|
+
edge_layout = QHBoxLayout()
|
|
223
|
+
edge_layout.setContentsMargins(15, 0, 15, 0)
|
|
224
|
+
|
|
225
|
+
# Determine range for universal slider (symmetric)
|
|
226
|
+
max_range = (
|
|
227
|
+
self.edge_range[1]
|
|
228
|
+
if hasattr(self, "edge_range") and self.edge_range
|
|
229
|
+
else 30
|
|
230
|
+
)
|
|
231
|
+
slider_min, slider_max = -max_range, max_range
|
|
232
|
+
|
|
233
|
+
self.edge_slider = QLabeledRangeSlider(Qt.Horizontal)
|
|
234
|
+
self.edge_slider.setRange(slider_min, slider_max)
|
|
235
|
+
|
|
236
|
+
# Initial Value: convert scalar edge_size to range
|
|
237
|
+
# precise initial state might need adjustment, defaulting to [0, edge_size] if positive
|
|
238
|
+
init_val = self.edge_size if hasattr(self, "edge_size") else 1
|
|
239
|
+
if init_val >= 0:
|
|
240
|
+
self.edge_slider.setValue((0, init_val))
|
|
241
|
+
else:
|
|
242
|
+
self.edge_slider.setValue((init_val, 0))
|
|
243
|
+
|
|
244
|
+
self.edge_slider.setEdgeLabelMode(QLabeledRangeSlider.EdgeLabelMode.NoLabel)
|
|
245
|
+
self.edge_slider.valueChanged.connect(self.change_edge_size)
|
|
246
|
+
|
|
247
|
+
edge_layout.addWidget(QLabel("Edge: "), 15)
|
|
248
|
+
edge_layout.addWidget(self.edge_slider, 85)
|
|
249
|
+
|
|
250
|
+
self.canvas.layout.addLayout(edge_layout)
|
|
251
|
+
|
|
252
|
+
def generate_opacity_slider(self):
|
|
253
|
+
# Generate the opacity slider for the mask
|
|
254
|
+
|
|
255
|
+
self.opacity_slider = QLabeledDoubleSlider()
|
|
256
|
+
opacity_layout = QuickSliderLayout(
|
|
257
|
+
label="Opacity: ",
|
|
258
|
+
slider=self.opacity_slider,
|
|
259
|
+
slider_initial_value=0.5,
|
|
260
|
+
slider_range=(0, 1),
|
|
261
|
+
decimal_option=True,
|
|
262
|
+
precision=3,
|
|
263
|
+
)
|
|
264
|
+
opacity_layout.setContentsMargins(15, 0, 15, 0)
|
|
265
|
+
self.opacity_slider.valueChanged.connect(self.change_mask_opacity)
|
|
266
|
+
self.canvas.layout.addLayout(opacity_layout)
|
|
267
|
+
|
|
268
|
+
def change_mask_opacity(self, value):
|
|
269
|
+
# Change the opacity of the mask
|
|
270
|
+
|
|
271
|
+
self.mask_alpha = value
|
|
272
|
+
self.im_mask.set_alpha(self.mask_alpha)
|
|
273
|
+
self.canvas.canvas.draw_idle()
|
|
274
|
+
|
|
275
|
+
def change_edge_size(self, value):
|
|
276
|
+
self.edge_size = value # Tuple (min, max)
|
|
277
|
+
self.compute_edge_labels()
|
|
278
|
+
mask = np.ma.masked_where(self.edge_labels == 0, self.edge_labels)
|
|
279
|
+
self.im_mask.set_data(mask)
|
|
280
|
+
self.canvas.canvas.draw_idle()
|
|
281
|
+
|
|
282
|
+
def change_frame(self, value):
|
|
283
|
+
# Change the displayed frame and update the edge labels
|
|
284
|
+
|
|
285
|
+
super().change_frame(value)
|
|
286
|
+
|
|
287
|
+
# Check unified cache first
|
|
288
|
+
if hasattr(self, "sdf_cache") and value in self.sdf_cache:
|
|
289
|
+
self.init_label, self.dist_in, self.dist_out, self.voronoi_map = (
|
|
290
|
+
self.sdf_cache[value]
|
|
291
|
+
)
|
|
292
|
+
self.sdf_cache.move_to_end(value)
|
|
293
|
+
else:
|
|
294
|
+
# Cache Miss: Load Label
|
|
295
|
+
if self.mode == "virtual":
|
|
296
|
+
if hasattr(self, "label_map") and value in self.label_map:
|
|
297
|
+
try:
|
|
298
|
+
self.init_label = imread(self.label_map[value])
|
|
299
|
+
except Exception:
|
|
300
|
+
self.init_label = np.zeros_like(self.init_frame)
|
|
301
|
+
else:
|
|
302
|
+
self.init_label = np.zeros_like(self.init_frame)
|
|
303
|
+
elif self.mode == "direct":
|
|
304
|
+
if value < len(self.labels):
|
|
305
|
+
self.init_label = self.labels[value, :, :]
|
|
306
|
+
else:
|
|
307
|
+
self.init_label = np.zeros_like(self.init_frame)
|
|
308
|
+
|
|
309
|
+
# Compute SDFs
|
|
310
|
+
self.update_sdf()
|
|
311
|
+
|
|
312
|
+
# Store in unified cache
|
|
313
|
+
|
|
314
|
+
if hasattr(self, "sdf_cache"):
|
|
315
|
+
self.sdf_cache[value] = (
|
|
316
|
+
self.init_label,
|
|
317
|
+
self.dist_in,
|
|
318
|
+
self.dist_out,
|
|
319
|
+
self.voronoi_map,
|
|
320
|
+
)
|
|
321
|
+
if len(self.sdf_cache) > self.max_sdf_cache_size:
|
|
322
|
+
self.sdf_cache.popitem(last=False)
|
|
323
|
+
|
|
324
|
+
self.compute_edge_labels()
|
|
325
|
+
mask = np.ma.masked_where(self.edge_labels == 0, self.edge_labels)
|
|
326
|
+
self.im_mask.set_data(mask)
|
|
327
|
+
|
|
328
|
+
def update_sdf(self):
|
|
329
|
+
# Compute Signed Distance Functions (SDFs) and Voronoi map for the current labels
|
|
330
|
+
from scipy.ndimage import distance_transform_edt
|
|
331
|
+
|
|
332
|
+
# Ensure init_label is not empty
|
|
333
|
+
if self.init_label is None or self.init_label.size == 0:
|
|
334
|
+
self.dist_in = np.zeros_like(self.init_label, dtype=float)
|
|
335
|
+
self.dist_out = np.zeros_like(self.init_label, dtype=float)
|
|
336
|
+
self.voronoi_map = np.zeros_like(self.init_label, dtype=int)
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
# Inner Distance
|
|
340
|
+
# Distance to background. Touching cells might merge, but this matches legacy behavior
|
|
341
|
+
# and is much faster than looping.
|
|
342
|
+
self.dist_in = distance_transform_edt(self.init_label > 0)
|
|
343
|
+
|
|
344
|
+
# Outer Distance & Voronoi Map
|
|
345
|
+
# Distance to boundaries from outside.
|
|
346
|
+
# return_indices=True gives us the coordinates of the nearest foreground pixel for every background pixel.
|
|
347
|
+
# We use this to look up the label of the nearest object (Voronoi tessellation).
|
|
348
|
+
self.dist_out, indices = distance_transform_edt(
|
|
349
|
+
self.init_label == 0, return_indices=True
|
|
350
|
+
)
|
|
351
|
+
self.voronoi_map = self.init_label[indices[0], indices[1]]
|
|
352
|
+
|
|
353
|
+
def compute_edge_labels(self):
|
|
354
|
+
# Compute the edge labels using Composite SDF and Range Masking
|
|
355
|
+
# Delegates to the unified utility in celldetective.utils
|
|
356
|
+
|
|
357
|
+
# Ensure SDFs exist
|
|
358
|
+
if not hasattr(self, "dist_in"):
|
|
359
|
+
self.update_sdf()
|
|
360
|
+
|
|
361
|
+
# Ensure array shapes
|
|
362
|
+
if self.dist_in.shape != self.init_label.shape:
|
|
363
|
+
self.edge_labels = np.zeros_like(self.init_label)
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
# Composite SDF
|
|
367
|
+
sdf = self.dist_in - self.dist_out
|
|
368
|
+
|
|
369
|
+
# Use unified utility
|
|
370
|
+
from celldetective.utils.masks import contour_of_instance_segmentation
|
|
371
|
+
|
|
372
|
+
self.edge_labels = contour_of_instance_segmentation(
|
|
373
|
+
self.init_label,
|
|
374
|
+
distance=self.edge_size,
|
|
375
|
+
sdf=sdf,
|
|
376
|
+
voronoi_map=self.voronoi_map,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Handle scalar edge_size (fallback or initial init)
|
|
380
|
+
if isinstance(self.edge_size, (int, float)):
|
|
381
|
+
if self.edge_size >= 0:
|
|
382
|
+
r_min, r_max = 0, self.edge_size
|
|
383
|
+
else:
|
|
384
|
+
r_min, r_max = self.edge_size, 0
|
|
385
|
+
else:
|
|
386
|
+
r_min, r_max = self.edge_size
|
|
387
|
+
|
|
388
|
+
# Create Mask [r_min, r_max]
|
|
389
|
+
# Example: [2, 5] -> Stripe inside. [-5, -2] -> Stripe outside. [-2, 2] -> Border crossing.
|
|
390
|
+
mask = (sdf >= r_min) & (sdf <= r_max)
|
|
391
|
+
|
|
392
|
+
# Apply Instance Identities using Voronoi Map
|
|
393
|
+
# Note: Voronoi map covers entire space.
|
|
394
|
+
self.edge_labels = self.voronoi_map * mask
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from PyQt5.QtCore import QSize
|
|
3
|
+
from PyQt5.QtWidgets import QLineEdit, QListWidget, QHBoxLayout, QPushButton, QLabel
|
|
4
|
+
from fonticon_mdi6 import MDI6
|
|
5
|
+
from superqt import QLabeledDoubleSlider
|
|
6
|
+
from superqt.fonticon import icon
|
|
7
|
+
|
|
8
|
+
from celldetective.gui.gui_utils import QuickSliderLayout
|
|
9
|
+
from celldetective.gui.viewers.base_viewer import StackVisualizer
|
|
10
|
+
from celldetective import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
class CellSizeViewer(StackVisualizer):
|
|
15
|
+
"""
|
|
16
|
+
A widget for visualizing cell size with interactive sliders and circle display.
|
|
17
|
+
|
|
18
|
+
Parameters:
|
|
19
|
+
- initial_diameter (int): Initial diameter of the circle (40 by default).
|
|
20
|
+
- set_radius_in_list (bool): Flag to set radius instead of diameter in the list (False by default).
|
|
21
|
+
- diameter_slider_range (tuple): Range of the diameter slider (0, 200) by default.
|
|
22
|
+
- parent_le: The parent QLineEdit instance to set the diameter.
|
|
23
|
+
- parent_list_widget: The parent QListWidget instance to add diameter measurements.
|
|
24
|
+
- args, kwargs: Additional arguments to pass to the parent class constructor.
|
|
25
|
+
|
|
26
|
+
Methods:
|
|
27
|
+
- generate_circle(): Generate the circle for visualization.
|
|
28
|
+
- generate_add_to_list_btn(): Generate the add to list button.
|
|
29
|
+
- set_measurement_in_parent_list(): Add the diameter to the parent QListWidget.
|
|
30
|
+
- on_xlims_or_ylims_change(event_ax): Update the circle position on axis limits change.
|
|
31
|
+
- generate_set_btn(): Generate the set button for QLineEdit.
|
|
32
|
+
- set_threshold_in_parent_le(): Set the diameter in the parent QLineEdit.
|
|
33
|
+
- generate_diameter_slider(): Generate the diameter slider.
|
|
34
|
+
- change_diameter(value): Change the diameter of the circle.
|
|
35
|
+
|
|
36
|
+
Notes:
|
|
37
|
+
- This class extends the functionality of StackVisualizer to visualize cell size
|
|
38
|
+
with interactive sliders for diameter adjustment and circle display.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
initial_diameter=40,
|
|
44
|
+
set_radius_in_list=False,
|
|
45
|
+
diameter_slider_range=(0, 500),
|
|
46
|
+
parent_le=None,
|
|
47
|
+
parent_list_widget=None,
|
|
48
|
+
*args,
|
|
49
|
+
**kwargs,
|
|
50
|
+
):
|
|
51
|
+
# Initialize the widget and its attributes
|
|
52
|
+
|
|
53
|
+
super().__init__(*args, **kwargs)
|
|
54
|
+
self.diameter = initial_diameter
|
|
55
|
+
self.parent_le = parent_le
|
|
56
|
+
self.diameter_slider_range = diameter_slider_range
|
|
57
|
+
self.parent_list_widget = parent_list_widget
|
|
58
|
+
self.set_radius_in_list = set_radius_in_list
|
|
59
|
+
self.generate_circle()
|
|
60
|
+
self.generate_diameter_slider()
|
|
61
|
+
|
|
62
|
+
if isinstance(self.parent_le, QLineEdit):
|
|
63
|
+
self.generate_set_btn()
|
|
64
|
+
if isinstance(self.parent_list_widget, QListWidget):
|
|
65
|
+
self.generate_add_to_list_btn()
|
|
66
|
+
|
|
67
|
+
def generate_circle(self):
|
|
68
|
+
# Generate the circle for visualization
|
|
69
|
+
|
|
70
|
+
import matplotlib.pyplot as plt
|
|
71
|
+
|
|
72
|
+
self.circ = plt.Circle(
|
|
73
|
+
(self.init_frame.shape[0] // 2, self.init_frame.shape[1] // 2),
|
|
74
|
+
self.diameter // 2 / self.PxToUm,
|
|
75
|
+
ec="tab:red",
|
|
76
|
+
fill=False,
|
|
77
|
+
)
|
|
78
|
+
self.ax.add_patch(self.circ)
|
|
79
|
+
|
|
80
|
+
self.ax.callbacks.connect("xlim_changed", self.on_xlims_or_ylims_change)
|
|
81
|
+
self.ax.callbacks.connect("ylim_changed", self.on_xlims_or_ylims_change)
|
|
82
|
+
|
|
83
|
+
def generate_add_to_list_btn(self):
|
|
84
|
+
# Generate the add to list button
|
|
85
|
+
|
|
86
|
+
add_hbox = QHBoxLayout()
|
|
87
|
+
self.add_measurement_btn = QPushButton("Add measurement")
|
|
88
|
+
self.add_measurement_btn.clicked.connect(self.set_measurement_in_parent_list)
|
|
89
|
+
self.add_measurement_btn.setIcon(icon(MDI6.plus, color="white"))
|
|
90
|
+
self.add_measurement_btn.setIconSize(QSize(20, 20))
|
|
91
|
+
self.add_measurement_btn.setStyleSheet(self.button_style_sheet)
|
|
92
|
+
add_hbox.addWidget(QLabel(""), 33)
|
|
93
|
+
add_hbox.addWidget(self.add_measurement_btn, 33)
|
|
94
|
+
add_hbox.addWidget(QLabel(""), 33)
|
|
95
|
+
self.canvas.layout.addLayout(add_hbox)
|
|
96
|
+
|
|
97
|
+
def set_measurement_in_parent_list(self):
|
|
98
|
+
# Add the diameter to the parent QListWidget
|
|
99
|
+
|
|
100
|
+
if self.set_radius_in_list:
|
|
101
|
+
val = int(self.diameter_slider.value() // 2)
|
|
102
|
+
else:
|
|
103
|
+
val = int(self.diameter_slider.value())
|
|
104
|
+
|
|
105
|
+
self.parent_list_widget.addItems([str(val)])
|
|
106
|
+
self.close()
|
|
107
|
+
|
|
108
|
+
def on_xlims_or_ylims_change(self, event_ax):
|
|
109
|
+
# Update the circle position on axis limits change
|
|
110
|
+
|
|
111
|
+
xmin, xmax = event_ax.get_xlim()
|
|
112
|
+
ymin, ymax = event_ax.get_ylim()
|
|
113
|
+
self.circ.center = np.mean([xmin, xmax]), np.mean([ymin, ymax])
|
|
114
|
+
|
|
115
|
+
def generate_set_btn(self):
|
|
116
|
+
# Generate the set button for QLineEdit
|
|
117
|
+
|
|
118
|
+
apply_hbox = QHBoxLayout()
|
|
119
|
+
self.apply_threshold_btn = QPushButton("Set")
|
|
120
|
+
self.apply_threshold_btn.clicked.connect(self.set_threshold_in_parent_le)
|
|
121
|
+
self.apply_threshold_btn.setStyleSheet(self.button_style_sheet)
|
|
122
|
+
apply_hbox.addWidget(QLabel(""), 33)
|
|
123
|
+
apply_hbox.addWidget(self.apply_threshold_btn, 33)
|
|
124
|
+
apply_hbox.addWidget(QLabel(""), 33)
|
|
125
|
+
self.canvas.layout.addLayout(apply_hbox)
|
|
126
|
+
|
|
127
|
+
def set_threshold_in_parent_le(self):
|
|
128
|
+
# Set the diameter in the parent QLineEdit
|
|
129
|
+
|
|
130
|
+
self.parent_le.set_threshold(self.diameter_slider.value())
|
|
131
|
+
self.close()
|
|
132
|
+
|
|
133
|
+
def generate_diameter_slider(self):
|
|
134
|
+
# Generate the diameter slider
|
|
135
|
+
|
|
136
|
+
self.diameter_slider = QLabeledDoubleSlider()
|
|
137
|
+
diameter_layout = QuickSliderLayout(
|
|
138
|
+
label="Diameter: ",
|
|
139
|
+
slider=self.diameter_slider,
|
|
140
|
+
slider_initial_value=self.diameter,
|
|
141
|
+
slider_range=self.diameter_slider_range,
|
|
142
|
+
decimal_option=True,
|
|
143
|
+
precision=5,
|
|
144
|
+
)
|
|
145
|
+
diameter_layout.setContentsMargins(15, 0, 15, 0)
|
|
146
|
+
self.diameter_slider.valueChanged.connect(self.change_diameter)
|
|
147
|
+
self.canvas.layout.addLayout(diameter_layout)
|
|
148
|
+
|
|
149
|
+
def change_diameter(self, value):
|
|
150
|
+
# Change the diameter of the circle
|
|
151
|
+
self.diameter = value
|
|
152
|
+
self.circ.set_radius(self.diameter // 2 / self.PxToUm)
|
|
153
|
+
self.canvas.canvas.draw_idle()
|