phasor-handler 2.2.0__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 (37) hide show
  1. phasor_handler/__init__.py +9 -0
  2. phasor_handler/app.py +249 -0
  3. phasor_handler/img/icons/chevron-down.svg +3 -0
  4. phasor_handler/img/icons/chevron-up.svg +3 -0
  5. phasor_handler/img/logo.ico +0 -0
  6. phasor_handler/models/dir_manager.py +100 -0
  7. phasor_handler/scripts/contrast.py +131 -0
  8. phasor_handler/scripts/convert.py +155 -0
  9. phasor_handler/scripts/meta_reader.py +467 -0
  10. phasor_handler/scripts/plot.py +110 -0
  11. phasor_handler/scripts/register.py +86 -0
  12. phasor_handler/themes/__init__.py +8 -0
  13. phasor_handler/themes/dark_theme.py +330 -0
  14. phasor_handler/tools/__init__.py +1 -0
  15. phasor_handler/tools/check_stylesheet.py +15 -0
  16. phasor_handler/tools/misc.py +20 -0
  17. phasor_handler/widgets/__init__.py +5 -0
  18. phasor_handler/widgets/analysis/components/__init__.py +9 -0
  19. phasor_handler/widgets/analysis/components/bnc.py +426 -0
  20. phasor_handler/widgets/analysis/components/circle_roi.py +850 -0
  21. phasor_handler/widgets/analysis/components/image_view.py +667 -0
  22. phasor_handler/widgets/analysis/components/meta_info.py +481 -0
  23. phasor_handler/widgets/analysis/components/roi_list.py +659 -0
  24. phasor_handler/widgets/analysis/components/trace_plot.py +621 -0
  25. phasor_handler/widgets/analysis/view.py +1735 -0
  26. phasor_handler/widgets/conversion/view.py +83 -0
  27. phasor_handler/widgets/registration/view.py +110 -0
  28. phasor_handler/workers/__init__.py +2 -0
  29. phasor_handler/workers/analysis_worker.py +0 -0
  30. phasor_handler/workers/histogram_worker.py +55 -0
  31. phasor_handler/workers/registration_worker.py +242 -0
  32. phasor_handler-2.2.0.dist-info/METADATA +134 -0
  33. phasor_handler-2.2.0.dist-info/RECORD +37 -0
  34. phasor_handler-2.2.0.dist-info/WHEEL +5 -0
  35. phasor_handler-2.2.0.dist-info/entry_points.txt +5 -0
  36. phasor_handler-2.2.0.dist-info/licenses/LICENSE.md +21 -0
  37. phasor_handler-2.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,621 @@
