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,803 @@
|
|
|
1
|
+
"""Export napari response views as Illustrator-friendly figures.
|
|
2
|
+
|
|
3
|
+
Inputs: the current napari viewer state, loaded recording metadata, ROI Labels
|
|
4
|
+
data, and plot-ready response traces.
|
|
5
|
+
Outputs: PDF and PNG files for recording views, ROI overlays, and response
|
|
6
|
+
plots.
|
|
7
|
+
|
|
8
|
+
This module owns figure export only. The live napari widgets decide when to
|
|
9
|
+
call it, while this file keeps publication-output choices explicit and easy to
|
|
10
|
+
audit.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Protocol, cast
|
|
16
|
+
|
|
17
|
+
import matplotlib as mpl
|
|
18
|
+
import numpy as np
|
|
19
|
+
import numpy.typing as npt
|
|
20
|
+
from matplotlib.axes import Axes
|
|
21
|
+
from matplotlib.collections import LineCollection
|
|
22
|
+
from matplotlib.figure import Figure
|
|
23
|
+
|
|
24
|
+
from twopy.converted import RecordingData
|
|
25
|
+
from twopy.napari.display import display_image_from_movie_image
|
|
26
|
+
from twopy.napari.plotting.data import EpochResponsePlotData, ResponsePlotData
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"export_epoch_plots",
|
|
30
|
+
"export_epoch_roi_overlay_plots",
|
|
31
|
+
"export_recording_roi_overlay",
|
|
32
|
+
"export_recording_view",
|
|
33
|
+
"export_roi_view",
|
|
34
|
+
"roi_boundary_segments",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
MM_TO_INCH = 1.0 / 25.4
|
|
38
|
+
DEFAULT_FORMATS = ("pdf", "png")
|
|
39
|
+
type BoundarySegment = tuple[tuple[float, float], tuple[float, float]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _LayerWithData(Protocol):
|
|
43
|
+
"""Small protocol for napari layers that expose array data."""
|
|
44
|
+
|
|
45
|
+
data: object
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _LayerList(Protocol):
|
|
49
|
+
"""Small protocol for napari layer lists used by export."""
|
|
50
|
+
|
|
51
|
+
def __getitem__(self, name: str) -> object:
|
|
52
|
+
"""Return a layer by name.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
name: Layer name.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Matching layer object.
|
|
59
|
+
"""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
def __iter__(self) -> Iterator[object]:
|
|
63
|
+
"""Return an iterator over layers.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
None.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Layer iterator.
|
|
70
|
+
"""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _ViewerWithLayers(Protocol):
|
|
75
|
+
"""Small protocol for napari viewers that expose a layer list."""
|
|
76
|
+
|
|
77
|
+
layers: _LayerList
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _ViewerWithDims(Protocol):
|
|
81
|
+
"""Small protocol for napari viewers that expose dimension state."""
|
|
82
|
+
|
|
83
|
+
dims: object
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def export_recording_view(
|
|
87
|
+
*,
|
|
88
|
+
viewer: object | None,
|
|
89
|
+
recording: RecordingData,
|
|
90
|
+
output_dir: Path,
|
|
91
|
+
formats: tuple[str, ...] = DEFAULT_FORMATS,
|
|
92
|
+
) -> tuple[Path, ...]:
|
|
93
|
+
"""Save the current movie frame, or the mean image when no movie is loaded.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
viewer: Napari viewer used to find the current displayed movie frame.
|
|
97
|
+
recording: Loaded converted recording, used for mean-image fallback.
|
|
98
|
+
output_dir: Directory where exported files should be written.
|
|
99
|
+
formats: File formats to write.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Paths written to disk.
|
|
103
|
+
"""
|
|
104
|
+
image = current_recording_image(viewer=viewer, recording=recording)
|
|
105
|
+
fig, ax = image_figure(image.shape)
|
|
106
|
+
draw_recording_image(ax, image)
|
|
107
|
+
return save_figure_bundle(
|
|
108
|
+
fig,
|
|
109
|
+
output_dir / "recording_view" / "recording_view",
|
|
110
|
+
formats=formats,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def export_roi_view(
|
|
115
|
+
*,
|
|
116
|
+
roi_labels_layer: object | None,
|
|
117
|
+
recording: RecordingData,
|
|
118
|
+
output_dir: Path,
|
|
119
|
+
roi_label_values: tuple[int, ...],
|
|
120
|
+
roi_colors: tuple[str, ...],
|
|
121
|
+
formats: tuple[str, ...] = DEFAULT_FORMATS,
|
|
122
|
+
) -> tuple[Path, ...]:
|
|
123
|
+
"""Save current ROI contours without a recording image underneath.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
roi_labels_layer: Current napari Labels layer.
|
|
127
|
+
recording: Loaded converted recording, used for empty-label fallback.
|
|
128
|
+
output_dir: Directory where exported files should be written.
|
|
129
|
+
roi_label_values: Integer Labels values in plot ROI order.
|
|
130
|
+
roi_colors: Hex colors in plot ROI order.
|
|
131
|
+
formats: File formats to write.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Paths written to disk.
|
|
135
|
+
"""
|
|
136
|
+
labels = current_roi_labels(
|
|
137
|
+
roi_labels_layer=roi_labels_layer,
|
|
138
|
+
recording=recording,
|
|
139
|
+
)
|
|
140
|
+
labels = labels_for_recording_image(labels, recording=recording)
|
|
141
|
+
fig, ax = image_figure(labels.shape)
|
|
142
|
+
draw_roi_contours(
|
|
143
|
+
ax,
|
|
144
|
+
labels=labels,
|
|
145
|
+
roi_label_values=roi_label_values,
|
|
146
|
+
roi_indices=tuple(range(len(roi_label_values))),
|
|
147
|
+
roi_colors=roi_colors,
|
|
148
|
+
)
|
|
149
|
+
return save_figure_bundle(
|
|
150
|
+
fig, output_dir / "roi_view" / "roi_view", formats=formats
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def export_recording_roi_overlay(
|
|
155
|
+
*,
|
|
156
|
+
viewer: object | None,
|
|
157
|
+
recording: RecordingData,
|
|
158
|
+
roi_labels_layer: object | None,
|
|
159
|
+
output_dir: Path,
|
|
160
|
+
roi_label_values: tuple[int, ...],
|
|
161
|
+
roi_colors: tuple[str, ...],
|
|
162
|
+
formats: tuple[str, ...] = DEFAULT_FORMATS,
|
|
163
|
+
) -> tuple[Path, ...]:
|
|
164
|
+
"""Save current recording image with all current ROI contours overlaid.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
viewer: Napari viewer used to find the current displayed movie frame.
|
|
168
|
+
recording: Loaded converted recording, used for mean-image fallback.
|
|
169
|
+
roi_labels_layer: Current napari Labels layer.
|
|
170
|
+
output_dir: Directory where exported files should be written.
|
|
171
|
+
roi_label_values: Integer Labels values in plot ROI order.
|
|
172
|
+
roi_colors: Hex colors in plot ROI order.
|
|
173
|
+
formats: File formats to write.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Paths written to disk.
|
|
177
|
+
"""
|
|
178
|
+
image = current_recording_image(viewer=viewer, recording=recording)
|
|
179
|
+
labels = current_roi_labels(
|
|
180
|
+
roi_labels_layer=roi_labels_layer,
|
|
181
|
+
recording=recording,
|
|
182
|
+
)
|
|
183
|
+
labels = labels_for_recording_image(labels, recording=recording)
|
|
184
|
+
fig, ax = image_figure(image.shape)
|
|
185
|
+
draw_recording_image(ax, image)
|
|
186
|
+
draw_roi_contours(
|
|
187
|
+
ax,
|
|
188
|
+
labels=labels,
|
|
189
|
+
roi_label_values=roi_label_values,
|
|
190
|
+
roi_indices=tuple(range(len(roi_label_values))),
|
|
191
|
+
roi_colors=roi_colors,
|
|
192
|
+
)
|
|
193
|
+
return save_figure_bundle(
|
|
194
|
+
fig,
|
|
195
|
+
output_dir / "recording_roi_overlay" / "recording_roi_overlay",
|
|
196
|
+
formats=formats,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def export_epoch_plots(
|
|
201
|
+
*,
|
|
202
|
+
plot_data: ResponsePlotData,
|
|
203
|
+
output_dir: Path,
|
|
204
|
+
epoch_indices: tuple[int, ...],
|
|
205
|
+
roi_indices: tuple[int, ...],
|
|
206
|
+
roi_colors: tuple[str, ...],
|
|
207
|
+
show_sem: bool,
|
|
208
|
+
time_bounds: tuple[float, float],
|
|
209
|
+
value_bounds: tuple[float, float],
|
|
210
|
+
formats: tuple[str, ...] = DEFAULT_FORMATS,
|
|
211
|
+
) -> tuple[Path, ...]:
|
|
212
|
+
"""Save one editable response plot per visible stimulus epoch.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
plot_data: Plot-ready response data.
|
|
216
|
+
output_dir: Directory where exported files should be written.
|
|
217
|
+
epoch_indices: Visible epoch row indices.
|
|
218
|
+
roi_indices: Zero-based ROI indices to draw.
|
|
219
|
+
roi_colors: Hex colors in plot ROI order.
|
|
220
|
+
show_sem: Whether to draw SEM bands.
|
|
221
|
+
time_bounds: Shared x-axis bounds.
|
|
222
|
+
value_bounds: Shared y-axis bounds.
|
|
223
|
+
formats: File formats to write.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Paths written to disk.
|
|
227
|
+
"""
|
|
228
|
+
written: list[Path] = []
|
|
229
|
+
for epoch in _visible_epochs(plot_data, epoch_indices):
|
|
230
|
+
fig, ax = plot_figure()
|
|
231
|
+
draw_epoch_response_plot(
|
|
232
|
+
ax,
|
|
233
|
+
epoch=epoch,
|
|
234
|
+
roi_indices=roi_indices,
|
|
235
|
+
roi_colors=roi_colors,
|
|
236
|
+
show_sem=show_sem,
|
|
237
|
+
time_bounds=time_bounds,
|
|
238
|
+
value_bounds=value_bounds,
|
|
239
|
+
)
|
|
240
|
+
ax.set_title(_epoch_title(epoch), fontsize=7)
|
|
241
|
+
written.extend(
|
|
242
|
+
save_figure_bundle(
|
|
243
|
+
fig,
|
|
244
|
+
output_dir / "plots" / safe_stem(_epoch_title(epoch)),
|
|
245
|
+
formats=formats,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
return tuple(written)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def export_epoch_roi_overlay_plots(
|
|
252
|
+
*,
|
|
253
|
+
viewer: object | None,
|
|
254
|
+
recording: RecordingData,
|
|
255
|
+
roi_labels_layer: object | None,
|
|
256
|
+
plot_data: ResponsePlotData,
|
|
257
|
+
output_dir: Path,
|
|
258
|
+
epoch_indices: tuple[int, ...],
|
|
259
|
+
roi_indices: tuple[int, ...],
|
|
260
|
+
roi_label_values: tuple[int, ...],
|
|
261
|
+
roi_colors: tuple[str, ...],
|
|
262
|
+
show_sem: bool,
|
|
263
|
+
time_bounds: tuple[float, float],
|
|
264
|
+
value_bounds: tuple[float, float],
|
|
265
|
+
formats: tuple[str, ...] = DEFAULT_FORMATS,
|
|
266
|
+
) -> tuple[Path, ...]:
|
|
267
|
+
"""Save one two-column ROI overlay plus response plot per epoch and ROI.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
viewer: Napari viewer used to find the current displayed movie frame.
|
|
271
|
+
recording: Loaded converted recording, used for mean-image fallback.
|
|
272
|
+
roi_labels_layer: Current napari Labels layer.
|
|
273
|
+
plot_data: Plot-ready response data.
|
|
274
|
+
output_dir: Directory where exported files should be written.
|
|
275
|
+
epoch_indices: Visible epoch row indices.
|
|
276
|
+
roi_indices: Zero-based ROI indices to draw.
|
|
277
|
+
roi_label_values: Integer Labels values in plot ROI order.
|
|
278
|
+
roi_colors: Hex colors in plot ROI order.
|
|
279
|
+
show_sem: Whether to draw SEM bands.
|
|
280
|
+
time_bounds: Shared x-axis bounds.
|
|
281
|
+
value_bounds: Shared y-axis bounds.
|
|
282
|
+
formats: File formats to write.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Paths written to disk.
|
|
286
|
+
"""
|
|
287
|
+
image = current_recording_image(viewer=viewer, recording=recording)
|
|
288
|
+
labels = current_roi_labels(
|
|
289
|
+
roi_labels_layer=roi_labels_layer,
|
|
290
|
+
recording=recording,
|
|
291
|
+
)
|
|
292
|
+
labels = labels_for_recording_image(labels, recording=recording)
|
|
293
|
+
written: list[Path] = []
|
|
294
|
+
for epoch in _visible_epochs(plot_data, epoch_indices):
|
|
295
|
+
for roi_index in roi_indices:
|
|
296
|
+
fig, image_ax, plot_ax = overlay_plot_figure(image.shape)
|
|
297
|
+
draw_recording_image(image_ax, image)
|
|
298
|
+
draw_roi_contours(
|
|
299
|
+
image_ax,
|
|
300
|
+
labels=labels,
|
|
301
|
+
roi_label_values=roi_label_values,
|
|
302
|
+
roi_indices=(roi_index,),
|
|
303
|
+
roi_colors=roi_colors,
|
|
304
|
+
)
|
|
305
|
+
draw_epoch_response_plot(
|
|
306
|
+
plot_ax,
|
|
307
|
+
epoch=epoch,
|
|
308
|
+
roi_indices=(roi_index,),
|
|
309
|
+
roi_colors=roi_colors,
|
|
310
|
+
show_sem=show_sem,
|
|
311
|
+
time_bounds=time_bounds,
|
|
312
|
+
value_bounds=value_bounds,
|
|
313
|
+
)
|
|
314
|
+
plot_ax.set_title(_epoch_title(epoch), fontsize=7)
|
|
315
|
+
stem = safe_stem(f"{_epoch_title(epoch)} {epoch.roi_labels[roi_index]}")
|
|
316
|
+
written.extend(
|
|
317
|
+
save_figure_bundle(
|
|
318
|
+
fig,
|
|
319
|
+
output_dir / "plots_with_rois" / stem,
|
|
320
|
+
formats=formats,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
return tuple(written)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def current_recording_image(
|
|
327
|
+
*,
|
|
328
|
+
viewer: object | None,
|
|
329
|
+
recording: RecordingData,
|
|
330
|
+
) -> npt.NDArray[np.float64]:
|
|
331
|
+
"""Return the current display-coordinate recording image.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
viewer: Napari viewer that may have an ``aligned movie`` layer.
|
|
335
|
+
recording: Loaded converted recording, used for mean-image fallback.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Two-dimensional display-coordinate image.
|
|
339
|
+
"""
|
|
340
|
+
movie_layer = _layer_by_name(viewer, "aligned movie")
|
|
341
|
+
if movie_layer is not None:
|
|
342
|
+
data = np.asarray(cast(_LayerWithData, movie_layer).data)
|
|
343
|
+
if data.ndim == 3:
|
|
344
|
+
index = _current_movie_layer_index(viewer, data.shape[0])
|
|
345
|
+
return cast(npt.NDArray[np.float64], data[index, :, :])
|
|
346
|
+
if data.ndim == 2:
|
|
347
|
+
return cast(npt.NDArray[np.float64], data)
|
|
348
|
+
|
|
349
|
+
mean_image = recording.alignment_valid_crop.crop_image(recording.mean_image)
|
|
350
|
+
return cast(npt.NDArray[np.float64], display_image_from_movie_image(mean_image))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def current_roi_labels(
|
|
354
|
+
*,
|
|
355
|
+
roi_labels_layer: object | None,
|
|
356
|
+
recording: RecordingData,
|
|
357
|
+
) -> npt.NDArray[np.int64]:
|
|
358
|
+
"""Return current display-coordinate ROI labels.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
roi_labels_layer: Current napari Labels layer.
|
|
362
|
+
recording: Loaded converted recording, used for empty-label fallback.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
Two-dimensional display-coordinate integer label image.
|
|
366
|
+
"""
|
|
367
|
+
if roi_labels_layer is None:
|
|
368
|
+
crop = recording.alignment_valid_crop
|
|
369
|
+
return np.zeros((crop.shape[1], crop.shape[0]), dtype=np.int64)
|
|
370
|
+
return np.asarray(cast(_LayerWithData, roi_labels_layer).data, dtype=np.int64)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def labels_for_recording_image(
|
|
374
|
+
labels: npt.NDArray[np.int64],
|
|
375
|
+
*,
|
|
376
|
+
recording: RecordingData,
|
|
377
|
+
) -> npt.NDArray[np.int64]:
|
|
378
|
+
"""Return display-coordinate labels matching the cropped recording image.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
labels: Current Labels-layer data in display coordinates.
|
|
382
|
+
recording: Loaded converted recording that defines the displayed crop.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Label image with the same shape as the exported recording image.
|
|
386
|
+
|
|
387
|
+
The viewer shows the alignment-valid crop. Older sessions or manually
|
|
388
|
+
created layers may still contain a full-frame display label image; in that
|
|
389
|
+
case export crops it before drawing so ROIs outside the valid display area
|
|
390
|
+
cannot expand the figure beyond the recording image.
|
|
391
|
+
"""
|
|
392
|
+
crop = recording.alignment_valid_crop
|
|
393
|
+
expected_shape = (crop.shape[1], crop.shape[0])
|
|
394
|
+
if labels.shape == expected_shape:
|
|
395
|
+
return labels
|
|
396
|
+
|
|
397
|
+
full_frame_display_shape = (
|
|
398
|
+
recording.movie.shape[2],
|
|
399
|
+
recording.movie.shape[1],
|
|
400
|
+
)
|
|
401
|
+
if labels.shape == full_frame_display_shape:
|
|
402
|
+
movie_labels = display_image_from_movie_image(labels)
|
|
403
|
+
cropped = crop.crop_image(movie_labels)
|
|
404
|
+
return cast(npt.NDArray[np.int64], display_image_from_movie_image(cropped))
|
|
405
|
+
|
|
406
|
+
msg = (
|
|
407
|
+
f"ROI label image shape {labels.shape} does not match displayed recording "
|
|
408
|
+
f"crop {expected_shape} or full-frame display shape {full_frame_display_shape}"
|
|
409
|
+
)
|
|
410
|
+
raise ValueError(msg)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def image_figure(shape: tuple[int, int]) -> tuple[Figure, Axes]:
|
|
414
|
+
"""Create a figure sized for one recording or ROI image.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
shape: ``(rows, columns)`` image shape.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
Matplotlib figure and image axis.
|
|
421
|
+
"""
|
|
422
|
+
rows, columns = shape
|
|
423
|
+
width_mm = max(30.0, min(80.0, 45.0 * columns / max(rows, 1)))
|
|
424
|
+
height_mm = max(30.0, min(80.0, 45.0 * rows / max(columns, 1)))
|
|
425
|
+
fig, ax = _axis_figure(
|
|
426
|
+
axis_w_mm=width_mm,
|
|
427
|
+
axis_h_mm=height_mm,
|
|
428
|
+
left_margin_mm=2.0,
|
|
429
|
+
bottom_margin_mm=2.0,
|
|
430
|
+
right_margin_mm=2.0,
|
|
431
|
+
top_margin_mm=2.0,
|
|
432
|
+
)
|
|
433
|
+
ax.set_axis_off()
|
|
434
|
+
return fig, ax
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def plot_figure() -> tuple[Figure, Axes]:
|
|
438
|
+
"""Create one fixed-size response plot figure.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
None.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Matplotlib figure and response axis.
|
|
445
|
+
"""
|
|
446
|
+
return _axis_figure(axis_w_mm=48.0, axis_h_mm=48.0)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def overlay_plot_figure(shape: tuple[int, int]) -> tuple[Figure, Axes, Axes]:
|
|
450
|
+
"""Create a two-column figure with ROI image plus response plot.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
shape: ``(rows, columns)`` recording image shape.
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
Figure, image axis, and response axis.
|
|
457
|
+
"""
|
|
458
|
+
rows, columns = shape
|
|
459
|
+
image_w_mm = max(28.0, min(42.0, 38.0 * columns / max(rows, 1)))
|
|
460
|
+
axis_w_mm = image_w_mm + 8.0 + 48.0
|
|
461
|
+
fig, left_ax = _axis_figure(
|
|
462
|
+
axis_w_mm=axis_w_mm,
|
|
463
|
+
axis_h_mm=48.0,
|
|
464
|
+
left_margin_mm=4.0,
|
|
465
|
+
bottom_margin_mm=12.0,
|
|
466
|
+
right_margin_mm=4.0,
|
|
467
|
+
top_margin_mm=5.0,
|
|
468
|
+
)
|
|
469
|
+
total_w_mm, total_h_mm = _figure_size_mm(fig)
|
|
470
|
+
left_ax.set_position(
|
|
471
|
+
(
|
|
472
|
+
4.0 / total_w_mm,
|
|
473
|
+
12.0 / total_h_mm,
|
|
474
|
+
image_w_mm / total_w_mm,
|
|
475
|
+
48.0 / total_h_mm,
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
left_ax.set_axis_off()
|
|
479
|
+
plot_left_mm = 4.0 + image_w_mm + 8.0
|
|
480
|
+
plot_ax = fig.add_axes(
|
|
481
|
+
(
|
|
482
|
+
plot_left_mm / total_w_mm,
|
|
483
|
+
12.0 / total_h_mm,
|
|
484
|
+
48.0 / total_w_mm,
|
|
485
|
+
48.0 / total_h_mm,
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
_style_axis(plot_ax)
|
|
489
|
+
return fig, left_ax, plot_ax
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def draw_recording_image(ax: Axes, image: npt.NDArray[np.float64]) -> None:
|
|
493
|
+
"""Draw one grayscale recording image.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
ax: Matplotlib axis to draw into.
|
|
497
|
+
image: Two-dimensional display-coordinate image.
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
None.
|
|
501
|
+
"""
|
|
502
|
+
ax.imshow(image, cmap="gray", origin="upper", interpolation="nearest")
|
|
503
|
+
ax.set_xlim(-0.5, image.shape[1] - 0.5)
|
|
504
|
+
ax.set_ylim(image.shape[0] - 0.5, -0.5)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def draw_roi_contours(
|
|
508
|
+
ax: Axes,
|
|
509
|
+
*,
|
|
510
|
+
labels: npt.NDArray[np.int64],
|
|
511
|
+
roi_label_values: tuple[int, ...],
|
|
512
|
+
roi_indices: tuple[int, ...],
|
|
513
|
+
roi_colors: tuple[str, ...],
|
|
514
|
+
) -> None:
|
|
515
|
+
"""Draw ROI boundaries as editable vector contours.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
ax: Matplotlib axis to draw into.
|
|
519
|
+
labels: Display-coordinate integer Labels image.
|
|
520
|
+
roi_label_values: Integer Labels values in plot ROI order.
|
|
521
|
+
roi_indices: Zero-based ROI indices to draw.
|
|
522
|
+
roi_colors: Hex colors in plot ROI order.
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
None.
|
|
526
|
+
"""
|
|
527
|
+
for roi_index in roi_indices:
|
|
528
|
+
if roi_index >= len(roi_label_values) or roi_index >= len(roi_colors):
|
|
529
|
+
continue
|
|
530
|
+
mask = labels == roi_label_values[roi_index]
|
|
531
|
+
if not np.any(mask):
|
|
532
|
+
continue
|
|
533
|
+
ax.add_collection(
|
|
534
|
+
LineCollection(
|
|
535
|
+
roi_boundary_segments(mask),
|
|
536
|
+
colors=(roi_colors[roi_index],),
|
|
537
|
+
linewidths=0.75,
|
|
538
|
+
)
|
|
539
|
+
)
|
|
540
|
+
ax.set_xlim(-0.5, labels.shape[1] - 0.5)
|
|
541
|
+
ax.set_ylim(labels.shape[0] - 0.5, -0.5)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def roi_boundary_segments(mask: npt.NDArray[np.bool_]) -> list[BoundarySegment]:
|
|
545
|
+
"""Return pixel-edge line segments around one ROI mask.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
mask: Two-dimensional boolean ROI mask in display coordinates.
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
Line segments in Matplotlib data coordinates.
|
|
552
|
+
|
|
553
|
+
Matplotlib contours trace between pixel centers, which misses visible
|
|
554
|
+
boundaries when an ROI touches the image edge. These segments follow the
|
|
555
|
+
outside edge of ROI pixels exactly, including pixels on the first or last
|
|
556
|
+
row/column.
|
|
557
|
+
"""
|
|
558
|
+
rows, columns = mask.shape
|
|
559
|
+
segments: list[tuple[tuple[float, float], tuple[float, float]]] = []
|
|
560
|
+
for row, column in np.argwhere(mask):
|
|
561
|
+
row_index = int(row)
|
|
562
|
+
column_index = int(column)
|
|
563
|
+
left = float(column_index) - 0.5
|
|
564
|
+
right = float(column_index) + 0.5
|
|
565
|
+
top = float(row_index) - 0.5
|
|
566
|
+
bottom = float(row_index) + 0.5
|
|
567
|
+
if row_index == 0 or not mask[row_index - 1, column_index]:
|
|
568
|
+
segments.append(((left, top), (right, top)))
|
|
569
|
+
if row_index == rows - 1 or not mask[row_index + 1, column_index]:
|
|
570
|
+
segments.append(((left, bottom), (right, bottom)))
|
|
571
|
+
if column_index == 0 or not mask[row_index, column_index - 1]:
|
|
572
|
+
segments.append(((left, top), (left, bottom)))
|
|
573
|
+
if column_index == columns - 1 or not mask[row_index, column_index + 1]:
|
|
574
|
+
segments.append(((right, top), (right, bottom)))
|
|
575
|
+
return segments
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def draw_epoch_response_plot(
|
|
579
|
+
ax: Axes,
|
|
580
|
+
*,
|
|
581
|
+
epoch: EpochResponsePlotData,
|
|
582
|
+
roi_indices: tuple[int, ...],
|
|
583
|
+
roi_colors: tuple[str, ...],
|
|
584
|
+
show_sem: bool,
|
|
585
|
+
time_bounds: tuple[float, float],
|
|
586
|
+
value_bounds: tuple[float, float],
|
|
587
|
+
) -> None:
|
|
588
|
+
"""Draw one editable response plot matching the live napari plot.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
ax: Matplotlib axis to draw into.
|
|
592
|
+
epoch: Plot-ready data for one stimulus epoch.
|
|
593
|
+
roi_indices: Zero-based ROI indices to draw.
|
|
594
|
+
roi_colors: Hex colors in plot ROI order.
|
|
595
|
+
show_sem: Whether to draw SEM bands.
|
|
596
|
+
time_bounds: Shared x-axis bounds.
|
|
597
|
+
value_bounds: Shared y-axis bounds.
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
None.
|
|
601
|
+
"""
|
|
602
|
+
for roi_index in roi_indices:
|
|
603
|
+
color = roi_colors[roi_index]
|
|
604
|
+
mean = epoch.mean_values[roi_index]
|
|
605
|
+
sem = epoch.sem_values[roi_index]
|
|
606
|
+
if show_sem:
|
|
607
|
+
ax.fill_between(
|
|
608
|
+
epoch.time_seconds,
|
|
609
|
+
mean - sem,
|
|
610
|
+
mean + sem,
|
|
611
|
+
color=color,
|
|
612
|
+
alpha=0.25,
|
|
613
|
+
linewidth=0.0,
|
|
614
|
+
zorder=1,
|
|
615
|
+
)
|
|
616
|
+
ax.plot(
|
|
617
|
+
epoch.time_seconds,
|
|
618
|
+
mean,
|
|
619
|
+
color=color,
|
|
620
|
+
linewidth=0.75,
|
|
621
|
+
zorder=2,
|
|
622
|
+
)
|
|
623
|
+
ax.axhline(0.0, color="#6d7683", linewidth=0.5, linestyle=(0, (6, 4)), zorder=0)
|
|
624
|
+
ax.axvline(0.0, color="#6d7683", linewidth=0.5, linestyle=(0, (6, 4)), zorder=0)
|
|
625
|
+
ax.set_xlim(*time_bounds)
|
|
626
|
+
ax.set_ylim(*value_bounds)
|
|
627
|
+
ax.set_xticks(tuple(np.linspace(time_bounds[0], time_bounds[1], 6)))
|
|
628
|
+
ax.set_yticks(tuple(np.linspace(value_bounds[0], value_bounds[1], 6)))
|
|
629
|
+
ax.set_xlabel("Time (s)")
|
|
630
|
+
ax.set_ylabel("dF/F")
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def save_figure_bundle(
|
|
634
|
+
fig: Figure,
|
|
635
|
+
path_without_suffix: Path,
|
|
636
|
+
*,
|
|
637
|
+
formats: tuple[str, ...] = DEFAULT_FORMATS,
|
|
638
|
+
) -> tuple[Path, ...]:
|
|
639
|
+
"""Save a figure as PDF and PNG with editable vector-friendly PDF text.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
fig: Matplotlib figure to save.
|
|
643
|
+
path_without_suffix: Output path without file extension.
|
|
644
|
+
formats: File suffixes to write.
|
|
645
|
+
|
|
646
|
+
Returns:
|
|
647
|
+
Paths written to disk.
|
|
648
|
+
"""
|
|
649
|
+
set_export_style()
|
|
650
|
+
path_without_suffix.parent.mkdir(parents=True, exist_ok=True)
|
|
651
|
+
written: list[Path] = []
|
|
652
|
+
for suffix in formats:
|
|
653
|
+
output_path = path_without_suffix.with_suffix(f".{suffix}")
|
|
654
|
+
fig.savefig(output_path, format=suffix, dpi=300, transparent=True)
|
|
655
|
+
written.append(output_path)
|
|
656
|
+
return tuple(written)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def set_export_style() -> None:
|
|
660
|
+
"""Apply Matplotlib settings that keep exported text and paths editable."""
|
|
661
|
+
mpl.rcParams["font.family"] = "sans-serif"
|
|
662
|
+
mpl.rcParams["font.sans-serif"] = [
|
|
663
|
+
"Atkinson Hyperlegible",
|
|
664
|
+
"Arial",
|
|
665
|
+
"Helvetica",
|
|
666
|
+
"DejaVu Sans",
|
|
667
|
+
]
|
|
668
|
+
mpl.rcParams["font.size"] = 7
|
|
669
|
+
mpl.rcParams["axes.labelsize"] = 7
|
|
670
|
+
mpl.rcParams["xtick.labelsize"] = 6
|
|
671
|
+
mpl.rcParams["ytick.labelsize"] = 6
|
|
672
|
+
mpl.rcParams["pdf.fonttype"] = 42
|
|
673
|
+
mpl.rcParams["ps.fonttype"] = 42
|
|
674
|
+
mpl.rcParams["svg.fonttype"] = "none"
|
|
675
|
+
mpl.rcParams["figure.facecolor"] = "none"
|
|
676
|
+
mpl.rcParams["axes.facecolor"] = "none"
|
|
677
|
+
mpl.rcParams["savefig.facecolor"] = "none"
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def safe_stem(text: str) -> str:
|
|
681
|
+
"""Return a filesystem-safe, readable file stem.
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
text: Human-readable plot title or label.
|
|
685
|
+
|
|
686
|
+
Returns:
|
|
687
|
+
Lowercase stem with unsafe characters replaced by underscores.
|
|
688
|
+
"""
|
|
689
|
+
characters = [
|
|
690
|
+
character.lower() if character.isalnum() else "_" for character in text.strip()
|
|
691
|
+
]
|
|
692
|
+
stem = "".join(characters).strip("_")
|
|
693
|
+
while "__" in stem:
|
|
694
|
+
stem = stem.replace("__", "_")
|
|
695
|
+
return stem or "figure"
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _visible_epochs(
|
|
699
|
+
plot_data: ResponsePlotData,
|
|
700
|
+
epoch_indices: tuple[int, ...],
|
|
701
|
+
) -> tuple[EpochResponsePlotData, ...]:
|
|
702
|
+
"""Return plot data for selected epochs in saved plot order."""
|
|
703
|
+
return tuple(
|
|
704
|
+
plot_data.epochs[index]
|
|
705
|
+
for index in epoch_indices
|
|
706
|
+
if 0 <= index < len(plot_data.epochs)
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _epoch_title(epoch: EpochResponsePlotData) -> str:
|
|
711
|
+
"""Return a display title for one epoch plot."""
|
|
712
|
+
return f"Epoch {epoch.epoch_number}: {epoch.epoch_name}"
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _axis_figure(
|
|
716
|
+
*,
|
|
717
|
+
axis_w_mm: float,
|
|
718
|
+
axis_h_mm: float,
|
|
719
|
+
left_margin_mm: float = 15.0,
|
|
720
|
+
bottom_margin_mm: float = 12.0,
|
|
721
|
+
right_margin_mm: float = 5.0,
|
|
722
|
+
top_margin_mm: float = 5.0,
|
|
723
|
+
) -> tuple[Figure, Axes]:
|
|
724
|
+
"""Create a figure with an exact data-axis size.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
axis_w_mm: Width of the data axis in millimeters.
|
|
728
|
+
axis_h_mm: Height of the data axis in millimeters.
|
|
729
|
+
left_margin_mm: Left margin for labels.
|
|
730
|
+
bottom_margin_mm: Bottom margin for labels.
|
|
731
|
+
right_margin_mm: Right margin.
|
|
732
|
+
top_margin_mm: Top margin.
|
|
733
|
+
|
|
734
|
+
Returns:
|
|
735
|
+
Matplotlib figure and axis.
|
|
736
|
+
|
|
737
|
+
Fixed axis dimensions make exported figures predictable in Illustrator.
|
|
738
|
+
Avoiding ``bbox_inches='tight'`` also avoids surprise clipping masks and
|
|
739
|
+
changing axis sizes.
|
|
740
|
+
"""
|
|
741
|
+
set_export_style()
|
|
742
|
+
total_w_mm = left_margin_mm + axis_w_mm + right_margin_mm
|
|
743
|
+
total_h_mm = bottom_margin_mm + axis_h_mm + top_margin_mm
|
|
744
|
+
fig = Figure(figsize=(total_w_mm * MM_TO_INCH, total_h_mm * MM_TO_INCH))
|
|
745
|
+
ax = fig.add_axes(
|
|
746
|
+
(
|
|
747
|
+
left_margin_mm / total_w_mm,
|
|
748
|
+
bottom_margin_mm / total_h_mm,
|
|
749
|
+
axis_w_mm / total_w_mm,
|
|
750
|
+
axis_h_mm / total_h_mm,
|
|
751
|
+
)
|
|
752
|
+
)
|
|
753
|
+
_style_axis(ax)
|
|
754
|
+
return fig, ax
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _style_axis(ax: Axes) -> None:
|
|
758
|
+
"""Apply simple publication-style axes to one response plot."""
|
|
759
|
+
ax.patch.set_alpha(0.0)
|
|
760
|
+
ax.spines["top"].set_visible(False)
|
|
761
|
+
ax.spines["right"].set_visible(False)
|
|
762
|
+
ax.spines["left"].set_linewidth(0.25)
|
|
763
|
+
ax.spines["bottom"].set_linewidth(0.25)
|
|
764
|
+
ax.tick_params(direction="in", width=0.25, length=1.4, pad=1.0)
|
|
765
|
+
ax.xaxis.labelpad = 1.0
|
|
766
|
+
ax.yaxis.labelpad = 0.0
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _figure_size_mm(fig: Figure) -> tuple[float, float]:
|
|
770
|
+
"""Return one figure's size in millimeters."""
|
|
771
|
+
width_in, height_in = fig.get_size_inches()
|
|
772
|
+
return float(width_in / MM_TO_INCH), float(height_in / MM_TO_INCH)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _layer_by_name(viewer: object | None, name: str) -> object | None:
|
|
776
|
+
"""Return a napari layer by name using napari's layer-list API when present."""
|
|
777
|
+
if viewer is None or not hasattr(viewer, "layers"):
|
|
778
|
+
return None
|
|
779
|
+
layers = cast(_ViewerWithLayers, viewer).layers
|
|
780
|
+
try:
|
|
781
|
+
return layers[name]
|
|
782
|
+
except (KeyError, TypeError, IndexError):
|
|
783
|
+
pass
|
|
784
|
+
for layer in layers:
|
|
785
|
+
if getattr(layer, "name", None) == name:
|
|
786
|
+
return layer
|
|
787
|
+
return None
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _current_movie_layer_index(viewer: object | None, frame_count: int) -> int:
|
|
791
|
+
"""Return the current movie-layer frame index from napari dims."""
|
|
792
|
+
if viewer is None or not hasattr(viewer, "dims"):
|
|
793
|
+
return 0
|
|
794
|
+
dims = cast(_ViewerWithDims, viewer).dims
|
|
795
|
+
for attribute in ("current_step", "point"):
|
|
796
|
+
value = getattr(dims, attribute, None)
|
|
797
|
+
if value is None:
|
|
798
|
+
continue
|
|
799
|
+
try:
|
|
800
|
+
return max(0, min(int(value[0]), frame_count - 1))
|
|
801
|
+
except (IndexError, TypeError, ValueError):
|
|
802
|
+
continue
|
|
803
|
+
return 0
|