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.
Files changed (84) hide show
  1. twopy/__init__.py +168 -0
  2. twopy/_segments.py +53 -0
  3. twopy/analysis/__init__.py +101 -0
  4. twopy/analysis/background_subtraction.py +672 -0
  5. twopy/analysis/dff.py +524 -0
  6. twopy/analysis/epoch_mapping.py +223 -0
  7. twopy/analysis/motion.py +66 -0
  8. twopy/analysis/persistence.py +975 -0
  9. twopy/analysis/response_processing/__init__.py +55 -0
  10. twopy/analysis/response_processing/apply.py +298 -0
  11. twopy/analysis/response_processing/options.py +192 -0
  12. twopy/analysis/response_processing/qc.py +270 -0
  13. twopy/analysis/response_processing/signals.py +200 -0
  14. twopy/analysis/responses.py +345 -0
  15. twopy/analysis/trials.py +341 -0
  16. twopy/analysis/workflow.py +457 -0
  17. twopy/api.py +78 -0
  18. twopy/config.py +200 -0
  19. twopy/conversion/__init__.py +40 -0
  20. twopy/conversion/alignment_crop.py +329 -0
  21. twopy/conversion/api.py +113 -0
  22. twopy/conversion/frame_ranges.py +35 -0
  23. twopy/conversion/hdf5_writing.py +362 -0
  24. twopy/conversion/matlab_values.py +123 -0
  25. twopy/conversion/source_loading.py +403 -0
  26. twopy/conversion/stimulus_code.py +248 -0
  27. twopy/conversion/types.py +295 -0
  28. twopy/converted.py +681 -0
  29. twopy/database/__init__.py +33 -0
  30. twopy/database/cache.py +166 -0
  31. twopy/database/catalog.py +90 -0
  32. twopy/database/connection.py +27 -0
  33. twopy/database/generic.py +254 -0
  34. twopy/database/modeled.py +419 -0
  35. twopy/database/sql.py +46 -0
  36. twopy/database/types.py +116 -0
  37. twopy/inspection.py +492 -0
  38. twopy/matlab.py +398 -0
  39. twopy/napari/__init__.py +27 -0
  40. twopy/napari/__main__.py +10 -0
  41. twopy/napari/constants.py +13 -0
  42. twopy/napari/controls.py +739 -0
  43. twopy/napari/display.py +181 -0
  44. twopy/napari/display_paths.py +197 -0
  45. twopy/napari/interactive.py +310 -0
  46. twopy/napari/launcher.py +175 -0
  47. twopy/napari/layout.py +147 -0
  48. twopy/napari/loading.py +207 -0
  49. twopy/napari/movie.py +71 -0
  50. twopy/napari/paths.py +181 -0
  51. twopy/napari/plotting/__init__.py +21 -0
  52. twopy/napari/plotting/data.py +266 -0
  53. twopy/napari/plotting/docks.py +1052 -0
  54. twopy/napari/plotting/export.py +803 -0
  55. twopy/napari/plotting/export_controls.py +209 -0
  56. twopy/napari/plotting/label_visibility.py +260 -0
  57. twopy/napari/plotting/options.py +269 -0
  58. twopy/napari/plotting/panels.py +119 -0
  59. twopy/napari/plotting/widgets.py +767 -0
  60. twopy/napari/protocols.py +90 -0
  61. twopy/napari/responses.py +91 -0
  62. twopy/napari/roi.py +238 -0
  63. twopy/napari/session.py +381 -0
  64. twopy/napari/state.py +106 -0
  65. twopy/napari/text.py +26 -0
  66. twopy/napari/types.py +39 -0
  67. twopy/napari/viewer.py +405 -0
  68. twopy/parity/__init__.py +30 -0
  69. twopy/parity/dff.py +399 -0
  70. twopy/parity/roi.py +145 -0
  71. twopy/parity/saved_analysis.py +174 -0
  72. twopy/photodiode.py +375 -0
  73. twopy/photodiode_classification.py +484 -0
  74. twopy/roi.py +462 -0
  75. twopy/session.py +167 -0
  76. twopy/spatial.py +154 -0
  77. twopy/stimulus.py +233 -0
  78. twopy/synchronization.py +235 -0
  79. twopy/typing_guards.py +255 -0
  80. twopy-0.1.0.dist-info/METADATA +311 -0
  81. twopy-0.1.0.dist-info/RECORD +84 -0
  82. twopy-0.1.0.dist-info/WHEEL +5 -0
  83. twopy-0.1.0.dist-info/entry_points.txt +2 -0
  84. twopy-0.1.0.dist-info/top_level.txt +1 -0