1
+ """
2
+ TraceplotWidget - A standalone widget for trace plotting functionality.
3
+
4
+ This widget encapsulates all trace plotting logic including:
5
+ - Y-limit controls
6
+ - Formula selection dropdown
7
+ - Time display toggle
8
+ - Matplotlib figure and canvas
9
+ - Signal extraction and plotting methods
10
+ """
11
+
12
+ # TODO Make options only raw Fg and Fg - Fog / Fog for single channel recordings
13
+
14
+ import numpy as np
15
+ import matplotlib.pyplot as plt
16
+ from PyQt6.QtWidgets import (
17
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
18
+ QComboBox, QSizePolicy, QSpinBox
19
+ )
20
+ from PyQt6.QtCore import Qt, pyqtSignal
21
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
22
+
23
+
24
+ class TraceplotWidget(QWidget):
25
+ """A widget that handles all trace plotting functionality."""
26
+
27
+ # Signal emitted when trace plot needs to update due to user controls
28
+ traceUpdateRequested = pyqtSignal()
29
+
30
+ def __init__(self, parent=None):
31
+ super().__init__(parent)
32
+ self.main_window = None # Will be set by parent
33
+ self._show_time_in_seconds = False # Track current display mode
34
+ self._frame_vline = None # Reference to the current frame line
35
+
36
+ self._init_ui()
37
+
38
+ def _init_ui(self):
39
+ """Initialize the UI components for trace plotting."""
40
+ # Main layout
41
+ main_layout = QHBoxLayout()
42
+ main_layout.setContentsMargins(0, 0, 0, 0)
43
+
44
+ # Left side: Controls
45
+ controls_layout = QVBoxLayout()
46
+
47
+ # Y limits inputs
48
+ ylim_layout = QVBoxLayout()
49
+ ylim_layout.setSpacing(2)
50
+ ylim_layout.setContentsMargins(0, 0, 0, 0)
51
+
52
+ ylim_label_inner = QHBoxLayout()
53
+ ylim_label_inner.setContentsMargins(0, 0, 0, 0)
54
+
55
+ ylim_label = QLabel("Y limits:")
56
+ ylim_label.setSizePolicy(QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed))
57
+ ylim_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
58
+
59
+ self.ylim_min_edit = QLineEdit()
60
+ self.ylim_min_edit.setFixedWidth(50)
61
+ self.ylim_min_edit.setFixedHeight(20)
62
+ self.ylim_min_edit.setPlaceholderText("Min")
63
+ self.ylim_min_edit.editingFinished.connect(self._update_trace_from_roi)
64
+
65
+ self.ylim_max_edit = QLineEdit()
66
+ self.ylim_max_edit.setFixedWidth(50)
67
+ self.ylim_max_edit.setFixedHeight(20)
68
+ self.ylim_max_edit.setPlaceholderText("Max")
69
+ self.ylim_max_edit.editingFinished.connect(self._update_trace_from_roi)
70
+
71
+ ylim_label_inner.addWidget(self.ylim_min_edit)
72
+ ylim_label_inner.addWidget(self.ylim_max_edit)
73
+ ylim_label_inner.addStretch()
74
+
75
+ self.reset_ylim_button = QPushButton("Reset")
76
+ self.reset_ylim_button.setFixedWidth(50)
77
+ self.reset_ylim_button.setFixedHeight(20)
78
+ self.reset_ylim_button.clicked.connect(self._reset_ylim)
79
+
80
+ ylim_layout.addWidget(ylim_label)
81
+ ylim_layout.addLayout(ylim_label_inner)
82
+ ylim_layout.addSpacing(4)
83
+ ylim_layout.addWidget(self.reset_ylim_button)
84
+ controls_layout.addLayout(ylim_layout)
85
+
86
+ # Baseline percentage spinbox
87
+ self.base_spinbox = QSpinBox()
88
+ self.base_spinbox.setRange(1, 99)
89
+ self.base_spinbox.setValue(10)
90
+ self.base_spinbox.setFixedWidth(60)
91
+ self.base_spinbox.setFixedHeight(25)
92
+ self.base_spinbox.valueChanged.connect(self._update_trace_from_roi)
93
+ controls_layout.addWidget(QLabel("Baseline %:"))
94
+ controls_layout.addWidget(self.base_spinbox)
95
+
96
+
97
+ # Formula dropdown
98
+ self.formula_dropdown = QComboBox()
99
+ self.formula_dropdown.setFixedWidth(100)
100
+ self.formula_dropdown.setStyleSheet("QComboBox { font-size: 8pt; }")
101
+ self.formula_dropdown.addItem("Fg - Fog / Fr")
102
+ self.formula_dropdown.addItem("Fg - Fog / Fog")
103
+ self.formula_dropdown.addItem("Fg only")
104
+ self.formula_dropdown.addItem("Fr only")
105
+ self.formula_dropdown.setContentsMargins(0, 0, 0, 0)
106
+ self.formula_dropdown.currentIndexChanged.connect(self._update_trace_from_roi)
107
+ controls_layout.addWidget(self.formula_dropdown)
108
+
109
+ controls_layout.addSpacing(15)
110
+
111
+ # Time display toggle button
112
+ self.time_display_button = QPushButton("Frames")
113
+ self.time_display_button.setFixedWidth(100)
114
+ self.time_display_button.setFixedHeight(20)
115
+ self.time_display_button.setCheckable(True)
116
+ self.time_display_button.setChecked(False) # Default to frames
117
+ self.time_display_button.setStyleSheet("QPushButton { font-size: 8pt; }")
118
+ self.time_display_button.setToolTip("Toggle between frame numbers and time in seconds")
119
+ self.time_display_button.clicked.connect(self._toggle_time_display)
120
+ controls_layout.addWidget(self.time_display_button)
121
+
122
+ main_layout.addLayout(controls_layout, 0)
123
+
124
+ # Right side: Figure and canvas
125
+ self.trace_fig, self.trace_ax = plt.subplots(figsize=(12, 6), dpi=100)
126
+ self.trace_ax.set_xticks([])
127
+ self.trace_ax.set_yticks([])
128
+ self.trace_ax.set_xlabel("")
129
+ self.trace_ax.set_ylabel("")
130
+ for spine in self.trace_ax.spines.values():
131
+ spine.set_visible(True)
132
+ self.trace_fig.patch.set_alpha(0.0)
133
+ self.trace_ax.set_facecolor('none')
134
+ self.trace_canvas = FigureCanvas(self.trace_fig)
135
+ self.trace_canvas.setStyleSheet("background:transparent; border: 1px solid #888;")
136
+ self.trace_canvas.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
137
+ self.trace_canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
138
+ self.trace_ax.xaxis.label.set_color('white')
139
+ self.trace_ax.yaxis.label.set_color('white')
140
+ self.trace_ax.tick_params(axis='x', colors='white')
141
+ self.trace_ax.tick_params(axis='y', colors='white')
142
+ for spine in self.trace_ax.spines.values():
143
+ spine.set_color('white')
144
+ self.trace_fig.tight_layout()
145
+
146
+ main_layout.addWidget(self.trace_canvas, 1) # Give stretch factor of 1 to make it expand
147
+
148
+ self.setLayout(main_layout)
149
+
150
+ def set_main_window(self, main_window):
151
+ """Set reference to the main window for accessing data."""
152
+ self.main_window = main_window
153
+
154
+ # Store user overrides on main window for compatibility
155
+ if not hasattr(main_window, '_ylim_min_user'):
156
+ main_window._ylim_min_user = None
157
+ if not hasattr(main_window, '_ylim_max_user'):
158
+ main_window._ylim_max_user = None
159
+
160
+ def get_widgets_for_compatibility(self):
161
+ """Return a dict of widgets for backward compatibility."""
162
+ return {
163
+ 'ylim_min_edit': self.ylim_min_edit,
164
+ 'ylim_max_edit': self.ylim_max_edit,
165
+ 'reset_ylim_button': self.reset_ylim_button,
166
+ 'formula_dropdown': self.formula_dropdown,
167
+ 'time_display_button': self.time_display_button,
168
+ 'trace_fig': self.trace_fig,
169
+ 'trace_ax': self.trace_ax,
170
+ 'trace_canvas': self.trace_canvas
171
+ }
172
+
173
+ def _update_trace_from_roi(self, index=None):
174
+ """Update the trace plot based on current ROI selection."""
175
+ print(f"DEBUG: TraceplotWidget._update_trace_from_roi called with index={index}")
176
+
177
+ # Check conditions
178
+ has_main_window = self.main_window is not None
179
+ has_current_tif = self.main_window._current_tif is not None if has_main_window else False
180
+ has_roi_xyxy = getattr(self.main_window, '_last_roi_xyxy', None) is not None if has_main_window else False
181
+
182
+ print(f"DEBUG: has_main_window={has_main_window}, has_current_tif={has_current_tif}, has_roi_xyxy={has_roi_xyxy}")
183
+
184
+ if not has_main_window or not has_current_tif or not has_roi_xyxy:
185
+ print("DEBUG: Early return - missing required data")
186
+ # clear your plot if you want
187
+ return
188
+
189
+ print(f"DEBUG: ROI xyxy: {self.main_window._last_roi_xyxy}")
190
+ print(f"DEBUG: Image shape: {self.main_window._current_tif.shape}")
191
+
192
+ # Get the ellipse mask from the ROI tool (handles rotation correctly)
193
+ mask_result = None
194
+ try:
195
+ if hasattr(self.main_window, 'roi_tool'):
196
+ mask_result = self.main_window.roi_tool.get_ellipse_mask()
197
+ print(f"DEBUG: Got ellipse mask result: {mask_result is not None}")
198
+ except Exception as e:
199
+ print(f"Warning: Could not get ellipse mask: {e}")
200
+
201
+ if mask_result is None:
202
+ # Fallback to rectangular region
203
+ x0, y0, x1, y1 = self.main_window._last_roi_xyxy
204
+ def stack3d(a):
205
+ a = np.asarray(a).squeeze()
206
+ return a[None, ...] if a.ndim == 2 else a
207
+
208
+ ch1 = stack3d(self.main_window._current_tif)
209
+ sig1 = ch1[:, y0:y1, x0:x1].mean(axis=(1,2)) if (x1>x0 and y1>y0) else np.zeros((ch1.shape[0],), dtype=np.float32)
210
+
211
+ ch2 = getattr(self.main_window, "_current_tif_chan2", None)
212
+ sig2 = None
213
+ if ch2 is not None:
214
+ ch2 = stack3d(ch2)
215
+ sig2 = ch2[:, y0:y1, x0:x1].mean(axis=(1,2))
216
+ else:
217
+ # Use ellipse mask for proper signal extraction
218
+ X0, Y0, X1, Y1, mask = mask_result
219
+
220
+ def stack3d(a):
221
+ a = np.asarray(a).squeeze()
222
+ return a[None, ...] if a.ndim == 2 else a
223
+
224
+ ch1 = stack3d(self.main_window._current_tif)
225
+
226
+ # Extract signal using the ellipse mask
227
+ if mask.size > 0 and np.any(mask):
228
+ # Apply mask to each frame
229
+ sig1_frames = []
230
+ for frame_idx in range(ch1.shape[0]):
231
+ frame = ch1[frame_idx, Y0:Y1, X0:X1]
232
+ if frame.shape == mask.shape:
233
+ masked_values = frame[mask]
234
+ sig1_frames.append(np.mean(masked_values) if len(masked_values) > 0 else 0.0)
235
+ else:
236
+ print(f"Warning: Frame shape {frame.shape} doesn't match mask shape {mask.shape}")
237
+ sig1_frames.append(0.0)
238
+ sig1 = np.array(sig1_frames, dtype=np.float32)
239
+ else:
240
+ sig1 = np.zeros((ch1.shape[0],), dtype=np.float32)
241
+
242
+ ch2 = getattr(self.main_window, "_current_tif_chan2", None)
243
+ sig2 = None
244
+ if ch2 is not None:
245
+ ch2 = stack3d(ch2)
246
+ if mask.size > 0 and np.any(mask):
247
+ # Apply mask to each frame of channel 2
248
+ sig2_frames = []
249
+ for frame_idx in range(ch2.shape[0]):
250
+ frame = ch2[frame_idx, Y0:Y1, X0:X1]
251
+ if frame.shape == mask.shape:
252
+ masked_values = frame[mask]
253
+ sig2_frames.append(np.mean(masked_values) if len(masked_values) > 0 else 0.0)
254
+ else:
255
+ sig2_frames.append(0.0)
256
+ sig2 = np.array(sig2_frames, dtype=np.float32)
257
+ else:
258
+ sig2 = np.zeros((ch2.shape[0],), dtype=np.float32)
259
+
260
+ # Compute Fo (baseline) as mean over first 10% of frames of sig1
261
+ nframes = sig1.shape[0]
262
+ if nframes <= 0:
263
+ return
264
+ # Determine baseline fraction from base_spinbox (percent).
265
+ try:
266
+ pct = int(self.base_spinbox.value()) if hasattr(self, 'base_spinbox') else 10
267
+ # Clamp between 1 and 99
268
+ pct = max(1, min(99, pct))
269
+ frac = float(pct) / 100.0
270
+ except Exception:
271
+ frac = 0.10
272
+
273
+ baseline_count = max(1, int(np.ceil(nframes * frac)))
274
+ Fog = float(np.mean(sig1[:baseline_count]))
275
+
276
+ self.trace_ax.cla()
277
+
278
+ # Compute metric depending on available channels and selected formula
279
+ # If red channel missing, switch to (Fg - Fo)/Fo (index 1) and disable other choices
280
+ if sig2 is None:
281
+ # Single-channel data: use (Fg - Fo)/Fo
282
+ try:
283
+ # set dropdown to index 1 (Fg - Fo / Fo) but do not allow changing it
284
+ if self.formula_dropdown.count() > 1:
285
+ # If index argument was provided override, respect it, otherwise set selection
286
+ if index is None:
287
+ self.formula_dropdown.setCurrentIndex(1)
288
+ self.formula_dropdown.setEnabled(False)
289
+ except Exception:
290
+ pass
291
+
292
+ # Safe denom: avoid division by zero
293
+ denom_val = Fog if (Fog is not None and Fog != 0) else 1e-6
294
+ metric = (sig1 - Fog) / denom_val
295
+ else:
296
+ # Two-channel data: enable formula selection
297
+ self.formula_dropdown.setEnabled(True)
298
+ formula_index = self.formula_dropdown.currentIndex() if index is None else index
299
+ if formula_index == 0:
300
+ denom = sig2.copy().astype(np.float32)
301
+ denom[denom == 0] = 1e-6
302
+ metric = (sig1 - Fog) / denom
303
+ elif formula_index == 1:
304
+ denom_val = Fog if (Fog is not None and Fog != 0) else 1e-6
305
+ metric = (sig1 - Fog) / denom_val
306
+ elif formula_index == 2:
307
+ metric = sig1
308
+ elif formula_index == 3:
309
+ metric = sig2 if sig2 is not None else np.full_like(sig1, 0)
310
+ else:
311
+ # Default fallback for any unexpected formula_index
312
+ denom_val = Fog if (Fog is not None and Fog != 0) else 1e-6
313
+ metric = (sig1 - Fog) / denom_val
314
+
315
+ current_frame = 0
316
+ if hasattr(self.main_window, 'tif_slider'):
317
+ try:
318
+ current_frame = int(self.main_window.tif_slider.value())
319
+ except Exception:
320
+ current_frame = 0
321
+
322
+ # Determine x-axis values and labels based on time display mode
323
+ show_time = getattr(self, '_show_time_in_seconds', False)
324
+ x_values = None
325
+ x_label = "Frame"
326
+ current_x_pos = current_frame
327
+
328
+ if show_time:
329
+ # Try to get time stamps from experiment data
330
+ try:
331
+ ed = getattr(self.main_window, '_exp_data', None)
332
+ time_stamps = None
333
+
334
+ if ed is not None:
335
+ # Try different possible attribute names for time stamps
336
+ # Handle both dictionary and object metadata formats
337
+ for attr_name in ['time_stamps', 'timeStamps', 'timestamps', 'ElapsedTimes']:
338
+ if isinstance(ed, dict):
339
+ if attr_name in ed:
340
+ time_stamps = ed[attr_name]
341
+ print(f"DEBUG: Found time stamps in dict key '{attr_name}', length: {len(time_stamps) if hasattr(time_stamps, '__len__') else 'unknown'}")
342
+ if hasattr(time_stamps, '__len__') and len(time_stamps) > 0:
343
+ print(f"DEBUG: First few time stamps: {time_stamps[:min(5, len(time_stamps))]}")
344
+ break
345
+ else:
346
+ if hasattr(ed, attr_name):
347
+ time_stamps = getattr(ed, attr_name)
348
+ print(f"DEBUG: Found time stamps in attribute '{attr_name}', length: {len(time_stamps) if hasattr(time_stamps, '__len__') else 'unknown'}")
349
+ if hasattr(time_stamps, '__len__') and len(time_stamps) > 0:
350
+ print(f"DEBUG: First few time stamps: {time_stamps[:min(5, len(time_stamps))]}")
351
+ break
352
+
353
+ if time_stamps is not None and len(time_stamps) >= len(metric):
354
+ # Use time stamps as x-axis (convert from ms to seconds)
355
+ x_values = np.array(time_stamps[:len(metric)]) / 1000.0
356
+ x_label = "Time (s)"
357
+ # Convert current frame position to time
358
+ if current_frame < len(time_stamps):
359
+ current_x_pos = time_stamps[current_frame] / 1000.0
360
+ else:
361
+ current_x_pos = time_stamps[-1] / 1000.0 if len(time_stamps) > 0 else current_frame
362
+ print(f"DEBUG: Using time stamps for x-axis (converted from ms), current position: {current_x_pos}s")
363
+ else:
364
+ # Fallback: estimate time based on frame rate (if available)
365
+ frame_rate = getattr(ed, 'frame_rate', None) if ed else None
366
+ if frame_rate and frame_rate > 0:
367
+ x_values = np.arange(len(metric)) / frame_rate
368
+ x_label = "Time (s)"
369
+ current_x_pos = current_frame / frame_rate
370
+ print(f"DEBUG: Using estimated time from frame rate {frame_rate} Hz, current position: {current_x_pos}s")
371
+ else:
372
+ # No time data available, fall back to frames
373
+ show_time = False
374
+ print("DEBUG: No time stamp data or frame rate found, showing frames instead")
375
+ except Exception as e:
376
+ print(f"DEBUG: Error getting time stamps: {e}")
377
+ show_time = False
378
+
379
+ # Plot metric with appropriate x-axis
380
+ if x_values is not None:
381
+ self.trace_ax.plot(x_values, metric, label="(F green - Fo green)/F red", color='white')
382
+ else:
383
+ self.trace_ax.plot(metric, label="(F green - Fo green)/F red", color='white')
384
+
385
+ self.trace_ax.set_xlabel(x_label, color='white', labelpad=2)
386
+ self.trace_ax.tick_params(axis='x', pad=1, labelsize=9)
387
+ self._frame_vline = self.trace_ax.axvline(current_x_pos, color='yellow', linestyle='-', zorder=20, linewidth=2)
388
+
389
+ # Store frame vline reference on main window for compatibility
390
+ if self.main_window:
391
+ self.main_window._frame_vline = self._frame_vline
392
+
393
+ try:
394
+ stims = []
395
+ ed = getattr(self.main_window, '_exp_data', None)
396
+ if ed is None:
397
+ stims = []
398
+ else:
399
+ # Handle both dictionary and object metadata formats
400
+ if isinstance(ed, dict):
401
+ stims = ed.get('stimulation_timeframes', [])
402
+ else:
403
+ stims = getattr(ed, 'stimulation_timeframes', [])
404
+
405
+ print(f"DEBUG: Found {len(stims)} stimulation timeframes: {stims}")
406
+
407
+ # Convert stimulation timeframes to appropriate x-axis units
408
+ if show_time and x_values is not None:
409
+ # Convert stim frames to time positions (from ms to seconds)
410
+ for stim in stims:
411
+ stim_frame = int(stim)
412
+ if stim_frame < len(time_stamps):
413
+ stim_x_pos = time_stamps[stim_frame] / 1000.0
414
+ print(f"DEBUG: Adding stimulation vline at time {stim_x_pos:.2f}s (frame {stim_frame})")
415
+ self.trace_ax.axvline(stim_x_pos, color='red', linestyle='--', zorder=15, linewidth=2)
416
+ else:
417
+ # Use frame numbers
418
+ for stim in stims:
419
+ stim_frame = int(stim)
420
+ print(f"DEBUG: Adding stimulation vline at frame {stim_frame}")
421
+ self.trace_ax.axvline(stim_frame, color='red', linestyle='--', zorder=15, linewidth=2)
422
+ except Exception as e:
423
+ # keep plotting even if stim drawing fails
424
+ print(f"DEBUG: Error adding stimulation vlines: {e}")
425
+ pass
426
+
427
+ # Parse y-limits from the QLineEdits (if present) and apply them
428
+ try:
429
+ def _parse(txt):
430
+ try:
431
+ s = str(txt).strip()
432
+ return float(s) if s != '' else None
433
+ except Exception:
434
+ return None
435
+
436
+ ymin = None
437
+ ymax = None
438
+ if hasattr(self, 'ylim_min_edit'):
439
+ ymin = _parse(self.ylim_min_edit.text())
440
+ if hasattr(self, 'ylim_max_edit'):
441
+ ymax = _parse(self.ylim_max_edit.text())
442
+
443
+ # If both provided and inverted, swap
444
+ if ymin is not None and ymax is not None and ymin > ymax:
445
+ ymin, ymax = ymax, ymin
446
+
447
+ if ymin is not None or ymax is not None:
448
+ # If one side missing, keep current autoscaled value for that side
449
+ cur = self.trace_ax.get_ylim()
450
+ if ymin is None:
451
+ ymin = cur[0]
452
+ if ymax is None:
453
+ ymax = cur[1]
454
+ self.trace_ax.set_ylim(ymin, ymax)
455
+ except Exception:
456
+ pass
457
+
458
+ self.trace_fig.tight_layout()
459
+ self.trace_canvas.draw_idle()
460
+
461
+ def _update_trace_vline(self):
462
+ """Lightweight: update only the vertical frame line on the existing trace."""
463
+ if self.main_window is None:
464
+ return
465
+
466
+ # If the axes are empty, don't try to add a vline (use full update instead)
467
+ try:
468
+ current_frame = 0
469
+ if hasattr(self.main_window, 'tif_slider'):
470
+ current_frame = int(self.main_window.tif_slider.value())
471
+ except Exception:
472
+ return
473
+
474
+ # Determine current position based on time display mode
475
+ show_time = getattr(self, '_show_time_in_seconds', False)
476
+ current_x_pos = current_frame
477
+
478
+ if show_time:
479
+ try:
480
+ ed = getattr(self.main_window, '_exp_data', None)
481
+ time_stamps = None
482
+
483
+ if ed is not None:
484
+ # Try different possible attribute names for time stamps
485
+ # Handle both dictionary and object metadata formats
486
+ for attr_name in ['time_stamps', 'timeStamps', 'timestamps', 'ElapsedTimes']:
487
+ if isinstance(ed, dict):
488
+ if attr_name in ed:
489
+ time_stamps = ed[attr_name]
490
+ break
491
+ else:
492
+ if hasattr(ed, attr_name):
493
+ time_stamps = getattr(ed, attr_name)
494
+ break
495
+
496
+ if time_stamps is not None and current_frame < len(time_stamps):
497
+ current_x_pos = time_stamps[current_frame] / 1000.0
498
+ elif ed is not None:
499
+ # Fallback: estimate time based on frame rate
500
+ if isinstance(ed, dict):
501
+ frame_rate = ed.get('frame_rate', None)
502
+ else:
503
+ frame_rate = getattr(ed, 'frame_rate', None)
504
+ if frame_rate and frame_rate > 0:
505
+ current_x_pos = current_frame / frame_rate
506
+ except Exception:
507
+ pass
508
+
509
+ # If there's no existing metric plotted, set sensible x-limits so a
510
+ # standalone vline will be visible (use number of frames when available).
511
+ if not self.trace_ax.lines:
512
+ try:
513
+ nframes = 1
514
+ if (hasattr(self.main_window, '_current_tif') and
515
+ self.main_window._current_tif is not None and
516
+ self.main_window._current_tif.ndim >= 3):
517
+ nframes = self.main_window._current_tif.shape[0]
518
+
519
+ # Set x-limits based on display mode
520
+ if show_time:
521
+ # Try to get max time value
522
+ try:
523
+ ed = getattr(self.main_window, '_exp_data', None)
524
+ time_stamps = None
525
+
526
+ if ed is not None:
527
+ for attr_name in ['time_stamps', 'timeStamps', 'timestamps', 'ElapsedTimes']:
528
+ if hasattr(ed, attr_name):
529
+ time_stamps = getattr(ed, attr_name)
530
+ break
531
+
532
+ if time_stamps is not None and len(time_stamps) > 0:
533
+ xmax = max(np.array(time_stamps[:min(nframes, len(time_stamps))]) / 1000.0)
534
+ elif ed is not None:
535
+ frame_rate = getattr(ed, 'frame_rate', None)
536
+ if frame_rate and frame_rate > 0:
537
+ xmax = (nframes - 1) / frame_rate
538
+ else:
539
+ xmax = max(1, nframes - 1)
540
+ else:
541
+ xmax = max(1, nframes - 1)
542
+ except Exception:
543
+ xmax = max(1, nframes - 1)
544
+ else:
545
+ xmax = max(1, nframes - 1)
546
+
547
+ self.trace_ax.set_xlim(0, xmax)
548
+ except Exception:
549
+ pass
550
+
551
+ # Ensure we have a persistent vline and move it (create if missing)
552
+ if not hasattr(self, '_frame_vline') or self._frame_vline is None:
553
+ self._frame_vline = self.trace_ax.axvline(current_x_pos, color='yellow', linestyle='-', zorder=10, linewidth=2)
554
+ if self.main_window:
555
+ self.main_window._frame_vline = self._frame_vline
556
+ else:
557
+ try:
558
+ self._frame_vline.set_xdata([current_x_pos, current_x_pos])
559
+ except Exception:
560
+ # recreate fallback
561
+ self._frame_vline = self.trace_ax.axvline(current_x_pos, color='yellow', linestyle='-', zorder=10, linewidth=2)
562
+ if self.main_window:
563
+ self.main_window._frame_vline = self._frame_vline
564
+
565
+ # Redraw canvas (fast)
566
+ try:
567
+ self.trace_canvas.draw_idle()
568
+ except Exception:
569
+ pass
570
+
571
+ def _reset_ylim(self):
572
+ """Clear any user-set y-limits and revert to autoscaling."""
573
+ if hasattr(self, 'ylim_min_edit'):
574
+ self.ylim_min_edit.setText("")
575
+ if hasattr(self, 'ylim_max_edit'):
576
+ self.ylim_max_edit.setText("")
577
+ self._update_trace_from_roi()
578
+
579
+ def _toggle_time_display(self):
580
+ """Toggle between showing frame numbers and time in seconds on the trace plot."""
581
+ self._show_time_in_seconds = not getattr(self, '_show_time_in_seconds', False)
582
+
583
+ # Update button text to show current mode
584
+ if self._show_time_in_seconds:
585
+ self.time_display_button.setText("Seconds")
586
+ else:
587
+ self.time_display_button.setText("Frames")
588
+
589
+ # Update the trace plot with new x-axis
590
+ self._update_trace_from_roi()
591
+
592
+ def clear_trace(self):
593
+ """Clear the trace plot and reset it to initial state."""
594
+ if hasattr(self, 'trace_ax') and self.trace_ax is not None:
595
+ self.trace_ax.cla()
596
+
597
+ # Reset the plot appearance
598
+ self.trace_ax.set_xticks([])
599
+ self.trace_ax.set_yticks([])
600
+ self.trace_ax.set_xlabel("")
601
+ self.trace_ax.set_ylabel("")
602
+ for spine in self.trace_ax.spines.values():
603
+ spine.set_visible(True)
604
+ self.trace_ax.set_facecolor('none')
605
+ self.trace_ax.xaxis.label.set_color('white')
606
+ self.trace_ax.yaxis.label.set_color('white')
607
+ self.trace_ax.tick_params(axis='x', colors='white')
608
+ self.trace_ax.tick_params(axis='y', colors='white')
609
+ for spine in self.trace_ax.spines.values():
610
+ spine.set_color('white')
611
+
612
+ # Clear the frame vline reference
613
+ if hasattr(self, '_frame_vline'):
614
+ self._frame_vline = None
615
+ if self.main_window and hasattr(self.main_window, '_frame_vline'):
616
+ self.main_window._frame_vline = None
617
+
618
+ # Redraw the trace canvas
619
+ if hasattr(self, 'trace_canvas') and self.trace_canvas is not None:
620
+ self.trace_fig.tight_layout()
621
+ self.trace_canvas.draw()