TwoPy 0.1.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.
- twopy/__init__.py +168 -0
- twopy/_segments.py +53 -0
- twopy/analysis/__init__.py +101 -0
- twopy/analysis/background_subtraction.py +672 -0
- twopy/analysis/dff.py +524 -0
- twopy/analysis/epoch_mapping.py +223 -0
- twopy/analysis/motion.py +66 -0
- twopy/analysis/persistence.py +975 -0
- twopy/analysis/response_processing/__init__.py +55 -0
- twopy/analysis/response_processing/apply.py +298 -0
- twopy/analysis/response_processing/options.py +192 -0
- twopy/analysis/response_processing/qc.py +270 -0
- twopy/analysis/response_processing/signals.py +200 -0
- twopy/analysis/responses.py +345 -0
- twopy/analysis/trials.py +341 -0
- twopy/analysis/workflow.py +457 -0
- twopy/api.py +78 -0
- twopy/config.py +200 -0
- twopy/conversion/__init__.py +40 -0
- twopy/conversion/alignment_crop.py +329 -0
- twopy/conversion/api.py +113 -0
- twopy/conversion/frame_ranges.py +35 -0
- twopy/conversion/hdf5_writing.py +362 -0
- twopy/conversion/matlab_values.py +123 -0
- twopy/conversion/source_loading.py +403 -0
- twopy/conversion/stimulus_code.py +248 -0
- twopy/conversion/types.py +295 -0
- twopy/converted.py +681 -0
- twopy/database/__init__.py +33 -0
- twopy/database/cache.py +166 -0
- twopy/database/catalog.py +90 -0
- twopy/database/connection.py +27 -0
- twopy/database/generic.py +254 -0
- twopy/database/modeled.py +419 -0
- twopy/database/sql.py +46 -0
- twopy/database/types.py +116 -0
- twopy/inspection.py +492 -0
- twopy/matlab.py +398 -0
- twopy/napari/__init__.py +27 -0
- twopy/napari/__main__.py +10 -0
- twopy/napari/constants.py +13 -0
- twopy/napari/controls.py +739 -0
- twopy/napari/display.py +181 -0
- twopy/napari/display_paths.py +197 -0
- twopy/napari/interactive.py +310 -0
- twopy/napari/launcher.py +175 -0
- twopy/napari/layout.py +147 -0
- twopy/napari/loading.py +207 -0
- twopy/napari/movie.py +71 -0
- twopy/napari/paths.py +181 -0
- twopy/napari/plotting/__init__.py +21 -0
- twopy/napari/plotting/data.py +266 -0
- twopy/napari/plotting/docks.py +1052 -0
- twopy/napari/plotting/export.py +803 -0
- twopy/napari/plotting/export_controls.py +209 -0
- twopy/napari/plotting/label_visibility.py +260 -0
- twopy/napari/plotting/options.py +269 -0
- twopy/napari/plotting/panels.py +119 -0
- twopy/napari/plotting/widgets.py +767 -0
- twopy/napari/protocols.py +90 -0
- twopy/napari/responses.py +91 -0
- twopy/napari/roi.py +238 -0
- twopy/napari/session.py +381 -0
- twopy/napari/state.py +106 -0
- twopy/napari/text.py +26 -0
- twopy/napari/types.py +39 -0
- twopy/napari/viewer.py +405 -0
- twopy/parity/__init__.py +30 -0
- twopy/parity/dff.py +399 -0
- twopy/parity/roi.py +145 -0
- twopy/parity/saved_analysis.py +174 -0
- twopy/photodiode.py +375 -0
- twopy/photodiode_classification.py +484 -0
- twopy/roi.py +462 -0
- twopy/session.py +167 -0
- twopy/spatial.py +154 -0
- twopy/stimulus.py +233 -0
- twopy/synchronization.py +235 -0
- twopy/typing_guards.py +255 -0
- twopy-0.1.0.dist-info/METADATA +311 -0
- twopy-0.1.0.dist-info/RECORD +84 -0
- twopy-0.1.0.dist-info/WHEEL +5 -0
- twopy-0.1.0.dist-info/entry_points.txt +2 -0
- twopy-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
"""Response plotting widgets for the twopy napari adapter.
|
|
2
|
+
|
|
3
|
+
Inputs: loaded twopy analysis outputs or the current napari Labels ROI layer.
|
|
4
|
+
Outputs: Qt widgets showing one response plot per stimulus epoch and a
|
|
5
|
+
separate options panel.
|
|
6
|
+
|
|
7
|
+
The plotting code only owns GUI state and drawing. When the user asks to update
|
|
8
|
+
responses from the current Labels layer, this module converts labels to a
|
|
9
|
+
``RoiSet`` and calls the normal analysis workflow.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from qtpy.QtGui import QColor
|
|
16
|
+
from qtpy.QtWidgets import (
|
|
17
|
+
QCheckBox,
|
|
18
|
+
QHBoxLayout,
|
|
19
|
+
QLabel,
|
|
20
|
+
QPushButton,
|
|
21
|
+
QScrollArea,
|
|
22
|
+
QSpinBox,
|
|
23
|
+
QTabWidget,
|
|
24
|
+
QVBoxLayout,
|
|
25
|
+
QWidget,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from twopy.analysis.responses import is_gray_epoch_name
|
|
29
|
+
from twopy.analysis.workflow import analyze_recording_responses
|
|
30
|
+
from twopy.converted import RecordingData
|
|
31
|
+
from twopy.napari.display_paths import (
|
|
32
|
+
format_output_folder,
|
|
33
|
+
format_twopy_h5_output,
|
|
34
|
+
recording_display_summary,
|
|
35
|
+
)
|
|
36
|
+
from twopy.napari.interactive import LiveResponseController
|
|
37
|
+
from twopy.napari.plotting.data import (
|
|
38
|
+
ResponsePlotData,
|
|
39
|
+
default_analysis_output_path,
|
|
40
|
+
load_response_plot_data,
|
|
41
|
+
)
|
|
42
|
+
from twopy.napari.plotting.export_controls import (
|
|
43
|
+
ResponseExportState,
|
|
44
|
+
create_response_export_tab,
|
|
45
|
+
)
|
|
46
|
+
from twopy.napari.plotting.label_visibility import (
|
|
47
|
+
apply_roi_visibility_to_labels_layer,
|
|
48
|
+
roi_label_values_from_labels,
|
|
49
|
+
)
|
|
50
|
+
from twopy.napari.plotting.options import (
|
|
51
|
+
axis_options_widget,
|
|
52
|
+
visibility_options_widget,
|
|
53
|
+
)
|
|
54
|
+
from twopy.napari.plotting.panels import (
|
|
55
|
+
epoch_plot_panel,
|
|
56
|
+
plot_size_widget,
|
|
57
|
+
response_update_tab,
|
|
58
|
+
scrolling_tab,
|
|
59
|
+
)
|
|
60
|
+
from twopy.napari.plotting.widgets import (
|
|
61
|
+
EpochPlotWidget,
|
|
62
|
+
clear_layout,
|
|
63
|
+
global_time_bounds,
|
|
64
|
+
global_value_bounds,
|
|
65
|
+
opaque_colors,
|
|
66
|
+
resolved_time_bounds,
|
|
67
|
+
resolved_value_bounds,
|
|
68
|
+
roi_colors_from_label_values,
|
|
69
|
+
)
|
|
70
|
+
from twopy.napari.protocols import NapariViewer
|
|
71
|
+
from twopy.napari.roi import (
|
|
72
|
+
roi_label_image_from_layer_for_recording,
|
|
73
|
+
save_napari_label_rois,
|
|
74
|
+
)
|
|
75
|
+
from twopy.napari.text import counted_noun
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"add_twopy_response_options_widget",
|
|
79
|
+
"add_twopy_response_plot_widget",
|
|
80
|
+
"refresh_response_plot_widget",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
_QT_APPLICATION: object | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def add_twopy_response_plot_widget(
|
|
87
|
+
viewer: NapariViewer,
|
|
88
|
+
*,
|
|
89
|
+
recording: RecordingData | None,
|
|
90
|
+
roi_labels_layer: object | None = None,
|
|
91
|
+
roi_save_file: Path | None = None,
|
|
92
|
+
dock_name: str = "twopy responses",
|
|
93
|
+
dock_area: str = "top",
|
|
94
|
+
) -> tuple[object, object]:
|
|
95
|
+
"""Add the response plotting dock widget to a napari viewer.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
viewer: Napari viewer that should receive the dock widget.
|
|
99
|
+
recording: Optional loaded converted recording.
|
|
100
|
+
roi_labels_layer: Optional napari Labels layer used for analysis
|
|
101
|
+
previews from current ROIs.
|
|
102
|
+
roi_save_file: Optional ROI HDF5 path used by the Save Analysis action.
|
|
103
|
+
dock_name: Dock widget title.
|
|
104
|
+
dock_area: Napari dock area.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
``(widget, dock_widget)`` created by Qt and napari.
|
|
108
|
+
"""
|
|
109
|
+
widget = create_response_plot_widget(
|
|
110
|
+
recording,
|
|
111
|
+
viewer=viewer,
|
|
112
|
+
roi_labels_layer=roi_labels_layer,
|
|
113
|
+
roi_save_file=roi_save_file,
|
|
114
|
+
)
|
|
115
|
+
dock_widget = viewer.window.add_dock_widget(
|
|
116
|
+
widget,
|
|
117
|
+
name=dock_name,
|
|
118
|
+
area=dock_area,
|
|
119
|
+
)
|
|
120
|
+
return widget, dock_widget
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def add_twopy_response_options_widget(
|
|
124
|
+
viewer: NapariViewer,
|
|
125
|
+
response_plot_widget: object,
|
|
126
|
+
*,
|
|
127
|
+
dock_name: str = "twopy response options",
|
|
128
|
+
dock_area: str = "right",
|
|
129
|
+
) -> tuple[object, object] | tuple[None, None]:
|
|
130
|
+
"""Add the response plot options as a separate napari dock.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
viewer: Napari viewer that should receive the dock widget.
|
|
134
|
+
response_plot_widget: Widget returned by ``create_response_plot_widget``.
|
|
135
|
+
dock_name: Dock widget title.
|
|
136
|
+
dock_area: Napari dock area.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
``(widget, dock_widget)`` when the plot widget owns options, otherwise
|
|
140
|
+
``(None, None)``.
|
|
141
|
+
"""
|
|
142
|
+
if not isinstance(response_plot_widget, _ResponsePlotWidget):
|
|
143
|
+
return None, None
|
|
144
|
+
widget = response_plot_widget.options_widget()
|
|
145
|
+
dock_widget = viewer.window.add_dock_widget(
|
|
146
|
+
widget,
|
|
147
|
+
name=dock_name,
|
|
148
|
+
area=dock_area,
|
|
149
|
+
)
|
|
150
|
+
return widget, dock_widget
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def create_response_plot_widget(
|
|
154
|
+
recording: RecordingData | None,
|
|
155
|
+
*,
|
|
156
|
+
viewer: object | None = None,
|
|
157
|
+
roi_labels_layer: object | None = None,
|
|
158
|
+
roi_save_file: Path | None = None,
|
|
159
|
+
) -> object:
|
|
160
|
+
"""Create a response plotting widget.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
recording: Optional loaded converted recording.
|
|
164
|
+
viewer: Optional napari viewer used for figure export.
|
|
165
|
+
roi_labels_layer: Optional napari Labels layer used when the user asks
|
|
166
|
+
to compute responses from the current ROIs.
|
|
167
|
+
roi_save_file: Optional ROI HDF5 path used by the Save Analysis action.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Qt widget as a plain object for napari docking.
|
|
171
|
+
"""
|
|
172
|
+
_ensure_qapplication()
|
|
173
|
+
widget = _ResponsePlotWidget(viewer=viewer)
|
|
174
|
+
refresh_response_plot_widget(
|
|
175
|
+
widget,
|
|
176
|
+
recording=recording,
|
|
177
|
+
roi_labels_layer=roi_labels_layer,
|
|
178
|
+
roi_save_file=roi_save_file,
|
|
179
|
+
)
|
|
180
|
+
return widget
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def refresh_response_plot_widget(
|
|
184
|
+
widget: object | None,
|
|
185
|
+
*,
|
|
186
|
+
recording: RecordingData | None,
|
|
187
|
+
roi_labels_layer: object | None = None,
|
|
188
|
+
roi_save_file: Path | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Refresh a response plotting widget after a recording is loaded.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
widget: Optional widget returned by ``create_response_plot_widget``.
|
|
194
|
+
recording: Optional loaded converted recording.
|
|
195
|
+
roi_labels_layer: Optional current ROI Labels layer.
|
|
196
|
+
roi_save_file: Optional ROI HDF5 path used by the Save Analysis action.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
None.
|
|
200
|
+
"""
|
|
201
|
+
if not isinstance(widget, _ResponsePlotWidget):
|
|
202
|
+
return
|
|
203
|
+
widget.set_roi_labels_layer(roi_labels_layer)
|
|
204
|
+
widget.set_roi_save_file(roi_save_file)
|
|
205
|
+
if recording is None:
|
|
206
|
+
widget.clear_recording()
|
|
207
|
+
return
|
|
208
|
+
widget.load_recording(recording)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _ensure_qapplication() -> None:
|
|
212
|
+
"""Create a Qt application when tests instantiate widgets without napari.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
None.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
None.
|
|
219
|
+
"""
|
|
220
|
+
from qtpy.QtWidgets import QApplication
|
|
221
|
+
|
|
222
|
+
global _QT_APPLICATION
|
|
223
|
+
if QApplication.instance() is None:
|
|
224
|
+
_QT_APPLICATION = QApplication([])
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class _ResponsePlotWidget(QWidget):
|
|
228
|
+
"""Qt widget for response plots plus a separately docked options panel."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, *, viewer: object | None = None) -> None:
|
|
231
|
+
"""Create empty response plots and an options panel.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
viewer: Optional napari viewer used by export buttons to read the
|
|
235
|
+
currently displayed movie frame.
|
|
236
|
+
"""
|
|
237
|
+
super().__init__()
|
|
238
|
+
self._viewer = viewer
|
|
239
|
+
self._recording: RecordingData | None = None
|
|
240
|
+
self._roi_labels_layer: object | None = None
|
|
241
|
+
self._analysis_path: Path | None = None
|
|
242
|
+
self._roi_save_file: Path | None = None
|
|
243
|
+
self._plot_data: ResponsePlotData | None = None
|
|
244
|
+
self._show_sem = True
|
|
245
|
+
self._plot_size = 360
|
|
246
|
+
self._epoch_plot_widgets: dict[int, EpochPlotWidget] = {}
|
|
247
|
+
self._epoch_plot_panels: dict[int, QWidget] = {}
|
|
248
|
+
self._roi_visibility: dict[int, bool] = {}
|
|
249
|
+
self._roi_colors: tuple[QColor, ...] = ()
|
|
250
|
+
self._epoch_visibility: dict[int, bool] = {}
|
|
251
|
+
self._manual_x_min: float | None = None
|
|
252
|
+
self._manual_x_max: float | None = None
|
|
253
|
+
self._manual_y_min: float | None = None
|
|
254
|
+
self._manual_y_max: float | None = None
|
|
255
|
+
self._live_controller = LiveResponseController(self)
|
|
256
|
+
self._status_label = QLabel("No recording loaded.")
|
|
257
|
+
self._plot_layout = QHBoxLayout()
|
|
258
|
+
self._plot_layout.addWidget(self._status_label)
|
|
259
|
+
plot_content = QWidget()
|
|
260
|
+
plot_content.setLayout(self._plot_layout)
|
|
261
|
+
plot_scroll = QScrollArea()
|
|
262
|
+
plot_scroll.setWidgetResizable(True)
|
|
263
|
+
plot_scroll.setWidget(plot_content)
|
|
264
|
+
layout = QVBoxLayout()
|
|
265
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
266
|
+
layout.addWidget(plot_scroll)
|
|
267
|
+
self.setLayout(layout)
|
|
268
|
+
|
|
269
|
+
self._options_tabs = QTabWidget()
|
|
270
|
+
self._show_sem_checkbox = QCheckBox("Show SEM")
|
|
271
|
+
self._show_sem_checkbox.setChecked(True)
|
|
272
|
+
self._show_sem_checkbox.stateChanged.connect(self._set_show_sem)
|
|
273
|
+
reload_saved_button = QPushButton("Reload saved analysis")
|
|
274
|
+
reload_saved_button.clicked.connect(self.reload)
|
|
275
|
+
recompute_preview_button = QPushButton("Recompute preview now")
|
|
276
|
+
recompute_preview_button.clicked.connect(self.update_from_current_rois)
|
|
277
|
+
save_analysis_button = QPushButton("Save analysis + ROIs")
|
|
278
|
+
save_analysis_button.clicked.connect(self.save_analysis_and_rois)
|
|
279
|
+
self._recording_summary_label = QLabel("No recording loaded.")
|
|
280
|
+
self._recording_summary_label.setWordWrap(True)
|
|
281
|
+
self._analysis_path_label = QLabel("Analysis output: default")
|
|
282
|
+
self._analysis_path_label.setWordWrap(True)
|
|
283
|
+
self._roi_save_path_label = QLabel("ROI output: default")
|
|
284
|
+
self._roi_save_path_label.setWordWrap(True)
|
|
285
|
+
self._update_status_label = QLabel("")
|
|
286
|
+
self._update_status_label.setWordWrap(True)
|
|
287
|
+
self._plot_options_layout = QVBoxLayout()
|
|
288
|
+
self._plot_axis_layout = QVBoxLayout()
|
|
289
|
+
plot_axis_widget = QWidget()
|
|
290
|
+
plot_axis_widget.setLayout(self._plot_axis_layout)
|
|
291
|
+
self._plot_size_spin = QSpinBox()
|
|
292
|
+
self._plot_size_spin.setRange(240, 900)
|
|
293
|
+
self._plot_size_spin.setSingleStep(20)
|
|
294
|
+
self._plot_size_spin.setSuffix(" px")
|
|
295
|
+
self._plot_size_spin.setValue(self._plot_size)
|
|
296
|
+
self._plot_size_spin.valueChanged.connect(self._set_plot_size)
|
|
297
|
+
self._plot_options_layout.addWidget(self._show_sem_checkbox)
|
|
298
|
+
self._plot_options_layout.addWidget(plot_size_widget(self._plot_size_spin))
|
|
299
|
+
self._plot_options_layout.addWidget(plot_axis_widget)
|
|
300
|
+
self._plot_options_layout.addStretch(1)
|
|
301
|
+
self._roi_options_layout = QVBoxLayout()
|
|
302
|
+
self._epoch_options_layout = QVBoxLayout()
|
|
303
|
+
self._options_tabs.addTab(
|
|
304
|
+
response_update_tab(
|
|
305
|
+
reload_saved_button=reload_saved_button,
|
|
306
|
+
recompute_preview_button=recompute_preview_button,
|
|
307
|
+
save_analysis_button=save_analysis_button,
|
|
308
|
+
recording_summary_label=self._recording_summary_label,
|
|
309
|
+
analysis_output_label=self._analysis_path_label,
|
|
310
|
+
roi_output_label=self._roi_save_path_label,
|
|
311
|
+
status_label=self._update_status_label,
|
|
312
|
+
),
|
|
313
|
+
"Update",
|
|
314
|
+
)
|
|
315
|
+
self._options_tabs.addTab(
|
|
316
|
+
scrolling_tab(self._plot_options_layout),
|
|
317
|
+
"Plot",
|
|
318
|
+
)
|
|
319
|
+
self._options_tabs.addTab(
|
|
320
|
+
scrolling_tab(self._roi_options_layout),
|
|
321
|
+
"ROIs",
|
|
322
|
+
)
|
|
323
|
+
self._options_tabs.addTab(
|
|
324
|
+
scrolling_tab(self._epoch_options_layout),
|
|
325
|
+
"Epochs",
|
|
326
|
+
)
|
|
327
|
+
self._options_tabs.addTab(
|
|
328
|
+
create_response_export_tab(self._export_state),
|
|
329
|
+
"Export",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def options_widget(self) -> object:
|
|
333
|
+
"""Return the separately docked response-options widget.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
None.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Tabbed Qt widget with response plot controls.
|
|
340
|
+
"""
|
|
341
|
+
return self._options_tabs
|
|
342
|
+
|
|
343
|
+
def set_roi_labels_layer(self, roi_labels_layer: object | None) -> None:
|
|
344
|
+
"""Store the current editable ROI Labels layer.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
roi_labels_layer: Optional napari Labels layer.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
None.
|
|
351
|
+
"""
|
|
352
|
+
self._roi_labels_layer = roi_labels_layer
|
|
353
|
+
self._live_controller.set_context(self._recording, self._roi_labels_layer)
|
|
354
|
+
self._render_plots()
|
|
355
|
+
|
|
356
|
+
def set_roi_save_file(self, roi_save_file: Path | None) -> None:
|
|
357
|
+
"""Store the ROI HDF5 path used by Save Analysis.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
roi_save_file: Optional ROI output path for the selected recording.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
None.
|
|
364
|
+
"""
|
|
365
|
+
self._roi_save_file = roi_save_file
|
|
366
|
+
self._refresh_update_path_labels()
|
|
367
|
+
|
|
368
|
+
def clear_recording(self) -> None:
|
|
369
|
+
"""Reset the widget to the empty-launch state.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
None.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
None.
|
|
376
|
+
"""
|
|
377
|
+
self._recording = None
|
|
378
|
+
self._analysis_path = None
|
|
379
|
+
self._plot_data = None
|
|
380
|
+
self._live_controller.set_context(None, None)
|
|
381
|
+
self._reset_plot_state()
|
|
382
|
+
self._recording_summary_label.setText("No recording loaded.")
|
|
383
|
+
self._analysis_path_label.setText("Analysis output: default")
|
|
384
|
+
self._roi_save_path_label.setText("ROI output: default")
|
|
385
|
+
self._update_status_label.setText("")
|
|
386
|
+
self._clear_dynamic_option_tabs()
|
|
387
|
+
self._set_status("No recording loaded.")
|
|
388
|
+
|
|
389
|
+
def load_recording(self, recording: RecordingData) -> None:
|
|
390
|
+
"""Load response plots for one recording.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
recording: Loaded converted recording.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
None.
|
|
397
|
+
"""
|
|
398
|
+
self._recording = recording
|
|
399
|
+
self._analysis_path = default_analysis_output_path(recording)
|
|
400
|
+
self._refresh_update_path_labels()
|
|
401
|
+
self.reload()
|
|
402
|
+
self._live_controller.set_context(self._recording, self._roi_labels_layer)
|
|
403
|
+
|
|
404
|
+
def update_from_current_rois(self) -> None:
|
|
405
|
+
"""Compute and plot responses from the current ROI Labels layer.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
None.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
None.
|
|
412
|
+
|
|
413
|
+
The live controller computes in memory and does not write analysis
|
|
414
|
+
files. Explicit analysis saves remain separate from plot previews.
|
|
415
|
+
"""
|
|
416
|
+
self._live_controller.update_now()
|
|
417
|
+
|
|
418
|
+
def save_analysis_and_rois(self) -> None:
|
|
419
|
+
"""Save current ROI labels plus persisted analysis outputs.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
None.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
None.
|
|
426
|
+
|
|
427
|
+
Live response plots are previews. This method is the explicit
|
|
428
|
+
persistence action: it saves the current Labels layer as ROI HDF5, then
|
|
429
|
+
runs the normal script-facing analysis workflow to write analysis HDF5
|
|
430
|
+
and response CSV outputs.
|
|
431
|
+
"""
|
|
432
|
+
if self._recording is None:
|
|
433
|
+
self._set_status("No recording loaded.")
|
|
434
|
+
return
|
|
435
|
+
if self._roi_labels_layer is None:
|
|
436
|
+
self._set_status("No ROI Labels layer is available.")
|
|
437
|
+
return
|
|
438
|
+
try:
|
|
439
|
+
label_image = roi_label_image_from_layer_for_recording(
|
|
440
|
+
self._roi_labels_layer,
|
|
441
|
+
self._recording,
|
|
442
|
+
)
|
|
443
|
+
except ValueError as error:
|
|
444
|
+
self._set_status(str(error))
|
|
445
|
+
return
|
|
446
|
+
if not np.any(label_image > 0):
|
|
447
|
+
self._set_status("No ROI labels to save or analyze.")
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
roi_output_path = self._resolved_roi_save_file()
|
|
451
|
+
analysis_output_path = self._resolved_analysis_path()
|
|
452
|
+
try:
|
|
453
|
+
roi_set = save_napari_label_rois(label_image, roi_output_path)
|
|
454
|
+
run = analyze_recording_responses(
|
|
455
|
+
self._recording.path,
|
|
456
|
+
roi_set,
|
|
457
|
+
output_path=analysis_output_path,
|
|
458
|
+
)
|
|
459
|
+
except ValueError as error:
|
|
460
|
+
self._set_status(str(error))
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
self._roi_save_file = roi_output_path
|
|
464
|
+
self._analysis_path = run.output_path
|
|
465
|
+
self._refresh_update_path_labels()
|
|
466
|
+
output_folder = format_output_folder(run.output_path, self._recording)
|
|
467
|
+
self._update_status_label.setText(
|
|
468
|
+
f"Saved {counted_noun(len(roi_set.labels), 'ROI', 'ROIs')} "
|
|
469
|
+
f"to {output_folder}"
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
def set_response_plot_data(
|
|
473
|
+
self,
|
|
474
|
+
plot_data: ResponsePlotData,
|
|
475
|
+
*,
|
|
476
|
+
reset_axes: bool,
|
|
477
|
+
) -> None:
|
|
478
|
+
"""Show plot data computed from live or saved analysis outputs.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
plot_data: Plot-ready response data.
|
|
482
|
+
reset_axes: Whether manual axis bounds should return to automatic
|
|
483
|
+
data-derived values.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
None.
|
|
487
|
+
"""
|
|
488
|
+
self._clear_epoch_plot_cache()
|
|
489
|
+
self._plot_data = plot_data
|
|
490
|
+
self._sync_plot_state(reset_axes=reset_axes)
|
|
491
|
+
self._render_plots()
|
|
492
|
+
|
|
493
|
+
def show_response_status(self, text: str) -> None:
|
|
494
|
+
"""Show a user-facing response-analysis status message.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
text: Status text.
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
None.
|
|
501
|
+
"""
|
|
502
|
+
self._plot_data = None
|
|
503
|
+
self._set_status(text)
|
|
504
|
+
|
|
505
|
+
def reload(self) -> None:
|
|
506
|
+
"""Reload response plots from the current analysis output file."""
|
|
507
|
+
if self._analysis_path is None:
|
|
508
|
+
self._set_status("No recording loaded.")
|
|
509
|
+
return
|
|
510
|
+
result = load_response_plot_data(self._analysis_path)
|
|
511
|
+
if isinstance(result, str):
|
|
512
|
+
self._plot_data = None
|
|
513
|
+
self._reset_plot_state()
|
|
514
|
+
self._clear_dynamic_option_tabs()
|
|
515
|
+
self._set_status(result)
|
|
516
|
+
return
|
|
517
|
+
self.set_response_plot_data(result, reset_axes=True)
|
|
518
|
+
|
|
519
|
+
def _set_show_sem(self, state: int) -> None:
|
|
520
|
+
"""Update whether SEM bands are drawn."""
|
|
521
|
+
del state
|
|
522
|
+
self._show_sem = self._show_sem_checkbox.isChecked()
|
|
523
|
+
self._update_visible_plot_widgets(apply_roi_layer_visibility=False)
|
|
524
|
+
|
|
525
|
+
def _set_plot_size(self, value: int) -> None:
|
|
526
|
+
"""Update the square pixel size used for live response plots."""
|
|
527
|
+
self._plot_size = int(value)
|
|
528
|
+
self._update_visible_plot_widgets(apply_roi_layer_visibility=False)
|
|
529
|
+
|
|
530
|
+
def _sync_plot_state(self, *, reset_axes: bool) -> None:
|
|
531
|
+
"""Synchronize option state with currently loaded plot data.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
reset_axes: Whether axis spin boxes should return to data-derived
|
|
535
|
+
defaults.
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
None.
|
|
539
|
+
"""
|
|
540
|
+
if self._plot_data is None:
|
|
541
|
+
self._reset_plot_state()
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
roi_labels = self._plot_data.epochs[0].roi_labels
|
|
545
|
+
self._roi_visibility = {
|
|
546
|
+
index: self._roi_visibility.get(index, True)
|
|
547
|
+
for index in range(len(roi_labels))
|
|
548
|
+
}
|
|
549
|
+
self._roi_colors = opaque_colors(
|
|
550
|
+
roi_colors_from_label_values(
|
|
551
|
+
self._roi_labels_layer,
|
|
552
|
+
roi_label_values_from_labels(roi_labels),
|
|
553
|
+
fallback_count=len(roi_labels),
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
self._epoch_visibility = {
|
|
557
|
+
index: self._epoch_visibility.get(
|
|
558
|
+
index,
|
|
559
|
+
not is_gray_epoch_name(epoch.epoch_name),
|
|
560
|
+
)
|
|
561
|
+
for index, epoch in enumerate(self._plot_data.epochs)
|
|
562
|
+
}
|
|
563
|
+
if reset_axes:
|
|
564
|
+
self._manual_x_min = None
|
|
565
|
+
self._manual_x_max = None
|
|
566
|
+
self._manual_y_min = None
|
|
567
|
+
self._manual_y_max = None
|
|
568
|
+
self._render_options()
|
|
569
|
+
|
|
570
|
+
def _reset_plot_state(self) -> None:
|
|
571
|
+
"""Clear plot-specific option state."""
|
|
572
|
+
self._roi_visibility = {}
|
|
573
|
+
self._roi_colors = ()
|
|
574
|
+
self._epoch_visibility = {}
|
|
575
|
+
self._manual_x_min = None
|
|
576
|
+
self._manual_x_max = None
|
|
577
|
+
self._manual_y_min = None
|
|
578
|
+
self._manual_y_max = None
|
|
579
|
+
|
|
580
|
+
def _render_options(self) -> None:
|
|
581
|
+
"""Render axis, ROI, and epoch visibility controls."""
|
|
582
|
+
self._clear_dynamic_option_tabs()
|
|
583
|
+
if self._plot_data is None or len(self._plot_data.epochs) == 0:
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
auto_x_min, auto_x_max = global_time_bounds(self._plot_data)
|
|
587
|
+
auto_y_min, auto_y_max = global_value_bounds(
|
|
588
|
+
self._plot_data,
|
|
589
|
+
self._visible_roi_indices(),
|
|
590
|
+
self._visible_epoch_indices(),
|
|
591
|
+
)
|
|
592
|
+
axis_widget = axis_options_widget(
|
|
593
|
+
x_min=self._manual_x_min if self._manual_x_min is not None else auto_x_min,
|
|
594
|
+
x_max=self._manual_x_max if self._manual_x_max is not None else auto_x_max,
|
|
595
|
+
y_min=self._manual_y_min if self._manual_y_min is not None else auto_y_min,
|
|
596
|
+
y_max=self._manual_y_max if self._manual_y_max is not None else auto_y_max,
|
|
597
|
+
on_change=self._set_manual_axis_bounds,
|
|
598
|
+
)
|
|
599
|
+
self._plot_axis_layout.addWidget(axis_widget)
|
|
600
|
+
self._roi_options_layout.addWidget(
|
|
601
|
+
visibility_options_widget(
|
|
602
|
+
title="ROIs",
|
|
603
|
+
labels=self._roi_labels(),
|
|
604
|
+
visibility=self._roi_visibility,
|
|
605
|
+
on_change=self._set_roi_visibility,
|
|
606
|
+
on_change_batch=self._set_roi_visibility_batch,
|
|
607
|
+
keys=tuple(range(len(self._roi_labels()))),
|
|
608
|
+
colors=self._roi_colors,
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
self._roi_options_layout.addStretch(1)
|
|
612
|
+
self._epoch_options_layout.addWidget(
|
|
613
|
+
visibility_options_widget(
|
|
614
|
+
title="Epochs",
|
|
615
|
+
labels=tuple(
|
|
616
|
+
f"{epoch.epoch_number}: {epoch.epoch_name}"
|
|
617
|
+
for epoch in self._plot_data.epochs
|
|
618
|
+
),
|
|
619
|
+
visibility=self._epoch_visibility,
|
|
620
|
+
on_change=self._set_epoch_visibility,
|
|
621
|
+
on_change_batch=self._set_epoch_visibility_batch,
|
|
622
|
+
keys=tuple(range(len(self._plot_data.epochs))),
|
|
623
|
+
),
|
|
624
|
+
)
|
|
625
|
+
self._epoch_options_layout.addStretch(1)
|
|
626
|
+
|
|
627
|
+
def _clear_dynamic_option_tabs(self) -> None:
|
|
628
|
+
"""Clear dynamic option widgets from plot, ROI, and epoch tabs."""
|
|
629
|
+
clear_layout(self._plot_axis_layout)
|
|
630
|
+
clear_layout(self._roi_options_layout)
|
|
631
|
+
clear_layout(self._epoch_options_layout)
|
|
632
|
+
|
|
633
|
+
def _set_manual_axis_bounds(
|
|
634
|
+
self,
|
|
635
|
+
*,
|
|
636
|
+
x_min: float,
|
|
637
|
+
x_max: float,
|
|
638
|
+
y_min: float,
|
|
639
|
+
y_max: float,
|
|
640
|
+
) -> None:
|
|
641
|
+
"""Store user-selected plot bounds.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
x_min: Minimum x-axis value in seconds.
|
|
645
|
+
x_max: Maximum x-axis value in seconds.
|
|
646
|
+
y_min: Minimum dF/F value.
|
|
647
|
+
y_max: Maximum dF/F value.
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
None.
|
|
651
|
+
"""
|
|
652
|
+
self._manual_x_min = x_min
|
|
653
|
+
self._manual_x_max = x_max
|
|
654
|
+
self._manual_y_min = y_min
|
|
655
|
+
self._manual_y_max = y_max
|
|
656
|
+
self._update_visible_plot_widgets(apply_roi_layer_visibility=False)
|
|
657
|
+
|
|
658
|
+
def _export_state(self) -> ResponseExportState:
|
|
659
|
+
"""Return the current state needed by export buttons."""
|
|
660
|
+
return ResponseExportState(
|
|
661
|
+
viewer=self._viewer,
|
|
662
|
+
recording=self._recording,
|
|
663
|
+
roi_labels_layer=self._roi_labels_layer,
|
|
664
|
+
plot_data=self._plot_data,
|
|
665
|
+
output_dir=self._export_output_dir(),
|
|
666
|
+
roi_label_values=self._roi_label_values(),
|
|
667
|
+
roi_colors=self._roi_color_hex(),
|
|
668
|
+
epoch_indices=self._visible_epoch_indices(),
|
|
669
|
+
roi_indices=self._visible_roi_indices(),
|
|
670
|
+
show_sem=self._show_sem,
|
|
671
|
+
time_bounds=resolved_time_bounds(
|
|
672
|
+
self._plot_data,
|
|
673
|
+
self._visible_epoch_indices(),
|
|
674
|
+
manual_min=self._manual_x_min,
|
|
675
|
+
manual_max=self._manual_x_max,
|
|
676
|
+
)
|
|
677
|
+
if self._plot_data is not None
|
|
678
|
+
else (-1.0, 1.0),
|
|
679
|
+
value_bounds=resolved_value_bounds(
|
|
680
|
+
self._plot_data,
|
|
681
|
+
self._visible_roi_indices(),
|
|
682
|
+
self._visible_epoch_indices(),
|
|
683
|
+
manual_min=self._manual_y_min,
|
|
684
|
+
manual_max=self._manual_y_max,
|
|
685
|
+
)
|
|
686
|
+
if self._plot_data is not None
|
|
687
|
+
else (-1.0, 1.0),
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
def _export_output_dir(self) -> Path:
|
|
691
|
+
"""Return the default export folder beside the current recording."""
|
|
692
|
+
if self._recording is None:
|
|
693
|
+
return Path("exports")
|
|
694
|
+
return self._recording.path.parent / "exports"
|
|
695
|
+
|
|
696
|
+
def _resolved_analysis_path(self) -> Path:
|
|
697
|
+
"""Return the analysis HDF5 path for the selected recording."""
|
|
698
|
+
if self._analysis_path is not None:
|
|
699
|
+
return self._analysis_path
|
|
700
|
+
if self._recording is not None:
|
|
701
|
+
return default_analysis_output_path(self._recording)
|
|
702
|
+
return Path("analysis_outputs.h5")
|
|
703
|
+
|
|
704
|
+
def _resolved_roi_save_file(self) -> Path:
|
|
705
|
+
"""Return the ROI HDF5 path for the selected recording."""
|
|
706
|
+
if self._roi_save_file is not None:
|
|
707
|
+
return self._roi_save_file
|
|
708
|
+
if self._recording is not None:
|
|
709
|
+
return self._recording.path.parent / "rois.h5"
|
|
710
|
+
return Path("rois.h5")
|
|
711
|
+
|
|
712
|
+
def _refresh_update_path_labels(self) -> None:
|
|
713
|
+
"""Refresh compact recording and output labels in the Update tab.
|
|
714
|
+
|
|
715
|
+
Args:
|
|
716
|
+
None.
|
|
717
|
+
|
|
718
|
+
Returns:
|
|
719
|
+
None.
|
|
720
|
+
"""
|
|
721
|
+
if self._recording is None:
|
|
722
|
+
self._recording_summary_label.setText("No recording loaded.")
|
|
723
|
+
self._analysis_path_label.setText("Analysis output: default")
|
|
724
|
+
self._roi_save_path_label.setText("ROI output: default")
|
|
725
|
+
return
|
|
726
|
+
|
|
727
|
+
summary = recording_display_summary(self._recording)
|
|
728
|
+
self._recording_summary_label.setText("\n\n".join(summary.lines()))
|
|
729
|
+
self._analysis_path_label.setText(
|
|
730
|
+
f"Analysis output: {format_twopy_h5_output(self._resolved_analysis_path())}"
|
|
731
|
+
)
|
|
732
|
+
self._roi_save_path_label.setText(
|
|
733
|
+
f"ROI output: {format_twopy_h5_output(self._resolved_roi_save_file())}"
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
def _roi_color_hex(self) -> tuple[str, ...]:
|
|
737
|
+
"""Return ROI colors as hex strings in plot ROI order."""
|
|
738
|
+
return tuple(color.name() for color in self._roi_colors)
|
|
739
|
+
|
|
740
|
+
def _roi_label_values(self) -> tuple[int, ...]:
|
|
741
|
+
"""Return integer Labels values in plot ROI order."""
|
|
742
|
+
return roi_label_values_from_labels(self._roi_labels())
|
|
743
|
+
|
|
744
|
+
def _set_roi_visibility(self, index: object, visible: bool) -> None:
|
|
745
|
+
"""Update one ROI visibility flag from its displayed row index."""
|
|
746
|
+
row_index = _row_index_or_none(index, len(self._roi_labels()))
|
|
747
|
+
if row_index is not None:
|
|
748
|
+
self._roi_visibility[row_index] = visible
|
|
749
|
+
self._update_visible_plot_widgets()
|
|
750
|
+
|
|
751
|
+
def _set_roi_visibility_batch(self, visibility: dict[object, bool]) -> None:
|
|
752
|
+
"""Update several ROI visibility flags with one plot refresh."""
|
|
753
|
+
for index, visible in visibility.items():
|
|
754
|
+
row_index = _row_index_or_none(index, len(self._roi_labels()))
|
|
755
|
+
if row_index is not None:
|
|
756
|
+
self._roi_visibility[row_index] = visible
|
|
757
|
+
self._update_visible_plot_widgets()
|
|
758
|
+
|
|
759
|
+
def _set_epoch_visibility(self, index: object, visible: bool) -> None:
|
|
760
|
+
"""Update one epoch visibility flag from its displayed row index."""
|
|
761
|
+
row_index = _row_index_or_none(index, self._epoch_count())
|
|
762
|
+
if row_index is not None:
|
|
763
|
+
self._epoch_visibility[row_index] = visible
|
|
764
|
+
self._render_plots(apply_roi_layer_visibility=False)
|
|
765
|
+
|
|
766
|
+
def _set_epoch_visibility_batch(self, visibility: dict[object, bool]) -> None:
|
|
767
|
+
"""Update several epoch visibility flags with one plot refresh."""
|
|
768
|
+
for index, visible in visibility.items():
|
|
769
|
+
row_index = _row_index_or_none(index, self._epoch_count())
|
|
770
|
+
if row_index is not None:
|
|
771
|
+
self._epoch_visibility[row_index] = visible
|
|
772
|
+
self._render_plots(apply_roi_layer_visibility=False)
|
|
773
|
+
|
|
774
|
+
def _visible_roi_indices(self) -> tuple[int, ...]:
|
|
775
|
+
"""Return zero-based ROI indices currently visible in plots.
|
|
776
|
+
|
|
777
|
+
Args:
|
|
778
|
+
None.
|
|
779
|
+
|
|
780
|
+
Returns:
|
|
781
|
+
Tuple of ROI indices.
|
|
782
|
+
"""
|
|
783
|
+
return tuple(
|
|
784
|
+
index
|
|
785
|
+
for index in range(len(self._roi_labels()))
|
|
786
|
+
if self._roi_visibility.get(index, True)
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
def _visible_epoch_indices(self) -> tuple[int, ...]:
|
|
790
|
+
"""Return epoch row indices currently visible in plots.
|
|
791
|
+
|
|
792
|
+
Args:
|
|
793
|
+
None.
|
|
794
|
+
|
|
795
|
+
Returns:
|
|
796
|
+
Tuple of displayed epoch row indices.
|
|
797
|
+
"""
|
|
798
|
+
return tuple(
|
|
799
|
+
index
|
|
800
|
+
for index in range(self._epoch_count())
|
|
801
|
+
if self._epoch_visibility.get(index, True)
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
def _roi_labels(self) -> tuple[str, ...]:
|
|
805
|
+
"""Return ROI labels from the current plot data.
|
|
806
|
+
|
|
807
|
+
Args:
|
|
808
|
+
None.
|
|
809
|
+
|
|
810
|
+
Returns:
|
|
811
|
+
ROI labels, or an empty tuple before data is loaded.
|
|
812
|
+
"""
|
|
813
|
+
if self._plot_data is None or len(self._plot_data.epochs) == 0:
|
|
814
|
+
return ()
|
|
815
|
+
return self._plot_data.epochs[0].roi_labels
|
|
816
|
+
|
|
817
|
+
def _epoch_count(self) -> int:
|
|
818
|
+
"""Return the number of epoch rows in the current plot data."""
|
|
819
|
+
if self._plot_data is None:
|
|
820
|
+
return 0
|
|
821
|
+
return len(self._plot_data.epochs)
|
|
822
|
+
|
|
823
|
+
def _set_status(self, text: str) -> None:
|
|
824
|
+
"""Replace plots with one status message.
|
|
825
|
+
|
|
826
|
+
Args:
|
|
827
|
+
text: User-facing status message.
|
|
828
|
+
|
|
829
|
+
Returns:
|
|
830
|
+
None.
|
|
831
|
+
"""
|
|
832
|
+
self._clear_epoch_plot_cache()
|
|
833
|
+
clear_layout(self._plot_layout)
|
|
834
|
+
self._status_label = QLabel(text)
|
|
835
|
+
self._status_label.setWordWrap(True)
|
|
836
|
+
self._plot_layout.addWidget(self._status_label)
|
|
837
|
+
self._plot_layout.addStretch(1)
|
|
838
|
+
|
|
839
|
+
def _render_plots(self, *, apply_roi_layer_visibility: bool = True) -> None:
|
|
840
|
+
"""Render one plot widget per epoch type."""
|
|
841
|
+
self._clear_plot_layout_preserving_epoch_cache()
|
|
842
|
+
if self._plot_data is None or len(self._plot_data.epochs) == 0:
|
|
843
|
+
self._set_status("No responses available.")
|
|
844
|
+
return
|
|
845
|
+
if apply_roi_layer_visibility:
|
|
846
|
+
self._apply_roi_layer_visibility()
|
|
847
|
+
roi_indices = self._visible_roi_indices()
|
|
848
|
+
epoch_indices = self._visible_epoch_indices()
|
|
849
|
+
if len(roi_indices) == 0 or len(epoch_indices) == 0:
|
|
850
|
+
self._set_status("No responses selected.")
|
|
851
|
+
return
|
|
852
|
+
time_min, time_max = resolved_time_bounds(
|
|
853
|
+
self._plot_data,
|
|
854
|
+
epoch_indices,
|
|
855
|
+
manual_min=self._manual_x_min,
|
|
856
|
+
manual_max=self._manual_x_max,
|
|
857
|
+
)
|
|
858
|
+
value_min, value_max = resolved_value_bounds(
|
|
859
|
+
self._plot_data,
|
|
860
|
+
roi_indices,
|
|
861
|
+
epoch_indices,
|
|
862
|
+
manual_min=self._manual_y_min,
|
|
863
|
+
manual_max=self._manual_y_max,
|
|
864
|
+
)
|
|
865
|
+
colors = self._roi_colors
|
|
866
|
+
self._ensure_epoch_plot_cache()
|
|
867
|
+
for epoch_index, _epoch in enumerate(self._plot_data.epochs):
|
|
868
|
+
if not self._epoch_visibility.get(epoch_index, True):
|
|
869
|
+
continue
|
|
870
|
+
self._plot_layout.addWidget(self._epoch_plot_panels[epoch_index])
|
|
871
|
+
self._epoch_plot_panels[epoch_index].show()
|
|
872
|
+
self._plot_layout.addStretch(1)
|
|
873
|
+
self._update_epoch_plot_widgets(
|
|
874
|
+
roi_indices=roi_indices,
|
|
875
|
+
epoch_indices=epoch_indices,
|
|
876
|
+
time_min=time_min,
|
|
877
|
+
time_max=time_max,
|
|
878
|
+
value_min=value_min,
|
|
879
|
+
value_max=value_max,
|
|
880
|
+
colors=colors,
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
def _update_visible_plot_widgets(
|
|
884
|
+
self,
|
|
885
|
+
*,
|
|
886
|
+
apply_roi_layer_visibility: bool = True,
|
|
887
|
+
) -> None:
|
|
888
|
+
"""Repaint currently shown plot widgets without rebuilding the layout.
|
|
889
|
+
|
|
890
|
+
Args:
|
|
891
|
+
apply_roi_layer_visibility: Whether ROI visibility should also dim
|
|
892
|
+
matching Labels-layer values in the napari viewer.
|
|
893
|
+
|
|
894
|
+
Returns:
|
|
895
|
+
None.
|
|
896
|
+
"""
|
|
897
|
+
if self._plot_data is None or len(self._plot_data.epochs) == 0:
|
|
898
|
+
self._render_plots(apply_roi_layer_visibility=apply_roi_layer_visibility)
|
|
899
|
+
return
|
|
900
|
+
roi_indices = self._visible_roi_indices()
|
|
901
|
+
epoch_indices = self._visible_epoch_indices()
|
|
902
|
+
if len(roi_indices) == 0 or len(epoch_indices) == 0:
|
|
903
|
+
self._render_plots(apply_roi_layer_visibility=apply_roi_layer_visibility)
|
|
904
|
+
return
|
|
905
|
+
if any(index not in self._epoch_plot_widgets for index in epoch_indices):
|
|
906
|
+
self._render_plots(apply_roi_layer_visibility=apply_roi_layer_visibility)
|
|
907
|
+
return
|
|
908
|
+
if apply_roi_layer_visibility:
|
|
909
|
+
self._apply_roi_layer_visibility()
|
|
910
|
+
time_min, time_max = resolved_time_bounds(
|
|
911
|
+
self._plot_data,
|
|
912
|
+
epoch_indices,
|
|
913
|
+
manual_min=self._manual_x_min,
|
|
914
|
+
manual_max=self._manual_x_max,
|
|
915
|
+
)
|
|
916
|
+
value_min, value_max = resolved_value_bounds(
|
|
917
|
+
self._plot_data,
|
|
918
|
+
roi_indices,
|
|
919
|
+
epoch_indices,
|
|
920
|
+
manual_min=self._manual_y_min,
|
|
921
|
+
manual_max=self._manual_y_max,
|
|
922
|
+
)
|
|
923
|
+
self._update_epoch_plot_widgets(
|
|
924
|
+
roi_indices=roi_indices,
|
|
925
|
+
epoch_indices=epoch_indices,
|
|
926
|
+
time_min=time_min,
|
|
927
|
+
time_max=time_max,
|
|
928
|
+
value_min=value_min,
|
|
929
|
+
value_max=value_max,
|
|
930
|
+
colors=self._roi_colors,
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
def _update_epoch_plot_widgets(
|
|
934
|
+
self,
|
|
935
|
+
*,
|
|
936
|
+
roi_indices: tuple[int, ...],
|
|
937
|
+
epoch_indices: tuple[int, ...],
|
|
938
|
+
time_min: float,
|
|
939
|
+
time_max: float,
|
|
940
|
+
value_min: float,
|
|
941
|
+
value_max: float,
|
|
942
|
+
colors: tuple[QColor, ...],
|
|
943
|
+
) -> None:
|
|
944
|
+
"""Update cached epoch widgets from already-computed plot data.
|
|
945
|
+
|
|
946
|
+
Args:
|
|
947
|
+
roi_indices: ROI rows to draw.
|
|
948
|
+
epoch_indices: Epoch plot rows to update.
|
|
949
|
+
time_min: Shared x-axis minimum.
|
|
950
|
+
time_max: Shared x-axis maximum.
|
|
951
|
+
value_min: Shared y-axis minimum.
|
|
952
|
+
value_max: Shared y-axis maximum.
|
|
953
|
+
colors: ROI plot colors.
|
|
954
|
+
|
|
955
|
+
Returns:
|
|
956
|
+
None.
|
|
957
|
+
"""
|
|
958
|
+
for epoch_index in epoch_indices:
|
|
959
|
+
plot = self._epoch_plot_widgets[epoch_index]
|
|
960
|
+
plot.update_display(
|
|
961
|
+
show_sem=self._show_sem,
|
|
962
|
+
roi_indices=roi_indices,
|
|
963
|
+
roi_colors=colors,
|
|
964
|
+
time_min=time_min,
|
|
965
|
+
time_max=time_max,
|
|
966
|
+
value_min=value_min,
|
|
967
|
+
value_max=value_max,
|
|
968
|
+
plot_size=self._plot_size,
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
def _ensure_epoch_plot_cache(self) -> None:
|
|
972
|
+
"""Create one persistent plot widget per loaded epoch."""
|
|
973
|
+
if self._plot_data is None:
|
|
974
|
+
self._clear_epoch_plot_cache()
|
|
975
|
+
return
|
|
976
|
+
expected_indices = set(range(len(self._plot_data.epochs)))
|
|
977
|
+
for epoch_index in tuple(self._epoch_plot_widgets):
|
|
978
|
+
if epoch_index not in expected_indices:
|
|
979
|
+
self._epoch_plot_panels[epoch_index].deleteLater()
|
|
980
|
+
del self._epoch_plot_panels[epoch_index]
|
|
981
|
+
del self._epoch_plot_widgets[epoch_index]
|
|
982
|
+
for epoch_index, epoch in enumerate(self._plot_data.epochs):
|
|
983
|
+
if epoch_index in self._epoch_plot_widgets:
|
|
984
|
+
continue
|
|
985
|
+
plot = EpochPlotWidget(
|
|
986
|
+
epoch,
|
|
987
|
+
show_sem=self._show_sem,
|
|
988
|
+
roi_indices=self._visible_roi_indices(),
|
|
989
|
+
roi_colors=self._roi_colors,
|
|
990
|
+
time_min=0.0,
|
|
991
|
+
time_max=1.0,
|
|
992
|
+
value_min=0.0,
|
|
993
|
+
value_max=1.0,
|
|
994
|
+
plot_size=self._plot_size,
|
|
995
|
+
)
|
|
996
|
+
self._epoch_plot_widgets[epoch_index] = plot
|
|
997
|
+
panel = epoch_plot_panel(
|
|
998
|
+
title=f"Epoch {epoch.epoch_number}: {epoch.epoch_name}",
|
|
999
|
+
plot=plot,
|
|
1000
|
+
)
|
|
1001
|
+
panel.hide()
|
|
1002
|
+
self._epoch_plot_panels[epoch_index] = panel
|
|
1003
|
+
|
|
1004
|
+
def _clear_epoch_plot_cache(self) -> None:
|
|
1005
|
+
"""Delete cached epoch plot panels and widgets."""
|
|
1006
|
+
for panel in self._epoch_plot_panels.values():
|
|
1007
|
+
panel.deleteLater()
|
|
1008
|
+
self._epoch_plot_widgets.clear()
|
|
1009
|
+
self._epoch_plot_panels.clear()
|
|
1010
|
+
|
|
1011
|
+
def _clear_plot_layout_preserving_epoch_cache(self) -> None:
|
|
1012
|
+
"""Clear the plot strip while keeping cached epoch panels alive."""
|
|
1013
|
+
cached_panels = set(self._epoch_plot_panels.values())
|
|
1014
|
+
while self._plot_layout.count():
|
|
1015
|
+
item = self._plot_layout.takeAt(0)
|
|
1016
|
+
if item is None:
|
|
1017
|
+
continue
|
|
1018
|
+
widget = item.widget()
|
|
1019
|
+
if widget is None:
|
|
1020
|
+
continue
|
|
1021
|
+
if widget in cached_panels:
|
|
1022
|
+
# Qt keeps a widget visible after it is removed from a layout.
|
|
1023
|
+
# Hidden epoch plots must be hidden explicitly so stale panels
|
|
1024
|
+
# cannot remain on screen after checkbox changes.
|
|
1025
|
+
widget.hide()
|
|
1026
|
+
else:
|
|
1027
|
+
widget.deleteLater()
|
|
1028
|
+
|
|
1029
|
+
def _apply_roi_layer_visibility(self) -> None:
|
|
1030
|
+
"""Mirror plot ROI visibility onto the napari Labels overlay."""
|
|
1031
|
+
apply_roi_visibility_to_labels_layer(
|
|
1032
|
+
self._roi_labels_layer,
|
|
1033
|
+
roi_labels=self._roi_labels(),
|
|
1034
|
+
visibility=self._roi_visibility,
|
|
1035
|
+
colors=self._roi_colors,
|
|
1036
|
+
keys=tuple(range(len(self._roi_labels()))),
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _row_index_or_none(value: object, row_count: int) -> int | None:
|
|
1041
|
+
"""Return a valid widget row index from an external callback value.
|
|
1042
|
+
|
|
1043
|
+
Args:
|
|
1044
|
+
value: Callback key from a visibility checkbox.
|
|
1045
|
+
row_count: Number of rows currently displayed by that widget.
|
|
1046
|
+
|
|
1047
|
+
Returns:
|
|
1048
|
+
Zero-based row index, or ``None`` when ``value`` is invalid.
|
|
1049
|
+
"""
|
|
1050
|
+
if type(value) is not int or value < 0 or value >= row_count:
|
|
1051
|
+
return None
|
|
1052
|
+
return value
|