twopy/config.py ADDED
@@ -0,0 +1,200 @@
1
+ """Load twopy configuration from a small YAML file.
2
+
3
+ Inputs: a YAML config file with lab paths and database access mode.
4
+ Outputs: a typed ``TwopyConfig`` object with normalized values.
5
+
6
+ The config layer keeps machine-specific paths out of analysis code and makes the
7
+ GUI easy to point at the right database and recording roots.
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Literal, cast
13
+
14
+ import yaml
15
+
16
+ from twopy.database.types import DatabaseAccess
17
+
18
+ __all__ = [
19
+ "DEFAULT_CONFIG_PATH",
20
+ "AnalysisOutputMode",
21
+ "TwopyConfig",
22
+ "load_config",
23
+ "resolve_analysis_output_dir",
24
+ ]
25
+
26
+ DEFAULT_CONFIG_PATH = Path("config.yml")
27
+ AnalysisOutputMode = Path | Literal["source"]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TwopyConfig:
32
+ """Machine-local paths used by twopy.
33
+
34
+ Inputs: lab paths, DB access mode, and analysis output from ``config.yml``.
35
+ Outputs: immutable config values that GUI and analysis code can pass around.
36
+
37
+ The paths are not required to exist during parsing so tests and machines
38
+ without mounted lab drives can still load and inspect configuration.
39
+ """
40
+
41
+ database_path: Path
42
+ data_path: Path
43
+ database_access: DatabaseAccess
44
+ analysis_output: AnalysisOutputMode
45
+
46
+
47
+ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> TwopyConfig:
48
+ """Load twopy configuration from YAML.
49
+
50
+ Args:
51
+ path: YAML config file path. Defaults to ``config.yml`` in the current
52
+ working directory.
53
+
54
+ Returns:
55
+ A typed configuration object with paths, DB access, and output mode.
56
+
57
+ Raises:
58
+ FileNotFoundError: If the config file is absent.
59
+ ValueError: If the YAML is not a mapping or required keys are missing.
60
+
61
+ YAML is used because the file is edited by people and benefits from comments.
62
+ """
63
+ config_path = path.expanduser()
64
+ if not config_path.is_file():
65
+ msg = f"Missing twopy config file: {config_path}"
66
+ raise FileNotFoundError(msg)
67
+
68
+ with config_path.open("r", encoding="utf-8") as config_file:
69
+ loaded: object = yaml.safe_load(config_file)
70
+
71
+ if not isinstance(loaded, dict):
72
+ msg = f"twopy config must be a YAML mapping: {config_path}"
73
+ raise ValueError(msg)
74
+
75
+ raw_config = cast(dict[object, object], loaded)
76
+
77
+ return TwopyConfig(
78
+ database_path=_required_path(raw_config, "database_path", config_path),
79
+ data_path=_required_path(raw_config, "data_path", config_path),
80
+ database_access=_database_access(raw_config, config_path),
81
+ analysis_output=_analysis_output(raw_config, config_path),
82
+ )
83
+
84
+
85
+ def resolve_analysis_output_dir(config: TwopyConfig, recording_dir: Path) -> Path:
86
+ """Resolve the output directory for one recording.
87
+
88
+ Args:
89
+ config: Loaded twopy configuration.
90
+ recording_dir: Recording folder being analyzed.
91
+
92
+ Returns:
93
+ Directory where twopy should write analysis output for the recording.
94
+
95
+ Raises:
96
+ ValueError: If ``analysis_output`` is a root path and the recording is
97
+ not inside ``config.data_path``.
98
+
99
+ ``analysis_output: source`` writes into ``recording_dir/twopy``. A path
100
+ value mirrors the recording's path relative to ``data_path`` under that
101
+ output root.
102
+ """
103
+ source_dir = recording_dir.expanduser()
104
+ if config.analysis_output == "source":
105
+ return source_dir / "twopy"
106
+
107
+ data_root = config.data_path.expanduser()
108
+ try:
109
+ relative_recording = source_dir.relative_to(data_root)
110
+ except ValueError as error:
111
+ msg = (
112
+ f"Recording {source_dir} is not inside configured data_path "
113
+ f"{data_root}; cannot mirror analysis output directory"
114
+ )
115
+ raise ValueError(msg) from error
116
+
117
+ return config.analysis_output / relative_recording
118
+
119
+
120
+ def _required_path(config: dict[object, object], key: str, config_path: Path) -> Path:
121
+ """Read one required path value from parsed YAML.
122
+
123
+ Args:
124
+ config: Parsed YAML mapping.
125
+ key: Required key to read.
126
+ config_path: Source config file path, used for clear errors.
127
+
128
+ Returns:
129
+ Expanded ``Path`` value for the requested key.
130
+
131
+ Raises:
132
+ ValueError: If the key is missing or is not a string.
133
+
134
+ This helper keeps config validation explicit and avoids hidden defaults.
135
+ """
136
+ value = config.get(key)
137
+ if not isinstance(value, str) or value == "":
138
+ msg = f"twopy config key {key!r} must be a non-empty string: {config_path}"
139
+ raise ValueError(msg)
140
+
141
+ return Path(value).expanduser()
142
+
143
+
144
+ def _database_access(
145
+ config: dict[object, object],
146
+ config_path: Path,
147
+ ) -> DatabaseAccess:
148
+ """Read the optional database access mode from parsed YAML.
149
+
150
+ Args:
151
+ config: Parsed YAML mapping.
152
+ config_path: Source config file path, used for clear errors.
153
+
154
+ Returns:
155
+ ``copy`` or ``direct``. Missing values default to ``copy``.
156
+
157
+ The default favors local DB copies because database queries over the network
158
+ can be slow, but transferring the DB file locally is usually fast. twopy
159
+ verifies whether a copy is stale before copying again.
160
+ """
161
+ value = config.get("database_access", "copy")
162
+ if value in {"copy", "direct"}:
163
+ return cast(DatabaseAccess, value)
164
+
165
+ msg = (
166
+ f"twopy config key 'database_access' must be 'copy' or 'direct': {config_path}"
167
+ )
168
+ raise ValueError(msg)
169
+
170
+
171
+ def _analysis_output(
172
+ config: dict[object, object],
173
+ config_path: Path,
174
+ ) -> AnalysisOutputMode:
175
+ """Read the required analysis output setting from parsed YAML.
176
+
177
+ Args:
178
+ config: Parsed YAML mapping.
179
+ config_path: Source config file path, used for clear errors.
180
+
181
+ Returns:
182
+ ``source`` or an expanded output root path.
183
+
184
+ Raises:
185
+ ValueError: If the key is absent or not a non-empty string.
186
+
187
+ Keeping this explicit avoids accidental writes to unclear locations.
188
+ """
189
+ value = config.get("analysis_output")
190
+ if not isinstance(value, str) or value == "":
191
+ msg = (
192
+ "twopy config key 'analysis_output' must be 'source' or a "
193
+ f"non-empty path string: {config_path}"
194
+ )
195
+ raise ValueError(msg)
196
+
197
+ if value == "source":
198
+ return "source"
199
+
200
+ return Path(value).expanduser()
@@ -0,0 +1,40 @@
1
+ """Convert microscope source files into twopy-owned HDF5 files.
2
+
3
+ Inputs: one validated two-photon recording session directory.
4
+ Outputs: HDF5 files containing twopy-owned datasets for later analysis.
5
+
6
+ This package keeps the public conversion API small while separating source
7
+ loading, HDF5 writing, and data-object definitions into focused modules.
8
+ """
9
+
10
+ from twopy.conversion.api import convert_recording_to_twopy
11
+ from twopy.conversion.source_loading import load_source_conversion_inputs
12
+ from twopy.conversion.types import (
13
+ AcquisitionMetadata,
14
+ AlignedMovieSource,
15
+ AlignmentCropAudit,
16
+ ConvertedRecording,
17
+ FrameCountAudit,
18
+ PhotodiodeSignals,
19
+ RunMetadata,
20
+ SourceConversionInputs,
21
+ StimulusCodeMetadata,
22
+ StimulusData,
23
+ StimulusParameters,
24
+ )
25
+
26
+ __all__ = [
27
+ "AcquisitionMetadata",
28
+ "AlignmentCropAudit",
29
+ "AlignedMovieSource",
30
+ "ConvertedRecording",
31
+ "FrameCountAudit",
32
+ "PhotodiodeSignals",
33
+ "RunMetadata",
34
+ "SourceConversionInputs",
35
+ "StimulusCodeMetadata",
36
+ "StimulusParameters",
37
+ "StimulusData",
38
+ "convert_recording_to_twopy",
39
+ "load_source_conversion_inputs",
40
+ ]
@@ -0,0 +1,329 @@
1
+ """Compute alignment-valid spatial crops during source conversion.
2
+
3
+ Inputs: alignment text offsets, acquisition metadata, movie shape, and
4
+ photodiode signals.
5
+ Outputs: a typed crop audit object saved into converted HDF5 metadata.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import numpy.typing as npt
12
+
13
+ from twopy.conversion.types import (
14
+ AcquisitionMetadata,
15
+ AlignedMovieSource,
16
+ AlignmentCropAudit,
17
+ PhotodiodeSignals,
18
+ )
19
+ from twopy.photodiode import stimulus_frame_bounds_from_photodiode
20
+ from twopy.spatial import SpatialCrop
21
+
22
+ __all__ = ["load_alignment_valid_crop"]
23
+
24
+ ONE_X_PIXELS_PER_MICRON_AT_256_X = 0.4
25
+ DEFAULT_MOTION_THRESHOLD_PERCENT = 150.0
26
+ DEFAULT_PLANE_MOTION_LIMIT_MICRONS = 5.0
27
+ DEFAULT_LINESCAN_MOTION_LIMIT_MICRONS = 15.0
28
+
29
+
30
+ def load_alignment_valid_crop(
31
+ *,
32
+ alignment_path: Path,
33
+ aligned_movie: AlignedMovieSource,
34
+ acquisition: AcquisitionMetadata,
35
+ photodiode: PhotodiodeSignals,
36
+ ) -> AlignmentCropAudit:
37
+ """Compute the alignment-valid crop used by crop-domain analysis.
38
+
39
+ Args:
40
+ alignment_path: Text file with per-frame alignment offsets.
41
+ aligned_movie: Aligned movie source metadata.
42
+ acquisition: Acquisition metadata with zoom and frame-rate fields.
43
+ photodiode: Photodiode vectors used to find stimulus-bounded frames.
44
+
45
+ Returns:
46
+ Crop bounds plus audit values.
47
+
48
+ The source analysis path estimates this crop from alignment rows inside the
49
+ stimulus presentation window, then applies background correction inside that
50
+ crop. twopy saves the same contract while keeping the converted movie
51
+ full-frame.
52
+ """
53
+ alignment = np.asarray(np.loadtxt(alignment_path), dtype=np.float64)
54
+ if alignment.ndim != 2 or alignment.shape[1] < 2:
55
+ msg = f"Alignment text must be a 2D table with x/y offsets: {alignment_path}"
56
+ raise ValueError(msg)
57
+
58
+ frame_start, frame_stop = _stimulus_alignment_frame_range(
59
+ aligned_movie=aligned_movie,
60
+ acquisition=acquisition,
61
+ photodiode=photodiode,
62
+ )
63
+ if frame_stop > alignment.shape[0]:
64
+ msg = (
65
+ "Stimulus-bounded alignment range exceeds alignment rows: "
66
+ f"[{frame_start}, {frame_stop}) for {alignment.shape[0]} rows"
67
+ )
68
+ raise ValueError(msg)
69
+
70
+ alignment_window = alignment[frame_start:frame_stop, :]
71
+ line_scan = _is_line_scan(acquisition)
72
+ motion_threshold_pixels = _motion_threshold_pixels(
73
+ acquisition=acquisition,
74
+ spatial_shape=_movie_spatial_shape(aligned_movie),
75
+ line_scan=line_scan,
76
+ )
77
+ alignment_shift_pixels = _alignment_shift_pixels(
78
+ alignment[: aligned_movie.shape[0], :],
79
+ line_scan=line_scan,
80
+ )
81
+ motion_artifact_mask = alignment_shift_pixels > motion_threshold_pixels
82
+ kept_alignment, over_moved_count = _remove_over_moved_alignment_rows(
83
+ alignment_window,
84
+ line_scan=line_scan,
85
+ motion_threshold_pixels=motion_threshold_pixels,
86
+ )
87
+ crop, x_cutoff, y_cutoff = _alignment_crop_from_offsets(
88
+ kept_alignment,
89
+ spatial_shape=_movie_spatial_shape(aligned_movie),
90
+ line_scan=line_scan,
91
+ )
92
+
93
+ return AlignmentCropAudit(
94
+ crop=crop,
95
+ alignment_frame_start=frame_start,
96
+ alignment_frame_stop=frame_stop,
97
+ x_cutoff_pixels=x_cutoff,
98
+ y_cutoff_pixels=y_cutoff,
99
+ over_moved_frame_count=over_moved_count,
100
+ motion_threshold_pixels=motion_threshold_pixels,
101
+ alignment_shift_pixels=alignment_shift_pixels,
102
+ motion_artifact_mask=motion_artifact_mask,
103
+ )
104
+
105
+
106
+ def _stimulus_alignment_frame_range(
107
+ *,
108
+ aligned_movie: AlignedMovieSource,
109
+ acquisition: AcquisitionMetadata,
110
+ photodiode: PhotodiodeSignals,
111
+ ) -> tuple[int, int]:
112
+ """Find the alignment rows that cover stimulus presentation.
113
+
114
+ Args:
115
+ aligned_movie: Movie metadata that gives frame count.
116
+ acquisition: Acquisition metadata with frame rate and line-scan flag.
117
+ photodiode: High-resolution photodiode signal.
118
+
119
+ Returns:
120
+ Half-open frame range used for the alignment-valid crop.
121
+ """
122
+ if _is_line_scan(acquisition):
123
+ return (0, aligned_movie.shape[0])
124
+
125
+ return stimulus_frame_bounds_from_photodiode(
126
+ photodiode.high_res_pd,
127
+ movie_frame_count=aligned_movie.shape[0],
128
+ frame_rate_hz=_required_float_field(acquisition, "acq.frameRate"),
129
+ )
130
+
131
+
132
+ def _motion_threshold_pixels(
133
+ *,
134
+ acquisition: AcquisitionMetadata,
135
+ spatial_shape: tuple[int, int],
136
+ line_scan: bool,
137
+ ) -> float:
138
+ """Convert the motion rejection threshold from microns to pixels.
139
+
140
+ Args:
141
+ acquisition: Acquisition metadata with zoom.
142
+ spatial_shape: Full movie spatial shape in twopy array axes.
143
+ line_scan: Whether this recording is a line scan.
144
+
145
+ Returns:
146
+ Maximum allowed alignment movement in pixels.
147
+ """
148
+ micron_limit = (
149
+ DEFAULT_LINESCAN_MOTION_LIMIT_MICRONS
150
+ if line_scan
151
+ else DEFAULT_PLANE_MOTION_LIMIT_MICRONS
152
+ )
153
+ zoom = _required_float_field(acquisition, "acq.zoomFactor")
154
+ x_axis_conversion = 256.0 / float(spatial_shape[0])
155
+ return micron_limit * ONE_X_PIXELS_PER_MICRON_AT_256_X * zoom * x_axis_conversion
156
+
157
+
158
+ def _remove_over_moved_alignment_rows(
159
+ alignment: npt.NDArray[np.float64],
160
+ *,
161
+ line_scan: bool,
162
+ motion_threshold_pixels: float,
163
+ ) -> tuple[npt.NDArray[np.float64], int]:
164
+ """Drop alignment rows with motion beyond the allowed threshold.
165
+
166
+ Args:
167
+ alignment: Stimulus-bounded alignment table.
168
+ line_scan: Whether this recording is a line scan.
169
+ motion_threshold_pixels: Maximum allowed movement in pixels.
170
+
171
+ Returns:
172
+ Filtered alignment rows and the number of dropped rows.
173
+ """
174
+ full_shift = _alignment_shift_pixels(alignment, line_scan=line_scan)
175
+ over_moved = full_shift > motion_threshold_pixels
176
+ over_moved_percent = (
177
+ 100.0 * float(np.count_nonzero(over_moved)) / alignment.shape[0]
178
+ )
179
+ if over_moved_percent > DEFAULT_MOTION_THRESHOLD_PERCENT:
180
+ return alignment, 0
181
+
182
+ kept = alignment[~over_moved, :]
183
+ if kept.size == 0:
184
+ msg = (
185
+ "Alignment-valid crop cannot be computed; all alignment rows moved too far"
186
+ )
187
+ raise ValueError(msg)
188
+ return kept, int(np.count_nonzero(over_moved))
189
+
190
+
191
+ def _alignment_shift_pixels(
192
+ alignment: npt.NDArray[np.float64],
193
+ *,
194
+ line_scan: bool,
195
+ ) -> npt.NDArray[np.float64]:
196
+ """Return per-frame alignment shift magnitudes in pixels.
197
+
198
+ Args:
199
+ alignment: Alignment table with x/y offset columns.
200
+ line_scan: Whether this recording is a line scan.
201
+
202
+ Returns:
203
+ One shift magnitude per alignment row.
204
+ """
205
+ if line_scan:
206
+ return np.abs(alignment[:, 0])
207
+ return np.sqrt(alignment[:, 0] ** 2 + alignment[:, 1] ** 2)
208
+
209
+
210
+ def _alignment_crop_from_offsets(
211
+ alignment: npt.NDArray[np.float64],
212
+ *,
213
+ spatial_shape: tuple[int, int],
214
+ line_scan: bool,
215
+ ) -> tuple[SpatialCrop, int, int]:
216
+ """Convert alignment offsets into twopy movie-axis crop bounds.
217
+
218
+ Args:
219
+ alignment: Filtered alignment table.
220
+ spatial_shape: Full movie spatial shape in twopy array axes.
221
+ line_scan: Whether this recording is a line scan.
222
+
223
+ Returns:
224
+ Spatial crop plus x/y cutoff values.
225
+
226
+ Source alignment columns are x/y motion offsets. twopy arrays store x along
227
+ spatial axis 0 and y along spatial axis 1, so the crop is mapped directly to
228
+ those axes.
229
+ """
230
+ x_cutoff = _ceil_abs_offset(alignment[:, 0])
231
+ y_cutoff = 1 if line_scan else _ceil_abs_offset(alignment[:, 1])
232
+ axis0_start, axis0_stop = _crop_bounds_from_cutoff(
233
+ length=spatial_shape[0],
234
+ cutoff=x_cutoff,
235
+ )
236
+ axis1_start, axis1_stop = _crop_bounds_from_cutoff(
237
+ length=spatial_shape[1],
238
+ cutoff=y_cutoff,
239
+ )
240
+ return (
241
+ SpatialCrop(
242
+ axis0_start=axis0_start,
243
+ axis0_stop=axis0_stop,
244
+ axis1_start=axis1_start,
245
+ axis1_stop=axis1_stop,
246
+ original_shape=spatial_shape,
247
+ source="alignment_valid_crop",
248
+ ),
249
+ x_cutoff,
250
+ y_cutoff,
251
+ )
252
+
253
+
254
+ def _ceil_abs_offset(values: npt.NDArray[np.float64]) -> int:
255
+ """Return the ceiling of the largest absolute alignment offset.
256
+
257
+ Args:
258
+ values: One alignment offset column.
259
+
260
+ Returns:
261
+ Nonnegative integer cutoff.
262
+ """
263
+ return int(np.ceil(float(np.nanmax(np.abs(values)))))
264
+
265
+
266
+ def _crop_bounds_from_cutoff(*, length: int, cutoff: int) -> tuple[int, int]:
267
+ """Convert a source cutoff value into Python half-open bounds.
268
+
269
+ Args:
270
+ length: Axis length.
271
+ cutoff: One-based cutoff convention from the source crop code.
272
+
273
+ Returns:
274
+ ``(start, stop)`` bounds.
275
+ """
276
+ if cutoff <= 1:
277
+ return (0, length)
278
+ start = cutoff - 1
279
+ stop = length - cutoff + 1
280
+ if start >= stop:
281
+ msg = f"Alignment cutoff {cutoff} leaves no valid pixels for length {length}"
282
+ raise ValueError(msg)
283
+ return start, stop
284
+
285
+
286
+ def _movie_spatial_shape(aligned_movie: AlignedMovieSource) -> tuple[int, int]:
287
+ """Return the two spatial dimensions of the aligned source movie.
288
+
289
+ Args:
290
+ aligned_movie: Source movie metadata.
291
+
292
+ Returns:
293
+ ``(axis0, axis1)`` spatial shape.
294
+ """
295
+ if len(aligned_movie.shape) != 3:
296
+ msg = f"Aligned movie must be 3D; got {aligned_movie.shape}"
297
+ raise ValueError(msg)
298
+ return (aligned_movie.shape[1], aligned_movie.shape[2])
299
+
300
+
301
+ def _required_float_field(acquisition: AcquisitionMetadata, key: str) -> float:
302
+ """Read one required numeric acquisition metadata field.
303
+
304
+ Args:
305
+ acquisition: Loaded acquisition metadata.
306
+ key: Field name to read.
307
+
308
+ Returns:
309
+ Field value as a float.
310
+ """
311
+ value = acquisition.fields[key]
312
+ if isinstance(value, str | bytes | int | float | np.generic):
313
+ return float(value)
314
+
315
+ value_type = type(value).__name__
316
+ msg = f"Acquisition field {key!r} must be numeric, got {value_type}"
317
+ raise ValueError(msg)
318
+
319
+
320
+ def _is_line_scan(acquisition: AcquisitionMetadata) -> bool:
321
+ """Return whether acquisition metadata describes a line scan.
322
+
323
+ Args:
324
+ acquisition: Loaded acquisition metadata.
325
+
326
+ Returns:
327
+ ``True`` for line-scan recordings.
328
+ """
329
+ return _required_float_field(acquisition, "acq.scanAngleMultiplierSlow") == 0.0
@@ -0,0 +1,113 @@
1
+ """Public conversion workflow from microscope source files to twopy files.
2
+
3
+ Inputs: one source recording folder plus one output folder.
4
+ Outputs: a ``ConvertedRecording`` pointing at twopy-owned HDF5 files.
5
+
6
+ The workflow reads MATLAB/TIFF-derived source data only during conversion.
7
+ Analysis code should operate on the converted HDF5 files.
8
+ """
9
+
10
+ from pathlib import Path
11
+
12
+ from twopy.config import DEFAULT_CONFIG_PATH, load_config, resolve_analysis_output_dir
13
+ from twopy.conversion.frame_ranges import normalize_frame_range
14
+ from twopy.conversion.hdf5_writing import (
15
+ CONVERTED_ALIGNED_MOVIE_FILENAME,
16
+ CONVERTED_RECORDING_FILENAME,
17
+ write_aligned_movie_file,
18
+ write_recording_data_file,
19
+ )
20
+ from twopy.conversion.source_loading import load_source_conversion_inputs
21
+ from twopy.conversion.types import ConvertedRecording
22
+
23
+ __all__ = ["convert_recording_to_twopy"]
24
+
25
+
26
+ def convert_recording_to_twopy(
27
+ session_dir: Path,
28
+ output_dir: Path | None = None,
29
+ *,
30
+ mean_start_frame: int | None = None,
31
+ mean_stop_frame: int | None = None,
32
+ config_path: Path = DEFAULT_CONFIG_PATH,
33
+ ) -> ConvertedRecording:
34
+ """Convert one recording folder into twopy HDF5 files.
35
+
36
+ Args:
37
+ session_dir: Source recording folder.
38
+ output_dir: Optional directory that receives converted twopy HDF5 files.
39
+ When omitted, twopy uses ``analysis_output`` from config.
40
+ mean_start_frame: Optional first frame for the mean image.
41
+ mean_stop_frame: Optional exclusive stop frame for the mean image.
42
+ config_path: YAML config file used when ``output_dir`` is omitted.
43
+
44
+ Returns:
45
+ Summary of the converted HDF5 files.
46
+
47
+ The conversion copies source data into twopy-owned datasets. The aligned
48
+ movie gets its own file because it dominates size, while the recording data
49
+ file keeps metadata, stimulus, photodiode, and the quick-look mean image.
50
+ """
51
+ inputs = load_source_conversion_inputs(session_dir)
52
+ start_frame, stop_frame = normalize_frame_range(
53
+ frame_count=inputs.aligned_movie.shape[0],
54
+ start_frame=mean_start_frame,
55
+ stop_frame=mean_stop_frame,
56
+ )
57
+ mean_image = inputs.aligned_movie.mean_image(
58
+ start_frame=start_frame,
59
+ stop_frame=stop_frame,
60
+ )
61
+
62
+ destination_dir = _resolve_conversion_output_dir(
63
+ session_dir=inputs.session_files.session_dir,
64
+ output_dir=output_dir,
65
+ config_path=config_path,
66
+ )
67
+ destination_dir.mkdir(parents=True, exist_ok=True)
68
+ recording_data_path = destination_dir / CONVERTED_RECORDING_FILENAME
69
+ movie_path = destination_dir / CONVERTED_ALIGNED_MOVIE_FILENAME
70
+
71
+ write_aligned_movie_file(inputs, movie_path)
72
+ write_recording_data_file(
73
+ inputs=inputs,
74
+ output_path=recording_data_path,
75
+ mean_image=mean_image,
76
+ mean_start_frame=start_frame,
77
+ mean_stop_frame=stop_frame,
78
+ )
79
+
80
+ return ConvertedRecording(
81
+ path=recording_data_path,
82
+ movie_path=movie_path,
83
+ source_session_dir=inputs.session_files.session_dir,
84
+ movie_shape=inputs.aligned_movie.shape,
85
+ mean_image_start_frame=start_frame,
86
+ mean_image_stop_frame=stop_frame,
87
+ )
88
+
89
+
90
+ def _resolve_conversion_output_dir(
91
+ *,
92
+ session_dir: Path,
93
+ output_dir: Path | None,
94
+ config_path: Path,
95
+ ) -> Path:
96
+ """Choose where conversion output should be written.
97
+
98
+ Args:
99
+ session_dir: Source recording folder.
100
+ output_dir: Caller-provided output directory, if any.
101
+ config_path: Config file used when ``output_dir`` is omitted.
102
+
103
+ Returns:
104
+ Expanded output directory path.
105
+
106
+ Passing ``output_dir`` is an explicit override. Otherwise conversion follows
107
+ the same ``analysis_output`` routing that the rest of twopy uses.
108
+ """
109
+ if output_dir is not None:
110
+ return output_dir.expanduser()
111
+
112
+ config = load_config(config_path)
113
+ return resolve_analysis_output_dir(config, session_dir)