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,659 @@
1
+ """
2
+ ROI List Widget Component
3
+
4
+ This component handles all ROI list management including:
5
+ - Display of saved ROIs in a list widget
6
+ - Add/Remove ROI functionality
7
+ - Save/Load ROI positions to/from JSON files
8
+ - Export ROI traces to text files
9
+ - ROI selection and editing
10
+ """
11
+
12
+ import json
13
+ import random
14
+ import numpy as np
15
+ from PyQt6.QtWidgets import (
16
+ QWidget, QVBoxLayout, QGroupBox, QListWidget, QPushButton,
17
+ QGridLayout, QFileDialog, QMessageBox, QProgressDialog, QSizePolicy
18
+ )
19
+ from PyQt6.QtCore import Qt, pyqtSignal
20
+
21
+
22
+ class RoiListWidget(QWidget):
23
+ """Widget for managing a list of saved ROIs with add/remove/save/load/export functionality."""
24
+
25
+ # Signals
26
+ roiSelected = pyqtSignal(dict) # Emitted when a ROI is selected from the list
27
+ roiAdded = pyqtSignal(dict) # Emitted when a new ROI is added
28
+ roiRemoved = pyqtSignal(int) # Emitted when a ROI is removed (index)
29
+ roiUpdated = pyqtSignal(int, dict) # Emitted when an existing ROI is updated
30
+
31
+ def __init__(self, main_window):
32
+ super().__init__()
33
+ self.main_window = main_window
34
+ self._editing_roi_index = None
35
+
36
+ self.init_ui()
37
+
38
+ def init_ui(self):
39
+ """Initialize the UI components."""
40
+ # Main layout
41
+ layout = QVBoxLayout()
42
+
43
+ # Group box for ROI list
44
+ roi_group = QGroupBox("Saved ROIs")
45
+ roi_vbox = QVBoxLayout()
46
+
47
+ # ROI list widget
48
+ self.roi_list_widget = QListWidget()
49
+ self.roi_list_widget.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
50
+ self.roi_list_widget.setMinimumWidth(220)
51
+ self.roi_list_widget.currentItemChanged.connect(self._on_saved_roi_selected)
52
+ roi_vbox.addWidget(self.roi_list_widget)
53
+
54
+ # Button grid layout
55
+ roi_grid_layout = QGridLayout()
56
+
57
+ # Create buttons
58
+ self.add_roi_btn = QPushButton("Add ROI")
59
+ self.remove_roi_btn = QPushButton("Remove ROI")
60
+ self.export_trace_btn = QPushButton("Export Trace...")
61
+ self.save_roi_btn = QPushButton("Save ROIs...")
62
+ self.load_roi_btn = QPushButton("Load ROIs...")
63
+
64
+ # Set button sizes
65
+ for btn in [self.add_roi_btn, self.remove_roi_btn,
66
+ self.save_roi_btn, self.load_roi_btn,
67
+ self.export_trace_btn]:
68
+ btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
69
+
70
+ # Arrange buttons in grid
71
+ roi_grid_layout.addWidget(self.add_roi_btn, 0, 0)
72
+ roi_grid_layout.addWidget(self.remove_roi_btn, 0, 1)
73
+ roi_grid_layout.addWidget(self.save_roi_btn, 1, 0)
74
+ roi_grid_layout.addWidget(self.load_roi_btn, 1, 1)
75
+ roi_grid_layout.addWidget(self.export_trace_btn, 2, 0, 1, 2)
76
+
77
+ # Create checkboxes for ROI display options
78
+ try:
79
+ from PyQt6.QtWidgets import QCheckBox
80
+ self.hide_rois_checkbox = QCheckBox("Hide ROIs")
81
+ self.hide_rois_checkbox.stateChanged.connect(self._on_hide_rois_toggled)
82
+ roi_grid_layout.addWidget(self.hide_rois_checkbox, 3, 0, 1, 2)
83
+
84
+ self.display_labels_checkbox = QCheckBox("Hide Labels")
85
+ self.display_labels_checkbox.stateChanged.connect(self._on_hide_labels_toggled)
86
+ roi_grid_layout.addWidget(self.display_labels_checkbox, 3, 1, 1, 2)
87
+ except Exception:
88
+ self.hide_rois_checkbox = None
89
+ self.display_labels_checkbox = None
90
+
91
+ roi_vbox.addLayout(roi_grid_layout)
92
+ roi_group.setLayout(roi_vbox)
93
+ layout.addWidget(roi_group)
94
+
95
+ # Connect button signals
96
+ self.add_roi_btn.clicked.connect(self._on_add_roi_clicked)
97
+ self.remove_roi_btn.clicked.connect(self._on_remove_roi_clicked)
98
+ self.save_roi_btn.clicked.connect(self._on_save_roi_positions_clicked)
99
+ self.load_roi_btn.clicked.connect(self._on_load_roi_positions_clicked)
100
+ self.export_trace_btn.clicked.connect(self._on_export_roi_clicked)
101
+
102
+ self.setLayout(layout)
103
+
104
+ def get_list_widget(self):
105
+ """Return the internal list widget for external access."""
106
+ return self.roi_list_widget
107
+
108
+ def set_editing_roi_index(self, index):
109
+ """Set which ROI is currently being edited."""
110
+ self._editing_roi_index = index
111
+
112
+ def get_editing_roi_index(self):
113
+ """Get which ROI is currently being edited."""
114
+ return self._editing_roi_index
115
+
116
+ def clear_editing_state(self):
117
+ """Clear the editing state."""
118
+ self._editing_roi_index = None
119
+
120
+ def _on_add_roi_clicked(self):
121
+ """Save the current ROI (if any) into an in-memory list and the list widget."""
122
+ print(f"DEBUG: _on_add_roi_clicked called - editing_index: {self._editing_roi_index}")
123
+
124
+ if getattr(self.main_window, '_last_roi_xyxy', None) is None:
125
+ print("DEBUG: No _last_roi_xyxy found, returning")
126
+ return
127
+
128
+ print(f"DEBUG: Current _last_roi_xyxy: {self.main_window._last_roi_xyxy}")
129
+
130
+ # Ensure storage exists on window
131
+ if not hasattr(self.main_window, '_saved_rois'):
132
+ self.main_window._saved_rois = []
133
+
134
+ # Check if this ROI already exists (same coordinates), but only if we're NOT editing an existing ROI
135
+ if self._editing_roi_index is None: # Only check for duplicates when creating new ROIs
136
+ current_xyxy = tuple(self.main_window._last_roi_xyxy)
137
+ for existing_roi in self.main_window._saved_rois:
138
+ existing_xyxy = existing_roi.get('xyxy')
139
+ if existing_xyxy and tuple(existing_xyxy) == current_xyxy:
140
+ print(f"DEBUG: ROI with coordinates {current_xyxy} already exists - skipping")
141
+ return
142
+ else:
143
+ print(f"DEBUG: In editing mode for ROI {self._editing_roi_index} - allowing coordinate updates")
144
+
145
+ # Get rotation angle from ROI tool
146
+ roi_tool = getattr(self.main_window, 'roi_tool', None)
147
+ rotation_angle = getattr(roi_tool, '_rotation_angle', 0.0) if roi_tool else 0.0
148
+
149
+ # Check if we're editing an existing ROI
150
+ if self._editing_roi_index is not None and 0 <= self._editing_roi_index < len(self.main_window._saved_rois):
151
+ # Update existing ROI
152
+ existing_roi = self.main_window._saved_rois[self._editing_roi_index]
153
+ existing_roi['xyxy'] = tuple(self.main_window._last_roi_xyxy)
154
+ existing_roi['rotation'] = rotation_angle
155
+
156
+ print(f"DEBUG: Updated {existing_roi['name']} with new position/rotation")
157
+ print(f"DEBUG: New xyxy: {existing_roi['xyxy']}, New rotation: {existing_roi['rotation']}")
158
+ # Emit update signal
159
+ self.roiUpdated.emit(self._editing_roi_index, existing_roi)
160
+ # After updating an ROI, clear editing state and deselect the item so it's no longer "active"
161
+ try:
162
+ # Clear internal editing index
163
+ self._editing_roi_index = None
164
+ # Deselect the list widget selection
165
+ lw = self.get_list_widget()
166
+ if lw is not None:
167
+ lw.clearSelection()
168
+ lw.setCurrentItem(None)
169
+ # Update ROI tool display
170
+ if hasattr(self.main_window, 'roi_tool') and self.main_window.roi_tool:
171
+ self.main_window.roi_tool.set_saved_rois(self.main_window._saved_rois)
172
+ self.main_window.roi_tool._paint_overlay()
173
+ print("DEBUG: Cleared editing state and deselected ROI after update")
174
+ except Exception:
175
+ pass
176
+ else:
177
+ # Create new ROI - calculate next available ROI number
178
+ existing_numbers = []
179
+ for roi in self.main_window._saved_rois:
180
+ roi_name = roi.get('name', '')
181
+ if roi_name.startswith('ROI '):
182
+ try:
183
+ number = int(roi_name.split('ROI ')[1])
184
+ existing_numbers.append(number)
185
+ except (IndexError, ValueError):
186
+ pass
187
+
188
+ next_num = max(existing_numbers) + 1 if existing_numbers else 1
189
+ name = f"ROI {next_num}"
190
+
191
+ color = (
192
+ random.randint(100, 255), # R
193
+ random.randint(100, 255), # G
194
+ random.randint(100, 255), # B
195
+ 200 # Alpha
196
+ )
197
+
198
+ roi_data = {
199
+ 'name': name,
200
+ 'xyxy': tuple(self.main_window._last_roi_xyxy),
201
+ 'color': color,
202
+ 'rotation': rotation_angle
203
+ }
204
+ self.main_window._saved_rois.append(roi_data)
205
+ self.roi_list_widget.addItem(name)
206
+ print(f"Created new {name}")
207
+
208
+ # Emit added signal
209
+ self.roiAdded.emit(roi_data)
210
+
211
+ # Always clear editing state after any ROI operation
212
+ self._editing_roi_index = None
213
+
214
+ # Update the ROI tool with all saved ROIs so they display persistently
215
+ if hasattr(self.main_window, 'roi_tool') and self.main_window.roi_tool:
216
+ self.main_window.roi_tool.set_saved_rois(self.main_window._saved_rois)
217
+ # Repaint overlay to show all saved ROIs
218
+ self.main_window.roi_tool._paint_overlay()
219
+
220
+ def _on_remove_roi_clicked(self):
221
+ """Remove selected saved ROI from widget and in-memory store."""
222
+ item = self.roi_list_widget.currentItem()
223
+ if not item:
224
+ return
225
+ row = self.roi_list_widget.row(item)
226
+ self.roi_list_widget.takeItem(row)
227
+
228
+ try:
229
+ if hasattr(self.main_window, '_saved_rois'):
230
+ del self.main_window._saved_rois[row]
231
+ # Update the ROI tool with remaining saved ROIs
232
+ if hasattr(self.main_window, 'roi_tool') and self.main_window.roi_tool:
233
+ self.main_window.roi_tool.set_saved_rois(self.main_window._saved_rois)
234
+ # Repaint overlay to show updated ROIs
235
+ self.main_window.roi_tool._paint_overlay()
236
+
237
+ # Emit removal signal
238
+ self.roiRemoved.emit(row)
239
+ except Exception:
240
+ pass
241
+
242
+ def _on_saved_roi_selected(self, current, previous=None):
243
+ """Restore the selected saved ROI onto the image/roi tool and update trace."""
244
+ if current is None:
245
+ return
246
+
247
+ row = self.roi_list_widget.row(current)
248
+ saved = None
249
+ if hasattr(self.main_window, '_saved_rois') and 0 <= row < len(self.main_window._saved_rois):
250
+ saved = self.main_window._saved_rois[row]
251
+ if saved is None:
252
+ return
253
+
254
+ xyxy = saved.get('xyxy')
255
+ if xyxy is None:
256
+ return
257
+
258
+ # Set editing mode for this ROI
259
+ self._editing_roi_index = row
260
+ print(f"DEBUG: Set editing_roi_index to {row} for ROI: {saved.get('name', 'Unknown')}")
261
+
262
+ # Restore and update
263
+ try:
264
+ self.main_window._last_roi_xyxy = xyxy
265
+ rotation = saved.get('rotation', 0.0)
266
+ if hasattr(self.main_window, 'roi_tool') and self.main_window.roi_tool:
267
+ self.main_window.roi_tool.show_bbox_image_coords(xyxy, rotation)
268
+ self.main_window.roi_tool._rotation_angle = rotation
269
+ print(f"Selected ROI {row + 1} for editing - press 'r' to update it")
270
+ print(f"DEBUG: Restored xyxy: {xyxy}, rotation: {rotation}")
271
+
272
+ # Emit selection signal
273
+ self.roiSelected.emit(saved)
274
+ except Exception:
275
+ pass
276
+
277
+ def _on_load_roi_positions_clicked(self):
278
+ """Load ROI positions from a JSON file."""
279
+ # Open file dialog to choose file to import
280
+ file_dialog = QFileDialog(self)
281
+ file_dialog.setWindowTitle("Load ROI Positions")
282
+ file_dialog.setNameFilter("JSON files (*.json)")
283
+ file_dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptOpen)
284
+
285
+ if not file_dialog.exec():
286
+ return
287
+
288
+ filename = file_dialog.selectedFiles()[0]
289
+
290
+ try:
291
+ with open(filename, 'r') as f:
292
+ loaded_rois = json.load(f)
293
+
294
+ # Clear existing ROIs
295
+ if not hasattr(self.main_window, '_saved_rois'):
296
+ self.main_window._saved_rois = []
297
+
298
+ self.roi_list_widget.clear()
299
+ self.main_window._saved_rois.clear()
300
+
301
+ # Add loaded ROIs
302
+ for roi in loaded_rois:
303
+ # Ensure required fields exist with defaults
304
+ if 'name' not in roi:
305
+ roi['name'] = f"ROI {len(self.main_window._saved_rois) + 1}"
306
+ if 'color' not in roi:
307
+ roi['color'] = (255, 255, 0, 200) # Default yellow
308
+ if 'rotation' not in roi:
309
+ roi['rotation'] = 0.0
310
+
311
+ self.main_window._saved_rois.append(roi)
312
+ self.roi_list_widget.addItem(roi['name'])
313
+
314
+ # Update ROI tool
315
+ if hasattr(self.main_window, 'roi_tool') and self.main_window.roi_tool:
316
+ self.main_window.roi_tool.set_saved_rois(self.main_window._saved_rois)
317
+ self.main_window.roi_tool._paint_overlay()
318
+
319
+ QMessageBox.information(self, "Load Complete",
320
+ f"Successfully loaded {len(loaded_rois)} ROIs from:\n{filename}")
321
+
322
+ except Exception as e:
323
+ QMessageBox.critical(self, "Load Error", f"Failed to load ROIs:\n{str(e)}")
324
+
325
+ def _on_save_roi_positions_clicked(self):
326
+ """Save ROI positions to a JSON file."""
327
+ if not hasattr(self.main_window, '_saved_rois') or not self.main_window._saved_rois:
328
+ QMessageBox.warning(self, "No ROIs", "No ROIs to save.")
329
+ return
330
+
331
+ # Open file dialog to choose save location
332
+ file_dialog = QFileDialog(self)
333
+ file_dialog.setWindowTitle("Save ROI Positions")
334
+ file_dialog.setNameFilter("JSON files (*.json)")
335
+ file_dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
336
+ file_dialog.setDefaultSuffix("json")
337
+
338
+ if not file_dialog.exec():
339
+ return
340
+
341
+ filename = file_dialog.selectedFiles()[0]
342
+
343
+ try:
344
+ # Prepare data for JSON serialization
345
+ roi_data = []
346
+ for roi in self.main_window._saved_rois:
347
+ roi_copy = roi.copy()
348
+ # Ensure xyxy is a list for JSON serialization
349
+ if 'xyxy' in roi_copy:
350
+ roi_copy['xyxy'] = list(roi_copy['xyxy'])
351
+ roi_data.append(roi_copy)
352
+
353
+ with open(filename, 'w') as f:
354
+ json.dump(roi_data, f, indent=2)
355
+
356
+ QMessageBox.information(self, "Save Complete",
357
+ f"Successfully saved {len(roi_data)} ROIs to:\n{filename}")
358
+
359
+ except Exception as e:
360
+ QMessageBox.critical(self, "Save Error", f"Failed to save ROIs:\n{str(e)}")
361
+
362
+ def _on_export_roi_clicked(self):
363
+ """Export all saved ROIs for all timepoints to a tab-separated text file."""
364
+ if not hasattr(self.main_window, '_saved_rois') or not self.main_window._saved_rois:
365
+ QMessageBox.information(self, "No ROIs", "No ROIs to export. Please add some ROIs first.")
366
+ return
367
+
368
+ # Check if we have image data
369
+ if not hasattr(self.main_window, '_current_tif') or self.main_window._current_tif is None:
370
+ QMessageBox.warning(self, "No Image Data", "No image data loaded. Please load a dataset first.")
371
+ return
372
+
373
+ # Open file dialog to choose save location
374
+ file_dialog = QFileDialog(self)
375
+ file_dialog.setWindowTitle("Export ROIs")
376
+ file_dialog.setNameFilter("Text files (*.txt)")
377
+ file_dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
378
+ file_dialog.setDefaultSuffix("txt")
379
+
380
+ if not file_dialog.exec():
381
+ return
382
+
383
+ filename = file_dialog.selectedFiles()[0]
384
+
385
+ try:
386
+ # Get image data dimensions
387
+ tif = self.main_window._current_tif
388
+ tif_chan2 = getattr(self.main_window, '_current_tif_chan2', None)
389
+
390
+ # Determine number of frames
391
+ if tif.ndim == 3:
392
+ nframes = tif.shape[0]
393
+ else:
394
+ nframes = 1
395
+ tif = tif[None, ...] # Add frame dimension
396
+ if tif_chan2 is not None:
397
+ tif_chan2 = tif_chan2[None, ...]
398
+
399
+ # Get current formula selection
400
+ formula_index = getattr(self.main_window, 'formula_dropdown', None)
401
+ if formula_index is not None:
402
+ formula_index = formula_index.currentIndex()
403
+ else:
404
+ formula_index = 0 # Default to first formula
405
+
406
+ # Progress tracking for large datasets
407
+ total_work = nframes * len(self.main_window._saved_rois)
408
+ if total_work > 1000:
409
+ progress = QProgressDialog("Extracting ROI data...", "Cancel", 0, total_work, self)
410
+ progress.setWindowModality(Qt.WindowModality.WindowModal)
411
+ progress.show()
412
+ else:
413
+ progress = None
414
+
415
+ # Prepare headers: Frame, Time, then for each ROI: Green_Mean_ROI#, Red_Mean_ROI#, Trace_ROI#
416
+ headers = ["Frame", "Time"]
417
+ for i, roi in enumerate(self.main_window._saved_rois):
418
+ roi_num = i + 1
419
+ headers.extend([
420
+ f"Green_Mean_ROI{roi_num}",
421
+ f"Red_Mean_ROI{roi_num}",
422
+ f"Trace_ROI{roi_num}"
423
+ ])
424
+
425
+ # Pre-calculate baseline (Fog) for each ROI using first 10% of frames
426
+ roi_baselines = {}
427
+ baseline_count = max(1, int(np.ceil(nframes * 0.10)))
428
+
429
+ for i, roi in enumerate(self.main_window._saved_rois):
430
+ xyxy = roi.get('xyxy')
431
+ if xyxy is None:
432
+ roi_baselines[i] = 0
433
+ continue
434
+
435
+ x0, y0, x1, y1 = xyxy
436
+ roi_height = y1 - y0
437
+ roi_width = x1 - x0
438
+
439
+ if roi_height > 0 and roi_width > 0:
440
+ # Extract green values from baseline frames using ellipse mask
441
+ green_baseline_values = []
442
+ try:
443
+ cy, cx = (y0 + y1) / 2.0, (x0 + x1) / 2.0
444
+ ry, rx = roi_height / 2.0, roi_width / 2.0
445
+ y_coords, x_coords = np.ogrid[y0:y1, x0:x1]
446
+ mask = ((x_coords - cx) / rx) ** 2 + ((y_coords - cy) / ry) ** 2 <= 1
447
+
448
+ for frame_idx in range(baseline_count):
449
+ green_frame = tif[frame_idx]
450
+ if mask.any():
451
+ green_roi_pixels = green_frame[y0:y1, x0:x1][mask]
452
+ green_baseline_values.append(np.mean(green_roi_pixels))
453
+ else:
454
+ green_baseline_values.append(np.mean(green_frame[y0:y1, x0:x1]))
455
+
456
+ roi_baselines[i] = float(np.mean(green_baseline_values))
457
+ except Exception as e:
458
+ print(f"Error calculating baseline for ROI {i+1}: {e}")
459
+ roi_baselines[i] = 0
460
+ else:
461
+ roi_baselines[i] = 0
462
+
463
+ # Extract data for all frames and all ROIs
464
+ export_data = []
465
+
466
+ for frame_idx in range(nframes):
467
+ if progress is not None:
468
+ if progress.wasCanceled():
469
+ return
470
+ progress.setValue(frame_idx * len(self.main_window._saved_rois))
471
+
472
+ # Get frames for this timepoint
473
+ green_frame = tif[frame_idx]
474
+ red_frame = tif_chan2[frame_idx] if tif_chan2 is not None else None
475
+
476
+ # Get time information
477
+ time_s = 0.0
478
+ if hasattr(self.main_window, '_exp_data') and self.main_window._exp_data:
479
+ try:
480
+ ed = self.main_window._exp_data
481
+ timestamps = None
482
+
483
+ # Handle both dictionary and object metadata formats
484
+ if isinstance(ed, dict):
485
+ timestamps = ed.get('time_stamps', [])
486
+ else:
487
+ if hasattr(ed, 'time_stamps'):
488
+ timestamps = getattr(ed, 'time_stamps', [])
489
+
490
+ if timestamps and frame_idx < len(timestamps):
491
+ time_s = float(timestamps[frame_idx]) / 1000.0 # Convert ms to seconds
492
+ except Exception:
493
+ pass
494
+
495
+ # Start row with frame number (0-indexed) and time
496
+ row_data = [str(frame_idx), f"{time_s:.6f}"]
497
+
498
+ # Process each ROI
499
+ for i, roi in enumerate(self.main_window._saved_rois):
500
+ xyxy = roi.get('xyxy')
501
+ if xyxy is None:
502
+ row_data.extend(["N/A", "N/A", "N/A"])
503
+ continue
504
+
505
+ x0, y0, x1, y1 = xyxy
506
+
507
+ # Extract green channel mean for this ROI using ellipse mask
508
+ try:
509
+ # Create ellipse mask for this ROI
510
+ roi_height = y1 - y0
511
+ roi_width = x1 - x0
512
+
513
+ if roi_height > 0 and roi_width > 0:
514
+ # Create ellipse mask
515
+ cy, cx = (y0 + y1) / 2.0, (x0 + x1) / 2.0
516
+ ry, rx = roi_height / 2.0, roi_width / 2.0
517
+
518
+ y_coords, x_coords = np.ogrid[y0:y1, x0:x1]
519
+ mask = ((x_coords - cx) / rx) ** 2 + ((y_coords - cy) / ry) ** 2 <= 1
520
+
521
+ # Extract green values
522
+ if mask.any():
523
+ green_roi_pixels = green_frame[y0:y1, x0:x1][mask]
524
+ green_mean = float(np.mean(green_roi_pixels))
525
+ else:
526
+ # Fallback to rectangular mean
527
+ green_mean = float(np.mean(green_frame[y0:y1, x0:x1]))
528
+ else:
529
+ green_mean = "N/A"
530
+ except Exception as e:
531
+ print(f"Error extracting green values for ROI {i+1}, frame {frame_idx}: {e}")
532
+ green_mean = "N/A"
533
+
534
+ # Extract red channel mean for this ROI using ellipse mask
535
+ try:
536
+ if red_frame is not None and roi_height > 0 and roi_width > 0:
537
+ if mask.any():
538
+ red_roi_pixels = red_frame[y0:y1, x0:x1][mask]
539
+ red_mean = float(np.mean(red_roi_pixels))
540
+ else:
541
+ red_mean = float(np.mean(red_frame[y0:y1, x0:x1]))
542
+ else:
543
+ red_mean = "N/A"
544
+ except Exception as e:
545
+ print(f"Error extracting red values for ROI {i+1}, frame {frame_idx}: {e}")
546
+ red_mean = "N/A"
547
+
548
+ # Calculate trace value based on formula index
549
+ try:
550
+ Fog = roi_baselines[i] # Get baseline for this ROI
551
+
552
+ if isinstance(green_mean, (int, float)) and isinstance(red_mean, (int, float)):
553
+ if formula_index == 0: # (Fg - Fog) / Fr
554
+ if red_mean != 0:
555
+ trace_value = (green_mean - Fog) / red_mean
556
+ else:
557
+ trace_value = (green_mean - Fog) / (red_mean + 1e-6) # Avoid division by zero
558
+ elif formula_index == 1: # (Fg - Fog) / Fog
559
+ if Fog != 0:
560
+ trace_value = (green_mean - Fog) / Fog
561
+ else:
562
+ trace_value = (green_mean - Fog) / (Fog + 1e-6) # Avoid division by zero
563
+ elif formula_index == 2: # Fg only
564
+ trace_value = green_mean
565
+ elif formula_index == 3: # Fr only
566
+ if red_mean != "N/A":
567
+ trace_value = red_mean
568
+ else:
569
+ trace_value = 0
570
+ else:
571
+ trace_value = green_mean - red_mean if red_mean != "N/A" else green_mean
572
+ elif isinstance(green_mean, (int, float)):
573
+ if formula_index == 0: # (Fg - Fog) / Fr but no red
574
+ trace_value = 0
575
+ elif formula_index == 1: # (Fg - Fog) / Fog
576
+ if Fog != 0:
577
+ trace_value = (green_mean - Fog) / Fog
578
+ else:
579
+ trace_value = (green_mean - Fog) / (Fog + 1e-6)
580
+ elif formula_index == 2: # Fg only
581
+ trace_value = green_mean
582
+ elif formula_index == 3: # Fr only but no red
583
+ trace_value = 0
584
+ else:
585
+ trace_value = green_mean
586
+ else:
587
+ trace_value = 0
588
+ except Exception as e:
589
+ print(f"Error calculating trace for ROI {i+1}, frame {frame_idx}: {e}")
590
+ trace_value = 0
591
+
592
+ # Format values for export
593
+ green_str = f"{green_mean:.6f}" if isinstance(green_mean, (int, float)) else str(green_mean)
594
+ red_str = f"{red_mean:.6f}" if isinstance(red_mean, (int, float)) else str(red_mean)
595
+ trace_str = f"{trace_value:.6f}" if isinstance(trace_value, (int, float)) else str(trace_value)
596
+
597
+ row_data.extend([green_str, red_str, trace_str])
598
+
599
+ export_data.append(row_data)
600
+
601
+ if progress is not None:
602
+ progress.setValue(total_work)
603
+ progress.close()
604
+
605
+ # Write to file
606
+ with open(filename, 'w', newline='', encoding='utf-8') as f:
607
+ # Write header
608
+ f.write('\t'.join(headers) + '\n')
609
+
610
+ # Write data rows
611
+ for row in export_data:
612
+ f.write('\t'.join(row) + '\n')
613
+
614
+ QMessageBox.information(self, "Export Complete",
615
+ f"Successfully exported {len(self.main_window._saved_rois)} ROIs across {nframes} frames to:\n{filename}")
616
+
617
+ except Exception as e:
618
+ QMessageBox.critical(self, "Export Error", f"Failed to export ROIs:\n{str(e)}")
619
+ import traceback
620
+ print("Full error traceback:")
621
+ traceback.print_exc()
622
+
623
+ def _on_hide_rois_toggled(self, state):
624
+ """Hide or show saved/stim ROIs when checkbox toggled."""
625
+ show = False if state else True
626
+ try:
627
+ # hide saved ROIs and stimulus ROIs when checkbox is checked
628
+ if hasattr(self.main_window, 'roi_tool') and self.main_window.roi_tool:
629
+ self.main_window.roi_tool.set_show_saved_rois(show)
630
+ # also hide the interactive bbox if ROIs are hidden to reduce clutter
631
+ self.main_window.roi_tool.set_show_current_bbox(show)
632
+ except Exception:
633
+ pass
634
+
635
+ def _on_hide_labels_toggled(self, state):
636
+ """Show or hide text labels within ROIs when checkbox toggled."""
637
+ show = False if state else True
638
+ try:
639
+ # Toggle label visibility within ROIs
640
+ if hasattr(self.main_window, 'roi_tool') and self.main_window.roi_tool:
641
+ self.main_window.roi_tool.set_show_labels(show)
642
+ except Exception:
643
+ pass
644
+
645
+ def auto_select_roi_by_click(self, roi_index):
646
+ """Automatically select a ROI from the list when clicked on the image."""
647
+ try:
648
+ # Select the corresponding item in the ROI list widget
649
+ if 0 <= roi_index < self.roi_list_widget.count():
650
+ self.roi_list_widget.setCurrentRow(roi_index)
651
+ print(f"Auto-selected ROI {roi_index + 1} by right-click")
652
+ except Exception as e:
653
+ print(f"Error selecting ROI by click: {e}")
654
+
655
+ def refresh_roi_display(self):
656
+ """Refresh the ROI display in the ROI tool."""
657
+ if hasattr(self.main_window, 'roi_tool') and self.main_window.roi_tool:
658
+ self.main_window.roi_tool.set_saved_rois(getattr(self.main_window, '_saved_rois', []))
659
+ self.main_window.roi_tool._paint_overlay()