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
twopy/napari/display.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Display-coordinate helpers for the twopy napari adapter.
|
|
2
|
+
|
|
3
|
+
Inputs: movie-coordinate images, label images, and spatial crops.
|
|
4
|
+
Outputs: napari-display arrays and full-frame movie-coordinate ROI labels.
|
|
5
|
+
|
|
6
|
+
twopy stores converted movie data as ``(frame, axis0, axis1)`` because those are
|
|
7
|
+
the HDF5 array axes. napari displays two-dimensional images as
|
|
8
|
+
``(row, column)``. These helpers keep that transpose explicit so ROI pixels
|
|
9
|
+
drawn in napari can be converted back to analysis coordinates without guessing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Any, Protocol, cast
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import numpy.typing as npt
|
|
16
|
+
|
|
17
|
+
from twopy.spatial import SpatialCrop
|
|
18
|
+
from twopy.typing_guards import (
|
|
19
|
+
int_or_none,
|
|
20
|
+
int_tuple_or_none,
|
|
21
|
+
string_key_mapping_or_none,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DISPLAY_CROP_METADATA_KEY = "twopy_display_spatial_crop"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"display_image_from_movie_image",
|
|
28
|
+
"display_labels_from_movie_labels",
|
|
29
|
+
"display_metadata_for_spatial_crop",
|
|
30
|
+
"movie_labels_from_display_layer",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _LayerWithData(Protocol):
|
|
35
|
+
"""Small protocol for napari-like layers with array data."""
|
|
36
|
+
|
|
37
|
+
data: object
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _LayerWithMetadata(Protocol):
|
|
41
|
+
"""Small protocol for napari-like layers with metadata."""
|
|
42
|
+
|
|
43
|
+
metadata: object
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _LayerWithOptions(Protocol):
|
|
47
|
+
"""Small protocol for fake test layers that store napari kwargs."""
|
|
48
|
+
|
|
49
|
+
options: dict[str, object]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def display_image_from_movie_image(
|
|
53
|
+
image: npt.NDArray[Any],
|
|
54
|
+
) -> npt.NDArray[Any]:
|
|
55
|
+
"""Convert a movie-coordinate image or movie block for napari display.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
image: Two-dimensional ``(axis0, axis1)`` image or three-dimensional
|
|
59
|
+
``(frame, axis0, axis1)`` movie block.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
View with the two spatial axes swapped for napari display.
|
|
63
|
+
"""
|
|
64
|
+
values = np.asarray(image)
|
|
65
|
+
if values.ndim not in (2, 3):
|
|
66
|
+
msg = f"Expected a 2-D image or 3-D movie block; got {values.shape}"
|
|
67
|
+
raise ValueError(msg)
|
|
68
|
+
return np.swapaxes(values, -1, -2)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def display_labels_from_movie_labels(
|
|
72
|
+
label_image: npt.NDArray[np.int64],
|
|
73
|
+
spatial_crop: SpatialCrop,
|
|
74
|
+
) -> npt.NDArray[np.int64]:
|
|
75
|
+
"""Convert full-frame movie-coordinate labels into napari display labels.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
label_image: Full-frame integer labels in movie coordinates.
|
|
79
|
+
spatial_crop: Spatial crop displayed in napari.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Display-coordinate label image for the crop.
|
|
83
|
+
|
|
84
|
+
The viewer shows the same alignment-valid crop used by default response
|
|
85
|
+
analysis. Cropping here prevents users from drawing ROIs in pixels that the
|
|
86
|
+
default analysis path will reject later.
|
|
87
|
+
"""
|
|
88
|
+
cropped_labels = spatial_crop.crop_image(label_image)
|
|
89
|
+
return display_image_from_movie_image(cropped_labels)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def movie_labels_from_display_layer(layer: object) -> npt.NDArray[np.int64]:
|
|
93
|
+
"""Read napari display labels and return full-frame movie labels.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
layer: napari Labels layer, or a test object with ``data`` and optional
|
|
97
|
+
crop metadata.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Full-frame integer label image in movie coordinates.
|
|
101
|
+
"""
|
|
102
|
+
display_labels = np.asarray(cast(_LayerWithData, layer).data, dtype=np.int64)
|
|
103
|
+
crop = _spatial_crop_from_layer_metadata(layer)
|
|
104
|
+
if crop is None:
|
|
105
|
+
return cast(npt.NDArray[np.int64], display_labels)
|
|
106
|
+
|
|
107
|
+
movie_crop_labels = display_image_from_movie_image(display_labels)
|
|
108
|
+
full_frame = np.zeros(crop.original_shape, dtype=np.int64)
|
|
109
|
+
full_frame[
|
|
110
|
+
crop.axis0_start : crop.axis0_stop,
|
|
111
|
+
crop.axis1_start : crop.axis1_stop,
|
|
112
|
+
] = movie_crop_labels
|
|
113
|
+
return full_frame
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def display_metadata_for_spatial_crop(spatial_crop: SpatialCrop) -> dict[str, object]:
|
|
117
|
+
"""Return napari layer metadata that records the displayed movie crop.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
spatial_crop: Crop shown in napari display coordinates.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Plain dictionary that can be stored in ``layer.metadata``.
|
|
124
|
+
"""
|
|
125
|
+
return {
|
|
126
|
+
DISPLAY_CROP_METADATA_KEY: {
|
|
127
|
+
"axis0_start": spatial_crop.axis0_start,
|
|
128
|
+
"axis0_stop": spatial_crop.axis0_stop,
|
|
129
|
+
"axis1_start": spatial_crop.axis1_start,
|
|
130
|
+
"axis1_stop": spatial_crop.axis1_stop,
|
|
131
|
+
"original_shape": spatial_crop.original_shape,
|
|
132
|
+
"source": spatial_crop.source,
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _spatial_crop_from_layer_metadata(layer: object) -> SpatialCrop | None:
|
|
138
|
+
"""Return the crop recorded on a napari layer, if present.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
layer: napari layer or test layer.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
``SpatialCrop`` or ``None`` when the layer has no twopy crop metadata.
|
|
145
|
+
"""
|
|
146
|
+
metadata = (
|
|
147
|
+
cast(_LayerWithMetadata, layer).metadata if hasattr(layer, "metadata") else None
|
|
148
|
+
)
|
|
149
|
+
if metadata is None and hasattr(layer, "options"):
|
|
150
|
+
metadata = cast(_LayerWithOptions, layer).options.get("metadata")
|
|
151
|
+
metadata_dict = string_key_mapping_or_none(metadata)
|
|
152
|
+
if metadata_dict is None:
|
|
153
|
+
return None
|
|
154
|
+
crop_data = metadata_dict.get(DISPLAY_CROP_METADATA_KEY)
|
|
155
|
+
crop_dict = string_key_mapping_or_none(crop_data)
|
|
156
|
+
if crop_dict is None:
|
|
157
|
+
return None
|
|
158
|
+
raw_shape = int_tuple_or_none(crop_dict.get("original_shape"), length=2)
|
|
159
|
+
if raw_shape is None:
|
|
160
|
+
return None
|
|
161
|
+
axis0_start = int_or_none(crop_dict.get("axis0_start"))
|
|
162
|
+
axis0_stop = int_or_none(crop_dict.get("axis0_stop"))
|
|
163
|
+
axis1_start = int_or_none(crop_dict.get("axis1_start"))
|
|
164
|
+
axis1_stop = int_or_none(crop_dict.get("axis1_stop"))
|
|
165
|
+
source = crop_dict.get("source")
|
|
166
|
+
if (
|
|
167
|
+
axis0_start is None
|
|
168
|
+
or axis0_stop is None
|
|
169
|
+
or axis1_start is None
|
|
170
|
+
or axis1_stop is None
|
|
171
|
+
or not isinstance(source, str)
|
|
172
|
+
):
|
|
173
|
+
return None
|
|
174
|
+
return SpatialCrop(
|
|
175
|
+
axis0_start=axis0_start,
|
|
176
|
+
axis0_stop=axis0_stop,
|
|
177
|
+
axis1_start=axis1_start,
|
|
178
|
+
axis1_stop=axis1_stop,
|
|
179
|
+
original_shape=(raw_shape[0], raw_shape[1]),
|
|
180
|
+
source=source,
|
|
181
|
+
)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Human-readable recording and output path labels for napari.
|
|
2
|
+
|
|
3
|
+
Inputs: loaded converted recordings and output paths.
|
|
4
|
+
Outputs: short strings for GUI labels and status messages.
|
|
5
|
+
|
|
6
|
+
Full recording paths are too long for the napari sidebars. These helpers keep
|
|
7
|
+
path presentation in one place while file-reading and file-writing code keeps
|
|
8
|
+
using full ``Path`` objects.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from twopy.config import DEFAULT_CONFIG_PATH, load_config
|
|
16
|
+
from twopy.converted import RecordingData
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"RecordingDisplaySummary",
|
|
20
|
+
"format_output_folder",
|
|
21
|
+
"format_twopy_h5_output",
|
|
22
|
+
"recording_display_summary",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_YEAR_PATTERN = re.compile(r"^\d{4}$")
|
|
26
|
+
_DATE_PATTERN = re.compile(r"^\d{2}_\d{2}$")
|
|
27
|
+
_TIME_PATTERN = re.compile(r"^\d{2}_\d{2}_\d{2}$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class RecordingDisplaySummary:
|
|
32
|
+
"""Short recording identity shown in the response Update tab.
|
|
33
|
+
|
|
34
|
+
Inputs: one converted recording path.
|
|
35
|
+
Outputs: root data path, genotype folder, stimulus folder, and recording
|
|
36
|
+
time.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
root: Path
|
|
40
|
+
genotype: str
|
|
41
|
+
stimulus: str
|
|
42
|
+
recording: str
|
|
43
|
+
|
|
44
|
+
def lines(self) -> tuple[str, str, str, str]:
|
|
45
|
+
"""Return display lines for a compact Qt label.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
None.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Four user-facing lines.
|
|
52
|
+
"""
|
|
53
|
+
return (
|
|
54
|
+
f"Root: {self.root}",
|
|
55
|
+
f"Genotype: {self.genotype}",
|
|
56
|
+
f"Stimulus: {self.stimulus}",
|
|
57
|
+
f"Recording: {self.recording}",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def recording_display_summary(recording: RecordingData) -> RecordingDisplaySummary:
|
|
62
|
+
"""Parse one recording into stable, readable identity fields.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
recording: Loaded converted recording.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Root/genotype/recording-time summary.
|
|
69
|
+
|
|
70
|
+
``config.yml`` gives the cleanest root and genotype split when present.
|
|
71
|
+
The fallback still reads the final ``YYYY/MM_DD/HH_MM_SS`` path contract so
|
|
72
|
+
napari remains usable from copied example folders.
|
|
73
|
+
"""
|
|
74
|
+
recording_dir = recording.source_session_dir.expanduser()
|
|
75
|
+
date_index = _recording_date_index(recording_dir)
|
|
76
|
+
recording_label = _recording_label(recording_dir, date_index)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
data_root = load_config(DEFAULT_CONFIG_PATH).data_path.expanduser()
|
|
80
|
+
relative = recording_dir.relative_to(data_root)
|
|
81
|
+
except (FileNotFoundError, ValueError):
|
|
82
|
+
data_root, genotype, stimulus = _fallback_path_parts(recording_dir, date_index)
|
|
83
|
+
else:
|
|
84
|
+
genotype = relative.parts[0] if len(relative.parts) > 0 else "unknown"
|
|
85
|
+
stimulus = relative.parts[1] if len(relative.parts) > 1 else "unknown"
|
|
86
|
+
|
|
87
|
+
return RecordingDisplaySummary(
|
|
88
|
+
root=data_root,
|
|
89
|
+
genotype=genotype,
|
|
90
|
+
stimulus=stimulus,
|
|
91
|
+
recording=recording_label,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def format_twopy_h5_output(path: Path) -> str:
|
|
96
|
+
"""Return the compact display form for twopy HDF5 outputs.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
path: Full output path.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
``./twopy/<filename>.h5`` style string.
|
|
103
|
+
"""
|
|
104
|
+
return f"./twopy/{path.name}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def format_output_folder(path: Path, recording: RecordingData | None) -> str:
|
|
108
|
+
"""Return a compact output-folder label for status messages.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
path: File or folder path that was written.
|
|
112
|
+
recording: Optional selected recording used to compute a twopy-relative
|
|
113
|
+
folder.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Short folder string such as ``./twopy`` or
|
|
117
|
+
``./twopy/exports/recording_view``.
|
|
118
|
+
"""
|
|
119
|
+
folder = path if path.suffix == "" else path.parent
|
|
120
|
+
if recording is None:
|
|
121
|
+
return str(folder)
|
|
122
|
+
|
|
123
|
+
twopy_dir = recording.path.parent
|
|
124
|
+
try:
|
|
125
|
+
relative = folder.relative_to(twopy_dir)
|
|
126
|
+
except ValueError:
|
|
127
|
+
return "./twopy"
|
|
128
|
+
|
|
129
|
+
if str(relative) == ".":
|
|
130
|
+
return "./twopy"
|
|
131
|
+
return f"./twopy/{relative}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _recording_date_index(recording_dir: Path) -> int | None:
|
|
135
|
+
"""Return the index of the ``YYYY/MM_DD/HH_MM_SS`` path segment.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
recording_dir: Source recording folder.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Index of the year segment, or ``None`` when the path is not recognized.
|
|
142
|
+
"""
|
|
143
|
+
parts = recording_dir.parts
|
|
144
|
+
for index in range(len(parts) - 2):
|
|
145
|
+
if (
|
|
146
|
+
_YEAR_PATTERN.match(parts[index]) is not None
|
|
147
|
+
and _DATE_PATTERN.match(parts[index + 1]) is not None
|
|
148
|
+
and _TIME_PATTERN.match(parts[index + 2]) is not None
|
|
149
|
+
):
|
|
150
|
+
return index
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _recording_label(recording_dir: Path, date_index: int | None) -> str:
|
|
155
|
+
"""Return the recording date label from the source recording path.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
recording_dir: Source recording folder.
|
|
159
|
+
date_index: Index of the year segment, if found.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
``YYYY-MM-DD HH:MM:SS`` or the folder name as a fallback.
|
|
163
|
+
"""
|
|
164
|
+
if date_index is None:
|
|
165
|
+
return recording_dir.name
|
|
166
|
+
|
|
167
|
+
parts = recording_dir.parts
|
|
168
|
+
year = parts[date_index]
|
|
169
|
+
month_day = parts[date_index + 1].replace("_", "-")
|
|
170
|
+
time = parts[date_index + 2].replace("_", ":")
|
|
171
|
+
return f"{year}-{month_day} {time}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _fallback_path_parts(
|
|
175
|
+
recording_dir: Path,
|
|
176
|
+
date_index: int | None,
|
|
177
|
+
) -> tuple[Path, str, str]:
|
|
178
|
+
"""Infer root, genotype, and stimulus when config is unavailable.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
recording_dir: Source recording folder.
|
|
182
|
+
date_index: Index of the year segment, if found.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
``(root, genotype, stimulus)`` best effort.
|
|
186
|
+
|
|
187
|
+
Stable lab recording paths end as
|
|
188
|
+
``<genotype>/<stimulus>/<YYYY>/<MM_DD>/<HH_MM_SS>``. The fallback keeps that
|
|
189
|
+
contract explicit instead of guessing from folder names.
|
|
190
|
+
"""
|
|
191
|
+
if date_index is None or date_index < 2:
|
|
192
|
+
return recording_dir.parent, recording_dir.name, "unknown"
|
|
193
|
+
|
|
194
|
+
parts = recording_dir.parts
|
|
195
|
+
genotype_index = date_index - 2
|
|
196
|
+
stimulus_index = date_index - 1
|
|
197
|
+
return Path(*parts[:genotype_index]), parts[genotype_index], parts[stimulus_index]
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Live response updates for napari ROI editing.
|
|
2
|
+
|
|
3
|
+
Inputs: the selected converted recording, its editable Labels layer, and the
|
|
4
|
+
response plot widget.
|
|
5
|
+
Outputs: debounced plot refreshes as ROI pixels change.
|
|
6
|
+
|
|
7
|
+
The controller owns GUI orchestration only. It copies Labels pixels on the GUI
|
|
8
|
+
thread, then runs the heavier response calculation through the same in-memory
|
|
9
|
+
analysis helper used by the manual update button.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Protocol, cast
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from qtpy.QtCore import QTimer
|
|
20
|
+
|
|
21
|
+
from twopy.converted import RecordingData
|
|
22
|
+
from twopy.napari.plotting.data import ResponsePlotData
|
|
23
|
+
from twopy.napari.responses import compute_response_plot_data_from_roi_set
|
|
24
|
+
from twopy.napari.roi import roi_label_image_from_layer_for_recording
|
|
25
|
+
from twopy.roi import make_roi_set_from_label_image
|
|
26
|
+
|
|
27
|
+
__all__ = ["LiveResponseController"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ResponsePlotReceiver(Protocol):
|
|
31
|
+
"""Small protocol for the plot widget methods used by live updates."""
|
|
32
|
+
|
|
33
|
+
def set_response_plot_data(
|
|
34
|
+
self,
|
|
35
|
+
plot_data: ResponsePlotData,
|
|
36
|
+
*,
|
|
37
|
+
reset_axes: bool,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Show newly computed plot data.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
plot_data: Plot-ready response data.
|
|
43
|
+
reset_axes: Whether plot bounds should return to data-derived
|
|
44
|
+
defaults.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
None.
|
|
48
|
+
"""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
def show_response_status(self, text: str) -> None:
|
|
52
|
+
"""Show a user-facing response status message.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
text: Status text.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
None.
|
|
59
|
+
"""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _EventEmitter(Protocol):
|
|
64
|
+
"""Small protocol for psygnal event emitters exposed by napari layers."""
|
|
65
|
+
|
|
66
|
+
def connect(self, callback: Callable[..., None]) -> object:
|
|
67
|
+
"""Connect one callback to the event emitter."""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
def disconnect(self, callback: Callable[..., None]) -> object:
|
|
71
|
+
"""Disconnect one callback from the event emitter."""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class _ConnectedEmitter:
|
|
77
|
+
"""One napari Labels event connection owned by the controller."""
|
|
78
|
+
|
|
79
|
+
emitter: _EventEmitter
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class LiveResponseController:
|
|
83
|
+
"""Debounced response updater for one active napari Labels layer.
|
|
84
|
+
|
|
85
|
+
Inputs: a plot widget plus the selected recording/layer context.
|
|
86
|
+
Outputs: live response plot refreshes after ROI edits.
|
|
87
|
+
|
|
88
|
+
The controller keeps only one worker job active. If ROI pixels change while
|
|
89
|
+
a job is running, the running result is discarded when stale and one newer
|
|
90
|
+
job is scheduled. This keeps the UI responsive without building a complex
|
|
91
|
+
queue that would compute obsolete ROIs.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
plot_widget: ResponsePlotReceiver,
|
|
97
|
+
*,
|
|
98
|
+
debounce_ms: int = 400,
|
|
99
|
+
run_async: bool = True,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Create a live updater for one response plot widget.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
plot_widget: Widget that receives plot data or status messages.
|
|
105
|
+
debounce_ms: Delay after the latest ROI edit before recomputing.
|
|
106
|
+
run_async: Whether heavy analysis should run in a worker thread.
|
|
107
|
+
Tests can turn this off to verify behavior synchronously.
|
|
108
|
+
"""
|
|
109
|
+
self._plot_widget = plot_widget
|
|
110
|
+
self._recording: RecordingData | None = None
|
|
111
|
+
self._roi_labels_layer: object | None = None
|
|
112
|
+
self._connections: list[_ConnectedEmitter] = []
|
|
113
|
+
self._version = 0
|
|
114
|
+
self._active_version: int | None = None
|
|
115
|
+
self._pending_after_active_job = False
|
|
116
|
+
self._future: Future[ResponsePlotData] | None = None
|
|
117
|
+
self._executor = (
|
|
118
|
+
ThreadPoolExecutor(max_workers=1, thread_name_prefix="twopy-roi-live")
|
|
119
|
+
if run_async
|
|
120
|
+
else None
|
|
121
|
+
)
|
|
122
|
+
self._debounce_timer = QTimer()
|
|
123
|
+
self._debounce_timer.setSingleShot(True)
|
|
124
|
+
self._debounce_timer.timeout.connect(self._start_latest_job)
|
|
125
|
+
self._poll_timer = QTimer()
|
|
126
|
+
self._poll_timer.setInterval(75)
|
|
127
|
+
self._poll_timer.timeout.connect(self._collect_finished_job)
|
|
128
|
+
self._debounce_ms = debounce_ms
|
|
129
|
+
|
|
130
|
+
def set_context(
|
|
131
|
+
self,
|
|
132
|
+
recording: RecordingData | None,
|
|
133
|
+
roi_labels_layer: object | None,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Set the recording and Labels layer watched by live updates.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
recording: Selected converted recording, or ``None``.
|
|
139
|
+
roi_labels_layer: Editable Labels layer for that recording, or
|
|
140
|
+
``None``.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
None.
|
|
144
|
+
"""
|
|
145
|
+
self._disconnect_layer_events()
|
|
146
|
+
self._recording = recording
|
|
147
|
+
self._roi_labels_layer = roi_labels_layer
|
|
148
|
+
self._version += 1
|
|
149
|
+
self._pending_after_active_job = False
|
|
150
|
+
self._connect_layer_events(roi_labels_layer)
|
|
151
|
+
|
|
152
|
+
def request_update(self, *_event_args: object) -> None:
|
|
153
|
+
"""Schedule a live response update after an ROI edit.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
_event_args: Optional napari event arguments. They are ignored
|
|
157
|
+
because the Labels layer itself is the source of truth.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
None.
|
|
161
|
+
"""
|
|
162
|
+
self._version += 1
|
|
163
|
+
if self._debounce_ms <= 0:
|
|
164
|
+
self._start_latest_job()
|
|
165
|
+
return
|
|
166
|
+
self._debounce_timer.start(self._debounce_ms)
|
|
167
|
+
|
|
168
|
+
def update_now(self) -> None:
|
|
169
|
+
"""Compute responses immediately on the GUI thread.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
None.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
None.
|
|
176
|
+
|
|
177
|
+
The manual update button uses this synchronous path because a direct
|
|
178
|
+
button click should report validation errors immediately. Continuous ROI
|
|
179
|
+
edits use ``request_update`` so heavier work can run in the background.
|
|
180
|
+
"""
|
|
181
|
+
self._version += 1
|
|
182
|
+
version = self._version
|
|
183
|
+
try:
|
|
184
|
+
plot_data = self._compute_current_plot_data()
|
|
185
|
+
except ValueError as error:
|
|
186
|
+
if version == self._version:
|
|
187
|
+
self._plot_widget.show_response_status(str(error))
|
|
188
|
+
return
|
|
189
|
+
if version == self._version:
|
|
190
|
+
self._plot_widget.set_response_plot_data(plot_data, reset_axes=True)
|
|
191
|
+
|
|
192
|
+
def shutdown(self) -> None:
|
|
193
|
+
"""Disconnect layer events and stop background work.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
None.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
None.
|
|
200
|
+
"""
|
|
201
|
+
self._disconnect_layer_events()
|
|
202
|
+
self._debounce_timer.stop()
|
|
203
|
+
self._poll_timer.stop()
|
|
204
|
+
if self._executor is not None:
|
|
205
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
206
|
+
|
|
207
|
+
def _start_latest_job(self) -> None:
|
|
208
|
+
"""Start the newest queued response computation."""
|
|
209
|
+
if self._executor is None:
|
|
210
|
+
self.update_now()
|
|
211
|
+
return
|
|
212
|
+
if self._future is not None:
|
|
213
|
+
self._pending_after_active_job = True
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
version = self._version
|
|
217
|
+
try:
|
|
218
|
+
label_image = self._current_label_image()
|
|
219
|
+
except ValueError as error:
|
|
220
|
+
if version == self._version:
|
|
221
|
+
self._plot_widget.show_response_status(str(error))
|
|
222
|
+
return
|
|
223
|
+
if not np.any(label_image > 0):
|
|
224
|
+
if version == self._version:
|
|
225
|
+
self._plot_widget.show_response_status("No ROI labels to analyze.")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
recording = self._require_recording()
|
|
229
|
+
roi_set = make_roi_set_from_label_image(label_image)
|
|
230
|
+
self._active_version = version
|
|
231
|
+
self._future = self._executor.submit(
|
|
232
|
+
compute_response_plot_data_from_roi_set,
|
|
233
|
+
recording,
|
|
234
|
+
roi_set,
|
|
235
|
+
source_path=None,
|
|
236
|
+
)
|
|
237
|
+
self._poll_timer.start()
|
|
238
|
+
|
|
239
|
+
def _collect_finished_job(self) -> None:
|
|
240
|
+
"""Apply a finished worker result when it is still current."""
|
|
241
|
+
future = self._future
|
|
242
|
+
if future is None or not future.done():
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
version = self._active_version
|
|
246
|
+
self._future = None
|
|
247
|
+
self._active_version = None
|
|
248
|
+
self._poll_timer.stop()
|
|
249
|
+
try:
|
|
250
|
+
plot_data = future.result()
|
|
251
|
+
except ValueError as error:
|
|
252
|
+
if version == self._version:
|
|
253
|
+
self._plot_widget.show_response_status(str(error))
|
|
254
|
+
else:
|
|
255
|
+
if version == self._version:
|
|
256
|
+
self._plot_widget.set_response_plot_data(plot_data, reset_axes=True)
|
|
257
|
+
|
|
258
|
+
if self._pending_after_active_job or version != self._version:
|
|
259
|
+
self._pending_after_active_job = False
|
|
260
|
+
self._start_latest_job()
|
|
261
|
+
|
|
262
|
+
def _compute_current_plot_data(self) -> ResponsePlotData:
|
|
263
|
+
"""Return plot data for the current recording and Labels layer."""
|
|
264
|
+
label_image = self._current_label_image()
|
|
265
|
+
if not np.any(label_image > 0):
|
|
266
|
+
msg = "No ROI labels to analyze."
|
|
267
|
+
raise ValueError(msg)
|
|
268
|
+
return compute_response_plot_data_from_roi_set(
|
|
269
|
+
self._require_recording(),
|
|
270
|
+
make_roi_set_from_label_image(label_image),
|
|
271
|
+
source_path=None,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _current_label_image(self) -> np.ndarray:
|
|
275
|
+
"""Return full-frame ROI labels from the current Labels layer."""
|
|
276
|
+
recording = self._require_recording()
|
|
277
|
+
layer = self._roi_labels_layer
|
|
278
|
+
if layer is None:
|
|
279
|
+
msg = "No ROI Labels layer is available."
|
|
280
|
+
raise ValueError(msg)
|
|
281
|
+
return roi_label_image_from_layer_for_recording(layer, recording)
|
|
282
|
+
|
|
283
|
+
def _require_recording(self) -> RecordingData:
|
|
284
|
+
"""Return the selected recording or raise a user-facing error."""
|
|
285
|
+
if self._recording is None:
|
|
286
|
+
msg = "No recording loaded."
|
|
287
|
+
raise ValueError(msg)
|
|
288
|
+
return self._recording
|
|
289
|
+
|
|
290
|
+
def _connect_layer_events(self, layer: object | None) -> None:
|
|
291
|
+
"""Connect Labels data-change events to debounced updates."""
|
|
292
|
+
if layer is None:
|
|
293
|
+
return
|
|
294
|
+
events = getattr(layer, "events", None)
|
|
295
|
+
if events is None:
|
|
296
|
+
return
|
|
297
|
+
for event_name in ("paint", "data"):
|
|
298
|
+
emitter = getattr(events, event_name, None)
|
|
299
|
+
if emitter is None:
|
|
300
|
+
continue
|
|
301
|
+
connected = cast(_EventEmitter, emitter)
|
|
302
|
+
connected.connect(self.request_update)
|
|
303
|
+
self._connections.append(_ConnectedEmitter(connected))
|
|
304
|
+
|
|
305
|
+
def _disconnect_layer_events(self) -> None:
|
|
306
|
+
"""Disconnect all Labels event handlers owned by the controller."""
|
|
307
|
+
for connection in self._connections:
|
|
308
|
+
with suppress(RuntimeError, ValueError):
|
|
309
|
+
connection.emitter.disconnect(self.request_update)
|
|
310
|
+
self._connections.clear()
|