celldetective 1.5.0b7__py3-none-any.whl → 1.5.0b9__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 (33) hide show
  1. celldetective/_version.py +1 -1
  2. celldetective/event_detection_models.py +2463 -0
  3. celldetective/gui/base/channel_norm_generator.py +19 -3
  4. celldetective/gui/base/figure_canvas.py +1 -1
  5. celldetective/gui/base/list_widget.py +1 -1
  6. celldetective/gui/base_annotator.py +2 -5
  7. celldetective/gui/event_annotator.py +248 -138
  8. celldetective/gui/generic_signal_plot.py +14 -14
  9. celldetective/gui/gui_utils.py +27 -6
  10. celldetective/gui/pair_event_annotator.py +146 -20
  11. celldetective/gui/plot_signals_ui.py +32 -15
  12. celldetective/gui/process_block.py +2 -2
  13. celldetective/gui/seg_model_loader.py +4 -4
  14. celldetective/gui/settings/_settings_event_model_training.py +32 -14
  15. celldetective/gui/settings/_settings_segmentation_model_training.py +5 -5
  16. celldetective/gui/settings/_settings_signal_annotator.py +0 -19
  17. celldetective/gui/survival_ui.py +39 -11
  18. celldetective/gui/tableUI.py +69 -148
  19. celldetective/gui/thresholds_gui.py +45 -5
  20. celldetective/gui/viewers/base_viewer.py +17 -20
  21. celldetective/gui/viewers/spot_detection_viewer.py +136 -27
  22. celldetective/processes/train_signal_model.py +1 -1
  23. celldetective/scripts/train_signal_model.py +1 -1
  24. celldetective/signals.py +4 -2426
  25. celldetective/utils/event_detection/__init__.py +1 -1
  26. {celldetective-1.5.0b7.dist-info → celldetective-1.5.0b9.dist-info}/METADATA +1 -1
  27. {celldetective-1.5.0b7.dist-info → celldetective-1.5.0b9.dist-info}/RECORD +33 -31
  28. tests/gui/test_spot_detection_viewer.py +187 -0
  29. tests/test_signals.py +135 -116
  30. {celldetective-1.5.0b7.dist-info → celldetective-1.5.0b9.dist-info}/WHEEL +0 -0
  31. {celldetective-1.5.0b7.dist-info → celldetective-1.5.0b9.dist-info}/entry_points.txt +0 -0
  32. {celldetective-1.5.0b7.dist-info → celldetective-1.5.0b9.dist-info}/licenses/LICENSE +0 -0
  33. {celldetective-1.5.0b7.dist-info → celldetective-1.5.0b9.dist-info}/top_level.txt +0 -0
@@ -36,6 +36,22 @@ class ChannelNormGenerator(QVBoxLayout, Styles):
36
36
  self.generate_widgets()
37
37
  self.add_to_layout()
38
38
 
39
+ def add_items_truncated(self, combo, items):
40
+ """
41
+ Add items to a combobox with truncated text and full text in tooltip/data.
42
+ """
43
+ combo.clear()
44
+ for item in items:
45
+ item_text = str(item)
46
+ if len(item_text) > 25:
47
+ display_text = item_text[:25] + "..."
48
+ else:
49
+ display_text = item_text
50
+ combo.addItem(display_text, item_text)
51
+ # Find the index of the added item
52
+ idx = combo.count() - 1
53
+ combo.setItemData(idx, item_text, Qt.ToolTipRole)
54
+
39
55
  def generate_widgets(self):
40
56
 
41
57
  self.channel_cbs = [QSearchableComboBox() for i in range(self.init_n_channels)]
@@ -178,7 +194,7 @@ class ChannelNormGenerator(QVBoxLayout, Styles):
178
194
 
179
195
  self.channel_cbs.append(QSearchableComboBox())
180
196
  self.channel_labels.append(QLabel())
181
- self.channel_cbs[-1].addItems(self.channel_items)
197
+ self.add_items_truncated(self.channel_cbs[-1], self.channel_items)
182
198
  self.channel_cbs[-1].currentIndexChanged.connect(self.check_valid_channels)
183
199
  self.channel_labels[-1].setText(f"channel {len(self.channel_cbs)-1}: ")
184
200
 
@@ -239,7 +255,7 @@ class ChannelNormGenerator(QVBoxLayout, Styles):
239
255
  ch_layout = QHBoxLayout()
240
256
  self.channel_labels[i].setText(f"channel {i}: ")
241
257
  ch_layout.addWidget(self.channel_labels[i], 30)
242
- self.channel_cbs[i].addItems(self.channel_items)
258
+ self.add_items_truncated(self.channel_cbs[i], self.channel_items)
243
259
  self.channel_cbs[i].currentIndexChanged.connect(self.check_valid_channels)
244
260
  ch_layout.addWidget(self.channel_cbs[i], 70)
245
261
  self.channels_vb.addLayout(ch_layout)
@@ -323,7 +339,7 @@ class ChannelNormGenerator(QVBoxLayout, Styles):
323
339
  def check_valid_channels(self):
324
340
 
325
341
  if hasattr(self.parent_window, "submit_btn"):
326
- if np.all([cb.currentText() == "--" for cb in self.channel_cbs]):
342
+ if np.all([cb.currentData() == "--" for cb in self.channel_cbs]):
327
343
  self.parent_window.submit_btn.setEnabled(False)
328
344
 
