TwoPy 0.1.2__tar.gz → 0.1.3__tar.gz

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 (122) hide show
  1. twopy-0.1.3/PKG-INFO +97 -0
  2. twopy-0.1.3/README.md +77 -0
  3. {twopy-0.1.2 → twopy-0.1.3}/pyproject.toml +1 -1
  4. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/database/cache.py +2 -5
  5. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/controls.py +43 -2
  6. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/loading.py +53 -1
  7. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/plotting/label_visibility.py +34 -8
  8. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/viewer.py +4 -2
  9. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/session.py +5 -1
  10. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/stimulus.py +8 -4
  11. twopy-0.1.3/src/twopy.egg-info/PKG-INFO +97 -0
  12. {twopy-0.1.2 → twopy-0.1.3}/tests/test_napari.py +87 -1
  13. {twopy-0.1.2 → twopy-0.1.3}/tests/test_session.py +24 -0
  14. {twopy-0.1.2 → twopy-0.1.3}/tests/test_stimulus.py +13 -0
  15. twopy-0.1.2/PKG-INFO +0 -304
  16. twopy-0.1.2/README.md +0 -284
  17. twopy-0.1.2/src/twopy.egg-info/PKG-INFO +0 -304
  18. {twopy-0.1.2 → twopy-0.1.3}/setup.cfg +0 -0
  19. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/__init__.py +0 -0
  20. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/_segments.py +0 -0
  21. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/_version.py +0 -0
  22. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/__init__.py +0 -0
  23. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/background_subtraction.py +0 -0
  24. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/dff.py +0 -0
  25. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/epoch_mapping.py +0 -0
  26. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/motion.py +0 -0
  27. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/persistence.py +0 -0
  28. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/response_processing/__init__.py +0 -0
  29. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/response_processing/apply.py +0 -0
  30. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/response_processing/options.py +0 -0
  31. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/response_processing/persistence.py +0 -0
  32. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/response_processing/qc.py +0 -0
  33. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/response_processing/signals.py +0 -0
  34. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/responses.py +0 -0
  35. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/trials.py +0 -0
  36. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/analysis/workflow.py +0 -0
  37. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/api.py +0 -0
  38. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/config.py +0 -0
  39. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/conversion/__init__.py +0 -0
  40. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/conversion/alignment_crop.py +0 -0
  41. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/conversion/api.py +0 -0
  42. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/conversion/frame_ranges.py +0 -0
  43. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/conversion/hdf5_writing.py +0 -0
  44. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/conversion/matlab_values.py +0 -0
  45. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/conversion/source_loading.py +0 -0
  46. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/conversion/stimulus_code.py +0 -0
  47. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/conversion/types.py +0 -0
  48. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/converted.py +0 -0
  49. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/database/__init__.py +0 -0
  50. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/database/catalog.py +0 -0
  51. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/database/connection.py +0 -0
  52. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/database/generic.py +0 -0
  53. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/database/modeled.py +0 -0
  54. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/database/sql.py +0 -0
  55. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/database/types.py +0 -0
  56. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/inspection.py +0 -0
  57. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/matlab.py +0 -0
  58. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/__init__.py +0 -0
  59. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/__main__.py +0 -0
  60. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/constants.py +0 -0
  61. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/display.py +0 -0
  62. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/display_paths.py +0 -0
  63. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/interactive.py +0 -0
  64. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/launcher.py +0 -0
  65. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/layout.py +0 -0
  66. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/movie.py +0 -0
  67. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/paths.py +0 -0
  68. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/plotting/__init__.py +0 -0
  69. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/plotting/data.py +0 -0
  70. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/plotting/docks.py +0 -0
  71. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/plotting/export.py +0 -0
  72. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/plotting/export_controls.py +0 -0
  73. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/plotting/options.py +0 -0
  74. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/plotting/panels.py +0 -0
  75. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/plotting/processing_options.py +0 -0
  76. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/plotting/widgets.py +0 -0
  77. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/protocols.py +0 -0
  78. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/responses.py +0 -0
  79. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/roi.py +0 -0
  80. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/session.py +0 -0
  81. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/state.py +0 -0
  82. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/text.py +0 -0
  83. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/napari/types.py +0 -0
  84. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/parity/__init__.py +0 -0
  85. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/parity/dff.py +0 -0
  86. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/parity/roi.py +0 -0
  87. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/parity/saved_analysis.py +0 -0
  88. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/photodiode.py +0 -0
  89. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/photodiode_classification.py +0 -0
  90. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/roi.py +0 -0
  91. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/spatial.py +0 -0
  92. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/synchronization.py +0 -0
  93. {twopy-0.1.2 → twopy-0.1.3}/src/twopy/typing_guards.py +0 -0
  94. {twopy-0.1.2 → twopy-0.1.3}/src/twopy.egg-info/SOURCES.txt +0 -0
  95. {twopy-0.1.2 → twopy-0.1.3}/src/twopy.egg-info/dependency_links.txt +0 -0
  96. {twopy-0.1.2 → twopy-0.1.3}/src/twopy.egg-info/entry_points.txt +0 -0
  97. {twopy-0.1.2 → twopy-0.1.3}/src/twopy.egg-info/requires.txt +0 -0
  98. {twopy-0.1.2 → twopy-0.1.3}/src/twopy.egg-info/top_level.txt +0 -0
  99. {twopy-0.1.2 → twopy-0.1.3}/tests/test_api.py +0 -0
  100. {twopy-0.1.2 → twopy-0.1.3}/tests/test_background_subtraction.py +0 -0
  101. {twopy-0.1.2 → twopy-0.1.3}/tests/test_config.py +0 -0
  102. {twopy-0.1.2 → twopy-0.1.3}/tests/test_conversion.py +0 -0
  103. {twopy-0.1.2 → twopy-0.1.3}/tests/test_converted.py +0 -0
  104. {twopy-0.1.2 → twopy-0.1.3}/tests/test_database.py +0 -0
  105. {twopy-0.1.2 → twopy-0.1.3}/tests/test_dff.py +0 -0
  106. {twopy-0.1.2 → twopy-0.1.3}/tests/test_epoch_mapping.py +0 -0
  107. {twopy-0.1.2 → twopy-0.1.3}/tests/test_import.py +0 -0
  108. {twopy-0.1.2 → twopy-0.1.3}/tests/test_inspection.py +0 -0
  109. {twopy-0.1.2 → twopy-0.1.3}/tests/test_matlab.py +0 -0
  110. {twopy-0.1.2 → twopy-0.1.3}/tests/test_motion.py +0 -0
  111. {twopy-0.1.2 → twopy-0.1.3}/tests/test_packaging.py +0 -0
  112. {twopy-0.1.2 → twopy-0.1.3}/tests/test_parity.py +0 -0
  113. {twopy-0.1.2 → twopy-0.1.3}/tests/test_persistence.py +0 -0
  114. {twopy-0.1.2 → twopy-0.1.3}/tests/test_photodiode_classification.py +0 -0
  115. {twopy-0.1.2 → twopy-0.1.3}/tests/test_real_fixture.py +0 -0
  116. {twopy-0.1.2 → twopy-0.1.3}/tests/test_response_processing.py +0 -0
  117. {twopy-0.1.2 → twopy-0.1.3}/tests/test_responses.py +0 -0
  118. {twopy-0.1.2 → twopy-0.1.3}/tests/test_roi.py +0 -0
  119. {twopy-0.1.2 → twopy-0.1.3}/tests/test_saved_analysis.py +0 -0
  120. {twopy-0.1.2 → twopy-0.1.3}/tests/test_synchronization.py +0 -0
  121. {twopy-0.1.2 → twopy-0.1.3}/tests/test_trials.py +0 -0
  122. {twopy-0.1.2 → twopy-0.1.3}/tests/test_workflow.py +0 -0
