PyOPIA 2.16.13__tar.gz → 2.16.15__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 (40) hide show
  1. {pyopia-2.16.13 → pyopia-2.16.15}/.gitignore +1 -0
  2. {pyopia-2.16.13 → pyopia-2.16.15}/PKG-INFO +1 -1
  3. pyopia-2.16.15/pyopia/__init__.py +1 -0
  4. pyopia-2.16.15/pyopia/tests/conftest.py +139 -0
  5. pyopia-2.16.15/pyopia/tests/test_cli.py +284 -0
  6. pyopia-2.16.13/pyopia/__init__.py +0 -1
  7. {pyopia-2.16.13 → pyopia-2.16.15}/LICENSE +0 -0
  8. {pyopia-2.16.13 → pyopia-2.16.15}/README.md +0 -0
  9. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/auxillarydata.py +0 -0
  10. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/background.py +0 -0
  11. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/cf_metadata.json +0 -0
  12. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/classify.py +0 -0
  13. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/classify_torch.py +0 -0
  14. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/cli.py +0 -0
  15. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/dataexport/__init__.py +0 -0
  16. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/dataexport/ecotaxa.py +0 -0
  17. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/exampledata.py +0 -0
  18. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/instrument/__init__.py +0 -0
  19. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/instrument/common.py +0 -0
  20. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/instrument/holo.py +0 -0
  21. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/instrument/silcam.py +0 -0
  22. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/instrument/uvp.py +0 -0
  23. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/io.py +0 -0
  24. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/metadata.py +0 -0
  25. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/pipeline.py +0 -0
  26. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/plotting.py +0 -0
  27. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/process.py +0 -0
  28. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/realtime.py +0 -0
  29. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/simulator/__init__.py +0 -0
  30. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/simulator/silcam.py +0 -0
  31. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/statistics.py +0 -0
  32. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/tests/__init__.py +0 -0
  33. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/tests/test_auxillarydata.py +0 -0
  34. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/tests/test_classify.py +0 -0
  35. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/tests/test_io.py +0 -0
  36. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/tests/test_notebooks.py +0 -0
  37. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/tests/test_pipeline.py +0 -0
  38. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/tests/test_process.py +0 -0
  39. {pyopia-2.16.13 → pyopia-2.16.15}/pyopia/tests/test_realtime.py +0 -0
  40. {pyopia-2.16.13 → pyopia-2.16.15}/pyproject.toml +0 -0
