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
@@ -0,0 +1,35 @@
1
+ """Validate frame ranges used by conversion helpers.
2
+
3
+ Inputs: frame counts and optional start/stop frame numbers.
4
+ Outputs: explicit valid ``(start, stop)`` frame ranges.
5
+ """
6
+
7
+
8
+ def normalize_frame_range(
9
+ *,
10
+ frame_count: int,
11
+ start_frame: int | None,
12
+ stop_frame: int | None,
13
+ ) -> tuple[int, int]:
14
+ """Normalize and validate an optional movie frame range.
15
+
16
+ Args:
17
+ frame_count: Total number of movie frames.
18
+ start_frame: Optional first frame.
19
+ stop_frame: Optional exclusive stop frame.
20
+
21
+ Returns:
22
+ Validated ``(start, stop)`` frame range.
23
+
24
+ Raises:
25
+ ValueError: If the requested range is empty or outside the movie.
26
+ """
27
+ start = 0 if start_frame is None else start_frame
28
+ stop = frame_count if stop_frame is None else stop_frame
29
+ if start < 0 or stop > frame_count or start >= stop:
30
+ msg = (
31
+ "Invalid frame range for mean image: "
32
+ f"start={start}, stop={stop}, frame_count={frame_count}"
33
+ )
34
+ raise ValueError(msg)
35
+ return start, stop
@@ -0,0 +1,362 @@
1
+ """Write converted twopy data into HDF5 files.
2
+
3
+ Inputs: loaded source conversion objects and computed summary images.
4
+ Outputs: HDF5 files owned by twopy, using gzip for large compressible arrays.
5
+
6
+ This module owns the on-disk converted layout. Source MATLAB and TIFF files are
7
+ read-only inputs handled before these writers run.
8
+ """
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ import h5py
14
+ import numpy as np
15
+ import numpy.typing as npt
16
+
17
+ from twopy.conversion.matlab_values import json_ready
18
+ from twopy.conversion.types import (
19
+ AlignedMovieSource,
20
+ AlignmentCropAudit,
21
+ PhotodiodeSignals,
22
+ SourceConversionInputs,
23
+ StimulusCodeMetadata,
24
+ StimulusData,
25
+ StimulusParameters,
26
+ )
27
+
28
+ __all__ = [
29
+ "ALIGNED_MOVIE_DATASET",
30
+ "CONVERTED_ALIGNED_MOVIE_FILENAME",
31
+ "CONVERTED_RECORDING_FILENAME",
32
+ "write_aligned_movie_file",
33
+ "write_recording_data_file",
34
+ ]
35
+
36
+ CONVERTED_RECORDING_FILENAME = "recording_data.h5"
37
+ CONVERTED_ALIGNED_MOVIE_FILENAME = "aligned_movie.h5"
38
+ ALIGNED_MOVIE_DATASET = "movie/aligned"
39
+ IMAGING_RES_PD_SAMPLE_DOMAIN = "one sample per aligned imaging frame"
40
+ HIGH_RES_PD_SAMPLE_DOMAIN = "high-rate photodiode signal for precise event detection"
41
+ SYNCHRONIZATION_MODEL = {
42
+ "imaging_clock": "imaging computer",
43
+ "stimulus_clock": "stimulus presentation computer",
44
+ "clock_relationship": (
45
+ "independent computers with different frame rates; align by photodiode, "
46
+ "not by nominal frame counts alone"
47
+ ),
48
+ "sync_signal": "photodiode",
49
+ "event_encoding": (
50
+ "stimulus computer flashes the photodiode at start, trial transitions, "
51
+ "and end; flash pattern or duration identifies the event type"
52
+ ),
53
+ "analysis_rule": (
54
+ "decode photodiode events before assigning imaging frames to stimulus "
55
+ "trials or computing responses"
56
+ ),
57
+ }
58
+
59
+
60
+ def write_recording_data_file(
61
+ *,
62
+ inputs: SourceConversionInputs,
63
+ output_path: Path,
64
+ mean_image: npt.NDArray[np.float64],
65
+ mean_start_frame: int,
66
+ mean_stop_frame: int,
67
+ ) -> None:
68
+ """Write non-movie converted recording data into the main HDF5 file.
69
+
70
+ Args:
71
+ inputs: Loaded source conversion inputs.
72
+ output_path: Destination ``recording_data.h5`` path.
73
+ mean_image: Precomputed quick-look image.
74
+ mean_start_frame: First frame included in the mean image.
75
+ mean_stop_frame: Exclusive stop frame for the mean image.
76
+
77
+ Returns:
78
+ None. The function writes ``recording_data.h5``.
79
+ """
80
+ with h5py.File(output_path, "w") as h5_file:
81
+ h5_file.attrs["twopy_format"] = "converted-recording"
82
+ h5_file.attrs["source_session_dir"] = str(inputs.session_files.session_dir)
83
+
84
+ _write_movie_reference_group(
85
+ h5_file=h5_file,
86
+ movie=inputs.aligned_movie,
87
+ alignment_crop=inputs.alignment_crop,
88
+ mean_image=mean_image,
89
+ mean_start_frame=mean_start_frame,
90
+ mean_stop_frame=mean_stop_frame,
91
+ )
92
+ metadata_group = h5_file.create_group("metadata")
93
+ _write_attrs(metadata_group, inputs.acquisition.fields)
94
+ run_group = h5_file.create_group("run")
95
+ _write_attrs(run_group, inputs.run.fields)
96
+ _write_stimulus_group(
97
+ h5_file,
98
+ inputs.stimulus_data,
99
+ inputs.stimulus_parameters,
100
+ inputs.stimulus_code,
101
+ )
102
+ _write_photodiode_group(h5_file, inputs.photodiode)
103
+ _write_frame_count_audit_group(h5_file, inputs)
104
+
105
+
106
+ def write_aligned_movie_file(
107
+ inputs: SourceConversionInputs,
108
+ movie_path: Path,
109
+ ) -> None:
110
+ """Write the large aligned movie into its own twopy HDF5 file.
111
+
112
+ Args:
113
+ inputs: Loaded source conversion inputs.
114
+ movie_path: Destination HDF5 path for aligned movie frames.
115
+
116
+ Returns:
117
+ None. The function writes the full aligned movie file.
118
+ """
119
+ with h5py.File(movie_path, "w") as h5_file:
120
+ h5_file.attrs["twopy_format"] = "aligned-movie"
121
+ h5_file.attrs["source_session_dir"] = str(inputs.session_files.session_dir)
122
+ movie_group = h5_file.create_group("movie")
123
+ _copy_aligned_movie(inputs.aligned_movie, movie_group)
124
+
125
+
126
+ def _write_movie_reference_group(
127
+ *,
128
+ h5_file: h5py.File,
129
+ movie: AlignedMovieSource,
130
+ alignment_crop: AlignmentCropAudit,
131
+ mean_image: npt.NDArray[np.float64],
132
+ mean_start_frame: int,
133
+ mean_stop_frame: int,
134
+ ) -> None:
135
+ """Write mean image data and a pointer to the separate aligned movie file.
136
+
137
+ Args:
138
+ h5_file: Open ``recording_data.h5`` file.
139
+ movie: Source aligned movie metadata.
140
+ alignment_crop: Alignment-derived crop audit metadata.
141
+ mean_image: Precomputed quick-look image.
142
+ mean_start_frame: First frame included in the mean image.
143
+ mean_stop_frame: Exclusive stop frame for the mean image.
144
+
145
+ Returns:
146
+ None. The function writes the ``movie`` group.
147
+ """
148
+ movie_group = h5_file.create_group("movie")
149
+ movie_group.attrs["aligned_movie_file"] = CONVERTED_ALIGNED_MOVIE_FILENAME
150
+ movie_group.attrs["aligned_movie_dataset"] = ALIGNED_MOVIE_DATASET
151
+ movie_group.attrs["aligned_movie_shape"] = movie.shape
152
+ movie_group.attrs["aligned_movie_dtype"] = movie.dtype
153
+
154
+ mean_dataset = movie_group.create_dataset(
155
+ "mean_image",
156
+ data=mean_image,
157
+ )
158
+ mean_dataset.attrs["start_frame"] = mean_start_frame
159
+ mean_dataset.attrs["stop_frame"] = mean_stop_frame
160
+ _write_alignment_crop_group(movie_group, alignment_crop)
161
+
162
+
163
+ def _write_alignment_crop_group(
164
+ movie_group: h5py.Group,
165
+ alignment_crop: AlignmentCropAudit,
166
+ ) -> None:
167
+ """Write alignment-valid crop metadata under the ``movie`` group.
168
+
169
+ Args:
170
+ movie_group: Open HDF5 ``movie`` group.
171
+ alignment_crop: ``AlignmentCropAudit`` object from source loading.
172
+
173
+ Returns:
174
+ None. The function writes crop bounds and audit attributes.
175
+ """
176
+ crop = alignment_crop.crop
177
+ crop_group = movie_group.create_group("alignment_valid_crop")
178
+ crop_group.attrs["source"] = crop.source
179
+ crop_group.attrs["axis0_start"] = crop.axis0_start
180
+ crop_group.attrs["axis0_stop"] = crop.axis0_stop
181
+ crop_group.attrs["axis1_start"] = crop.axis1_start
182
+ crop_group.attrs["axis1_stop"] = crop.axis1_stop
183
+ crop_group.attrs["original_shape"] = crop.original_shape
184
+ crop_group.attrs["alignment_frame_start"] = alignment_crop.alignment_frame_start
185
+ crop_group.attrs["alignment_frame_stop"] = alignment_crop.alignment_frame_stop
186
+ crop_group.attrs["x_cutoff_pixels"] = alignment_crop.x_cutoff_pixels
187
+ crop_group.attrs["y_cutoff_pixels"] = alignment_crop.y_cutoff_pixels
188
+ crop_group.attrs["over_moved_frame_count"] = alignment_crop.over_moved_frame_count
189
+ crop_group.attrs["motion_threshold_pixels"] = alignment_crop.motion_threshold_pixels
190
+ crop_group.create_dataset(
191
+ "alignment_shift_pixels",
192
+ data=alignment_crop.alignment_shift_pixels,
193
+ compression="gzip",
194
+ )
195
+ crop_group.create_dataset(
196
+ "motion_artifact_mask",
197
+ data=alignment_crop.motion_artifact_mask,
198
+ compression="gzip",
199
+ )
200
+
201
+
202
+ def _write_stimulus_group(
203
+ h5_file: h5py.File,
204
+ stimulus_data: StimulusData,
205
+ parameters: StimulusParameters,
206
+ code_metadata: StimulusCodeMetadata,
207
+ ) -> None:
208
+ """Write converted stimulus data and epoch parameters.
209
+
210
+ Args:
211
+ h5_file: Open ``recording_data.h5`` file.
212
+ stimulus_data: Stimulus data loaded from ``stimdata.mat``.
213
+ parameters: Stimulus epoch parameters loaded from ``stimParams.mat``.
214
+ code_metadata: Stimulus-code metadata decoded from ``filebackup.zip``.
215
+
216
+ Returns:
217
+ None. The function writes the ``stimulus`` group.
218
+ """
219
+ stimulus_group = h5_file.create_group("stimulus")
220
+ stimulus_group.attrs["clock_source"] = SYNCHRONIZATION_MODEL["stimulus_clock"]
221
+ stimulus_group.create_dataset(
222
+ "data",
223
+ data=stimulus_data.data,
224
+ compression="gzip",
225
+ )
226
+ stimulus_group.create_dataset(
227
+ "data_column_names",
228
+ data=np.asarray(stimulus_data.column_names, dtype=h5py.string_dtype("utf-8")),
229
+ )
230
+ stimulus_group.create_dataset(
231
+ "parameters_json",
232
+ data=json.dumps(json_ready(parameters.epochs)),
233
+ )
234
+ stimulus_group.create_dataset(
235
+ "function_lookup_json",
236
+ data=json.dumps(json_ready(code_metadata.function_lookup)),
237
+ )
238
+ stimulus_group.create_dataset(
239
+ "stimulus_specific_columns_json",
240
+ data=json.dumps(json_ready(code_metadata.stimulus_specific_columns)),
241
+ )
242
+
243
+
244
+ def _write_photodiode_group(
245
+ h5_file: h5py.File,
246
+ photodiode: PhotodiodeSignals,
247
+ ) -> None:
248
+ """Write photodiode vectors plus synchronization metadata.
249
+
250
+ Args:
251
+ h5_file: Open ``recording_data.h5`` file.
252
+ photodiode: Photodiode signals loaded from source MATLAB files.
253
+
254
+ Returns:
255
+ None. The function writes the ``photodiode`` group.
256
+ """
257
+ photodiode_group = h5_file.create_group("photodiode")
258
+ _write_synchronization_metadata(photodiode_group)
259
+
260
+ imaging_res_pd = photodiode_group.create_dataset(
261
+ "imaging_res_pd",
262
+ data=photodiode.imaging_res_pd,
263
+ compression="gzip",
264
+ )
265
+ imaging_res_pd.attrs["sample_domain"] = IMAGING_RES_PD_SAMPLE_DOMAIN
266
+
267
+ high_res_pd = photodiode_group.create_dataset(
268
+ "high_res_pd",
269
+ data=photodiode.high_res_pd,
270
+ compression="gzip",
271
+ )
272
+ high_res_pd.attrs["sample_domain"] = HIGH_RES_PD_SAMPLE_DOMAIN
273
+
274
+
275
+ def _write_frame_count_audit_group(
276
+ h5_file: h5py.File,
277
+ inputs: SourceConversionInputs,
278
+ ) -> None:
279
+ """Write source frame-count checks into the converted recording file.
280
+
281
+ Args:
282
+ h5_file: Open ``recording_data.h5`` file.
283
+ inputs: Loaded source conversion inputs with frame-count audit values.
284
+
285
+ Returns:
286
+ None. The function writes the ``frame_counts`` group.
287
+
288
+ These values keep the observed ScanImage one-frame metadata offset visible
289
+ before response-analysis code starts assigning frames to trials.
290
+ """
291
+ frame_counts = h5_file.create_group("frame_counts")
292
+ audit = inputs.frame_counts
293
+ frame_counts.attrs["aligned_movie_frames"] = audit.aligned_movie_frames
294
+ frame_counts.attrs["imaging_res_pd_samples"] = audit.imaging_res_pd_samples
295
+ frame_counts.attrs["acquisition_number_of_frames"] = (
296
+ audit.acquisition_number_of_frames
297
+ )
298
+ frame_counts.attrs["imaging_res_pd_minus_movie"] = audit.imaging_res_pd_minus_movie
299
+ frame_counts.attrs["acquisition_minus_movie"] = audit.acquisition_minus_movie
300
+
301
+
302
+ def _write_synchronization_metadata(group: h5py.Group) -> None:
303
+ """Write the timing model that makes photodiode data interpretable.
304
+
305
+ Args:
306
+ group: HDF5 group that receives synchronization attributes.
307
+
308
+ Returns:
309
+ None. The function writes human-readable HDF5 attributes.
310
+
311
+ These attributes are deliberately plain language. Later response-analysis
312
+ code should read them as the contract: stimulus rows and imaging frames are
313
+ on independent clocks until photodiode events align them.
314
+ """
315
+ for key, value in SYNCHRONIZATION_MODEL.items():
316
+ group.attrs[key] = value
317
+
318
+
319
+ def _copy_aligned_movie(source: AlignedMovieSource, movie_group: h5py.Group) -> None:
320
+ """Copy aligned movie frames into an HDF5 group in chunks.
321
+
322
+ Args:
323
+ source: Lazy source movie metadata and path.
324
+ movie_group: Destination HDF5 group in the aligned movie file.
325
+
326
+ Returns:
327
+ None. The function writes ``movie/aligned``.
328
+ """
329
+ chunk_frames = source.chunks[0] if source.chunks is not None else 64
330
+ with h5py.File(source.path, "r") as source_file:
331
+ source_dataset = source_file[source.dataset_name]
332
+ aligned = movie_group.create_dataset(
333
+ "aligned",
334
+ shape=source.shape,
335
+ dtype=source_dataset.dtype,
336
+ chunks=source.chunks,
337
+ compression="gzip",
338
+ )
339
+ aligned.attrs["source_path"] = str(source.path)
340
+ aligned.attrs["source_dataset"] = source.dataset_name
341
+
342
+ for start in range(0, source.shape[0], chunk_frames):
343
+ stop = min(start + chunk_frames, source.shape[0])
344
+ aligned[start:stop, :, :] = source_dataset[start:stop, :, :]
345
+
346
+
347
+ def _write_attrs(group: h5py.Group, values: dict[str, object]) -> None:
348
+ """Write scalar metadata values as HDF5 attributes.
349
+
350
+ Args:
351
+ group: HDF5 group that receives attributes.
352
+ values: Metadata dictionary.
353
+
354
+ Returns:
355
+ None.
356
+ """
357
+ for key, value in values.items():
358
+ ready = json_ready(value)
359
+ if isinstance(ready, str | int | float | bool):
360
+ group.attrs[key] = ready
361
+ else:
362
+ group.attrs[key] = json.dumps(ready)
@@ -0,0 +1,123 @@
1
+ """Convert MATLAB-loaded values into plain Python values.
2
+
3
+ Inputs: objects returned by SciPy MATLAB loading.
4
+ Outputs: scalars, tuples, dictionaries, and JSON-ready values.
5
+
6
+ The conversion layer keeps MATLAB-specific object handling out of analysis code.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Protocol, cast
11
+
12
+ import numpy as np
13
+ import scipy.io
14
+
15
+
16
+ class _MatlabStruct(Protocol):
17
+ """Small protocol for SciPy MATLAB structs.
18
+
19
+ Inputs: SciPy ``mat_struct`` objects.
20
+ Outputs: field names used by conversion helpers.
21
+ """
22
+
23
+ _fieldnames: list[str] | tuple[str, ...]
24
+
25
+
26
+ def load_scipy_variable(path: Path, name: str) -> object:
27
+ """Load one variable from an older MATLAB file through SciPy.
28
+
29
+ Args:
30
+ path: MATLAB file path.
31
+ name: Variable name to load.
32
+
33
+ Returns:
34
+ Loaded MATLAB variable as a Python/NumPy object.
35
+
36
+ Raises:
37
+ KeyError: If the variable is absent.
38
+ """
39
+ loaded = scipy.io.loadmat(path, squeeze_me=True, struct_as_record=False)
40
+ if name not in loaded:
41
+ msg = f"Missing variable {name!r} in {path}"
42
+ raise KeyError(msg)
43
+ return cast(object, loaded[name])
44
+
45
+
46
+ def nested_matlab_field(root: object, dotted_name: str) -> object:
47
+ """Read a dotted field path from a MATLAB struct.
48
+
49
+ Args:
50
+ root: MATLAB struct-like object.
51
+ dotted_name: Field path such as ``acq.frameRate``.
52
+
53
+ Returns:
54
+ Python value for the nested field.
55
+ """
56
+ value = root
57
+ for part in dotted_name.split("."):
58
+ value = getattr(value, part)
59
+ return matlab_value_to_python(value)
60
+
61
+
62
+ def matlab_struct_to_dict(value: object) -> dict[str, object]:
63
+ """Convert a MATLAB struct-like object to a plain dictionary.
64
+
65
+ Args:
66
+ value: Object returned by SciPy for a MATLAB struct.
67
+
68
+ Returns:
69
+ Dictionary of field names to converted values.
70
+ """
71
+ struct = cast(_MatlabStruct, value)
72
+ fields = tuple(struct._fieldnames)
73
+ return {field: matlab_value_to_python(getattr(struct, field)) for field in fields}
74
+
75
+
76
+ def matlab_value_to_python(value: object) -> object:
77
+ """Convert small MATLAB values into plain Python-friendly values.
78
+
79
+ Args:
80
+ value: MATLAB value loaded by SciPy.
81
+
82
+ Returns:
83
+ Scalars as Python scalars, struct arrays as tuples, and numeric arrays as
84
+ NumPy arrays.
85
+ """
86
+ if hasattr(value, "_fieldnames"):
87
+ return matlab_struct_to_dict(value)
88
+
89
+ if isinstance(value, np.ndarray):
90
+ if value.size == 0:
91
+ return ()
92
+ if value.dtype == object:
93
+ return tuple(matlab_value_to_python(item) for item in np.ravel(value))
94
+ if value.size == 1:
95
+ return matlab_value_to_python(np.ravel(value)[0])
96
+ return value
97
+
98
+ if isinstance(value, np.generic):
99
+ return value.item()
100
+
101
+ return value
102
+
103
+
104
+ def json_ready(value: object) -> object:
105
+ """Convert Python/NumPy values into JSON-compatible objects.
106
+
107
+ Args:
108
+ value: Converted MATLAB value or Python scalar.
109
+
110
+ Returns:
111
+ JSON-compatible nested scalars, lists, and dictionaries.
112
+ """
113
+ if isinstance(value, dict):
114
+ return {str(key): json_ready(item) for key, item in value.items()}
115
+ if isinstance(value, tuple | list):
116
+ return [json_ready(item) for item in value]
117
+ if isinstance(value, np.ndarray):
118
+ return np.asarray(value, dtype=object).tolist()
119
+ if isinstance(value, np.generic):
120
+ return value.item()
121
+ if isinstance(value, Path):
122
+ return str(value)
123
+ return value