twopy-0.1.3/PKG-INFO ADDED
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: twopy
3
+ Version: 0.1.3
4
+ Summary: Two-photon imaging analysis tool with a napari interface.
5
+ Author-email: Gustavo Madeira Santana <fromweb1@gumadeiras.com>
6
+ License-Expression: MIT
7
+ Requires-Python: <3.14,>=3.13
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: h5py<4,>=3.16
10
+ Requires-Dist: matplotlib<4,>=3.10
11
+ Requires-Dist: napari[all]<0.8,>=0.7
12
+ Requires-Dist: numpy<3,>=2.3
13
+ Requires-Dist: pre-commit<5,>=4.6
14
+ Requires-Dist: qtpy<3,>=2.4
15
+ Requires-Dist: pyyaml<7,>=6.0.3
16
+ Requires-Dist: ruff>=0.15
17
+ Requires-Dist: scipy<2,>=1.17
18
+ Requires-Dist: tifffile<2027,>=2026.3
19
+ Requires-Dist: ty<0.1,>=0.0.34
20
+
21
+ # twopy
22
+
23
+ Two-photon imaging analysis tool for the Clark Lab output format.
24
+
25
+ ![twopy napari interface](https://raw.githubusercontent.com/gumadeiras/twopy/main/docs/assets/twopy-napari.png)
26
+
27
+ ## Getting Started
28
+
29
+ twopy lets you open two-photon recordings, draw ROIs, plot responses in real time,
30
+ process and analyze them, and save them.
31
+
32
+ When you first load a recording, twopy converts it to a standardized HDF5 format.
33
+ The converted format includes the aligned movie, mean image, stimulus tables,
34
+ photodiode signals, and recording metadata. Analysis and the GUI both work from
35
+ the converted files, so the original source files remain separate from twopy's outputs.
36
+
37
+ ## Install
38
+
39
+ Examples use micromamba, but any conda-compatible environment manager should work.
40
+
41
+
42
+ ```sh
43
+ micromamba create -n twopy -c conda-forge python=3.13 pip -y
44
+ micromamba run -n twopy python -m pip install twopy
45
+ ```
46
+
47
+ Verify the install:
48
+
49
+ ```sh
50
+ micromamba run -n twopy python -c 'import twopy; print(twopy.__name__)'
51
+ ```
52
+
53
+ For a source checkout, copy `config.example.yml` to `config.yml` and edit the
54
+ paths for your computer. `config.yml` stays local and is not tracked by git.
55
+
56
+ ## Start
57
+
58
+ ```sh
59
+ micromamba activate twopy
60
+ twopy
61
+ ```
62
+
63
+ Open a source recording or converted recording directly:
64
+
65
+ ```sh
66
+ twopy /path/to/source/recording
67
+ twopy /path/to/recording_data.h5
68
+ ```
69
+
70
+ Basic GUI flow:
71
+
72
+ 1. Choose a recording.
73
+ 2. Draw or edit ROIs in the `rois` Labels layer.
74
+ 3. Click Save ROIs.
75
+ 4. Update response plots from the current ROIs.
76
+ 5. Save analysis outputs.
77
+
78
+ When a source recording has not been converted yet, twopy converts it first.
79
+ Converted data includes the aligned movie, mean image, stimulus tables,
80
+ photodiode signals, and recording metadata.
81
+
82
+ ## Docs
83
+
84
+ - [GUI guide](docs/gui.md): napari loading, ROI editing, plotting, and saved
85
+ outputs.
86
+ - [Python API guide](docs/python_api.md): find recordings, convert data, run
87
+ analysis, and open napari from scripts.
88
+ - [Development guide](docs/development.md): source setup and local checks.
89
+ - [Input data spec](docs/input_data_spec.md): short recording folder contract.
90
+ - [Recording file reference](docs/recording_file_schema.md): detailed source
91
+ and converted file schema.
92
+
93
+ ## Check
94
+
95
+ ```sh
96
+ micromamba run -n twopy pre-commit run --all-files
97
+ ```
twopy-0.1.3/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # twopy
2
+
3
+ Two-photon imaging analysis tool for the Clark Lab output format.
4
+
5
+ ![twopy napari interface](https://raw.githubusercontent.com/gumadeiras/twopy/main/docs/assets/twopy-napari.png)
6
+
7
+ ## Getting Started
8
+
9
+ twopy lets you open two-photon recordings, draw ROIs, plot responses in real time,
10
+ process and analyze them, and save them.
11
+
12
+ When you first load a recording, twopy converts it to a standardized HDF5 format.
13
+ The converted format includes the aligned movie, mean image, stimulus tables,
14
+ photodiode signals, and recording metadata. Analysis and the GUI both work from
15
+ the converted files, so the original source files remain separate from twopy's outputs.
16
+
17
+ ## Install
18
+
19
+ Examples use micromamba, but any conda-compatible environment manager should work.
20
+
21
+
22
+ ```sh
23
+ micromamba create -n twopy -c conda-forge python=3.13 pip -y
24
+ micromamba run -n twopy python -m pip install twopy
25
+ ```
26
+
27
+ Verify the install:
28
+
29
+ ```sh
30
+ micromamba run -n twopy python -c 'import twopy; print(twopy.__name__)'
31
+ ```
32
+
33
+ For a source checkout, copy `config.example.yml` to `config.yml` and edit the
34
+ paths for your computer. `config.yml` stays local and is not tracked by git.
35
+
36
+ ## Start
37
+
38
+ ```sh
39
+ micromamba activate twopy
40
+ twopy
41
+ ```
42
+
43
+ Open a source recording or converted recording directly:
44
+
45
+ ```sh
46
+ twopy /path/to/source/recording
47
+ twopy /path/to/recording_data.h5
48
+ ```
49
+
50
+ Basic GUI flow:
51
+
52
+ 1. Choose a recording.
53
+ 2. Draw or edit ROIs in the `rois` Labels layer.
54
+ 3. Click Save ROIs.
55
+ 4. Update response plots from the current ROIs.
56
+ 5. Save analysis outputs.
57
+
58
+ When a source recording has not been converted yet, twopy converts it first.
59
+ Converted data includes the aligned movie, mean image, stimulus tables,
60
+ photodiode signals, and recording metadata.
61
+
62
+ ## Docs
63
+
64
+ - [GUI guide](docs/gui.md): napari loading, ROI editing, plotting, and saved
65
+ outputs.
66
+ - [Python API guide](docs/python_api.md): find recordings, convert data, run
67
+ analysis, and open napari from scripts.
68
+ - [Development guide](docs/development.md): source setup and local checks.
69
+ - [Input data spec](docs/input_data_spec.md): short recording folder contract.
70
+ - [Recording file reference](docs/recording_file_schema.md): detailed source
71
+ and converted file schema.
72
+
73
+ ## Check
74
+
75
+ ```sh
76
+ micromamba run -n twopy pre-commit run --all-files
77
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "twopy"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Two-photon imaging analysis tool with a napari interface."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.14"
@@ -11,9 +11,9 @@ import hashlib
11
11
  import json
12
12
  import shutil
13
13
  from pathlib import Path
14
- from typing import cast
15
14
 
16
15
  from twopy.database.types import DatabaseAccess
16
+ from twopy.typing_guards import string_key_mapping_or_none
17
17
 
18
18
  __all__ = [
19
19
  "DEFAULT_DATABASE_CACHE_DIR",
@@ -107,10 +107,7 @@ def _read_copy_manifest(manifest_path: Path) -> dict[str, object] | None:
107
107
  except (OSError, json.JSONDecodeError):
108
108
  return None
109
109
 
110
- if not isinstance(loaded, dict):
111
- return None
112
-
113
- return cast(dict[str, object], loaded)
110
+ return string_key_mapping_or_none(loaded)
114
111
 
115
112
 
116
113
  def _manifest_metadata_matches(
@@ -272,9 +272,14 @@ def _make_twopy_load_widget(state: NapariControlState) -> object:
272
272
  )
273
273
  movie_range = None
274
274
  if load_movie:
275
+ start_frame, end_frame = _widget_movie_frame_range_for_load(
276
+ movie_frame_range,
277
+ frame_count=loaded_recording.movie.shape[0],
278
+ preserve_current_range=replace_selected,
279
+ )
275
280
  movie_range = resolve_movie_frame_range(
276
- start_frame=int(movie_frame_range[0]),
277
- end_frame=int(movie_frame_range[1]),
281
+ start_frame=start_frame,
282
+ end_frame=end_frame,
278
283
  frame_count=loaded_recording.movie.shape[0],
279
284
  zero_end_means_last=True,
280
285
  )
@@ -717,6 +722,42 @@ def _default_movie_end_frame(recording: RecordingData | None) -> int:
717
722
  return max(recording.movie.shape[0] - 1, 0)
718
723
 
719
724
 
725
+ def _widget_movie_frame_range_for_load(
726
+ movie_frame_range: tuple[int, int],
727
+ *,
728
+ frame_count: int,
729
+ preserve_current_range: bool,
730
+ ) -> tuple[int, int]:
731
+ """Choose a GUI movie preview range for the recording being loaded.
732
+
733
+ Args:
734
+ movie_frame_range: Current range-slider value.
735
+ frame_count: Number of movie frames in the recording being loaded.
736
+ preserve_current_range: Whether this is a reload of the selected
737
+ recording after an option change.
738
+
739
+ Returns:
740
+ Inclusive preview range constrained to the recording length. New
741
+ recordings default to their full movie, while reloads preserve the
742
+ visible slider value where possible.
743
+
744
+ The frame-range widget keeps its previous value until after a recording
745
+ loads. That value is meaningful when reloading the same recording after an
746
+ option change, but stale when choosing a different recording.
747
+ """
748
+ last_frame = max(frame_count - 1, 0)
749
+ if not preserve_current_range:
750
+ return (0, last_frame)
751
+
752
+ start_frame = int(movie_frame_range[0])
753
+ end_frame = int(movie_frame_range[1])
754
+ if start_frame > last_frame:
755
+ start_frame = last_frame
756
+ if end_frame > last_frame:
757
+ end_frame = last_frame
758
+ return (start_frame, end_frame)
759
+
760
+
720
761
  def _load_recording_for_defaults(
721
762
  *,
722
763
  recording_data_path: Path,
@@ -193,6 +193,7 @@ def _first_source_recording_dir(candidates: tuple[Path | None, ...]) -> Path | N
193
193
  Resolved source folder, or ``None``.
194
194
  """
195
195
  seen: set[Path] = set()
196
+ validation_error: FileNotFoundError | ValueError | None = None
196
197
  for candidate in candidates:
197
198
  if candidate is None:
198
199
  continue
@@ -202,6 +203,57 @@ def _first_source_recording_dir(candidates: tuple[Path | None, ...]) -> Path | N
202
203
  seen.add(expanded)
203
204
  try:
204
205
  return discover_session_files(expanded).session_dir.resolve()
205
- except (FileNotFoundError, ValueError):
206
+ except (FileNotFoundError, ValueError) as error:
207
+ if validation_error is None and _looks_like_source_recording_dir(expanded):
208
+ validation_error = error
206
209
  continue
210
+ if validation_error is not None:
211
+ raise validation_error
207
212
  return None
213
+
214
+
215
+ def _looks_like_source_recording_dir(path: Path) -> bool:
216
+ """Return whether a folder appears to be microscope source output.
217
+
218
+ Args:
219
+ path: Candidate folder.
220
+
221
+ Returns:
222
+ ``True`` when the folder contains stable source-recording markers.
223
+
224
+ Discovery errors from source-like folders should reach the user. Errors
225
+ from arbitrary folders should stay quiet so twopy can continue checking
226
+ other candidates such as a parent source folder.
227
+ """
228
+ if not path.is_dir():
229
+ return False
230
+ fixed_markers = (
231
+ "stimulusData",
232
+ "alignedMovie.mat",
233
+ "defaultAlignChannel.txt",
234
+ "highResPd.mat",
235
+ "imageDescription.mat",
236
+ "imagingResPd.mat",
237
+ )
238
+ if any((path / marker).exists() for marker in fixed_markers):
239
+ return True
240
+ return any(_source_candidate_files(path, "*.tif")) or any(
241
+ _source_candidate_files(path, "*_alignment.txt")
242
+ )
243
+
244
+
245
+ def _source_candidate_files(path: Path, pattern: str) -> tuple[Path, ...]:
246
+ """Return source files matching a flexible microscope filename pattern.
247
+
248
+ Args:
249
+ path: Candidate source folder.
250
+ pattern: Glob pattern.
251
+
252
+ Returns:
253
+ Matching files excluding macOS AppleDouble sidecars.
254
+ """
255
+ return tuple(
256
+ candidate
257
+ for candidate in path.glob(pattern)
258
+ if candidate.is_file() and not candidate.name.startswith("._")
259
+ )
@@ -9,10 +9,12 @@ image, so hiding an ROI in the plot options cannot delete ROI data.
9
9
  """
10
10
 
11
11
  from collections.abc import Mapping
12
- from typing import Any, Protocol, cast
12
+ from typing import Protocol, cast
13
13
 
14
14
  from qtpy.QtGui import QColor
15
15
 
16
+ from twopy.typing_guards import int_or_none, string_key_mapping_or_none
17
+
16
18
  __all__ = [
17
19
  "apply_roi_visibility_to_labels_layer",
18
20
  "roi_label_value_from_label",
@@ -241,13 +243,11 @@ def _base_label_colormap_params(
241
243
  color cycle while users toggle ROI visibility.
242
244
  """
243
245
  stored = layer.metadata.get(_BASE_LABEL_COLORMAP_METADATA_KEY)
244
- if isinstance(stored, dict):
245
- stored_params = cast(dict[str, object], stored)
246
- return {
247
- "num_colors": int(cast(Any, stored_params["num_colors"])),
248
- "seed": float(cast(Any, stored_params["seed"])),
249
- "background_value": int(cast(Any, stored_params["background_value"])),
250
- }
246
+ stored_params = string_key_mapping_or_none(stored)
247
+ if stored_params is not None:
248
+ params = _parsed_label_colormap_params(stored_params)
249
+ if params is not None:
250
+ return params
251
251
 
252
252
  colormap = cast(_LabelColormapLike, layer.colormap)
253
253
  colors = getattr(colormap, "colors", ())
@@ -258,3 +258,29 @@ def _base_label_colormap_params(
258
258
  }
259
259
  layer.metadata[_BASE_LABEL_COLORMAP_METADATA_KEY] = params
260
260
  return params
261
+
262
+
263
+ def _parsed_label_colormap_params(
264
+ params: dict[str, object],
265
+ ) -> dict[str, int | float] | None:
266
+ """Return persisted label-colormap parameters when all fields parse."""
267
+ num_colors = int_or_none(params.get("num_colors"))
268
+ seed = _float_or_none(params.get("seed"))
269
+ background_value = int_or_none(params.get("background_value"))
270
+ if num_colors is None or seed is None or background_value is None:
271
+ return None
272
+ return {
273
+ "num_colors": num_colors,
274
+ "seed": seed,
275
+ "background_value": background_value,
276
+ }
277
+
278
+
279
+ def _float_or_none(value: object) -> float | None:
280
+ """Return a float for simple numeric metadata values."""
281
+ if isinstance(value, bool) or not isinstance(value, int | float | str):
282
+ return None
283
+ try:
284
+ return float(value)
285
+ except ValueError:
286
+ return None
@@ -32,7 +32,9 @@ from twopy.napari.types import NapariRecordingView
32
32
  from twopy.roi import RoiSet
33
33
  from twopy.spatial import SpatialCrop
34
34
 
35
- __all__ = ["open_recording_in_napari"]
35
+ APPLICATION_TITLE = "twopy"
36
+
37
+ __all__ = ["APPLICATION_TITLE", "create_viewer", "open_recording_in_napari"]
36
38
 
37
39
 
38
40
  class _LabelsLayerWithBrushSize(Protocol):
@@ -229,7 +231,7 @@ def create_viewer() -> NapariViewer:
229
231
  """
230
232
  import napari
231
233
 
232
- return cast(NapariViewer, napari.Viewer())
234
+ return cast(NapariViewer, napari.Viewer(title=APPLICATION_TITLE))
233
235
 
234
236
 
235
237
  def set_labels_brush_size(layer: object, *, brush_size: int) -> None:
@@ -154,7 +154,11 @@ def _find_one_file(session_dir: Path, pattern: str, label: str) -> Path:
154
154
  The raw movie and alignment text include stimulus/run details in their
155
155
  names. A strict one-match check avoids guessing when the folder is messy.
156
156
  """
157
- matches = sorted(path for path in session_dir.glob(pattern) if path.is_file())
157
+ matches = sorted(
158
+ path
159
+ for path in session_dir.glob(pattern)
160
+ if path.is_file() and not path.name.startswith("._")
161
+ )
158
162
 
159
163
  if len(matches) != 1:
160
164
  joined = ", ".join(str(path) for path in matches) if matches else "none"
@@ -10,9 +10,9 @@ per-``stimtype`` mapping explicit for scripts and analysis code.
10
10
  """
11
11
 
12
12
  from dataclasses import dataclass
13
- from typing import cast
14
13
 
15
14
  from twopy.converted import RecordingData
15
+ from twopy.typing_guards import require_string_key_mapping
16
16
 
17
17
  __all__ = [
18
18
  "StimulusSpecificColumnMapping",
@@ -176,10 +176,14 @@ def _mappings_from_metadata(
176
176
 
177
177
  mappings: list[StimulusSpecificColumnMapping] = []
178
178
  for column in columns:
179
- if not isinstance(column, dict):
179
+ try:
180
+ column_metadata = require_string_key_mapping(
181
+ column,
182
+ name=f"stimulus metadata for stimtype {stimtype!r} column",
183
+ )
184
+ except ValueError as error:
180
185
  msg = f"stimulus metadata for stimtype {stimtype!r} is malformed"
181
- raise ValueError(msg)
182
- column_metadata = cast(dict[str, object], column)
186
+ raise ValueError(msg) from error
183
187
  if column_metadata.get("column_name") != stable_column_name:
184
188
  continue
185
189
 
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: twopy
3
+ Version: 0.1.3
4
+ Summary: Two-photon imaging analysis tool with a napari interface.
5
+ Author-email: Gustavo Madeira Santana <fromweb1@gumadeiras.com>
6
+ License-Expression: MIT
7
+ Requires-Python: <3.14,>=3.13
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: h5py<4,>=3.16
10
+ Requires-Dist: matplotlib<4,>=3.10
11
+ Requires-Dist: napari[all]<0.8,>=0.7
12
+ Requires-Dist: numpy<3,>=2.3
13
+ Requires-Dist: pre-commit<5,>=4.6
14
+ Requires-Dist: qtpy<3,>=2.4
15
+ Requires-Dist: pyyaml<7,>=6.0.3
16
+ Requires-Dist: ruff>=0.15
17
+ Requires-Dist: scipy<2,>=1.17
18
+ Requires-Dist: tifffile<2027,>=2026.3
19
+ Requires-Dist: ty<0.1,>=0.0.34
20
+
21
+ # twopy
22
+
23
+ Two-photon imaging analysis tool for the Clark Lab output format.
24
+
25
+ ![twopy napari interface](https://raw.githubusercontent.com/gumadeiras/twopy/main/docs/assets/twopy-napari.png)
26
+
27
+ ## Getting Started
28
+
29
+ twopy lets you open two-photon recordings, draw ROIs, plot responses in real time,
30
+ process and analyze them, and save them.
31
+
32
+ When you first load a recording, twopy converts it to a standardized HDF5 format.
33
+ The converted format includes the aligned movie, mean image, stimulus tables,
34
+ photodiode signals, and recording metadata. Analysis and the GUI both work from
35
+ the converted files, so the original source files remain separate from twopy's outputs.
36
+
37
+ ## Install
38
+
39
+ Examples use micromamba, but any conda-compatible environment manager should work.
40
+
41
+
42
+ ```sh
43
+ micromamba create -n twopy -c conda-forge python=3.13 pip -y
44
+ micromamba run -n twopy python -m pip install twopy
45
+ ```
46
+
47
+ Verify the install:
48
+
49
+ ```sh
50
+ micromamba run -n twopy python -c 'import twopy; print(twopy.__name__)'
51
+ ```
52
+
53
+ For a source checkout, copy `config.example.yml` to `config.yml` and edit the
54
+ paths for your computer. `config.yml` stays local and is not tracked by git.
55
+
56
+ ## Start
57
+
58
+ ```sh
59
+ micromamba activate twopy
60
+ twopy
61
+ ```
62
+
63
+ Open a source recording or converted recording directly:
64
+
65
+ ```sh
66
+ twopy /path/to/source/recording
67
+ twopy /path/to/recording_data.h5
68
+ ```
69
+
70
+ Basic GUI flow:
71
+
72
+ 1. Choose a recording.
73
+ 2. Draw or edit ROIs in the `rois` Labels layer.
74
+ 3. Click Save ROIs.
75
+ 4. Update response plots from the current ROIs.
76
+ 5. Save analysis outputs.
77
+
78
+ When a source recording has not been converted yet, twopy converts it first.
79
+ Converted data includes the aligned movie, mean image, stimulus tables,
80
+ photodiode signals, and recording metadata.
81
+
82
+ ## Docs
83
+
84
+ - [GUI guide](docs/gui.md): napari loading, ROI editing, plotting, and saved
85
+ outputs.
86
+ - [Python API guide](docs/python_api.md): find recordings, convert data, run
87
+ analysis, and open napari from scripts.
88
+ - [Development guide](docs/development.md): source setup and local checks.
89
+ - [Input data spec](docs/input_data_spec.md): short recording folder contract.
90
+ - [Recording file reference](docs/recording_file_schema.md): detailed source
91
+ and converted file schema.
92
+
93
+ ## Check
94
+
95
+ ```sh
96
+ micromamba run -n twopy pre-commit run --all-files
97
+ ```
@@ -90,7 +90,11 @@ from twopy.napari.plotting.widgets import (
90
90
  )
91
91
  from twopy.napari.state import write_last_recording_folder
92
92
  from twopy.napari.text import counted_noun
93
- from twopy.napari.viewer import interior_random_frame_indices
93
+ from twopy.napari.viewer import (
94
+ APPLICATION_TITLE,
95
+ create_viewer,
96
+ interior_random_frame_indices,
97
+ )
94
98
  from twopy.spatial import SpatialCrop
95
99
 
96
100
 
@@ -400,6 +404,20 @@ class NapariAdapterTest(unittest.TestCase):
400
404
  environ["TWOPY_NAPARI_STATE_FILE"] = self._previous_state_file
401
405
  self._state_temp_dir.cleanup()
402
406
 
407
+ def test_create_viewer_uses_twopy_window_title(self) -> None:
408
+ """Confirm the launcher brands the top-level napari window.
409
+
410
+ Inputs: patched napari Viewer constructor.
411
+ Outputs: viewer construction receives the twopy application title.
412
+ """
413
+ viewer = _FakeViewer()
414
+
415
+ with patch("napari.Viewer", return_value=viewer) as viewer_constructor:
416
+ created = create_viewer()
417
+
418
+ self.assertIs(created, viewer)
419
+ viewer_constructor.assert_called_once_with(title=APPLICATION_TITLE)
420
+
403
421
  def test_opens_empty_roi_labels_layer_by_default(self) -> None:
404
422
  """Confirm new recordings open with an editable empty ROI layer.
405
423
 
@@ -1311,6 +1329,42 @@ class NapariAdapterTest(unittest.TestCase):
1311
1329
  self.assertEqual(load_widget.movie_frame_range.value, (0, 1))
1312
1330
  self.assertEqual(len(viewer.images), 2)
1313
1331
 
1332
+ def test_loading_shorter_recording_clamps_stale_movie_range(self) -> None:
1333
+ """Confirm old movie slider values do not block a shorter recording.
1334
+
1335
+ Inputs: two converted recordings where the first movie is longer.
1336
+ Outputs: the second recording loads and the slider fits its frame count.
1337
+ """
1338
+ with tempfile.TemporaryDirectory() as temp_dir:
1339
+ root = Path(temp_dir)
1340
+ first = root / "first"
1341
+ second = root / "second"
1342
+ first.mkdir()
1343
+ second.mkdir()
1344
+ _write_converted_recording(
1345
+ first,
1346
+ movie_values=np.arange(20, dtype=np.float64).reshape(5, 2, 2),
1347
+ )
1348
+ _write_converted_recording(
1349
+ second,
1350
+ movie_values=np.arange(12, dtype=np.float64).reshape(3, 2, 2),
1351
+ )
1352
+ viewer = _FakeViewer()
1353
+ control_docks = add_twopy_magicgui_controls(
1354
+ viewer,
1355
+ roi_labels_layer=None,
1356
+ roi_save_file=Path("unused.h5"),
1357
+ )
1358
+ controls = cast(_ControlWidget, control_docks.load_widget)
1359
+ load_widget = cast(Any, controls[1])
1360
+
1361
+ load_widget(recording_folder=first)
1362
+ load_widget.movie_frame_range.value = (3, 4)
1363
+ load_widget(recording_folder=second)
1364
+
1365
+ self.assertEqual(load_widget.movie_frame_range.value, (0, 2))
1366
+ self.assertEqual(len(viewer.images), 4)
1367
+
1314
1368
  def test_recording_path_resolution_reports_available_files(self) -> None:
1315
1369
  """Confirm folder resolution finds optional movie and ROI paths.
1316
1370
 
@@ -1380,6 +1434,20 @@ class NapariAdapterTest(unittest.TestCase):
1380
1434
  (source_dir / "twopy" / "aligned_movie.h5").resolve(),
1381
1435
  )
1382
1436
 
1437
+ def test_source_recording_validation_error_is_reported(self) -> None:
1438
+ """Confirm malformed source folders do not look like missing HDF5.
1439
+
1440
+ Inputs: source-shaped recording folder with two real TIFF movies.
1441
+ Outputs: the source discovery error reaches the caller.
1442
+ """
1443
+ with tempfile.TemporaryDirectory() as temp_dir:
1444
+ source_dir = Path(temp_dir)
1445
+ _write_source_recording_shape(source_dir)
1446
+ (source_dir / "second_movie.tif").touch()
1447
+
1448
+ with self.assertRaisesRegex(ValueError, "raw TIFF movie"):
1449
+ resolve_or_convert_recording(source_dir)
1450
+
1383
1451
  def test_recording_path_resolution_repairs_missing_converted_movie(self) -> None:
1384
1452
  """Confirm incomplete source-local twopy output is regenerated in place.
1385
1453
 
@@ -1593,6 +1661,24 @@ class NapariAdapterTest(unittest.TestCase):
1593
1661
  self.assertEqual(layer.get_color(1)[3], 1.0)
1594
1662
  self.assertAlmostEqual(layer.get_color(2)[3], 0.2)
1595
1663
 
1664
+ def test_roi_visibility_ignores_malformed_colormap_metadata(self) -> None:
1665
+ """Confirm malformed persisted label-colormap metadata is rebuilt.
1666
+
1667
+ Inputs: Labels layer with incomplete twopy colormap metadata.
1668
+ Outputs: visibility still applies instead of raising from metadata access.
1669
+ """
1670
+ layer = Labels(np.array([[1]], dtype=np.int64))
1671
+ layer.metadata["twopy_base_label_colormap"] = {"num_colors": "invalid"}
1672
+
1673
+ apply_roi_visibility_to_labels_layer(
1674
+ layer,
1675
+ roi_labels=("roi_1",),
1676
+ visibility={"roi_1": False},
1677
+ colors=(QColor("#ff0000"),),
1678
+ )
1679
+
1680
+ self.assertAlmostEqual(layer.get_color(1)[3], 0.2)
1681
+
1596
1682
  def test_display_helpers_transpose_movie_axes_for_napari(self) -> None:
1597
1683
  """Confirm movie axes are transposed only at the napari boundary.
1598
1684