@@ -275,3 +275,4 @@ notebooks/model/*
275
275
  *.png
276
276
  *-summary.txt
277
277
  /dev/*
278
+ *.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyOPIA
3
- Version: 2.16.13
3
+ Version: 2.16.15
4
4
  Summary: A Python Ocean Particle Image Analysis toolbox.
5
5
  Project-URL: Repository, https://github.com/sintef/pyopia
6
6
  Project-URL: Documentation, https://pyopia.readthedocs.io
@@ -0,0 +1 @@
1
+ __version__ = "2.16.15"
@@ -0,0 +1,139 @@
1
+ '''
2
+ Shared pytest fixtures for pyopia.cli integration tests.
3
+ '''
4
+
5
+ import os
6
+ import shutil
7
+
8
+ import pytest
9
+ import toml
10
+ from typer.testing import CliRunner
11
+
12
+ import pyopia.exampledata
13
+ from pyopia.cli import app
14
+
15
+ runner = CliRunner()
16
+
17
+
18
+ def invoke_in(directory, args):
19
+ '''Invoke the CLI app with the working directory set to `directory`,
20
+ restoring the original working directory afterwards.
21
+ '''
22
+ original_cwd = os.getcwd()
23
+ os.chdir(directory)
24
+ try:
25
+ return runner.invoke(app, args)
26
+ finally:
27
+ os.chdir(original_cwd)
28
+
29
+
30
+ @pytest.fixture(scope='session')
31
+ def silcam_example_files(tmp_path_factory):
32
+ '''Download the real SilCam example image and classifier model once per test session.
33
+
34
+ pyopia.pipeline.FilesToProcess.prepare_chunking() refuses to process a raw file
35
+ list with fewer than 2 entries (`num_chunks > len(files) // 2`), even for the
36
+ default single-chunk case. Only one distinct real raw frame is available via
37
+ pyopia.exampledata, so the downloaded image is duplicated under a second,
38
+ differently-timestamped filename to satisfy that minimum. The image content
39
+ processed by the pipeline is still the real downloaded photo, just used twice;
40
+ no pipeline/classification logic is faked.
41
+ '''
42
+ download_dir = tmp_path_factory.mktemp('silcam_example_data')
43
+ image_filename = pyopia.exampledata.get_example_silc_image(str(download_dir))
44
+ model_path = pyopia.exampledata.get_example_model(str(download_dir))
45
+
46
+ original_path = download_dir / image_filename
47
+ duplicate_path = download_dir / 'D20181101T142732.838206.silc'
48
+ shutil.copy(original_path, duplicate_path)
49
+
50
+ return {
51
+ 'download_dir': download_dir,
52
+ 'model_path': model_path,
53
+ }
54
+
55
+
56
+ @pytest.fixture(scope='session')
57
+ def silcam_cli_project(tmp_path_factory, silcam_example_files):
58
+ '''Run the real `pyopia process` CLI command against the real downloaded SilCam
59
+ example image, producing genuine per-image STATS.nc output and exported ROI images.
60
+
61
+ Background correction is deliberately left out of this config: with only one real
62
+ raw frame available, CorrectBackgroundAccurate would build its background from
63
+ that same frame, which self-cancels the image and yields zero particles. This
64
+ mirrors the working config used in test_pipeline.py::test_silcam_pipeline.
65
+
66
+ Session-scoped so the real download and model inference only happens once, and is
67
+ shared by every CLI test that needs real particle stats/ROIs to operate on
68
+ (process, merge-mfdata, convert-raw-images, make-montage, export-to-ecotaxa).
69
+ '''
70
+ project_dir = tmp_path_factory.mktemp('silcam_cli_project')
71
+ outfolder = project_dir / 'proc'
72
+ roi_folder = outfolder / 'roi'
73
+
74
+ config_filename = project_dir / 'silcam-config.toml'
75
+ pipeline_config = {
76
+ 'general': {
77
+ 'raw_files': str(silcam_example_files['download_dir'] / '*.silc'),
78
+ 'pixel_size': 28,
79
+ },
80
+ 'steps': {
81
+ 'classifier': {
82
+ 'pipeline_class': 'pyopia.classify.Classify',
83
+ 'model_path': str(silcam_example_files['model_path']),
84
+ },
85
+ 'load': {
86
+ 'pipeline_class': 'pyopia.instrument.silcam.SilCamLoad',
87
+ },
88
+ 'imageprep': {
89
+ 'pipeline_class': 'pyopia.instrument.silcam.ImagePrep',
90
+ 'image_level': 'imraw',
91
+ },
92
+ 'segmentation': {
93
+ 'pipeline_class': 'pyopia.process.Segment',
94
+ 'threshold': 0.85,
95
+ 'segment_source': 'im_minimum',
96
+ },
97
+ 'statextract': {
98
+ 'pipeline_class': 'pyopia.process.CalculateStats',
99
+ 'export_outputpath': str(roi_folder),
100
+ 'roi_source': 'im_minimum',
101
+ },
102
+ 'output': {
103
+ 'pipeline_class': 'pyopia.io.StatsToDisc',
104
+ 'output_datafile': str(outfolder / 'test'),
105
+ 'append': False,
106
+ },
107
+ },
108
+ }
109
+ with open(config_filename, 'w') as fh:
110
+ toml.dump(pipeline_config, fh)
111
+
112
+ result = invoke_in(project_dir, ['process', str(config_filename)])
113
+ assert result.exit_code == 0, result.output
114
+
115
+ stats_files = sorted(outfolder.glob('*Image-D*-STATS.nc'))
116
+ assert len(stats_files) == 2, f'Expected two per-image STATS files, found: {stats_files}'
117
+
118
+ return {
119
+ 'project_dir': project_dir,
120
+ 'config_filename': config_filename,
121
+ 'outfolder': outfolder,
122
+ 'roi_folder': roi_folder,
123
+ 'stats_files': stats_files,
124
+ }
125
+
126
+
127
+ @pytest.fixture(scope='session')
128
+ def silcam_cli_merged_stats(silcam_cli_project):
129
+ '''Merge the per-image STATS.nc file(s) from `silcam_cli_project` via the real
130
+ `merge-mfdata` CLI command, producing a combined STATS.nc shared by the
131
+ make-montage and export-to-ecotaxa tests.
132
+ '''
133
+ outfolder = silcam_cli_project['outfolder']
134
+ result = invoke_in(silcam_cli_project['project_dir'], ['merge-mfdata', str(outfolder)])
135
+ assert result.exit_code == 0, result.output
136
+
137
+ merged_path = outfolder / 'test-STATS.nc'
138
+ assert merged_path.is_file()
139
+ return merged_path
@@ -0,0 +1,284 @@
1
+ '''
2
+ Tests for the pyopia.cli command line interface, using typer's CliRunner.
3
+
4
+ Commands that need real particle statistics/ROI images to operate on (process,
5
+ merge-mfdata, convert-raw-images, make-montage, export-to-ecotaxa) share a single
6
+ real end-to-end pipeline run against the downloaded SilCam example image, via the
7
+ session-scoped `silcam_cli_project` / `silcam_cli_merged_stats` fixtures defined in
8
+ conftest.py. This mirrors the real-download, real-processing approach already used
9
+ in test_pipeline.py and test_classify.py, rather than mocking the pipeline itself.
10
+ '''
11
+
12
+ import json
13
+ import pathlib
14
+ import zipfile
15
+
16
+ import pytest
17
+ import toml
18
+ import xarray
19
+
20
+ import pyopia
21
+ import pyopia.cli
22
+ from pyopia.cli import app
23
+
24
+ from pyopia.tests.conftest import invoke_in, runner
25
+
26
+
27
+ def test_generate_config_silcam(tmp_path):
28
+ result = invoke_in(tmp_path, [
29
+ 'generate-config', 'silcam', 'images/*.silc', 'model.keras', 'proc', 'test'
30
+ ])
31
+
32
+ assert result.exit_code == 0
33
+ config_path = tmp_path / 'silcam-config.toml'
34
+ assert config_path.is_file()
35
+
36
+ toml_settings = toml.load(config_path)
37
+ assert toml_settings['general']['raw_files'] == 'images/*.silc'
38
+ assert toml_settings['steps']['classifier']['model_path'] == 'model.keras'
39
+
40
+
41
+ def test_generate_config_holo(tmp_path):
42
+ result = invoke_in(tmp_path, [
43
+ 'generate-config', 'holo', 'images/*.pgm', 'model.keras', 'proc', 'test'
44
+ ])
45
+
46
+ assert result.exit_code == 0
47
+ config_path = tmp_path / 'holo-config.toml'
48
+ assert config_path.is_file()
49
+
50
+ toml_settings = toml.load(config_path)
51
+ assert toml_settings['general']['raw_files'] == 'images/*.pgm'
52
+ assert toml_settings['steps']['initial']['pipeline_class'] == 'pyopia.instrument.holo.Initial'
53
+
54
+
55
+ def test_generate_config_uvp(tmp_path):
56
+ result = invoke_in(tmp_path, [
57
+ 'generate-config', 'uvp', 'images/*.png', 'model.keras', 'proc', 'test'
58
+ ])
59
+
60
+ assert result.exit_code == 0
61
+ config_path = tmp_path / 'uvp-config.toml'
62
+ assert config_path.is_file()
63
+
64
+ toml_settings = toml.load(config_path)
65
+ assert toml_settings['general']['raw_files'] == 'images/*.png'
66
+
67
+
68
+ def test_modify_config(tmp_path):
69
+ generate_result = invoke_in(tmp_path, [
70
+ 'generate-config', 'silcam', 'images/*.silc', 'model.keras', 'proc', 'test'
71
+ ])
72
+ assert generate_result.exit_code == 0
73
+
74
+ modify_result = invoke_in(tmp_path, [
75
+ 'modify-config', 'silcam-config.toml', 'modified-config.toml',
76
+ '--raw-files', 'other_images/*.silc',
77
+ '--pixel-size', '12.5',
78
+ '--step-name', 'segmentation',
79
+ '--modify-arg', 'threshold',
80
+ '--modify-value', '0.85',
81
+ ])
82
+ assert modify_result.exit_code == 0
83
+
84
+ modified_path = tmp_path / 'modified-config.toml'
85
+ assert modified_path.is_file()
86
+
87
+ toml_settings = toml.load(modified_path)
88
+ assert toml_settings['general']['raw_files'] == 'other_images/*.silc'
89
+ assert toml_settings['general']['pixel_size'] == 12.5
90
+ assert toml_settings['steps']['segmentation']['threshold'] == 0.85
91
+
92
+
93
+ def test_docs_launches_readthedocs_url(monkeypatch):
94
+ launched = {}
95
+ monkeypatch.setattr(pyopia.cli.typer, 'launch', lambda url: launched.setdefault('url', url))
96
+
97
+ result = runner.invoke(app, ['docs'])
98
+
99
+ assert result.exit_code == 0
100
+ assert launched['url'] == 'https://pyopia.readthedocs.io'
101
+
102
+
103
+ def test_version_flag_prints_package_version():
104
+ result = runner.invoke(app, ['--version'])
105
+
106
+ assert result.exit_code == 0
107
+ assert f'PyOPIA version: {pyopia.__version__}' in result.output
108
+
109
+
110
+ def test_init_project_creates_expected_structure(tmp_path):
111
+ result = invoke_in(tmp_path, ['init-project', 'myproj'])
112
+
113
+ assert result.exit_code == 0, result.output
114
+
115
+ proj_folder = tmp_path / 'myproj'
116
+ assert (proj_folder / 'images').is_dir()
117
+ assert (proj_folder / 'auxillarydata' / 'auxillary_data.csv').is_file()
118
+ assert (proj_folder / 'README').is_file()
119
+ assert (proj_folder / 'metadata.json').is_file()
120
+ assert (proj_folder / 'config.toml').is_file()
121
+
122
+ config = toml.load(proj_folder / 'config.toml')
123
+ assert config['general']['raw_files'] == 'images/*.silc'
124
+ assert config['steps']['output']['project_metadata_file'] == 'metadata.json'
125
+ assert config['steps']['output']['auxillary_data_file'] == 'auxillarydata/auxillary_data.csv'
126
+
127
+ output_datafile = config['steps']['output']['output_datafile']
128
+ assert (proj_folder / pathlib.Path(output_datafile).parent).is_dir()
129
+
130
+ metadata = json.loads((proj_folder / 'metadata.json').read_text())
131
+ assert metadata['project_name'] == 'myproj'
132
+ assert metadata['instrument'] == 'silcam'
133
+
134
+
135
+ def test_init_project_refuses_to_overwrite_existing_folder(tmp_path):
136
+ existing_project = tmp_path / 'myproj'
137
+ existing_project.mkdir()
138
+
139
+ result = invoke_in(tmp_path, ['init-project', 'myproj'])
140
+
141
+ assert result.exit_code == 0
142
+ assert 'ERROR' in result.output
143
+ assert not (existing_project / 'config.toml').exists()
144
+
145
+
146
+ def test_check_chunks_rejects_less_than_one_chunk():
147
+ with pytest.raises(RuntimeError, match='at least 1 chunk'):
148
+ pyopia.cli.check_chunks(0, {'steps': {'output': {}}})
149
+
150
+
151
+ def test_check_chunks_rejects_multiple_chunks_when_append_enabled():
152
+ with pytest.raises(RuntimeError, match='append = false'):
153
+ pyopia.cli.check_chunks(2, {'steps': {'output': {'append': True}}})
154
+
155
+
156
+ def test_check_chunks_allows_multiple_chunks_when_append_disabled():
157
+ pyopia.cli.check_chunks(2, {'steps': {'output': {'append': False}}})
158
+
159
+
160
+ def test_process_requires_an_output_step(tmp_path):
161
+ config_filename = tmp_path / 'config.toml'
162
+ with open(config_filename, 'w') as fh:
163
+ toml.dump({'general': {'raw_files': str(tmp_path / '*.silc')}, 'steps': {}}, fh)
164
+
165
+ result = invoke_in(tmp_path, ['process', str(config_filename)])
166
+
167
+ assert result.exit_code != 0
168
+ assert 'output' in str(result.exception)
169
+
170
+
171
+ def test_process_produces_real_particle_stats_and_roi_export(silcam_cli_project):
172
+ for stats_file in silcam_cli_project['stats_files']:
173
+ with xarray.open_dataset(stats_file) as stats:
174
+ stats.load()
175
+
176
+ assert len(stats.major_axis_length) == 870
177
+ assert any(name.startswith('probability_') for name in stats.data_vars)
178
+
179
+ assert (silcam_cli_project['project_dir'] / 'filelist.txt').is_file()
180
+
181
+ roi_files = list(silcam_cli_project['roi_folder'].glob('*.h5'))
182
+ assert len(roi_files) == len(silcam_cli_project['stats_files']) == 2
183
+
184
+
185
+ def test_process_realtime_requires_an_output_step(tmp_path):
186
+ config_filename = tmp_path / 'config.toml'
187
+ with open(config_filename, 'w') as fh:
188
+ toml.dump({'general': {}, 'steps': {}}, fh)
189
+
190
+ result = invoke_in(tmp_path, ['process-realtime', str(config_filename)])
191
+
192
+ assert result.exit_code != 0
193
+ assert 'output' in str(result.exception)
194
+
195
+
196
+ def test_process_realtime_requires_output_datafile_setting(tmp_path):
197
+ config_filename = tmp_path / 'config.toml'
198
+ with open(config_filename, 'w') as fh:
199
+ toml.dump({
200
+ 'general': {},
201
+ 'steps': {'output': {'pipeline_class': 'pyopia.io.StatsToDisc'}},
202
+ }, fh)
203
+
204
+ result = invoke_in(tmp_path, ['process-realtime', str(config_filename)])
205
+
206
+ assert result.exit_code != 0
207
+ assert 'output_datafile' in str(result.exception)
208
+
209
+
210
+ def test_process_realtime_prepares_output_folder_and_calls_run_realtime(tmp_path, monkeypatch):
211
+ recorded = {}
212
+ monkeypatch.setattr(
213
+ pyopia.cli.pyopia.realtime, 'run_realtime',
214
+ lambda pipeline_config, watch_folder=None: recorded.update(
215
+ pipeline_config=pipeline_config, watch_folder=watch_folder
216
+ )
217
+ )
218
+
219
+ output_datafile = str(tmp_path / 'proc' / 'test')
220
+ config_filename = tmp_path / 'config.toml'
221
+ with open(config_filename, 'w') as fh:
222
+ toml.dump({
223
+ 'general': {'raw_files': str(tmp_path / 'images' / '*.silc')},
224
+ 'steps': {'output': {'pipeline_class': 'pyopia.io.StatsToDisc', 'output_datafile': output_datafile}},
225
+ }, fh)
226
+
227
+ result = invoke_in(tmp_path, [
228
+ 'process-realtime', str(config_filename), '--watch-folder', str(tmp_path / 'images')
229
+ ])
230
+
231
+ assert result.exit_code == 0, result.output
232
+ assert (tmp_path / 'proc').is_dir()
233
+ assert recorded['watch_folder'] == str(tmp_path / 'images')
234
+ assert recorded['pipeline_config']['steps']['output']['output_datafile'] == output_datafile
235
+
236
+
237
+ def test_merge_mfdata_combines_per_image_stats(silcam_cli_project, silcam_cli_merged_stats):
238
+ with xarray.open_dataset(silcam_cli_merged_stats) as merged:
239
+ merged.load()
240
+
241
+ per_image_particle_counts = []
242
+ for stats_file in silcam_cli_project['stats_files']:
243
+ with xarray.open_dataset(stats_file) as original:
244
+ original.load()
245
+ per_image_particle_counts.append(len(original.major_axis_length))
246
+
247
+ assert len(merged.major_axis_length) == sum(per_image_particle_counts) == 1740
248
+
249
+
250
+ def test_convert_raw_images_creates_png(silcam_cli_project, tmp_path):
251
+ result = invoke_in(tmp_path, ['convert-raw-images', str(silcam_cli_project['config_filename'])])
252
+
253
+ assert result.exit_code == 0, result.output
254
+
255
+ converted = list((tmp_path / 'images_converted').glob('*.png'))
256
+ assert len(converted) == 2
257
+
258
+
259
+ def test_make_montage_creates_real_montage_image(silcam_cli_merged_stats, tmp_path):
260
+ montage_path = tmp_path / 'montage.png'
261
+
262
+ result = invoke_in(tmp_path, [
263
+ 'make-montage', str(silcam_cli_merged_stats), '--output-filename', str(montage_path)
264
+ ])
265
+
266
+ assert result.exit_code == 0, result.output
267
+ assert montage_path.is_file()
268
+ assert montage_path.stat().st_size > 0
269
+
270
+
271
+ def test_export_to_ecotaxa_creates_bundle_zip(silcam_cli_merged_stats, tmp_path):
272
+ export_path = tmp_path / 'ecotaxa_export.zip'
273
+
274
+ result = invoke_in(tmp_path, [
275
+ 'export-to-ecotaxa', str(silcam_cli_merged_stats), str(export_path)
276
+ ])
277
+
278
+ assert result.exit_code == 0, result.output
279
+ assert export_path.is_file()
280
+
281
+ with zipfile.ZipFile(export_path) as bundle:
282
+ names = bundle.namelist()
283
+ assert 'ecotaxa_particle_statistics.tsv' in names
284
+ assert sum(name.endswith('.png') for name in names) == 1740
@@ -1 +0,0 @@
1
- __version__ = "2.16.13"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes