celldetective 1.4.2__py3-none-any.whl → 1.5.0b0__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.
Files changed (151) hide show
  1. celldetective/__init__.py +25 -0
  2. celldetective/__main__.py +62 -43
  3. celldetective/_version.py +1 -1
  4. celldetective/extra_properties.py +477 -399
  5. celldetective/filters.py +192 -97
  6. celldetective/gui/InitWindow.py +541 -411
  7. celldetective/gui/__init__.py +0 -15
  8. celldetective/gui/about.py +44 -39
  9. celldetective/gui/analyze_block.py +120 -84
  10. celldetective/gui/base/__init__.py +0 -0
  11. celldetective/gui/base/channel_norm_generator.py +335 -0
  12. celldetective/gui/base/components.py +249 -0
  13. celldetective/gui/base/feature_choice.py +92 -0
  14. celldetective/gui/base/figure_canvas.py +52 -0
  15. celldetective/gui/base/list_widget.py +133 -0
  16. celldetective/gui/{styles.py → base/styles.py} +92 -36
  17. celldetective/gui/base/utils.py +33 -0
  18. celldetective/gui/base_annotator.py +900 -767
  19. celldetective/gui/classifier_widget.py +6 -22
  20. celldetective/gui/configure_new_exp.py +777 -671
  21. celldetective/gui/control_panel.py +635 -524
  22. celldetective/gui/dynamic_progress.py +449 -0
  23. celldetective/gui/event_annotator.py +2023 -1662
  24. celldetective/gui/generic_signal_plot.py +1292 -944
  25. celldetective/gui/gui_utils.py +899 -1289
  26. celldetective/gui/interactions_block.py +658 -0
  27. celldetective/gui/interactive_timeseries_viewer.py +447 -0
  28. celldetective/gui/json_readers.py +48 -15
  29. celldetective/gui/layouts/__init__.py +5 -0
  30. celldetective/gui/layouts/background_model_free_layout.py +537 -0
  31. celldetective/gui/layouts/channel_offset_layout.py +134 -0
  32. celldetective/gui/layouts/local_correction_layout.py +91 -0
  33. celldetective/gui/layouts/model_fit_layout.py +372 -0
  34. celldetective/gui/layouts/operation_layout.py +68 -0
  35. celldetective/gui/layouts/protocol_designer_layout.py +96 -0
  36. celldetective/gui/pair_event_annotator.py +3130 -2435
  37. celldetective/gui/plot_measurements.py +586 -267
  38. celldetective/gui/plot_signals_ui.py +724 -506
  39. celldetective/gui/preprocessing_block.py +395 -0
  40. celldetective/gui/process_block.py +1678 -1831
  41. celldetective/gui/seg_model_loader.py +580 -473
  42. celldetective/gui/settings/__init__.py +0 -7
  43. celldetective/gui/settings/_cellpose_model_params.py +181 -0
  44. celldetective/gui/settings/_event_detection_model_params.py +95 -0
  45. celldetective/gui/settings/_segmentation_model_params.py +159 -0
  46. celldetective/gui/settings/_settings_base.py +77 -65
  47. celldetective/gui/settings/_settings_event_model_training.py +752 -526
  48. celldetective/gui/settings/_settings_measurements.py +1133 -964
  49. celldetective/gui/settings/_settings_neighborhood.py +574 -488
  50. celldetective/gui/settings/_settings_segmentation_model_training.py +779 -564
  51. celldetective/gui/settings/_settings_signal_annotator.py +329 -305
  52. celldetective/gui/settings/_settings_tracking.py +1304 -1094
  53. celldetective/gui/settings/_stardist_model_params.py +98 -0
  54. celldetective/gui/survival_ui.py +422 -312
  55. celldetective/gui/tableUI.py +1665 -1701
  56. celldetective/gui/table_ops/_maths.py +295 -0
  57. celldetective/gui/table_ops/_merge_groups.py +140 -0
  58. celldetective/gui/table_ops/_merge_one_hot.py +95 -0
  59. celldetective/gui/table_ops/_query_table.py +43 -0
  60. celldetective/gui/table_ops/_rename_col.py +44 -0
  61. celldetective/gui/thresholds_gui.py +382 -179
  62. celldetective/gui/viewers/__init__.py +0 -0
  63. celldetective/gui/viewers/base_viewer.py +700 -0
  64. celldetective/gui/viewers/channel_offset_viewer.py +331 -0
  65. celldetective/gui/viewers/contour_viewer.py +394 -0
  66. celldetective/gui/viewers/size_viewer.py +153 -0
  67. celldetective/gui/viewers/spot_detection_viewer.py +341 -0
  68. celldetective/gui/viewers/threshold_viewer.py +309 -0
  69. celldetective/gui/workers.py +304 -126
  70. celldetective/log_manager.py +92 -0
  71. celldetective/measure.py +1895 -1478
  72. celldetective/napari/__init__.py +0 -0
  73. celldetective/napari/utils.py +1025 -0
  74. celldetective/neighborhood.py +1914 -1448
  75. celldetective/preprocessing.py +1620 -1220
  76. celldetective/processes/__init__.py +0 -0
  77. celldetective/processes/background_correction.py +271 -0
  78. celldetective/processes/compute_neighborhood.py +894 -0
  79. celldetective/processes/detect_events.py +246 -0
  80. celldetective/processes/measure_cells.py +565 -0
  81. celldetective/processes/segment_cells.py +760 -0
  82. celldetective/processes/track_cells.py +435 -0
  83. celldetective/processes/train_segmentation_model.py +694 -0
  84. celldetective/processes/train_signal_model.py +265 -0
  85. celldetective/processes/unified_process.py +292 -0
  86. celldetective/regionprops/_regionprops.py +358 -317
  87. celldetective/relative_measurements.py +987 -710
  88. celldetective/scripts/measure_cells.py +313 -212
  89. celldetective/scripts/measure_relative.py +90 -46
  90. celldetective/scripts/segment_cells.py +165 -104
  91. celldetective/scripts/segment_cells_thresholds.py +96 -68
  92. celldetective/scripts/track_cells.py +198 -149
  93. celldetective/scripts/train_segmentation_model.py +324 -201
  94. celldetective/scripts/train_signal_model.py +87 -45
  95. celldetective/segmentation.py +844 -749
  96. celldetective/signals.py +3514 -2861
  97. celldetective/tracking.py +30 -15
  98. celldetective/utils/__init__.py +0 -0
  99. celldetective/utils/cellpose_utils/__init__.py +133 -0
  100. celldetective/utils/color_mappings.py +42 -0
  101. celldetective/utils/data_cleaning.py +630 -0
  102. celldetective/utils/data_loaders.py +450 -0
  103. celldetective/utils/dataset_helpers.py +207 -0
  104. celldetective/utils/downloaders.py +197 -0
  105. celldetective/utils/event_detection/__init__.py +8 -0
  106. celldetective/utils/experiment.py +1782 -0
  107. celldetective/utils/image_augmenters.py +308 -0
  108. celldetective/utils/image_cleaning.py +74 -0
  109. celldetective/utils/image_loaders.py +926 -0
  110. celldetective/utils/image_transforms.py +335 -0
  111. celldetective/utils/io.py +62 -0
  112. celldetective/utils/mask_cleaning.py +348 -0
  113. celldetective/utils/mask_transforms.py +5 -0
  114. celldetective/utils/masks.py +184 -0
  115. celldetective/utils/maths.py +351 -0
  116. celldetective/utils/model_getters.py +325 -0
  117. celldetective/utils/model_loaders.py +296 -0
  118. celldetective/utils/normalization.py +380 -0
  119. celldetective/utils/parsing.py +465 -0
  120. celldetective/utils/plots/__init__.py +0 -0
  121. celldetective/utils/plots/regression.py +53 -0
  122. celldetective/utils/resources.py +34 -0
  123. celldetective/utils/stardist_utils/__init__.py +104 -0
  124. celldetective/utils/stats.py +90 -0
  125. celldetective/utils/types.py +21 -0
  126. {celldetective-1.4.2.dist-info → celldetective-1.5.0b0.dist-info}/METADATA +1 -1
  127. celldetective-1.5.0b0.dist-info/RECORD +187 -0
  128. {celldetective-1.4.2.dist-info → celldetective-1.5.0b0.dist-info}/WHEEL +1 -1
  129. tests/gui/test_new_project.py +129 -117
  130. tests/gui/test_project.py +127 -79
  131. tests/test_filters.py +39 -15
  132. tests/test_notebooks.py +8 -0
  133. tests/test_tracking.py +232 -13
  134. tests/test_utils.py +123 -77
  135. celldetective/gui/base_components.py +0 -23
  136. celldetective/gui/layouts.py +0 -1602
  137. celldetective/gui/processes/compute_neighborhood.py +0 -594
  138. celldetective/gui/processes/measure_cells.py +0 -360
  139. celldetective/gui/processes/segment_cells.py +0 -499
  140. celldetective/gui/processes/track_cells.py +0 -303
  141. celldetective/gui/processes/train_segmentation_model.py +0 -270
  142. celldetective/gui/processes/train_signal_model.py +0 -108
  143. celldetective/gui/table_ops/merge_groups.py +0 -118
  144. celldetective/gui/viewers.py +0 -1354
  145. celldetective/io.py +0 -3663
  146. celldetective/utils.py +0 -3108
  147. celldetective-1.4.2.dist-info/RECORD +0 -123
  148. /celldetective/{gui/processes → processes}/downloader.py +0 -0
  149. {celldetective-1.4.2.dist-info → celldetective-1.5.0b0.dist-info}/entry_points.txt +0 -0
  150. {celldetective-1.4.2.dist-info → celldetective-1.5.0b0.dist-info}/licenses/LICENSE +0 -0
  151. {celldetective-1.4.2.dist-info → celldetective-1.5.0b0.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()