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,767 @@
|
|
|
1
|
+
"""Qt widgets and drawing helpers for twopy response plots.
|
|
2
|
+
|
|
3
|
+
Inputs: plot-ready response data, ROI visibility state, and napari Labels
|
|
4
|
+
colors.
|
|
5
|
+
Outputs: Qt widgets that draw response traces.
|
|
6
|
+
|
|
7
|
+
This module owns drawing helpers only. It does not load recordings, compute
|
|
8
|
+
analysis outputs, or build option panels.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Protocol, cast
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import numpy.typing as npt
|
|
15
|
+
from qtpy.QtCore import QPointF, QRectF, QSize
|
|
16
|
+
from qtpy.QtGui import QColor, QPainter, QPainterPath, QPaintEvent, QPen
|
|
17
|
+
from qtpy.QtWidgets import QLayout, QSizePolicy, QWidget
|
|
18
|
+
|
|
19
|
+
from twopy.napari.plotting.data import EpochResponsePlotData, ResponsePlotData
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"EpochPlotWidget",
|
|
23
|
+
"clear_layout",
|
|
24
|
+
"global_time_bounds",
|
|
25
|
+
"global_value_bounds",
|
|
26
|
+
"ordered_bounds",
|
|
27
|
+
"opaque_colors",
|
|
28
|
+
"resolved_time_bounds",
|
|
29
|
+
"resolved_value_bounds",
|
|
30
|
+
"roi_colors_from_label_values",
|
|
31
|
+
"roi_colors_from_layer",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _LabelsColorLayer(Protocol):
|
|
36
|
+
"""Small protocol for napari Labels layers that can report label colors."""
|
|
37
|
+
|
|
38
|
+
def get_color(self, label: int) -> object:
|
|
39
|
+
"""Return an RGBA color for one integer label.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
label: Positive Labels-layer value.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
RGBA-like color object.
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EpochPlotWidget(QWidget):
|
|
51
|
+
"""Small custom Qt plot for one stimulus epoch type."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
data: EpochResponsePlotData,
|
|
56
|
+
*,
|
|
57
|
+
show_sem: bool,
|
|
58
|
+
roi_indices: tuple[int, ...],
|
|
59
|
+
roi_colors: tuple[QColor, ...],
|
|
60
|
+
time_min: float,
|
|
61
|
+
time_max: float,
|
|
62
|
+
value_min: float,
|
|
63
|
+
value_max: float,
|
|
64
|
+
plot_size: int = 360,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Create a plot widget.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
data: Plot data for one epoch.
|
|
70
|
+
show_sem: Whether SEM bands should be drawn.
|
|
71
|
+
roi_indices: Zero-based ROI indices to draw.
|
|
72
|
+
roi_colors: Colors matching the viewer Labels layer by ROI index.
|
|
73
|
+
time_min: Shared x-axis minimum.
|
|
74
|
+
time_max: Shared x-axis maximum.
|
|
75
|
+
value_min: Shared y-axis minimum.
|
|
76
|
+
value_max: Shared y-axis maximum.
|
|
77
|
+
plot_size: Square widget size in screen pixels.
|
|
78
|
+
"""
|
|
79
|
+
super().__init__()
|
|
80
|
+
self._data = data
|
|
81
|
+
self._show_sem = show_sem
|
|
82
|
+
self._roi_indices = roi_indices
|
|
83
|
+
self._roi_colors = roi_colors
|
|
84
|
+
self._time_min = time_min
|
|
85
|
+
self._time_max = time_max
|
|
86
|
+
self._value_min = value_min
|
|
87
|
+
self._value_max = value_max
|
|
88
|
+
self._plot_size = int(plot_size)
|
|
89
|
+
self.setMinimumSize(self._plot_size, self._plot_size)
|
|
90
|
+
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
|
91
|
+
|
|
92
|
+
def sizeHint(self) -> QSize:
|
|
93
|
+
"""Return the preferred square size for one epoch plot.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
None.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Qt size with equal width and height.
|
|
100
|
+
"""
|
|
101
|
+
return QSize(self._plot_size, self._plot_size)
|
|
102
|
+
|
|
103
|
+
def update_display(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
show_sem: bool,
|
|
107
|
+
roi_indices: tuple[int, ...],
|
|
108
|
+
roi_colors: tuple[QColor, ...],
|
|
109
|
+
time_min: float,
|
|
110
|
+
time_max: float,
|
|
111
|
+
value_min: float,
|
|
112
|
+
value_max: float,
|
|
113
|
+
plot_size: int,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Update visible plot options without rebuilding the widget.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
show_sem: Whether SEM bands should be drawn.
|
|
119
|
+
roi_indices: Zero-based ROI indices to draw.
|
|
120
|
+
roi_colors: Colors matching the viewer Labels layer by ROI index.
|
|
121
|
+
time_min: Shared x-axis minimum.
|
|
122
|
+
time_max: Shared x-axis maximum.
|
|
123
|
+
value_min: Shared y-axis minimum.
|
|
124
|
+
value_max: Shared y-axis maximum.
|
|
125
|
+
plot_size: Square widget size in screen pixels.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
None.
|
|
129
|
+
|
|
130
|
+
Visibility toggles do not change the computed response arrays. Updating
|
|
131
|
+
the existing widget keeps checkbox interactions responsive because Qt
|
|
132
|
+
only repaints the cached plot instead of destroying and recreating the
|
|
133
|
+
full plot strip.
|
|
134
|
+
"""
|
|
135
|
+
self._show_sem = show_sem
|
|
136
|
+
self._roi_indices = roi_indices
|
|
137
|
+
self._roi_colors = roi_colors
|
|
138
|
+
self._time_min = time_min
|
|
139
|
+
self._time_max = time_max
|
|
140
|
+
self._value_min = value_min
|
|
141
|
+
self._value_max = value_max
|
|
142
|
+
self._plot_size = int(plot_size)
|
|
143
|
+
self.setMinimumSize(self._plot_size, self._plot_size)
|
|
144
|
+
self.updateGeometry()
|
|
145
|
+
self.update()
|
|
146
|
+
|
|
147
|
+
def paintEvent(self, a0: QPaintEvent | None) -> None:
|
|
148
|
+
"""Draw response traces.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
a0: Qt paint event.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
None.
|
|
155
|
+
"""
|
|
156
|
+
del a0
|
|
157
|
+
painter = QPainter(self)
|
|
158
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
159
|
+
plot_rect = _square_plot_rect(QRectF(self.rect()))
|
|
160
|
+
painter.fillRect(self.rect(), QColor("#20252d"))
|
|
161
|
+
_draw_axes(
|
|
162
|
+
painter=painter,
|
|
163
|
+
rect=plot_rect,
|
|
164
|
+
time_min=self._time_min,
|
|
165
|
+
time_max=self._time_max,
|
|
166
|
+
value_min=self._value_min,
|
|
167
|
+
value_max=self._value_max,
|
|
168
|
+
)
|
|
169
|
+
for roi_index in self._roi_indices:
|
|
170
|
+
color = self._roi_colors[roi_index]
|
|
171
|
+
if self._show_sem:
|
|
172
|
+
_draw_sem_band(
|
|
173
|
+
painter=painter,
|
|
174
|
+
rect=plot_rect,
|
|
175
|
+
time_values=self._data.time_seconds,
|
|
176
|
+
mean_values=self._data.mean_values[roi_index],
|
|
177
|
+
sem_values=self._data.sem_values[roi_index],
|
|
178
|
+
time_min=self._time_min,
|
|
179
|
+
time_max=self._time_max,
|
|
180
|
+
value_min=self._value_min,
|
|
181
|
+
value_max=self._value_max,
|
|
182
|
+
color=color,
|
|
183
|
+
)
|
|
184
|
+
_draw_trace(
|
|
185
|
+
painter=painter,
|
|
186
|
+
rect=plot_rect,
|
|
187
|
+
time_values=self._data.time_seconds,
|
|
188
|
+
values=self._data.mean_values[roi_index],
|
|
189
|
+
time_min=self._time_min,
|
|
190
|
+
time_max=self._time_max,
|
|
191
|
+
value_min=self._value_min,
|
|
192
|
+
value_max=self._value_max,
|
|
193
|
+
color=color,
|
|
194
|
+
)
|
|
195
|
+
painter.end()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _square_plot_rect(bounds: QRectF) -> QRectF:
|
|
199
|
+
"""Return a square plot rectangle with room for axis labels.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
bounds: Full widget rectangle.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Square drawing rectangle inside the widget.
|
|
206
|
+
"""
|
|
207
|
+
left_margin = 88.0
|
|
208
|
+
top_margin = 12.0
|
|
209
|
+
right_margin = 18.0
|
|
210
|
+
bottom_margin = 46.0
|
|
211
|
+
side = max(
|
|
212
|
+
1.0,
|
|
213
|
+
min(
|
|
214
|
+
bounds.width() - left_margin - right_margin,
|
|
215
|
+
bounds.height() - top_margin - bottom_margin,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
return QRectF(left_margin, top_margin, side, side)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def clear_layout(layout: QLayout) -> None:
|
|
222
|
+
"""Remove all widgets from a Qt layout.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
layout: Layout to clear.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
None.
|
|
229
|
+
"""
|
|
230
|
+
while layout.count():
|
|
231
|
+
item = layout.takeAt(0)
|
|
232
|
+
if item is None:
|
|
233
|
+
continue
|
|
234
|
+
widget = item.widget()
|
|
235
|
+
if widget is not None:
|
|
236
|
+
widget.deleteLater()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def global_time_bounds(
|
|
240
|
+
data: ResponsePlotData,
|
|
241
|
+
epoch_indices: tuple[int, ...] | None = None,
|
|
242
|
+
) -> tuple[float, float]:
|
|
243
|
+
"""Return shared x-axis bounds across selected epoch plots.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
data: Full response plot data.
|
|
247
|
+
epoch_indices: Optional selected epoch row indices.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
``(min, max)`` response time in seconds.
|
|
251
|
+
"""
|
|
252
|
+
epochs = _selected_epochs(data, epoch_indices)
|
|
253
|
+
time_min = min(float(epoch.time_seconds[0]) for epoch in epochs)
|
|
254
|
+
time_max = max(float(epoch.time_seconds[-1]) for epoch in epochs)
|
|
255
|
+
if time_min == time_max:
|
|
256
|
+
return time_min - 1.0, time_max + 1.0
|
|
257
|
+
return time_min, time_max
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def global_value_bounds(
|
|
261
|
+
data: ResponsePlotData,
|
|
262
|
+
roi_indices: tuple[int, ...] | None = None,
|
|
263
|
+
epoch_indices: tuple[int, ...] | None = None,
|
|
264
|
+
) -> tuple[float, float]:
|
|
265
|
+
"""Return shared y-axis bounds across all epoch plots.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
data: Full response plot data.
|
|
269
|
+
roi_indices: Optional selected ROI indices.
|
|
270
|
+
epoch_indices: Optional selected epoch row indices.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
``(min, max)`` bounds expanded by 20 percent and rounded to one decimal.
|
|
274
|
+
"""
|
|
275
|
+
epochs = _selected_epochs(data, epoch_indices)
|
|
276
|
+
selected_roi_indices = roi_indices or tuple(range(len(epochs[0].roi_labels)))
|
|
277
|
+
lower = np.concatenate(
|
|
278
|
+
tuple(
|
|
279
|
+
(epoch.mean_values - epoch.sem_values)[list(selected_roi_indices), :]
|
|
280
|
+
for epoch in epochs
|
|
281
|
+
),
|
|
282
|
+
axis=None,
|
|
283
|
+
)
|
|
284
|
+
upper = np.concatenate(
|
|
285
|
+
tuple(
|
|
286
|
+
(epoch.mean_values + epoch.sem_values)[list(selected_roi_indices), :]
|
|
287
|
+
for epoch in epochs
|
|
288
|
+
),
|
|
289
|
+
axis=None,
|
|
290
|
+
)
|
|
291
|
+
value_min = float(np.nanmin(lower))
|
|
292
|
+
value_max = float(np.nanmax(upper))
|
|
293
|
+
if value_min == value_max:
|
|
294
|
+
return value_min - 1.0, value_max + 1.0
|
|
295
|
+
lower_bound = value_min * 1.2 if value_min < 0 else value_min * 0.8
|
|
296
|
+
upper_bound = value_max * 1.2 if value_max > 0 else value_max * 0.8
|
|
297
|
+
rounded_min = round(lower_bound, 1)
|
|
298
|
+
rounded_max = round(upper_bound, 1)
|
|
299
|
+
if rounded_min >= rounded_max:
|
|
300
|
+
return rounded_min - 0.1, rounded_max + 0.1
|
|
301
|
+
return rounded_min, rounded_max
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def roi_colors_from_layer(layer: object | None, count: int) -> tuple[QColor, ...]:
|
|
305
|
+
"""Return ROI colors that match the napari Labels layer when possible.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
layer: Current napari Labels layer.
|
|
309
|
+
count: Number of ROI traces.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Tuple of Qt colors.
|
|
313
|
+
"""
|
|
314
|
+
return roi_colors_from_label_values(
|
|
315
|
+
layer,
|
|
316
|
+
tuple(range(1, count + 1)),
|
|
317
|
+
fallback_count=count,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def roi_colors_from_label_values(
|
|
322
|
+
layer: object | None,
|
|
323
|
+
label_values: tuple[int, ...],
|
|
324
|
+
*,
|
|
325
|
+
fallback_count: int,
|
|
326
|
+
) -> tuple[QColor, ...]:
|
|
327
|
+
"""Return ROI colors for explicit napari Labels-layer values.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
layer: Current napari Labels layer.
|
|
331
|
+
label_values: Integer label values to sample.
|
|
332
|
+
fallback_count: Number of fallback colors when no layer is available.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Tuple of Qt colors.
|
|
336
|
+
"""
|
|
337
|
+
if layer is not None and hasattr(layer, "get_color"):
|
|
338
|
+
labels_layer = cast(_LabelsColorLayer, layer)
|
|
339
|
+
return tuple(
|
|
340
|
+
_qcolor_from_rgba(labels_layer.get_color(label_value))
|
|
341
|
+
for label_value in label_values
|
|
342
|
+
)
|
|
343
|
+
base = (
|
|
344
|
+
QColor("#4cc9f0"),
|
|
345
|
+
QColor("#f72585"),
|
|
346
|
+
QColor("#f8961e"),
|
|
347
|
+
QColor("#90be6d"),
|
|
348
|
+
QColor("#b5179e"),
|
|
349
|
+
QColor("#43aa8b"),
|
|
350
|
+
)
|
|
351
|
+
return tuple(base[index % len(base)] for index in range(fallback_count))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def ordered_bounds(min_value: float, max_value: float) -> tuple[float, float]:
|
|
355
|
+
"""Return usable bounds even when manual inputs cross.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
min_value: Requested minimum.
|
|
359
|
+
max_value: Requested maximum.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Ordered bounds with nonzero span.
|
|
363
|
+
"""
|
|
364
|
+
if min_value < max_value:
|
|
365
|
+
return min_value, max_value
|
|
366
|
+
if min_value > max_value:
|
|
367
|
+
return max_value, min_value
|
|
368
|
+
return min_value - 0.5, max_value + 0.5
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def resolved_time_bounds(
|
|
372
|
+
data: ResponsePlotData,
|
|
373
|
+
epoch_indices: tuple[int, ...],
|
|
374
|
+
*,
|
|
375
|
+
manual_min: float | None,
|
|
376
|
+
manual_max: float | None,
|
|
377
|
+
) -> tuple[float, float]:
|
|
378
|
+
"""Return plot x-axis bounds after applying optional manual overrides.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
data: Full response plot data.
|
|
382
|
+
epoch_indices: Visible epoch row indices.
|
|
383
|
+
manual_min: Optional user-entered minimum.
|
|
384
|
+
manual_max: Optional user-entered maximum.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Ordered nonzero x-axis bounds.
|
|
388
|
+
"""
|
|
389
|
+
auto_min, auto_max = global_time_bounds(data, epoch_indices)
|
|
390
|
+
time_min = manual_min if manual_min is not None else auto_min
|
|
391
|
+
time_max = manual_max if manual_max is not None else auto_max
|
|
392
|
+
return ordered_bounds(time_min, time_max)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def resolved_value_bounds(
|
|
396
|
+
data: ResponsePlotData,
|
|
397
|
+
roi_indices: tuple[int, ...],
|
|
398
|
+
epoch_indices: tuple[int, ...],
|
|
399
|
+
*,
|
|
400
|
+
manual_min: float | None,
|
|
401
|
+
manual_max: float | None,
|
|
402
|
+
) -> tuple[float, float]:
|
|
403
|
+
"""Return plot y-axis bounds after applying optional manual overrides.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
data: Full response plot data.
|
|
407
|
+
roi_indices: Visible ROI indices.
|
|
408
|
+
epoch_indices: Visible epoch row indices.
|
|
409
|
+
manual_min: Optional user-entered minimum.
|
|
410
|
+
manual_max: Optional user-entered maximum.
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
Ordered nonzero y-axis bounds.
|
|
414
|
+
"""
|
|
415
|
+
auto_min, auto_max = global_value_bounds(data, roi_indices, epoch_indices)
|
|
416
|
+
value_min = manual_min if manual_min is not None else auto_min
|
|
417
|
+
value_max = manual_max if manual_max is not None else auto_max
|
|
418
|
+
return ordered_bounds(value_min, value_max)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def opaque_colors(colors: tuple[QColor, ...]) -> tuple[QColor, ...]:
|
|
422
|
+
"""Return ROI colors with full alpha for plotting and visibility restore.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
colors: Colors read from the Labels layer.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
Same RGB colors with alpha set to fully opaque.
|
|
429
|
+
"""
|
|
430
|
+
opaque: list[QColor] = []
|
|
431
|
+
for color in colors:
|
|
432
|
+
updated = QColor(color)
|
|
433
|
+
updated.setAlpha(255)
|
|
434
|
+
opaque.append(updated)
|
|
435
|
+
return tuple(opaque)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _selected_epochs(
|
|
439
|
+
data: ResponsePlotData,
|
|
440
|
+
epoch_indices: tuple[int, ...] | None,
|
|
441
|
+
) -> tuple[EpochResponsePlotData, ...]:
|
|
442
|
+
"""Return epochs selected for plotting.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
data: Full response plot data.
|
|
446
|
+
epoch_indices: Optional selected epoch row indices.
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
Tuple of selected epochs.
|
|
450
|
+
"""
|
|
451
|
+
if epoch_indices is None:
|
|
452
|
+
return data.epochs
|
|
453
|
+
return tuple(
|
|
454
|
+
data.epochs[index] for index in epoch_indices if 0 <= index < len(data.epochs)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _qcolor_from_rgba(value: object) -> QColor:
|
|
459
|
+
"""Convert a napari RGBA color into ``QColor``.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
value: RGBA-like sequence, usually floats in ``[0, 1]``.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
Qt color.
|
|
466
|
+
"""
|
|
467
|
+
array = np.asarray(value, dtype=np.float64).reshape(-1)
|
|
468
|
+
if array.size < 3:
|
|
469
|
+
return QColor("#4cc9f0")
|
|
470
|
+
if np.nanmax(array[:3]) <= 1.0:
|
|
471
|
+
array = array * 255.0
|
|
472
|
+
alpha = int(array[3]) if array.size > 3 else 255
|
|
473
|
+
return QColor(int(array[0]), int(array[1]), int(array[2]), alpha)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _draw_axes(
|
|
477
|
+
*,
|
|
478
|
+
painter: QPainter,
|
|
479
|
+
rect: QRectF,
|
|
480
|
+
time_min: float,
|
|
481
|
+
time_max: float,
|
|
482
|
+
value_min: float,
|
|
483
|
+
value_max: float,
|
|
484
|
+
) -> None:
|
|
485
|
+
"""Draw x/y axes, ticks, labels, and the zero-response line.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
painter: Active Qt painter.
|
|
489
|
+
rect: Plot rectangle.
|
|
490
|
+
time_min: X-axis minimum.
|
|
491
|
+
time_max: X-axis maximum.
|
|
492
|
+
value_min: Y-axis minimum.
|
|
493
|
+
value_max: Y-axis maximum.
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
None.
|
|
497
|
+
"""
|
|
498
|
+
axis_color = QColor("#cfd6df")
|
|
499
|
+
grid_color = QColor("#6d7683")
|
|
500
|
+
painter.setPen(QPen(axis_color, 1))
|
|
501
|
+
painter.drawLine(rect.bottomLeft(), rect.bottomRight())
|
|
502
|
+
painter.drawLine(rect.bottomLeft(), rect.topLeft())
|
|
503
|
+
|
|
504
|
+
if value_min <= 0 <= value_max:
|
|
505
|
+
zero_y = _plot_point(
|
|
506
|
+
rect,
|
|
507
|
+
time_min,
|
|
508
|
+
0.0,
|
|
509
|
+
time_min,
|
|
510
|
+
time_max,
|
|
511
|
+
value_min,
|
|
512
|
+
value_max,
|
|
513
|
+
).y()
|
|
514
|
+
painter.setPen(_zero_line_pen(grid_color))
|
|
515
|
+
painter.drawLine(QPointF(rect.left(), zero_y), QPointF(rect.right(), zero_y))
|
|
516
|
+
if time_min <= 0 <= time_max:
|
|
517
|
+
zero_x = _plot_point(
|
|
518
|
+
rect,
|
|
519
|
+
0.0,
|
|
520
|
+
value_min,
|
|
521
|
+
time_min,
|
|
522
|
+
time_max,
|
|
523
|
+
value_min,
|
|
524
|
+
value_max,
|
|
525
|
+
).x()
|
|
526
|
+
painter.setPen(_zero_line_pen(grid_color))
|
|
527
|
+
painter.drawLine(QPointF(zero_x, rect.bottom()), QPointF(zero_x, rect.top()))
|
|
528
|
+
|
|
529
|
+
painter.setPen(QPen(axis_color, 1))
|
|
530
|
+
for tick in _axis_ticks(time_min, time_max):
|
|
531
|
+
point = _plot_point(
|
|
532
|
+
rect,
|
|
533
|
+
tick,
|
|
534
|
+
value_min,
|
|
535
|
+
time_min,
|
|
536
|
+
time_max,
|
|
537
|
+
value_min,
|
|
538
|
+
value_max,
|
|
539
|
+
)
|
|
540
|
+
painter.drawLine(point, QPointF(point.x(), point.y() + 4.0))
|
|
541
|
+
painter.drawText(QPointF(point.x() - 12.0, point.y() + 18.0), f"{tick:.1f}")
|
|
542
|
+
for tick in _axis_ticks(value_min, value_max):
|
|
543
|
+
point = _plot_point(
|
|
544
|
+
rect,
|
|
545
|
+
time_min,
|
|
546
|
+
tick,
|
|
547
|
+
time_min,
|
|
548
|
+
time_max,
|
|
549
|
+
value_min,
|
|
550
|
+
value_max,
|
|
551
|
+
)
|
|
552
|
+
painter.drawLine(QPointF(point.x() - 4.0, point.y()), point)
|
|
553
|
+
painter.drawText(QPointF(point.x() - 56.0, point.y() + 4.0), f"{tick:.1f}")
|
|
554
|
+
|
|
555
|
+
painter.drawText(
|
|
556
|
+
QPointF(rect.center().x() - 26.0, rect.bottom() + 36.0),
|
|
557
|
+
"Time (s)",
|
|
558
|
+
)
|
|
559
|
+
painter.save()
|
|
560
|
+
painter.translate(18.0, rect.center().y() + 18.0)
|
|
561
|
+
painter.rotate(-90.0)
|
|
562
|
+
painter.drawText(QPointF(0.0, 0.0), "dF/F")
|
|
563
|
+
painter.restore()
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _zero_line_pen(color: QColor) -> QPen:
|
|
567
|
+
"""Create the shared dashed pen for zero-reference plot lines.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
color: Zero-line color.
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
Thick dashed pen used for both ``x=0`` and ``y=0``.
|
|
574
|
+
"""
|
|
575
|
+
pen = QPen(color, 2)
|
|
576
|
+
pen.setDashPattern([10.0, 8.0])
|
|
577
|
+
return pen
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _axis_ticks(min_value: float, max_value: float) -> tuple[float, ...]:
|
|
581
|
+
"""Return six tick values including axis endpoints.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
min_value: Axis minimum.
|
|
585
|
+
max_value: Axis maximum.
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
Tick values with endpoints included.
|
|
589
|
+
"""
|
|
590
|
+
if min_value == max_value:
|
|
591
|
+
return (min_value,)
|
|
592
|
+
return tuple(float(value) for value in np.linspace(min_value, max_value, 6))
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _draw_trace(
|
|
596
|
+
*,
|
|
597
|
+
painter: QPainter,
|
|
598
|
+
rect: QRectF,
|
|
599
|
+
time_values: npt.NDArray[np.float64],
|
|
600
|
+
values: npt.NDArray[np.float64],
|
|
601
|
+
time_min: float,
|
|
602
|
+
time_max: float,
|
|
603
|
+
value_min: float,
|
|
604
|
+
value_max: float,
|
|
605
|
+
color: QColor,
|
|
606
|
+
) -> None:
|
|
607
|
+
"""Draw one ROI mean trace.
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
painter: Active Qt painter.
|
|
611
|
+
rect: Plot rectangle.
|
|
612
|
+
time_values: X-axis values in seconds.
|
|
613
|
+
values: Mean response values.
|
|
614
|
+
time_min: X-axis minimum.
|
|
615
|
+
time_max: X-axis maximum.
|
|
616
|
+
value_min: Y-axis minimum.
|
|
617
|
+
value_max: Y-axis maximum.
|
|
618
|
+
color: Trace color.
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
None.
|
|
622
|
+
"""
|
|
623
|
+
path = _trace_path(
|
|
624
|
+
rect,
|
|
625
|
+
time_values,
|
|
626
|
+
values,
|
|
627
|
+
time_min,
|
|
628
|
+
time_max,
|
|
629
|
+
value_min,
|
|
630
|
+
value_max,
|
|
631
|
+
)
|
|
632
|
+
painter.setPen(QPen(color, 2))
|
|
633
|
+
painter.drawPath(path)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _draw_sem_band(
|
|
637
|
+
*,
|
|
638
|
+
painter: QPainter,
|
|
639
|
+
rect: QRectF,
|
|
640
|
+
time_values: npt.NDArray[np.float64],
|
|
641
|
+
mean_values: npt.NDArray[np.float64],
|
|
642
|
+
sem_values: npt.NDArray[np.float64],
|
|
643
|
+
time_min: float,
|
|
644
|
+
time_max: float,
|
|
645
|
+
value_min: float,
|
|
646
|
+
value_max: float,
|
|
647
|
+
color: QColor,
|
|
648
|
+
) -> None:
|
|
649
|
+
"""Draw one ROI SEM band.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
painter: Active Qt painter.
|
|
653
|
+
rect: Plot rectangle.
|
|
654
|
+
time_values: X-axis values in seconds.
|
|
655
|
+
mean_values: Mean response values.
|
|
656
|
+
sem_values: SEM values.
|
|
657
|
+
time_min: X-axis minimum.
|
|
658
|
+
time_max: X-axis maximum.
|
|
659
|
+
value_min: Y-axis minimum.
|
|
660
|
+
value_max: Y-axis maximum.
|
|
661
|
+
color: Band color.
|
|
662
|
+
|
|
663
|
+
Returns:
|
|
664
|
+
None.
|
|
665
|
+
"""
|
|
666
|
+
upper = mean_values + sem_values
|
|
667
|
+
lower = mean_values - sem_values
|
|
668
|
+
path = _trace_path(
|
|
669
|
+
rect,
|
|
670
|
+
time_values,
|
|
671
|
+
upper,
|
|
672
|
+
time_min,
|
|
673
|
+
time_max,
|
|
674
|
+
value_min,
|
|
675
|
+
value_max,
|
|
676
|
+
)
|
|
677
|
+
for index in range(len(time_values) - 1, -1, -1):
|
|
678
|
+
point = _plot_point(
|
|
679
|
+
rect,
|
|
680
|
+
time_values[index],
|
|
681
|
+
lower[index],
|
|
682
|
+
time_min,
|
|
683
|
+
time_max,
|
|
684
|
+
value_min,
|
|
685
|
+
value_max,
|
|
686
|
+
)
|
|
687
|
+
path.lineTo(point)
|
|
688
|
+
band_color = QColor(color)
|
|
689
|
+
band_color.setAlpha(55)
|
|
690
|
+
painter.fillPath(path, band_color)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _trace_path(
|
|
694
|
+
rect: QRectF,
|
|
695
|
+
time_values: npt.NDArray[np.float64],
|
|
696
|
+
values: npt.NDArray[np.float64],
|
|
697
|
+
time_min: float,
|
|
698
|
+
time_max: float,
|
|
699
|
+
value_min: float,
|
|
700
|
+
value_max: float,
|
|
701
|
+
) -> QPainterPath:
|
|
702
|
+
"""Build a Qt path for one trace.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
rect: Plot rectangle.
|
|
706
|
+
time_values: X-axis values in seconds.
|
|
707
|
+
values: Y-axis values.
|
|
708
|
+
time_min: X-axis minimum.
|
|
709
|
+
time_max: X-axis maximum.
|
|
710
|
+
value_min: Y-axis minimum.
|
|
711
|
+
value_max: Y-axis maximum.
|
|
712
|
+
|
|
713
|
+
Returns:
|
|
714
|
+
Painter path for valid values.
|
|
715
|
+
"""
|
|
716
|
+
path = QPainterPath()
|
|
717
|
+
first = True
|
|
718
|
+
for time_value, value in zip(time_values, values, strict=True):
|
|
719
|
+
if np.isnan(value) or time_value < time_min or time_value > time_max:
|
|
720
|
+
first = True
|
|
721
|
+
continue
|
|
722
|
+
point = _plot_point(
|
|
723
|
+
rect,
|
|
724
|
+
time_value,
|
|
725
|
+
value,
|
|
726
|
+
time_min,
|
|
727
|
+
time_max,
|
|
728
|
+
value_min,
|
|
729
|
+
value_max,
|
|
730
|
+
)
|
|
731
|
+
if first:
|
|
732
|
+
path.moveTo(point)
|
|
733
|
+
first = False
|
|
734
|
+
else:
|
|
735
|
+
path.lineTo(point)
|
|
736
|
+
return path
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _plot_point(
|
|
740
|
+
rect: QRectF,
|
|
741
|
+
time_value: float,
|
|
742
|
+
value: float,
|
|
743
|
+
time_min: float,
|
|
744
|
+
time_max: float,
|
|
745
|
+
value_min: float,
|
|
746
|
+
value_max: float,
|
|
747
|
+
) -> QPointF:
|
|
748
|
+
"""Map data coordinates into plot coordinates.
|
|
749
|
+
|
|
750
|
+
Args:
|
|
751
|
+
rect: Plot rectangle.
|
|
752
|
+
time_value: X value in seconds.
|
|
753
|
+
value: Y value.
|
|
754
|
+
time_min: Minimum time value.
|
|
755
|
+
time_max: Maximum time value.
|
|
756
|
+
value_min: Minimum y value.
|
|
757
|
+
value_max: Maximum y value.
|
|
758
|
+
|
|
759
|
+
Returns:
|
|
760
|
+
Qt point in widget coordinates.
|
|
761
|
+
"""
|
|
762
|
+
time_span = time_max - time_min
|
|
763
|
+
x_fraction = 0.0 if time_span == 0 else (time_value - time_min) / time_span
|
|
764
|
+
y_fraction = (value - value_min) / (value_max - value_min)
|
|
765
|
+
x = rect.left() + x_fraction * rect.width()
|
|
766
|
+
y = rect.bottom() - y_fraction * rect.height()
|
|
767
|
+
return QPointF(x, y)
|