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
|
File without changes
|
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
from collections import OrderedDict
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from PyQt5.QtCore import Qt
|
|
5
|
+
from PyQt5.QtWidgets import QHBoxLayout, QAction, QLabel, QComboBox
|
|
6
|
+
from fonticon_mdi6 import MDI6
|
|
7
|
+
from superqt import QLabeledDoubleRangeSlider, QLabeledSlider
|
|
8
|
+
from superqt.fonticon import icon
|
|
9
|
+
|
|
10
|
+
from celldetective.gui.base.components import CelldetectiveWidget
|
|
11
|
+
from celldetective.gui.base.utils import center_window
|
|
12
|
+
from celldetective.utils.image_loaders import auto_load_number_of_frames, _get_img_num_per_channel, load_frames
|
|
13
|
+
from celldetective import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
class StackVisualizer(CelldetectiveWidget):
|
|
18
|
+
"""
|
|
19
|
+
A widget for visualizing image stacks with interactive sliders and channel selection.
|
|
20
|
+
|
|
21
|
+
Parameters:
|
|
22
|
+
- stack (numpy.ndarray or None): The stack of images.
|
|
23
|
+
- stack_path (str or None): The path to the stack of images if provided as a file.
|
|
24
|
+
- frame_slider (bool): Enable frame navigation slider.
|
|
25
|
+
- contrast_slider (bool): Enable contrast adjustment slider.
|
|
26
|
+
- channel_cb (bool): Enable channel selection dropdown.
|
|
27
|
+
- channel_names (list or None): Names of the channels if `channel_cb` is True.
|
|
28
|
+
- n_channels (int): Number of channels.
|
|
29
|
+
- target_channel (int): Index of the target channel.
|
|
30
|
+
- window_title (str): Title of the window.
|
|
31
|
+
- PxToUm (float or None): Pixel to micrometer conversion factor.
|
|
32
|
+
- background_color (str): Background color of the widget.
|
|
33
|
+
- imshow_kwargs (dict): Additional keyword arguments for imshow function.
|
|
34
|
+
|
|
35
|
+
Methods:
|
|
36
|
+
- show(): Display the widget.
|
|
37
|
+
- load_stack(): Load the stack of images.
|
|
38
|
+
- locate_image_virtual(): Locate the stack of images if provided as a file.
|
|
39
|
+
- generate_figure_canvas(): Generate the figure canvas for displaying images.
|
|
40
|
+
- generate_channel_cb(): Generate the channel dropdown if enabled.
|
|
41
|
+
- generate_contrast_slider(): Generate the contrast slider if enabled.
|
|
42
|
+
- generate_frame_slider(): Generate the frame slider if enabled.
|
|
43
|
+
- set_target_channel(value): Set the target channel.
|
|
44
|
+
- change_contrast(value): Change contrast based on slider value.
|
|
45
|
+
- set_channel_index(value): Set the channel index based on dropdown value.
|
|
46
|
+
- change_frame(value): Change the displayed frame based on slider value.
|
|
47
|
+
- closeEvent(event): Event handler for closing the widget.
|
|
48
|
+
|
|
49
|
+
Notes:
|
|
50
|
+
- This class provides a convenient interface for visualizing image stacks with frame navigation,
|
|
51
|
+
contrast adjustment, and channel selection functionalities.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
stack=None,
|
|
57
|
+
stack_path=None,
|
|
58
|
+
frame_slider=True,
|
|
59
|
+
contrast_slider=True,
|
|
60
|
+
channel_cb=False,
|
|
61
|
+
channel_names=None,
|
|
62
|
+
n_channels=1,
|
|
63
|
+
target_channel=0,
|
|
64
|
+
window_title="View",
|
|
65
|
+
PxToUm=None,
|
|
66
|
+
background_color="transparent",
|
|
67
|
+
imshow_kwargs={},
|
|
68
|
+
):
|
|
69
|
+
super().__init__()
|
|
70
|
+
|
|
71
|
+
# self.setWindowTitle(window_title)
|
|
72
|
+
self.window_title = window_title
|
|
73
|
+
|
|
74
|
+
# LRU Cache for virtual mode
|
|
75
|
+
self.frame_cache = OrderedDict()
|
|
76
|
+
self.max_cache_size = 128
|
|
77
|
+
self.current_time_index = 0
|
|
78
|
+
|
|
79
|
+
self.stack = stack
|
|
80
|
+
self.stack_path = stack_path
|
|
81
|
+
self.create_frame_slider = frame_slider
|
|
82
|
+
self.background_color = background_color
|
|
83
|
+
self.create_contrast_slider = contrast_slider
|
|
84
|
+
self.create_channel_cb = channel_cb
|
|
85
|
+
self.n_channels = n_channels
|
|
86
|
+
self.channel_names = channel_names
|
|
87
|
+
self.target_channel = target_channel
|
|
88
|
+
self.imshow_kwargs = imshow_kwargs
|
|
89
|
+
self.PxToUm = PxToUm
|
|
90
|
+
self.init_contrast = False
|
|
91
|
+
self.channel_trigger = False
|
|
92
|
+
self.roi_mode = False
|
|
93
|
+
self.line_mode = False
|
|
94
|
+
self.line_artist = None
|
|
95
|
+
self.ax_profile = None
|
|
96
|
+
self._min = 0
|
|
97
|
+
self._max = 0
|
|
98
|
+
|
|
99
|
+
self.load_stack()
|
|
100
|
+
self.generate_figure_canvas()
|
|
101
|
+
if self.create_channel_cb:
|
|
102
|
+
self.generate_channel_cb()
|
|
103
|
+
if self.create_contrast_slider:
|
|
104
|
+
self.generate_contrast_slider()
|
|
105
|
+
if self.create_frame_slider:
|
|
106
|
+
self.generate_frame_slider()
|
|
107
|
+
|
|
108
|
+
self.line_color = "orange"
|
|
109
|
+
self.line_artist = None
|
|
110
|
+
self.ax_profile = None
|
|
111
|
+
self.line_text = None
|
|
112
|
+
self.background = None
|
|
113
|
+
self.is_drawing_line = False
|
|
114
|
+
self.generate_custom_tools()
|
|
115
|
+
|
|
116
|
+
self.canvas.layout.setContentsMargins(15, 15, 15, 30)
|
|
117
|
+
|
|
118
|
+
center_window(self)
|
|
119
|
+
|
|
120
|
+
def generate_custom_tools(self):
|
|
121
|
+
|
|
122
|
+
tools_layout = QHBoxLayout()
|
|
123
|
+
tools_layout.setContentsMargins(15, 0, 15, 0)
|
|
124
|
+
|
|
125
|
+
actions = self.canvas.toolbar.actions()
|
|
126
|
+
|
|
127
|
+
# Create the action
|
|
128
|
+
self.line_action = QAction(
|
|
129
|
+
icon(MDI6.chart_line, color="black"), "Line Profile", self.canvas.toolbar
|
|
130
|
+
)
|
|
131
|
+
self.line_action.setCheckable(True)
|
|
132
|
+
self.line_action.setToolTip("Draw a line to plot intensity profile.")
|
|
133
|
+
self.line_action.triggered.connect(self.toggle_line_mode)
|
|
134
|
+
|
|
135
|
+
# Lock Y-Axis Action
|
|
136
|
+
self.lock_y_action = QAction(
|
|
137
|
+
icon(MDI6.lock, color="black"), "Lock Y-Axis", self.canvas.toolbar
|
|
138
|
+
)
|
|
139
|
+
self.lock_y_action.setCheckable(True)
|
|
140
|
+
self.lock_y_action.setToolTip(
|
|
141
|
+
"Lock the Y-axis min/max values for the profile plot."
|
|
142
|
+
)
|
|
143
|
+
self.lock_y_action.setEnabled(False) # Enable only when line mode is active
|
|
144
|
+
|
|
145
|
+
target_action = None
|
|
146
|
+
for action in actions:
|
|
147
|
+
if "Zoom" in action.text() or "Pan" in action.text():
|
|
148
|
+
target_action = action
|
|
149
|
+
|
|
150
|
+
if target_action:
|
|
151
|
+
insert_before = None
|
|
152
|
+
for action in actions:
|
|
153
|
+
if "Subplots" in action.text() or "Configure" in action.text():
|
|
154
|
+
insert_before = action
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
if insert_before:
|
|
158
|
+
self.canvas.toolbar.insertAction(insert_before, self.line_action)
|
|
159
|
+
self.canvas.toolbar.insertAction(insert_before, self.lock_y_action)
|
|
160
|
+
else:
|
|
161
|
+
if len(actions) > 5:
|
|
162
|
+
self.canvas.toolbar.insertAction(actions[5], self.line_action)
|
|
163
|
+
self.canvas.toolbar.insertAction(actions[5], self.line_action)
|
|
164
|
+
self.canvas.toolbar.insertAction(actions[5], self.lock_y_action)
|
|
165
|
+
else:
|
|
166
|
+
self.canvas.toolbar.addAction(self.line_action)
|
|
167
|
+
self.canvas.toolbar.addAction(self.lock_y_action)
|
|
168
|
+
|
|
169
|
+
self.info_lbl = QLabel("")
|
|
170
|
+
tools_layout.addWidget(self.info_lbl)
|
|
171
|
+
|
|
172
|
+
self.canvas.layout.addLayout(tools_layout)
|
|
173
|
+
|
|
174
|
+
def toggle_line_mode(self):
|
|
175
|
+
|
|
176
|
+
if self.line_action.isChecked():
|
|
177
|
+
|
|
178
|
+
self.line_mode = True
|
|
179
|
+
self.lock_y_action.setEnabled(True)
|
|
180
|
+
self.canvas.toolbar.mode = ""
|
|
181
|
+
|
|
182
|
+
# Connect events
|
|
183
|
+
self.cid_press = self.fig.canvas.mpl_connect(
|
|
184
|
+
"button_press_event", self.on_line_press
|
|
185
|
+
)
|
|
186
|
+
self.cid_move = self.fig.canvas.mpl_connect(
|
|
187
|
+
"motion_notify_event", self.on_line_drag
|
|
188
|
+
)
|
|
189
|
+
self.cid_release = self.fig.canvas.mpl_connect(
|
|
190
|
+
"button_release_event", self.on_line_release
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Save original position if not saved
|
|
194
|
+
if not hasattr(self, "ax_original_pos"):
|
|
195
|
+
self.ax_original_pos = self.ax.get_position()
|
|
196
|
+
|
|
197
|
+
# Disable tight_layout/layout engine to prevent fighting manual positioning
|
|
198
|
+
if hasattr(self.fig, "set_layout_engine"):
|
|
199
|
+
self.fig.set_layout_engine("none")
|
|
200
|
+
else:
|
|
201
|
+
self.fig.set_tight_layout(False)
|
|
202
|
+
|
|
203
|
+
# Use GridSpec for robust layout
|
|
204
|
+
# 2 rows: Main Image (top, ~75%), Profile (bottom, ~25%)
|
|
205
|
+
# Add margins to ensure axis labels and text are visible
|
|
206
|
+
import matplotlib.gridspec as gridspec
|
|
207
|
+
|
|
208
|
+
gs = gridspec.GridSpec(
|
|
209
|
+
2,
|
|
210
|
+
1,
|
|
211
|
+
height_ratios=[3, 1],
|
|
212
|
+
hspace=0.05,
|
|
213
|
+
left=0.1,
|
|
214
|
+
right=0.9,
|
|
215
|
+
bottom=0.05,
|
|
216
|
+
top=1,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Move main axes to top slot
|
|
220
|
+
self.ax.set_subplotspec(gs[0])
|
|
221
|
+
self.ax.set_position(gs[0].get_position(self.fig))
|
|
222
|
+
|
|
223
|
+
# create profile axes as a subplot in the bottom slot
|
|
224
|
+
if self.ax_profile is None:
|
|
225
|
+
self.ax_profile = self.fig.add_subplot(gs[1])
|
|
226
|
+
else:
|
|
227
|
+
self.ax_profile.set_subplotspec(gs[1])
|
|
228
|
+
self.ax_profile.set_position(gs[1].get_position(self.fig))
|
|
229
|
+
|
|
230
|
+
self.ax_profile.set_visible(True)
|
|
231
|
+
self.ax_profile.set_facecolor("none")
|
|
232
|
+
self.ax_profile.tick_params(axis="y", which="major", labelsize=8)
|
|
233
|
+
self.ax_profile.set_xticks([])
|
|
234
|
+
self.ax_profile.set_xlabel("")
|
|
235
|
+
self.ax_profile.set_ylabel("Intensity", fontsize=8)
|
|
236
|
+
|
|
237
|
+
# Hide spines initially
|
|
238
|
+
self.ax_profile.spines["top"].set_visible(False)
|
|
239
|
+
self.ax_profile.spines["right"].set_visible(False)
|
|
240
|
+
self.ax_profile.spines["bottom"].set_color("black")
|
|
241
|
+
self.ax_profile.spines["left"].set_color("black")
|
|
242
|
+
|
|
243
|
+
self.canvas.draw()
|
|
244
|
+
else:
|
|
245
|
+
self.line_mode = False
|
|
246
|
+
self.lock_y_action.setChecked(False)
|
|
247
|
+
self.lock_y_action.setEnabled(False)
|
|
248
|
+
# Disconnect events
|
|
249
|
+
if hasattr(self, "cid_press"):
|
|
250
|
+
self.fig.canvas.mpl_disconnect(self.cid_press)
|
|
251
|
+
self.fig.canvas.mpl_disconnect(self.cid_move)
|
|
252
|
+
self.fig.canvas.mpl_disconnect(self.cid_release)
|
|
253
|
+
|
|
254
|
+
# Remove line artist
|
|
255
|
+
if self.line_artist:
|
|
256
|
+
self.line_artist.remove()
|
|
257
|
+
self.line_artist = None
|
|
258
|
+
|
|
259
|
+
if hasattr(self, "line_text") and self.line_text:
|
|
260
|
+
self.line_text.remove()
|
|
261
|
+
self.line_text = None
|
|
262
|
+
|
|
263
|
+
# Remove profile axes and restore space
|
|
264
|
+
if self.ax_profile is not None:
|
|
265
|
+
self.ax_profile.remove()
|
|
266
|
+
self.ax_profile = None
|
|
267
|
+
|
|
268
|
+
# Restore original layout
|
|
269
|
+
if hasattr(self, "ax_original_pos"):
|
|
270
|
+
# standard 1x1 GridSpec or manual restore
|
|
271
|
+
import matplotlib.gridspec as gridspec
|
|
272
|
+
|
|
273
|
+
gs = gridspec.GridSpec(1, 1)
|
|
274
|
+
self.ax.set_subplotspec(gs[0])
|
|
275
|
+
self.ax.set_position(gs[0].get_position(self.fig))
|
|
276
|
+
self.fig.subplots_adjust(
|
|
277
|
+
top=1, bottom=0, right=1, left=0, hspace=0, wspace=0
|
|
278
|
+
)
|
|
279
|
+
# self.ax.set_position(self.ax_original_pos) # tight layout should fix it
|
|
280
|
+
|
|
281
|
+
self.canvas.draw()
|
|
282
|
+
self.info_lbl.setText("")
|
|
283
|
+
|
|
284
|
+
def on_line_press(self, event):
|
|
285
|
+
if event.inaxes != self.ax:
|
|
286
|
+
return
|
|
287
|
+
if self.canvas.toolbar.mode:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
self.line_x = [event.xdata]
|
|
291
|
+
self.line_y = [event.ydata]
|
|
292
|
+
self.is_drawing_line = True
|
|
293
|
+
|
|
294
|
+
# Initialize line artist if needed
|
|
295
|
+
if self.line_artist is None:
|
|
296
|
+
(self.line_artist,) = self.ax.plot(
|
|
297
|
+
self.line_x,
|
|
298
|
+
self.line_y,
|
|
299
|
+
color=self.line_color,
|
|
300
|
+
linestyle="-",
|
|
301
|
+
linewidth=3,
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
self.line_artist.set_data(self.line_x, self.line_y)
|
|
305
|
+
self.line_artist.set_visible(True)
|
|
306
|
+
|
|
307
|
+
# Blitting setup
|
|
308
|
+
self.line_artist.set_animated(True)
|
|
309
|
+
self.canvas.draw()
|
|
310
|
+
self.background = self.canvas.canvas.copy_from_bbox(self.ax.bbox)
|
|
311
|
+
self.ax.draw_artist(self.line_artist)
|
|
312
|
+
self.canvas.canvas.blit(self.ax.bbox)
|
|
313
|
+
|
|
314
|
+
def on_line_drag(self, event):
|
|
315
|
+
if not getattr(self, "is_drawing_line", False) or event.inaxes != self.ax:
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
self.line_x = [self.line_x[0], event.xdata]
|
|
319
|
+
self.line_y = [self.line_y[0], event.ydata]
|
|
320
|
+
|
|
321
|
+
# Blitting update
|
|
322
|
+
if self.background:
|
|
323
|
+
self.canvas.canvas.restore_region(self.background)
|
|
324
|
+
|
|
325
|
+
self.line_artist.set_data(self.line_x, self.line_y)
|
|
326
|
+
self.ax.draw_artist(self.line_artist)
|
|
327
|
+
self.canvas.canvas.blit(self.ax.bbox)
|
|
328
|
+
|
|
329
|
+
def update_profile(self):
|
|
330
|
+
if not self.line_mode or not hasattr(self, "line_x") or not self.line_x:
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
# Calculate profile
|
|
334
|
+
x0, y0 = self.line_x[0], self.line_y[0]
|
|
335
|
+
x1, y1 = self.line_x[1], self.line_y[1]
|
|
336
|
+
length_px = np.hypot(x1 - x0, y1 - y0)
|
|
337
|
+
if length_px == 0:
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
num_points = int(length_px)
|
|
341
|
+
if num_points < 2:
|
|
342
|
+
num_points = 2
|
|
343
|
+
|
|
344
|
+
x, y = np.linspace(x0, x1, num_points), np.linspace(y0, y1, num_points)
|
|
345
|
+
|
|
346
|
+
# Use self.init_frame as self.im.get_array() might be unreliable or cached
|
|
347
|
+
if hasattr(self, "init_frame") and self.init_frame is not None:
|
|
348
|
+
from scipy.ndimage import map_coordinates
|
|
349
|
+
|
|
350
|
+
profile = map_coordinates(
|
|
351
|
+
self.init_frame, np.vstack((y, x)), order=1, mode="nearest"
|
|
352
|
+
)
|
|
353
|
+
else:
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
if np.all(np.isnan(profile)):
|
|
357
|
+
# If profile is all NaNs, we can't plot meaningful data, but we should maintain the axes
|
|
358
|
+
profile = np.zeros_like(profile)
|
|
359
|
+
profile[:] = np.nan
|
|
360
|
+
|
|
361
|
+
# Distance in microns if available
|
|
362
|
+
dist_axis = np.arange(num_points)
|
|
363
|
+
x_label = "Distance (px)"
|
|
364
|
+
|
|
365
|
+
# Only show pixel length, rounded to integer
|
|
366
|
+
title_str = f"{round(length_px,2)} [px]"
|
|
367
|
+
if self.PxToUm is not None:
|
|
368
|
+
title_str += f" | {round(length_px*self.PxToUm,3)} [µm]"
|
|
369
|
+
|
|
370
|
+
# Handle Y-Axis Locking
|
|
371
|
+
current_ylim = None
|
|
372
|
+
if self.lock_y_action.isChecked():
|
|
373
|
+
current_ylim = self.ax_profile.get_ylim()
|
|
374
|
+
|
|
375
|
+
# Plot profile
|
|
376
|
+
self.ax_profile.clear()
|
|
377
|
+
self.ax_profile.set_facecolor("none")
|
|
378
|
+
if hasattr(self, "profile_line") and self.profile_line:
|
|
379
|
+
try:
|
|
380
|
+
self.profile_line.remove()
|
|
381
|
+
except:
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
# Distance in microns if available
|
|
385
|
+
dist_axis = np.arange(num_points)
|
|
386
|
+
|
|
387
|
+
(self.profile_line,) = self.ax_profile.plot(
|
|
388
|
+
dist_axis, profile, color="black", linestyle="-"
|
|
389
|
+
)
|
|
390
|
+
self.ax_profile.set_xticks([])
|
|
391
|
+
self.ax_profile.set_ylabel("Intensity", fontsize=8)
|
|
392
|
+
self.ax_profile.set_xlabel(title_str, fontsize=8)
|
|
393
|
+
self.ax_profile.tick_params(axis="y", which="major", labelsize=6)
|
|
394
|
+
# self.ax_profile.grid(True)
|
|
395
|
+
|
|
396
|
+
# Hide spines
|
|
397
|
+
self.ax_profile.spines["top"].set_visible(False)
|
|
398
|
+
self.ax_profile.spines["right"].set_visible(False)
|
|
399
|
+
self.ax_profile.spines["bottom"].set_color("black")
|
|
400
|
+
self.ax_profile.spines["left"].set_color("black")
|
|
401
|
+
|
|
402
|
+
self.fig.set_facecolor("none")
|
|
403
|
+
|
|
404
|
+
if current_ylim:
|
|
405
|
+
self.ax_profile.set_ylim(current_ylim)
|
|
406
|
+
|
|
407
|
+
self.fig.canvas.draw_idle()
|
|
408
|
+
|
|
409
|
+
def on_line_release(self, event):
|
|
410
|
+
if not getattr(self, "is_drawing_line", False):
|
|
411
|
+
return
|
|
412
|
+
self.is_drawing_line = False
|
|
413
|
+
|
|
414
|
+
if event.inaxes != self.ax:
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
# Final update
|
|
418
|
+
self.line_x = [self.line_x[0], event.xdata]
|
|
419
|
+
self.line_y = [self.line_y[0], event.ydata]
|
|
420
|
+
self.line_artist.set_data(self.line_x, self.line_y)
|
|
421
|
+
|
|
422
|
+
# Finalize drawing (disable animation for persistence)
|
|
423
|
+
self.line_artist.set_animated(False)
|
|
424
|
+
self.background = None
|
|
425
|
+
|
|
426
|
+
self.update_profile()
|
|
427
|
+
self.canvas.canvas.draw_idle()
|
|
428
|
+
|
|
429
|
+
def show(self):
|
|
430
|
+
# Display the widget
|
|
431
|
+
self.canvas.show()
|
|
432
|
+
|
|
433
|
+
def load_stack(self):
|
|
434
|
+
# Load the stack of images
|
|
435
|
+
if self.stack is not None:
|
|
436
|
+
if isinstance(self.stack, list):
|
|
437
|
+
self.stack = np.asarray(self.stack)
|
|
438
|
+
|
|
439
|
+
if self.stack.ndim == 3:
|
|
440
|
+
# Assuming stack is (T, Y, X) -> convert to (T, Y, X, 1) for consistent 4D handling
|
|
441
|
+
logger.info(
|
|
442
|
+
"StackVisualizer: 3D stack detected (T, Y, X). Adding channel axis."
|
|
443
|
+
)
|
|
444
|
+
self.stack = self.stack[:, :, :, np.newaxis]
|
|
445
|
+
self.target_channel = 0
|
|
446
|
+
|
|
447
|
+
self.mode = "direct"
|
|
448
|
+
self.stack_length = len(self.stack)
|
|
449
|
+
self.mid_time = self.stack_length // 2
|
|
450
|
+
self.init_frame = self.stack[self.mid_time, :, :, self.target_channel]
|
|
451
|
+
self.last_frame = self.stack[-1, :, :, self.target_channel]
|
|
452
|
+
else:
|
|
453
|
+
self.mode = "virtual"
|
|
454
|
+
assert isinstance(self.stack_path, str)
|
|
455
|
+
assert self.stack_path.endswith(".tif")
|
|
456
|
+
self.locate_image_virtual()
|
|
457
|
+
|
|
458
|
+
def locate_image_virtual(self):
|
|
459
|
+
# Locate the stack of images if provided as a file
|
|
460
|
+
|
|
461
|
+
self.stack_length = auto_load_number_of_frames(self.stack_path)
|
|
462
|
+
self.mid_time = self.stack_length // 2
|
|
463
|
+
self.img_num_per_channel = _get_img_num_per_channel(
|
|
464
|
+
np.arange(self.n_channels), self.stack_length, self.n_channels
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
self.init_frame = load_frames(
|
|
468
|
+
self.img_num_per_channel[self.target_channel, self.mid_time],
|
|
469
|
+
self.stack_path,
|
|
470
|
+
normalize_input=False,
|
|
471
|
+
)[:, :, 0]
|
|
472
|
+
self.last_frame = load_frames(
|
|
473
|
+
self.img_num_per_channel[self.target_channel, self.stack_length - 1],
|
|
474
|
+
self.stack_path,
|
|
475
|
+
normalize_input=False,
|
|
476
|
+
)[:, :, 0]
|
|
477
|
+
|
|
478
|
+
def generate_figure_canvas(self):
|
|
479
|
+
|
|
480
|
+
if np.all(np.isnan(self.init_frame)):
|
|
481
|
+
p01, p99 = 0, 1
|
|
482
|
+
else:
|
|
483
|
+
p01 = np.nanpercentile(self.init_frame, 0.1)
|
|
484
|
+
p99 = np.nanpercentile(self.init_frame, 99.9)
|
|
485
|
+
|
|
486
|
+
if np.isnan(p01):
|
|
487
|
+
p01 = 0
|
|
488
|
+
if np.isnan(p99):
|
|
489
|
+
p99 = 1
|
|
490
|
+
|
|
491
|
+
import matplotlib.pyplot as plt
|
|
492
|
+
from celldetective.gui.base.figure_canvas import FigureCanvas
|
|
493
|
+
|
|
494
|
+
self.fig, self.ax = plt.subplots(figsize=(5, 5))
|
|
495
|
+
|
|
496
|
+
self.fig.subplots_adjust(top=1, bottom=0, right=1, left=0, hspace=0, wspace=0)
|
|
497
|
+
self.ax.margins(0)
|
|
498
|
+
self.fig.patch.set_alpha(0)
|
|
499
|
+
self.canvas = FigureCanvas(self.fig, title=self.window_title, interactive=True)
|
|
500
|
+
self.ax.clear()
|
|
501
|
+
self.im = self.ax.imshow(
|
|
502
|
+
self.init_frame,
|
|
503
|
+
cmap="gray",
|
|
504
|
+
interpolation="none",
|
|
505
|
+
zorder=0,
|
|
506
|
+
vmin=p01,
|
|
507
|
+
vmax=p99,
|
|
508
|
+
**self.imshow_kwargs,
|
|
509
|
+
)
|
|
510
|
+
if self.PxToUm is not None:
|
|
511
|
+
from matplotlib_scalebar.scalebar import ScaleBar
|
|
512
|
+
|
|
513
|
+
scalebar = ScaleBar(
|
|
514
|
+
self.PxToUm,
|
|
515
|
+
"um",
|
|
516
|
+
length_fraction=0.25,
|
|
517
|
+
location="upper right",
|
|
518
|
+
border_pad=0.4,
|
|
519
|
+
box_alpha=0.95,
|
|
520
|
+
color="white",
|
|
521
|
+
box_color="black",
|
|
522
|
+
font_properties={"weight": "bold", "size": 10},
|
|
523
|
+
)
|
|
524
|
+
self.ax.add_artist(scalebar)
|
|
525
|
+
self.ax.axis("off")
|
|
526
|
+
|
|
527
|
+
def generate_channel_cb(self):
|
|
528
|
+
|
|
529
|
+
self.channel_cb = QComboBox()
|
|
530
|
+
if self.channel_names is not None and len(self.channel_names) > 0:
|
|
531
|
+
for name in self.channel_names:
|
|
532
|
+
self.channel_cb.addItem(name)
|
|
533
|
+
else:
|
|
534
|
+
for i in range(self.n_channels):
|
|
535
|
+
self.channel_cb.addItem(f"Channel {i}")
|
|
536
|
+
self.channel_cb.currentIndexChanged.connect(self.set_channel_index)
|
|
537
|
+
|
|
538
|
+
layout = QHBoxLayout()
|
|
539
|
+
layout.addWidget(QLabel("Channel: "), 15)
|
|
540
|
+
layout.addWidget(self.channel_cb, 85)
|
|
541
|
+
self.canvas.layout.addLayout(layout)
|
|
542
|
+
|
|
543
|
+
def set_contrast_decimals(self):
|
|
544
|
+
from celldetective.utils.types import is_integer_array
|
|
545
|
+
|
|
546
|
+
if is_integer_array(self.init_frame):
|
|
547
|
+
self.contrast_decimals = 0
|
|
548
|
+
else:
|
|
549
|
+
self.contrast_decimals = 2
|
|
550
|
+
|
|
551
|
+
def generate_contrast_slider(self):
|
|
552
|
+
# Generate the contrast slider if enabled
|
|
553
|
+
|
|
554
|
+
layout = QHBoxLayout()
|
|
555
|
+
self.set_contrast_decimals()
|
|
556
|
+
self.contrast_slider = QLabeledDoubleRangeSlider(Qt.Horizontal)
|
|
557
|
+
if np.all(np.isnan(self.init_frame)):
|
|
558
|
+
min_val, max_val = 0, 1
|
|
559
|
+
else:
|
|
560
|
+
min_val = np.nanmin(self.init_frame)
|
|
561
|
+
max_val = np.nanmax(self.init_frame)
|
|
562
|
+
|
|
563
|
+
if np.isnan(min_val):
|
|
564
|
+
min_val = 0
|
|
565
|
+
if np.isnan(max_val):
|
|
566
|
+
max_val = 1
|
|
567
|
+
|
|
568
|
+
self.contrast_slider.setRange(min_val, max_val)
|
|
569
|
+
|
|
570
|
+
# Set initial value to percentiles to avoid outliers
|
|
571
|
+
if np.all(np.isnan(self.init_frame)):
|
|
572
|
+
p01, p99 = 0, 1
|
|
573
|
+
else:
|
|
574
|
+
p01 = np.nanpercentile(self.init_frame, 0.1)
|
|
575
|
+
p99 = np.nanpercentile(self.init_frame, 99.9)
|
|
576
|
+
|
|
577
|
+
if np.isnan(p01):
|
|
578
|
+
p01 = min_val
|
|
579
|
+
if np.isnan(p99):
|
|
580
|
+
p99 = max_val
|
|
581
|
+
|
|
582
|
+
if p99 > p01:
|
|
583
|
+
self.contrast_slider.setValue((p01, p99))
|
|
584
|
+
else:
|
|
585
|
+
self.contrast_slider.setValue((min_val, max_val))
|
|
586
|
+
|
|
587
|
+
self.contrast_slider.setEdgeLabelMode(
|
|
588
|
+
QLabeledDoubleRangeSlider.EdgeLabelMode.NoLabel
|
|
589
|
+
)
|
|
590
|
+
self.contrast_slider.setDecimals(self.contrast_decimals)
|
|
591
|
+
|
|
592
|
+
self.contrast_slider.valueChanged.connect(self.change_contrast)
|
|
593
|
+
layout.addWidget(QLabel("Contrast: "), 15)
|
|
594
|
+
layout.addWidget(self.contrast_slider, 85)
|
|
595
|
+
self.canvas.layout.addLayout(layout)
|
|
596
|
+
|
|
597
|
+
def generate_frame_slider(self):
|
|
598
|
+
# Generate the frame slider if enabled
|
|
599
|
+
|
|
600
|
+
layout = QHBoxLayout()
|
|
601
|
+
self.frame_slider = QLabeledSlider(Qt.Horizontal)
|
|
602
|
+
self.frame_slider.setRange(0, self.stack_length - 1)
|
|
603
|
+
self.frame_slider.setValue(self.mid_time)
|
|
604
|
+
self.frame_slider.valueChanged.connect(self.change_frame)
|
|
605
|
+
layout.addWidget(QLabel("Time: "), 15)
|
|
606
|
+
layout.addWidget(self.frame_slider, 85)
|
|
607
|
+
self.canvas.layout.addLayout(layout)
|
|
608
|
+
|
|
609
|
+
def set_target_channel(self, value):
|
|
610
|
+
self.target_channel = value
|
|
611
|
+
self.init_frame = self.stack[self.mid_time, :, :, self.target_channel]
|
|
612
|
+
self.im.set_data(self.init_frame)
|
|
613
|
+
self.canvas.draw()
|
|
614
|
+
self.update_profile()
|
|
615
|
+
|
|
616
|
+
def change_contrast(self, value):
|
|
617
|
+
# Change contrast based on slider value
|
|
618
|
+
if not self.init_contrast:
|
|
619
|
+
self.im.set_clim(vmin=value[0], vmax=value[1])
|
|
620
|
+
self.canvas.draw()
|
|
621
|
+
|
|
622
|
+
def set_channel_index(self, value):
|
|
623
|
+
self.target_channel = value
|
|
624
|
+
self.channel_trigger = True
|
|
625
|
+
if self.create_frame_slider:
|
|
626
|
+
self.change_frame_from_channel_switch(self.frame_slider.value())
|
|
627
|
+
else:
|
|
628
|
+
if self.stack is not None and self.stack.ndim == 4:
|
|
629
|
+
self.init_frame = self.stack[self.mid_time, :, :, self.target_channel]
|
|
630
|
+
self.im.set_data(self.init_frame)
|
|
631
|
+
self.canvas.draw()
|
|
632
|
+
self.update_profile()
|
|
633
|
+
|
|
634
|
+
def change_frame_from_channel_switch(self, value):
|
|
635
|
+
self._min = 0
|
|
636
|
+
self._max = 0
|
|
637
|
+
self.change_frame(value)
|
|
638
|
+
if self.channel_trigger:
|
|
639
|
+
p01 = np.nanpercentile(self.init_frame, 0.1)
|
|
640
|
+
p99 = np.nanpercentile(self.init_frame, 99.9)
|
|
641
|
+
self.im.set_clim(vmin=p01, vmax=p99)
|
|
642
|
+
self.contrast_slider.setValue((p01, p99))
|
|
643
|
+
self.channel_trigger = False
|
|
644
|
+
self.canvas.draw()
|
|
645
|
+
|
|
646
|
+
def change_frame(self, value):
|
|
647
|
+
|
|
648
|
+
self.current_time_index = value
|
|
649
|
+
|
|
650
|
+
if self.mode == "direct":
|
|
651
|
+
self.init_frame = self.stack[value, :, :, self.target_channel]
|
|
652
|
+
|
|
653
|
+
elif self.mode == "virtual":
|
|
654
|
+
# Check cache first
|
|
655
|
+
cache_key = (self.target_channel, value)
|
|
656
|
+
if cache_key in self.frame_cache:
|
|
657
|
+
self.init_frame = self.frame_cache[cache_key]
|
|
658
|
+
self.frame_cache.move_to_end(cache_key) # Mark as recently used
|
|
659
|
+
else:
|
|
660
|
+
self.init_frame = load_frames(
|
|
661
|
+
self.img_num_per_channel[self.target_channel, value],
|
|
662
|
+
self.stack_path,
|
|
663
|
+
normalize_input=False,
|
|
664
|
+
)[:, :, 0]
|
|
665
|
+
|
|
666
|
+
# Add to cache
|
|
667
|
+
self.frame_cache[cache_key] = self.init_frame
|
|
668
|
+
# Enforce size limit
|
|
669
|
+
if len(self.frame_cache) > self.max_cache_size:
|
|
670
|
+
self.frame_cache.popitem(last=False) # Remove oldest
|
|
671
|
+
|
|
672
|
+
self.im.set_data(self.init_frame)
|
|
673
|
+
rescale_contrast = False
|
|
674
|
+
|
|
675
|
+
# Optimization: Check min/max on subsampled array for large images
|
|
676
|
+
if self.init_frame.size > 1000000:
|
|
677
|
+
view = self.init_frame[::30, ::30]
|
|
678
|
+
else:
|
|
679
|
+
view = self.init_frame
|
|
680
|
+
|
|
681
|
+
curr_min = np.nanmin(view)
|
|
682
|
+
curr_max = np.nanmax(view)
|
|
683
|
+
|
|
684
|
+
if curr_min < self._min:
|
|
685
|
+
self._min = curr_min
|
|
686
|
+
rescale_constrast = True
|
|
687
|
+
if curr_max > self._max:
|
|
688
|
+
self._max = curr_max
|
|
689
|
+
rescale_contrast = True
|
|
690
|
+
|
|
691
|
+
if rescale_contrast:
|
|
692
|
+
self.contrast_slider.setRange(self._min, self._max)
|
|
693
|
+
self.canvas.canvas.draw_idle()
|
|
694
|
+
self.update_profile()
|
|
695
|
+
|
|
696
|
+
def closeEvent(self, event):
|
|
697
|
+
# Event handler for closing the widget
|
|
698
|
+
if hasattr(self, "frame_cache") and isinstance(self.frame_cache, OrderedDict):
|
|
699
|
+
self.frame_cache.clear()
|
|
700
|
+
self.canvas.close()
|