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.
- phasor_handler/__init__.py +9 -0
- phasor_handler/app.py +249 -0
- phasor_handler/img/icons/chevron-down.svg +3 -0
- phasor_handler/img/icons/chevron-up.svg +3 -0
- phasor_handler/img/logo.ico +0 -0
- phasor_handler/models/dir_manager.py +100 -0
- phasor_handler/scripts/contrast.py +131 -0
- phasor_handler/scripts/convert.py +155 -0
- phasor_handler/scripts/meta_reader.py +467 -0
- phasor_handler/scripts/plot.py +110 -0
- phasor_handler/scripts/register.py +86 -0
- phasor_handler/themes/__init__.py +8 -0
- phasor_handler/themes/dark_theme.py +330 -0
- phasor_handler/tools/__init__.py +1 -0
- phasor_handler/tools/check_stylesheet.py +15 -0
- phasor_handler/tools/misc.py +20 -0
- phasor_handler/widgets/__init__.py +5 -0
- phasor_handler/widgets/analysis/components/__init__.py +9 -0
- phasor_handler/widgets/analysis/components/bnc.py +426 -0
- phasor_handler/widgets/analysis/components/circle_roi.py +850 -0
- phasor_handler/widgets/analysis/components/image_view.py +667 -0
- phasor_handler/widgets/analysis/components/meta_info.py +481 -0
- phasor_handler/widgets/analysis/components/roi_list.py +659 -0
- phasor_handler/widgets/analysis/components/trace_plot.py +621 -0
- phasor_handler/widgets/analysis/view.py +1735 -0
- phasor_handler/widgets/conversion/view.py +83 -0
- phasor_handler/widgets/registration/view.py +110 -0
- phasor_handler/workers/__init__.py +2 -0
- phasor_handler/workers/analysis_worker.py +0 -0
- phasor_handler/workers/histogram_worker.py +55 -0
- phasor_handler/workers/registration_worker.py +242 -0
- phasor_handler-2.2.0.dist-info/METADATA +134 -0
- phasor_handler-2.2.0.dist-info/RECORD +37 -0
- phasor_handler-2.2.0.dist-info/WHEEL +5 -0
- phasor_handler-2.2.0.dist-info/entry_points.txt +5 -0
- phasor_handler-2.2.0.dist-info/licenses/LICENSE.md +21 -0
- 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()
|