329
345
  if hasattr(self.parent_window, "spatial_calib_le") and hasattr(
@@ -23,7 +23,7 @@ class FigureCanvas(CelldetectiveWidget):
23
23
 
24
24
  self.toolbar = NavigationToolbar2QT(self.canvas)
25
25
  self.toolbar.setStyleSheet(
26
- "QToolButton:hover {background-color: lightgray;} QToolButton {background-color: transparent; border: none;}"
26
+ "QToolButton:checked {background-color: darkgray;} QToolButton:hover {background-color: lightgray;} QToolButton {background-color: transparent; border: none;}"
27
27
  )
28
28
  self.layout = QVBoxLayout(self)
29
29
  self.layout.addWidget(self.canvas, 90)
@@ -128,6 +128,6 @@ class ListWidget(CelldetectiveWidget):
128
128
  return
129
129
  for item in listItems:
130
130
  idx = self.list_widget.row(item)
131
- self.list_widget.takeItem(idx)
132
131
  if self.items:
133
132
  del self.items[idx]
133
+ self.list_widget.takeItem(idx)
@@ -431,10 +431,7 @@ class BaseAnnotator(CelldetectiveMainWindow, Styles):
431
431
  else:
432
432
  self.fraction = 0.25
433
433
 
434
- if "interval" in instructions:
435
- self.anim_interval = int(instructions["interval"])
436
- else:
437
- self.anim_interval = 1
434
+ self.anim_interval = 33
438
435
 
439
436
  if "log" in instructions:
440
437
  self.log_option = instructions["log"]
@@ -446,7 +443,7 @@ class BaseAnnotator(CelldetectiveMainWindow, Styles):
446
443
  self.percentile_mode = True
447
444
  self.target_channels = [[self.channel_names[0], 0.01, 99.99]]
448
445
  self.fraction = 0.25
449
- self.anim_interval = 1
446
+ self.anim_interval = 33
450
447
 
451
448
  def locate_stack(self):
452
449
  """
@@ -20,7 +20,12 @@ from PyQt5.QtGui import QKeySequence, QIntValidator
20
20
 
21
21
  from celldetective.gui.gui_utils import color_from_state
22
22
  from celldetective.gui.base.utils import center_window, pretty_table
23
- from superqt import QLabeledDoubleSlider, QLabeledDoubleRangeSlider, QSearchableComboBox
23
+ from superqt import (
24
+ QLabeledDoubleSlider,
25
+ QLabeledDoubleRangeSlider,
26
+ QSearchableComboBox,
27
+ QLabeledSlider,
28
+ )
24
29
  from celldetective.utils.image_loaders import (
25
30
  locate_labels,
26
31
  load_frames,
@@ -96,14 +101,58 @@ class EventAnnotator(BaseAnnotator):
96
101
  self.class_name = "class"
97
102
  self.time_name = "t0"
98
103
  self.status_name = "status"
104
+ self._loader_thread = None
99
105
 
100
106
  # self.locate_stack()
101
107
  if not self.proceed:
102
108
  self.close()
103
109
  else:
104
110
  if not lazy_load:
105
- self.prepare_stack()
106
- self.finalize_init()
111
+ self._start_threaded_loading()
112
+
113
+ def _start_threaded_loading(self):
114
+ """Start loading the stack in a background thread with progress dialog."""
115
+ from celldetective.gui.base.components import CelldetectiveProgressDialog
116
+
117
+ self._progress_dialog = CelldetectiveProgressDialog(
118
+ "Loading stack...", "Cancel", 0, 100, self
119
+ )
120
+ self._progress_dialog.setWindowTitle("Loading")
121
+ self._progress_dialog.setMinimumDuration(0)
122
+ self._progress_dialog.setValue(0)
123
+
124
+ self._loader_thread = StackLoaderThread(self)
125
+ self._loader_thread.progress.connect(self._on_load_progress)
126
+ self._loader_thread.status_update.connect(self._on_load_status)
127
+ self._loader_thread.finished.connect(self._on_load_finished)
128
+ self._progress_dialog.canceled.connect(self._on_load_canceled)
129
+
130
+ self._loader_thread.start()
131
+
132
+ def _on_load_progress(self, value):
133
+ """Update progress dialog."""
134
+ if hasattr(self, "_progress_dialog") and self._progress_dialog:
135
+ self._progress_dialog.setValue(value)
136
+
137
+ def _on_load_status(self, status):
138
+ """Update progress dialog label."""
139
+ if hasattr(self, "_progress_dialog") and self._progress_dialog:
140
+ self._progress_dialog.setLabelText(status)
141
+
142
+ def _on_load_canceled(self):
143
+ """Handle cancel button click."""
144
+ if self._loader_thread:
145
+ self._loader_thread.stop()
146
+ self._loader_thread.wait()
147
+ self.close()
148
+
149
+ def _on_load_finished(self):
150
+ """Called when loading completes."""
151
+ if hasattr(self, "_progress_dialog") and self._progress_dialog:
152
+ self._progress_dialog.close()
153
+ self._progress_dialog = None
154
+ self._loader_thread = None
155
+ self.finalize_init()
107
156
 
108
157
  def finalize_init(self):
109
158
  self.frame_lbl = QLabel("frame: ")
@@ -200,7 +249,8 @@ class EventAnnotator(BaseAnnotator):
200
249
 
201
250
  self.first_frame_btn = QPushButton()
202
251
  self.first_frame_btn.clicked.connect(self.set_first_frame)
203
- self.first_frame_btn.setShortcut(QKeySequence("f"))
252
+ self.first_short = QShortcut(QKeySequence("f"), self)
253
+ self.first_short.activated.connect(self.set_first_frame)
204
254
  self.first_frame_btn.setIcon(icon(MDI6.page_first, color="black"))
205
255
  self.first_frame_btn.setStyleSheet(self.button_select_all)
206
256
  self.first_frame_btn.setFixedSize(QSize(60, 60))
@@ -208,7 +258,8 @@ class EventAnnotator(BaseAnnotator):
208
258
 
209
259
  self.last_frame_btn = QPushButton()
210
260
  self.last_frame_btn.clicked.connect(self.set_last_frame)
211
- self.last_frame_btn.setShortcut(QKeySequence("l"))
261
+ self.last_short = QShortcut(QKeySequence("l"), self)
262
+ self.last_short.activated.connect(self.set_last_frame)
212
263
  self.last_frame_btn.setIcon(icon(MDI6.page_last, color="black"))
213
264
  self.last_frame_btn.setStyleSheet(self.button_select_all)
214
265
  self.last_frame_btn.setFixedSize(QSize(60, 60))
@@ -229,11 +280,56 @@ class EventAnnotator(BaseAnnotator):
229
280
  self.start_btn.setIconSize(QSize(30, 30))
230
281
  self.start_btn.hide()
231
282
 
283
+ self.toggle_short = QShortcut(Qt.Key_Space, self)
284
+ self.toggle_short.activated.connect(self.toggle_animation)
285
+
286
+ self.speed_slider = QLabeledSlider(Qt.Horizontal)
287
+ self.speed_slider.setRange(1, 60)
288
+ # Convert initial interval (ms) to FPS
289
+ initial_fps = int(1000 / max(1, self.anim_interval))
290
+ self.speed_slider.setValue(initial_fps)
291
+ self.speed_slider.valueChanged.connect(self.update_speed)
292
+ self.speed_slider.setFixedWidth(200)
293
+ self.speed_slider.setToolTip("Adjust animation framerate (FPS)")
294
+
295
+ speed_layout = QHBoxLayout()
296
+ speed_layout.addWidget(QLabel("Framerate: "))
297
+ speed_layout.addWidget(self.speed_slider)
298
+
299
+ animation_buttons_box.addLayout(speed_layout, 20)
300
+
232
301
  animation_buttons_box.addWidget(
233
302
  self.first_frame_btn, 5, alignment=Qt.AlignRight
234
303
  )
304
+
305
+ self.prev_frame_btn = QPushButton()
306
+ self.prev_frame_btn.clicked.connect(self.prev_frame)
307
+ self.prev_frame_btn.setIcon(icon(MDI6.chevron_left, color="black"))
308
+ self.prev_frame_btn.setStyleSheet(self.button_select_all)
309
+ self.prev_frame_btn.setFixedSize(QSize(60, 60))
310
+ self.prev_frame_btn.setIconSize(QSize(30, 30))
311
+ self.prev_frame_btn.setEnabled(
312
+ False
313
+ ) # Disabled by default (assuming auto-start or initial state)
314
+ # Actually start is hidden initially, and stop shown?
315
+ # In populate_window: start_btn.hide() (line 235), stop_btn defaults?
316
+ # stop_btn is created.
317
+ # So assumed state is Playing? No, usually it starts, looped_animation matches.
318
+ # But if it starts playing, buttons should be disabled.
319
+ animation_buttons_box.addWidget(self.prev_frame_btn, 5, alignment=Qt.AlignRight)
320
+
235
321
  animation_buttons_box.addWidget(self.stop_btn, 5, alignment=Qt.AlignRight)
236
322
  animation_buttons_box.addWidget(self.start_btn, 5, alignment=Qt.AlignRight)
323
+
324
+ self.next_frame_btn = QPushButton()
325
+ self.next_frame_btn.clicked.connect(self.next_frame)
326
+ self.next_frame_btn.setIcon(icon(MDI6.chevron_right, color="black"))
327
+ self.next_frame_btn.setStyleSheet(self.button_select_all)
328
+ self.next_frame_btn.setFixedSize(QSize(60, 60))
329
+ self.next_frame_btn.setIconSize(QSize(30, 30))
330
+ self.next_frame_btn.setEnabled(False)
331
+ animation_buttons_box.addWidget(self.next_frame_btn, 5, alignment=Qt.AlignRight)
332
+
237
333
  animation_buttons_box.addWidget(self.last_frame_btn, 5, alignment=Qt.AlignRight)
238
334
 
239
335
  self.right_panel.addLayout(animation_buttons_box, 5)
@@ -246,15 +342,13 @@ class EventAnnotator(BaseAnnotator):
246
342
  self.contrast_slider.setSingleStep(0.001)
247
343
  self.contrast_slider.setTickInterval(0.001)
248
344
  self.contrast_slider.setOrientation(Qt.Horizontal)
249
- self.contrast_slider.setRange(
250
- *[
251
- np.nanpercentile(self.stack, 0.001),
252
- np.nanpercentile(self.stack, 99.999),
253
- ]
254
- )
255
- self.contrast_slider.setValue(
256
- [np.nanpercentile(self.stack, 1), np.nanpercentile(self.stack, 99.99)]
257
- )
345
+ # Cache percentile values to avoid recomputing on the full stack
346
+ self._stack_p_low = np.nanpercentile(self.stack, 0.001)
347
+ self._stack_p_high = np.nanpercentile(self.stack, 99.999)
348
+ self._stack_p1 = np.nanpercentile(self.stack, 1)
349
+ self._stack_p99 = np.nanpercentile(self.stack, 99.99)
350
+ self.contrast_slider.setRange(self._stack_p_low, self._stack_p_high)
351
+ self.contrast_slider.setValue([self._stack_p1, self._stack_p99])
258
352
  self.contrast_slider.valueChanged.connect(self.contrast_slider_action)
259
353
  contrast_hbox.addWidget(QLabel("contrast: "))
260
354
  contrast_hbox.addWidget(self.contrast_slider, 90)
@@ -263,10 +357,6 @@ class EventAnnotator(BaseAnnotator):
263
357
  if self.class_choice_cb.currentText() != "":
264
358
  self.compute_status_and_colors(0)
265
359
 
266
- self.no_event_shortcut = QShortcut(QKeySequence("n"), self) # QKeySequence("s")
267
- self.no_event_shortcut.activated.connect(self.shortcut_no_event)
268
- self.no_event_shortcut.setEnabled(False)
269
-
270
360
  QApplication.processEvents()
271
361
 
272
362
  # Add Menu for Interactive Plotter
@@ -429,7 +519,10 @@ class EventAnnotator(BaseAnnotator):
429
519
  self.enable_time_of_interest()
430
520
  self.correct_btn.setText("submit")
431
521
 
432
- self.correct_btn.disconnect()
522
+ try:
523
+ self.correct_btn.clicked.disconnect()
524
+ except TypeError:
525
+ pass # No connections to disconnect
433
526
  self.correct_btn.clicked.connect(self.apply_modification)
434
527
 
435
528
  def apply_modification(self):
@@ -441,8 +534,8 @@ class EventAnnotator(BaseAnnotator):
441
534
  t0 = float(self.time_of_interest_le.text().replace(",", "."))
442
535
  self.line_dt.set_xdata([t0, t0])
443
536
  self.cell_fcanvas.canvas.draw_idle()
444
- except Exception as e:
445
- print(f"L 598 {e=}")
537
+ except ValueError:
538
+ # Invalid time value entered
446
539
  t0 = -1
447
540
  cclass = 2
448
541
  elif self.no_event_btn.isChecked():
@@ -489,9 +582,11 @@ class EventAnnotator(BaseAnnotator):
489
582
  self.extract_scatter_from_trajectories()
490
583
  self.give_cell_information()
491
584
 
492
- self.correct_btn.disconnect()
585
+ try:
586
+ self.correct_btn.clicked.disconnect()
587
+ except TypeError:
588
+ pass # No connections to disconnect
493
589
  self.correct_btn.clicked.connect(self.show_annotation_buttons)
494
- # self.cancel_btn.click()
495
590
 
496
591
  self.hide_annotation_buttons()
497
592
  self.correct_btn.setEnabled(False)
@@ -652,73 +747,25 @@ class EventAnnotator(BaseAnnotator):
652
747
  self.cell_ax.set_ylim(self.value_magnitude, self.non_log_ymax)
653
748
 
654
749
  def extract_scatter_from_trajectories(self):
655
-
750
+ """Extract scatter data from trajectories using efficient groupby."""
656
751
  self.positions = []
657
752
  self.colors = []
658
753
  self.tracks = []
659
754
 
660
- for t in np.arange(self.len_movie):
661
- self.positions.append(
662
- self.df_tracks.loc[
663
- self.df_tracks["FRAME"] == t, ["x_anim", "y_anim"]
664
- ].to_numpy()
665
- )
666
- self.colors.append(
667
- self.df_tracks.loc[
668
- self.df_tracks["FRAME"] == t, ["class_color", "status_color"]
669
- ].to_numpy()
670
- )
671
- self.tracks.append(
672
- self.df_tracks.loc[self.df_tracks["FRAME"] == t, "TRACK_ID"].to_numpy()
673
- )
674
-
675
- # def load_annotator_config(self):
676
- #
677
- # """
678
- # Load settings from config or set default values.
679
- # """
680
- #
681
- # if os.path.exists(self.instructions_path):
682
- # with open(self.instructions_path, 'r') as f:
683
- #
684
- # instructions = json.load(f)
685
- #
686
- # if 'rgb_mode' in instructions:
687
- # self.rgb_mode = instructions['rgb_mode']
688
- # else:
689
- # self.rgb_mode = False
690
- #
691
- # if 'percentile_mode' in instructions:
692
- # self.percentile_mode = instructions['percentile_mode']
693
- # else:
694
- # self.percentile_mode = True
695
- #
696
- # if 'channels' in instructions:
697
- # self.target_channels = instructions['channels']
698
- # else:
699
- # self.target_channels = [[self.channel_names[0], 0.01, 99.99]]
700
- #
701
- # if 'fraction' in instructions:
702
- # self.fraction = float(instructions['fraction'])
703
- # else:
704
- # self.fraction = 0.25
705
- #
706
- # if 'interval' in instructions:
707
- # self.anim_interval = int(instructions['interval'])
708
- # else:
709
- # self.anim_interval = 1
710
- #
711
- # if 'log' in instructions:
712
- # self.log_option = instructions['log']
713
- # else:
714
- # self.log_option = False
715
- # else:
716
- # self.rgb_mode = False
717
- # self.log_option = False
718
- # self.percentile_mode = True
719
- # self.target_channels = [[self.channel_names[0], 0.01, 99.99]]
720
- # self.fraction = 0.25
721
- # self.anim_interval = 1
755
+ # Pre-allocate with empty arrays for all frames
756
+ for _ in range(self.len_movie):
757
+ self.positions.append(np.empty((0, 2)))
758
+ self.colors.append(np.empty((0, 2), dtype=object))
759
+ self.tracks.append(np.empty(0))
760
+
761
+ # Use groupby for efficient extraction
762
+ grouped = self.df_tracks.groupby("FRAME")
763
+ for frame, group in grouped:
764
+ frame = int(frame)
765
+ if 0 <= frame < self.len_movie:
766
+ self.positions[frame] = group[["x_anim", "y_anim"]].to_numpy()
767
+ self.colors[frame] = group[["class_color", "status_color"]].to_numpy()
768
+ self.tracks[frame] = group["TRACK_ID"].to_numpy()
722
769
 
723
770
  def prepare_stack(self, progress_callback=None):
724
771
 
@@ -808,6 +855,18 @@ class EventAnnotator(BaseAnnotator):
808
855
  except:
809
856
  pass
810
857
 
858
+ def animation_generator(self):
859
+ """
860
+ Generator yielding frame indices for the animation,
861
+ starting from the current self.framedata.
862
+ """
863
+ i = self.framedata
864
+ while True:
865
+ yield i
866
+ i += 1
867
+ if i >= self.len_movie:
868
+ i = 0
869
+
811
870
  def looped_animation(self):
812
871
  """
813
872
  Load an image.
@@ -849,12 +908,13 @@ class EventAnnotator(BaseAnnotator):
849
908
  self.anim = FuncAnimation(
850
909
  self.fig,
851
910
  self.draw_frame,
852
- frames=self.len_movie, # better would be to cast np.arange(len(movie)) in case frame column is incomplete
911
+ frames=self.animation_generator, # Use generator to allow seamless restarts
853
912
  interval=self.anim_interval, # in ms
854
913
  blit=True,
914
+ cache_frame_data=False,
855
915
  )
856
916
 
857
- self.fig.canvas.mpl_connect("pick_event", self.on_scatter_pick)
917
+ self._pick_cid = self.fig.canvas.mpl_connect("pick_event", self.on_scatter_pick)
858
918
  self.fcanvas.canvas.draw()
859
919
 
860
920
  def select_single_cell(self, index, timepoint):
@@ -957,8 +1017,92 @@ class EventAnnotator(BaseAnnotator):
957
1017
  self.stop_btn.hide()
958
1018
  self.start_btn.show()
959
1019
  self.anim.pause()
1020
+ self.prev_frame_btn.setEnabled(True)
1021
+ self.next_frame_btn.setEnabled(True)
960
1022
  self.stop_btn.clicked.connect(self.start)
961
1023
 
1024
+ def start(self):
1025
+ """
1026
+ Starts interactive animation.
1027
+ """
1028
+ self.start_btn.hide()
1029
+ self.stop_btn.show()
1030
+
1031
+ self.prev_frame_btn.setEnabled(False)
1032
+ self.next_frame_btn.setEnabled(False)
1033
+
1034
+ self.anim.resume()
1035
+ self.stop_btn.clicked.connect(self.stop)
1036
+
1037
+ def next_frame(self):
1038
+ self.framedata += 1
1039
+ if self.framedata >= self.len_movie:
1040
+ self.framedata = 0
1041
+ self.draw_frame(self.framedata)
1042
+ self.fcanvas.canvas.draw()
1043
+
1044
+ def prev_frame(self):
1045
+ self.framedata -= 1
1046
+ if self.framedata < 0:
1047
+ self.framedata = self.len_movie - 1
1048
+ self.draw_frame(self.framedata)
1049
+ self.fcanvas.canvas.draw()
1050
+
1051
+ def toggle_animation(self):
1052
+ if self.stop_btn.isVisible():
1053
+ self.stop()
1054
+ else:
1055
+ self.start()
1056
+
1057
+ def update_speed(self):
1058
+ fps = self.speed_slider.value()
1059
+ # Convert FPS to interval in ms
1060
+ # FPS = 1000 / interval_ms => interval_ms = 1000 / FPS
1061
+ val = int(1000 / max(1, fps))
1062
+ self.anim_interval = val
1063
+ print(
1064
+ f"DEBUG: Speed slider moved. FPS: {fps} -> Interval: {val} ms. Recreating animation object."
1065
+ )
1066
+
1067
+ # Check if animation is allowed to run (Pause button is visible means we are Playing)
1068
+ should_play = self.stop_btn.isVisible()
1069
+
1070
+ if hasattr(self, "anim") and self.anim:
1071
+ try:
1072
+ self.anim.event_source.stop()
1073
+ except Exception as e:
1074
+ print(f"DEBUG: Error stopping animation: {e}")
1075
+
1076
+ # Recreate animation with new interval
1077
+ try:
1078
+ # Disconnect the old pick event to avoid accumulating connections
1079
+ if hasattr(self, "_pick_cid"):
1080
+ try:
1081
+ self.fig.canvas.mpl_disconnect(self._pick_cid)
1082
+ except Exception:
1083
+ pass
1084
+
1085
+ self.anim = FuncAnimation(
1086
+ self.fig,
1087
+ self.draw_frame,
1088
+ frames=self.animation_generator,
1089
+ interval=self.anim_interval,
1090
+ blit=True,
1091
+ cache_frame_data=False,
1092
+ )
1093
+
1094
+ # Reconnect pick event and store cid
1095
+ self._pick_cid = self.fig.canvas.mpl_connect(
1096
+ "pick_event", self.on_scatter_pick
1097
+ )
1098
+
1099
+ # If we were NOT playing (i.e. Paused), pause the new animation immediately
1100
+ if not should_play:
1101
+ self.anim.event_source.stop()
1102
+
1103
+ except Exception as e:
1104
+ print(f"DEBUG: Error recreating animation: {e}")
1105
+
962
1106
  def give_cell_information(self):
963
1107
 
964
1108
  cell_selected = f"cell: {self.track_of_interest}\n"
@@ -982,56 +1126,22 @@ class EventAnnotator(BaseAnnotator):
982
1126
  self.compute_status_and_colors(0)
983
1127
  self.extract_scatter_from_trajectories()
984
1128
 
985
- def set_last_frame(self):
986
-
987
- self.last_frame_btn.setEnabled(False)
988
- self.last_frame_btn.disconnect()
989
-
990
- self.last_key = len(self.stack) - 1
991
- while len(np.where(self.stack[self.last_key].flatten() == 0)[0]) > 0.99 * len(
992
- self.stack[self.last_key].flatten()
993
- ):
994
- self.last_key -= 1
995
- self.anim._drawn_artists = self.draw_frame(self.last_key)
996
- self.anim._drawn_artists = sorted(
997
- self.anim._drawn_artists, key=lambda x: x.get_zorder()
998
- )
999
- for a in self.anim._drawn_artists:
1000
- a.set_visible(True)
1001
-
1002
- self.fig.canvas.draw()
1003
- self.anim.event_source.stop()
1004
-
1005
- # self.cell_plot.draw()
1006
- self.stop_btn.hide()
1007
- self.start_btn.show()
1008
- self.stop_btn.clicked.connect(self.start)
1009
- self.start_btn.setShortcut(QKeySequence("l"))
1010
-
1011
1129
  def set_first_frame(self):
1130
+ self.stop()
1131
+ self.framedata = 0
1132
+ self.draw_frame(self.framedata)
1133
+ self.fcanvas.canvas.draw()
1012
1134
 
1013
- self.first_frame_btn.setEnabled(False)
1014
- self.first_frame_btn.disconnect()
1015
-
1016
- self.first_key = 0
1017
- self.anim._drawn_artists = self.draw_frame(0)
1018
- self.vmin = self.contrast_slider.value()[0]
1019
- self.vmax = self.contrast_slider.value()[1]
1020
- self.im.set_clim(vmin=self.vmin, vmax=self.vmax)
1021
-
1022
- self.anim._drawn_artists = sorted(
1023
- self.anim._drawn_artists, key=lambda x: x.get_zorder()
1024
- )
1025
- for a in self.anim._drawn_artists:
1026
- a.set_visible(True)
1027
-
1028
- self.fig.canvas.draw()
1029
- self.anim.event_source.stop()
1030
-
1031
- # self.cell_plot.draw()
1032
- self.stop_btn.hide()
1033
- self.start_btn.show()
1034
- self.stop_btn.clicked.connect(self.start)
1035
- self.start_btn.setShortcut(QKeySequence("f"))
1036
-
1135
+ def set_last_frame(self):
1136
+ self.stop()
1137
+ self.framedata = len(self.stack) - 1
1138
+ while len(np.where(self.stack[self.framedata].flatten() == 0)[0]) > 0.99 * len(
1139
+ self.stack[self.framedata].flatten()
1140
+ ):
1141
+ self.framedata -= 1
1142
+ if self.framedata < 0:
1143
+ self.framedata = 0
1144
+ break
1037
1145
 
1146
+ self.draw_frame(self.framedata)
1147
+ self.fcanvas.canvas.draw()