PyOPIA 2.9.2__tar.gz → 2.9.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.
- {pyopia-2.9.2 → pyopia-2.9.3}/PKG-INFO +1 -1
- pyopia-2.9.3/pyopia/__init__.py +1 -0
- pyopia-2.9.3/pyopia/cf_metadata.json +70 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/io.py +268 -139
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/process.py +1 -1
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/statistics.py +4 -4
- pyopia-2.9.3/pyopia/tests/test_io.py +64 -0
- pyopia-2.9.2/pyopia/__init__.py +0 -1
- {pyopia-2.9.2 → pyopia-2.9.3}/.gitignore +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/LICENSE +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/README.md +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/background.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/classify.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/cli.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/exampledata.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/instrument/__init__.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/instrument/common.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/instrument/holo.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/instrument/silcam.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/instrument/uvp.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/pipeline.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/plotting.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/simulator/__init__.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/simulator/silcam.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/tests/__init__.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/tests/test_classify.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/tests/test_notebooks.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyopia/tests/test_pipeline.py +0 -0
- {pyopia-2.9.2 → pyopia-2.9.3}/pyproject.toml +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.9.3"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{"major_axis_length": {
|
|
2
|
+
"standard_name": "major_axis_length",
|
|
3
|
+
"long_name": "The length of the major axis of the ellipse that has the same normalized second central moments as the region",
|
|
4
|
+
"units": "micrometer",
|
|
5
|
+
"calculation_method": "Computed using skimage.measure.regionprops (axis_major_length)",
|
|
6
|
+
"pyopia_process_level": 1},
|
|
7
|
+
"minor_axis_length": {
|
|
8
|
+
"standard_name": "minor_axis_length",
|
|
9
|
+
"long_name": "The length of the minor axis of the ellipse that has the same normalized second central moments as the region",
|
|
10
|
+
"units": "micrometer",
|
|
11
|
+
"calculation_method": "Computed using skimage.measure.regionprops (axis_minor_length)",
|
|
12
|
+
"pyopia_process_level": 1},
|
|
13
|
+
"equivalent_diameter": {
|
|
14
|
+
"standard_name": "equivalent_circular_diameter",
|
|
15
|
+
"long_name": "Diameter of a circle with the same area as the particle",
|
|
16
|
+
"units": "micrometer",
|
|
17
|
+
"calculation_method": "Computed using skimage.measure.regionprops (equivalent_diameter)",
|
|
18
|
+
"pyopia_process_level": 1},
|
|
19
|
+
"minr": {
|
|
20
|
+
"standard_name": "minimum_row_index",
|
|
21
|
+
"long_name": "Minimum row index of the particle bounding box",
|
|
22
|
+
"units": "pixels",
|
|
23
|
+
"calculation_method": "Extracted from skimage.measure.regionprops (bbox[0])",
|
|
24
|
+
"pyopia_process_level": 1},
|
|
25
|
+
"maxr": {
|
|
26
|
+
"standard_name": "maximum_row_index",
|
|
27
|
+
"long_name": "Maximum row index of the particle bounding box",
|
|
28
|
+
"units": "pixels",
|
|
29
|
+
"calculation_method": "Extracted from skimage.measure.regionprops (bbox[2])",
|
|
30
|
+
"pyopia_process_level": 1},
|
|
31
|
+
"minc": {
|
|
32
|
+
"standard_name": "minimum_column_index",
|
|
33
|
+
"long_name": "Minimum column index of the particle bounding box",
|
|
34
|
+
"units": "pixels",
|
|
35
|
+
"calculation_method": "Extracted from skimage.measure.regionprops (bbox[1])",
|
|
36
|
+
"pyopia_process_level": 1},
|
|
37
|
+
"maxc": {
|
|
38
|
+
"standard_name": "maximum_column_index",
|
|
39
|
+
"long_name": "Maximum column index of the particle bounding box",
|
|
40
|
+
"units": "pixels",
|
|
41
|
+
"calculation_method": "Extracted from skimage.measure.regionprops (bbox[3])",
|
|
42
|
+
"pyopia_process_level": 1},
|
|
43
|
+
"saturation": {
|
|
44
|
+
"standard_name": "image_saturation",
|
|
45
|
+
"long_name": "Percentage saturation of the image",
|
|
46
|
+
"units": "percent",
|
|
47
|
+
"calculation_method": "Computed as the percentage of the image covered by particles relative to the maximum acceptable coverage",
|
|
48
|
+
"pyopia_process_level": 1},
|
|
49
|
+
"index": {
|
|
50
|
+
"standard_name": "index",
|
|
51
|
+
"long_name": "Index of the particle in the dataset",
|
|
52
|
+
"units": "",
|
|
53
|
+
"calculation_method": "Sequential numbering of particles in the dataset",
|
|
54
|
+
"pyopia_process_level": 1},
|
|
55
|
+
"export_name": {
|
|
56
|
+
"standard_name": "export_name",
|
|
57
|
+
"long_name": "Name of the exported particle ROI file",
|
|
58
|
+
"units": "",
|
|
59
|
+
"calculation_method": "Generated during particle export",
|
|
60
|
+
"pyopia_process_level": 1},
|
|
61
|
+
"time": {
|
|
62
|
+
"standard_name": "time",
|
|
63
|
+
"long_name": "Time of particle observation",
|
|
64
|
+
"calculation_method": "Extracted from the timestamp of the observation",
|
|
65
|
+
"pyopia_process_level": 0},
|
|
66
|
+
"timestamp": {
|
|
67
|
+
"standard_name": "timestamp",
|
|
68
|
+
"long_name": "Timestamp of particle observation",
|
|
69
|
+
"calculation_method": "Recorded during particle observation",
|
|
70
|
+
"pyopia_process_level": 0}}
|
|
@@ -1,35 +1,41 @@
|
|
|
1
|
-
|
|
1
|
+
"""
|
|
2
2
|
Module containing tools for datafile and metadata handling
|
|
3
|
-
|
|
3
|
+
"""
|
|
4
4
|
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
5
7
|
from datetime import datetime
|
|
6
|
-
|
|
8
|
+
from glob import glob
|
|
9
|
+
|
|
7
10
|
import h5py
|
|
11
|
+
import numpy as np
|
|
8
12
|
import pandas as pd
|
|
9
13
|
import toml
|
|
10
14
|
import xarray
|
|
11
|
-
import os
|
|
12
|
-
from glob import glob
|
|
13
15
|
import xarray as xr
|
|
14
16
|
from tqdm.auto import tqdm
|
|
15
17
|
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
16
21
|
from pyopia import __version__ as pyopia_version
|
|
17
22
|
|
|
18
|
-
import logging
|
|
19
23
|
logger = logging.getLogger()
|
|
20
24
|
|
|
21
25
|
# The netcdf4 engine seems to produce errors with the stats dataset, so we use h5netcdf instead
|
|
22
|
-
NETCDF_ENGINE =
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def write_stats(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
NETCDF_ENGINE = "h5netcdf"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_stats(
|
|
30
|
+
stats,
|
|
31
|
+
datafilename,
|
|
32
|
+
settings=None,
|
|
33
|
+
export_name_len=40,
|
|
34
|
+
dataformat="nc",
|
|
35
|
+
append=True,
|
|
36
|
+
image_stats=None,
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
33
39
|
Writes particle stats into the ouput file.
|
|
34
40
|
Appends if file already exists.
|
|
35
41
|
|
|
@@ -40,7 +46,7 @@ def write_stats(stats,
|
|
|
40
46
|
stats : DataFrame or xr.Dataset
|
|
41
47
|
particle statistics
|
|
42
48
|
export_name_len : int
|
|
43
|
-
Max number of chars allowed for col '
|
|
49
|
+
Max number of chars allowed for col 'export_name'
|
|
44
50
|
append : bool
|
|
45
51
|
Append all processed data into one nc file.
|
|
46
52
|
Defaults to True.
|
|
@@ -51,45 +57,60 @@ def write_stats(stats,
|
|
|
51
57
|
as the dataset gets larger.
|
|
52
58
|
image_stats : xr.Dataset
|
|
53
59
|
summary statistics of each raw image (including those with no particles)
|
|
54
|
-
|
|
60
|
+
"""
|
|
55
61
|
|
|
56
62
|
if len(stats) == 0: # to avoid issue with wrong time datatypes in xarray
|
|
57
63
|
return
|
|
58
64
|
|
|
59
|
-
if
|
|
60
|
-
min_itemsize = {
|
|
65
|
+
if "export_name" in stats.columns:
|
|
66
|
+
min_itemsize = {"export_name": export_name_len}
|
|
61
67
|
else:
|
|
62
68
|
min_itemsize = None
|
|
63
69
|
|
|
64
|
-
if dataformat ==
|
|
65
|
-
with pd.HDFStore(datafilename +
|
|
70
|
+
if dataformat == "h5":
|
|
71
|
+
with pd.HDFStore(datafilename + "-STATS.h5", "a") as fh:
|
|
66
72
|
stats.to_hdf(
|
|
67
|
-
fh,
|
|
68
|
-
|
|
73
|
+
fh,
|
|
74
|
+
"ParticleStats/stats",
|
|
75
|
+
append=True,
|
|
76
|
+
format="t",
|
|
77
|
+
data_columns=True,
|
|
78
|
+
min_itemsize=min_itemsize,
|
|
79
|
+
)
|
|
69
80
|
|
|
70
81
|
# metadata
|
|
71
|
-
with h5py.File(datafilename +
|
|
72
|
-
meta = fh.require_group(
|
|
73
|
-
meta.attrs[
|
|
74
|
-
meta.attrs[
|
|
75
|
-
meta.attrs[
|
|
76
|
-
elif dataformat ==
|
|
77
|
-
|
|
82
|
+
with h5py.File(datafilename + "-STATS.h5", "a") as fh:
|
|
83
|
+
meta = fh.require_group("Meta")
|
|
84
|
+
meta.attrs["Modified"] = str(datetime.now())
|
|
85
|
+
meta.attrs["PyOPIA_version"] = pyopia_version
|
|
86
|
+
meta.attrs["steps"] = settings
|
|
87
|
+
elif dataformat == "nc":
|
|
78
88
|
if isinstance(stats, xr.Dataset):
|
|
79
89
|
xstats = stats
|
|
80
90
|
else:
|
|
81
91
|
xstats = make_xstats(stats, settings)
|
|
82
92
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
xstats = add_cf_attributes(xstats)
|
|
94
|
+
|
|
95
|
+
if append and os.path.isfile(datafilename + "-STATS.nc"):
|
|
96
|
+
existing_stats = load_stats(datafilename + "-STATS.nc")
|
|
97
|
+
xstats = xarray.concat([existing_stats, xstats], "index")
|
|
86
98
|
elif not append:
|
|
87
99
|
# When appending, only store the last row in the image_stats DataFrame
|
|
88
|
-
datafilename +=
|
|
89
|
-
|
|
100
|
+
datafilename += "-Image-D" + str(xstats["timestamp"][0].values).replace(
|
|
101
|
+
"-", ""
|
|
102
|
+
).replace(":", "").replace(".", "-")
|
|
103
|
+
|
|
104
|
+
logger.info(f"Writing stats to file: {datafilename + '-STATS.nc'}")
|
|
105
|
+
logger.debug(f"Settings provided: {settings}")
|
|
90
106
|
|
|
91
107
|
encoding = setup_xstats_encoding(xstats)
|
|
92
|
-
xstats.to_netcdf(
|
|
108
|
+
xstats.to_netcdf(
|
|
109
|
+
datafilename + "-STATS.nc",
|
|
110
|
+
encoding=encoding,
|
|
111
|
+
engine=NETCDF_ENGINE,
|
|
112
|
+
format="NETCDF4",
|
|
113
|
+
)
|
|
93
114
|
|
|
94
115
|
# If we have image statistics (summary data for each raw image), add the image_stats a group
|
|
95
116
|
if image_stats is not None:
|
|
@@ -98,11 +119,16 @@ def write_stats(stats,
|
|
|
98
119
|
else:
|
|
99
120
|
ximage_stats = image_stats.loc[[image_stats.index[-1]], :].to_xarray()
|
|
100
121
|
|
|
101
|
-
ximage_stats.to_netcdf(
|
|
122
|
+
ximage_stats.to_netcdf(
|
|
123
|
+
datafilename + "-STATS.nc",
|
|
124
|
+
group="image_stats",
|
|
125
|
+
mode="a",
|
|
126
|
+
engine=NETCDF_ENGINE,
|
|
127
|
+
)
|
|
102
128
|
|
|
103
129
|
|
|
104
|
-
def setup_xstats_encoding(xstats, string_vars=[
|
|
105
|
-
|
|
130
|
+
def setup_xstats_encoding(xstats, string_vars=["export_name", "holo_filename"]):
|
|
131
|
+
"""Setup encoding for writing to NetCDF, where string variables are explicitly defined as string types
|
|
106
132
|
|
|
107
133
|
Notes
|
|
108
134
|
-----
|
|
@@ -115,19 +141,19 @@ def setup_xstats_encoding(xstats, string_vars=['export name', 'holo_filename']):
|
|
|
115
141
|
xstats : xarray.Dataset
|
|
116
142
|
Xarray version of stats dataframe, including metadata
|
|
117
143
|
string_vars : list, optional
|
|
118
|
-
list of string columns in xstats, by default ['
|
|
144
|
+
list of string columns in xstats, by default ['export_name', 'holo_filename']
|
|
119
145
|
|
|
120
146
|
Returns
|
|
121
147
|
-------
|
|
122
148
|
encoding : dict
|
|
123
149
|
'encoding' input argument to be given to xstats.to_netcdf()
|
|
124
|
-
|
|
125
|
-
encoding = {k: {
|
|
150
|
+
"""
|
|
151
|
+
encoding = {k: {"dtype": "str"} for k in string_vars if k in xstats.data_vars}
|
|
126
152
|
return encoding
|
|
127
153
|
|
|
128
154
|
|
|
129
155
|
def make_xstats(stats, toml_steps):
|
|
130
|
-
|
|
156
|
+
"""Converts a stats dataframe into xarray DataSet, with metadata
|
|
131
157
|
|
|
132
158
|
Parameters
|
|
133
159
|
----------
|
|
@@ -140,17 +166,17 @@ def make_xstats(stats, toml_steps):
|
|
|
140
166
|
-------
|
|
141
167
|
xstats : xarray.Dataset
|
|
142
168
|
Xarray version of stats dataframe, including metadata
|
|
143
|
-
|
|
169
|
+
"""
|
|
144
170
|
xstats = stats.to_xarray()
|
|
145
171
|
xstats.attrs["steps"] = toml.dumps(toml_steps)
|
|
146
|
-
xstats.attrs[
|
|
147
|
-
xstats.attrs[
|
|
172
|
+
xstats.attrs["Modified"] = str(datetime.now())
|
|
173
|
+
xstats.attrs["PyOPIA_version"] = pyopia_version
|
|
148
174
|
xstats = xstats.assign_coords(time=xstats.timestamp)
|
|
149
175
|
return xstats
|
|
150
176
|
|
|
151
177
|
|
|
152
178
|
def load_image_stats(datafilename):
|
|
153
|
-
|
|
179
|
+
"""Load the summary stats and time information for each image
|
|
154
180
|
|
|
155
181
|
Parameters
|
|
156
182
|
----------
|
|
@@ -161,14 +187,16 @@ def load_image_stats(datafilename):
|
|
|
161
187
|
-------
|
|
162
188
|
image_stats : xarray.Dataset
|
|
163
189
|
summary statistics of each raw image (including those with no particles)
|
|
164
|
-
|
|
165
|
-
with xarray.open_dataset(
|
|
190
|
+
"""
|
|
191
|
+
with xarray.open_dataset(
|
|
192
|
+
datafilename, engine=NETCDF_ENGINE, group="image_stats"
|
|
193
|
+
) as image_stats:
|
|
166
194
|
image_stats.load()
|
|
167
195
|
return image_stats
|
|
168
196
|
|
|
169
197
|
|
|
170
198
|
def load_stats(datafilename):
|
|
171
|
-
|
|
199
|
+
"""Load -STATS.nc file as xarray Dataset
|
|
172
200
|
|
|
173
201
|
.. warning:: Support for loading of old -STATS.h5 formats will be removed in version 3.0.0.
|
|
174
202
|
They will need to be converted to .nc prior to loading.
|
|
@@ -183,26 +211,28 @@ def load_stats(datafilename):
|
|
|
183
211
|
-------
|
|
184
212
|
xstats : xarray.Dataset
|
|
185
213
|
Particle statistics
|
|
186
|
-
|
|
214
|
+
"""
|
|
187
215
|
|
|
188
|
-
if datafilename.endswith(
|
|
216
|
+
if datafilename.endswith(".nc"):
|
|
189
217
|
with xarray.open_dataset(datafilename, engine=NETCDF_ENGINE) as xstats:
|
|
190
218
|
xstats.load()
|
|
191
|
-
elif datafilename.endswith(
|
|
192
|
-
logger.warning(
|
|
193
|
-
stats = pd.read_hdf(datafilename,
|
|
219
|
+
elif datafilename.endswith(".h5"):
|
|
220
|
+
logger.warning("In future, load_stats will only take .nc files")
|
|
221
|
+
stats = pd.read_hdf(datafilename, "ParticleStats/stats")
|
|
194
222
|
xstats = stats.to_xarray()
|
|
195
223
|
else:
|
|
196
|
-
logger.warning(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
224
|
+
logger.warning(
|
|
225
|
+
"WARNING. File extension not specified."
|
|
226
|
+
+ "Assuming prefix of -STATS.h5 for backwards compatability."
|
|
227
|
+
+ "In future, load_stats will only take .nc files"
|
|
228
|
+
)
|
|
229
|
+
stats = pd.read_hdf(datafilename + "-STATS.h5", "ParticleStats/stats")
|
|
200
230
|
xstats = stats.to_xarray()
|
|
201
231
|
return xstats
|
|
202
232
|
|
|
203
233
|
|
|
204
|
-
def combine_stats_netcdf_files(path_to_data, prefix=
|
|
205
|
-
|
|
234
|
+
def combine_stats_netcdf_files(path_to_data, prefix="*"):
|
|
235
|
+
""".. deprecated:: 2.4.11
|
|
206
236
|
:class:`pyopia.io.combine_stats_netcdf_files` will be removed in version 3.0.0, it is replaced by
|
|
207
237
|
:class:`pyopia.io.concat_stats_netcdf_files`.
|
|
208
238
|
|
|
@@ -224,27 +254,37 @@ def combine_stats_netcdf_files(path_to_data, prefix='*'):
|
|
|
224
254
|
Particle statistics and metatdata from processing steps
|
|
225
255
|
image_stats : xarray.Dataset
|
|
226
256
|
summary statistics of each raw image (including those with no particles)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
sorted_filelist = sorted(
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
sorted_filelist = sorted(
|
|
260
|
+
glob(os.path.join(path_to_data, prefix + "Image-D*-STATS.nc"))
|
|
261
|
+
)
|
|
262
|
+
with xarray.open_mfdataset(
|
|
263
|
+
sorted_filelist,
|
|
264
|
+
combine="nested",
|
|
265
|
+
concat_dim="index",
|
|
266
|
+
decode_cf=True,
|
|
267
|
+
parallel=False,
|
|
268
|
+
coords="minimal",
|
|
269
|
+
compat="override",
|
|
270
|
+
) as ds:
|
|
233
271
|
xstats = ds.load()
|
|
234
272
|
|
|
235
273
|
# Check if we have image statistics, if so, load it.
|
|
236
274
|
try:
|
|
237
|
-
with xarray.open_mfdataset(sorted_filelist, group=
|
|
275
|
+
with xarray.open_mfdataset(sorted_filelist, group="image_stats") as ds:
|
|
238
276
|
image_stats = ds.load()
|
|
239
277
|
except OSError:
|
|
240
|
-
logger.info(
|
|
278
|
+
logger.info(
|
|
279
|
+
"Could get image_stats from netcdf files for merging, returning None for this."
|
|
280
|
+
)
|
|
241
281
|
image_stats = None
|
|
242
282
|
|
|
243
283
|
return xstats, image_stats
|
|
244
284
|
|
|
245
285
|
|
|
246
286
|
def concat_stats_netcdf_files(sorted_filelist):
|
|
247
|
-
|
|
287
|
+
"""Concatenate specified list of STATS.nc files into one 'xstats' xarray dataset
|
|
248
288
|
created by :func:`pyopia.io.write_stats when using 'append = false'.
|
|
249
289
|
|
|
250
290
|
Existing files are first loaded and then combined, so memory usage will go up with longer file lists.
|
|
@@ -260,9 +300,9 @@ def concat_stats_netcdf_files(sorted_filelist):
|
|
|
260
300
|
Particle statistics and metatdata from processing steps
|
|
261
301
|
image_stats : xarray.Dataset or None
|
|
262
302
|
Summary statistics of each raw image (including those with no particles)
|
|
263
|
-
|
|
303
|
+
"""
|
|
264
304
|
if len(sorted_filelist) < 1:
|
|
265
|
-
logger.error(
|
|
305
|
+
logger.error("No files found to concatenate, doing nothing.")
|
|
266
306
|
return None, None
|
|
267
307
|
|
|
268
308
|
# We load one dataset at the time into a list for later merge
|
|
@@ -272,35 +312,41 @@ def concat_stats_netcdf_files(sorted_filelist):
|
|
|
272
312
|
# Check if we have image statistics in first file, if not, we skip checking the rest
|
|
273
313
|
skip_image_stats = False
|
|
274
314
|
try:
|
|
275
|
-
with xr.open_dataset(
|
|
315
|
+
with xr.open_dataset(
|
|
316
|
+
sorted_filelist[0], group="image_stats", engine=NETCDF_ENGINE
|
|
317
|
+
) as ds:
|
|
276
318
|
ds.load()
|
|
277
319
|
except OSError:
|
|
278
|
-
logger.info(
|
|
320
|
+
logger.info(
|
|
321
|
+
"Could get image_stats from netcdf files for merging, returning None for this."
|
|
322
|
+
)
|
|
279
323
|
skip_image_stats = True
|
|
280
324
|
|
|
281
325
|
# Load datasets from each file into the lists
|
|
282
|
-
for f in tqdm(sorted_filelist, desc=
|
|
326
|
+
for f in tqdm(sorted_filelist, desc="Loading datasets"):
|
|
283
327
|
with xr.open_dataset(f, engine=NETCDF_ENGINE) as ds:
|
|
284
328
|
ds.load()
|
|
285
329
|
datasets.append(ds)
|
|
286
330
|
|
|
287
331
|
if not skip_image_stats:
|
|
288
|
-
with xr.open_dataset(f, group=
|
|
332
|
+
with xr.open_dataset(f, group="image_stats", engine=NETCDF_ENGINE) as ds:
|
|
289
333
|
ds.load()
|
|
290
334
|
datasets_image_stats.append(ds)
|
|
291
335
|
|
|
292
336
|
# Combine the individual datasets loaded above
|
|
293
|
-
logging.info(
|
|
294
|
-
xstats = xr.concat(datasets, dim=
|
|
337
|
+
logging.info("Combining datasets")
|
|
338
|
+
xstats = xr.concat(datasets, dim="index")
|
|
295
339
|
image_stats = None
|
|
296
340
|
if not skip_image_stats:
|
|
297
|
-
image_stats = xr.concat(datasets_image_stats, dim=
|
|
341
|
+
image_stats = xr.concat(datasets_image_stats, dim="timestamp")
|
|
298
342
|
|
|
299
343
|
return xstats, image_stats
|
|
300
344
|
|
|
301
345
|
|
|
302
|
-
def merge_and_save_mfdataset(
|
|
303
|
-
|
|
346
|
+
def merge_and_save_mfdataset(
|
|
347
|
+
path_to_data, prefix="*", overwrite_existing_partials=False, chunk_size=None
|
|
348
|
+
):
|
|
349
|
+
"""Combine a multi-file directory of STATS.nc files into a single '-STATS.nc' file
|
|
304
350
|
that can then be loaded with :func:`pyopia.io.load_stats`
|
|
305
351
|
|
|
306
352
|
Parameters
|
|
@@ -323,49 +369,68 @@ def merge_and_save_mfdataset(path_to_data, prefix='*', overwrite_existing_partia
|
|
|
323
369
|
of input files divided by chunk_size. The last chunk may contain less
|
|
324
370
|
files than specified, depending on the total number of files.
|
|
325
371
|
Default: None, which processes all files together.
|
|
326
|
-
|
|
327
|
-
logging.info(f
|
|
372
|
+
"""
|
|
373
|
+
logging.info(f"Combine stats netcdf files from {path_to_data}")
|
|
328
374
|
if (chunk_size is not None) and (chunk_size < 1):
|
|
329
|
-
raise ValueError(
|
|
375
|
+
raise ValueError(
|
|
376
|
+
f"Invalid chunk size, must be greater than 0, was {chunk_size}"
|
|
377
|
+
)
|
|
330
378
|
|
|
331
379
|
# Get sorted list of per-image stats netcdf files
|
|
332
|
-
sorted_filelist = sorted(
|
|
380
|
+
sorted_filelist = sorted(
|
|
381
|
+
glob(os.path.join(path_to_data, prefix + "Image-D*-STATS.nc"))
|
|
382
|
+
)
|
|
333
383
|
|
|
384
|
+
# fmt: off
|
|
334
385
|
# Chunk the file list into smaller parts if specified
|
|
335
386
|
num_files = len(sorted_filelist)
|
|
336
387
|
chunk_size_used = num_files if chunk_size is None else min(chunk_size, num_files)
|
|
337
388
|
num_chunks = int(np.ceil(num_files / chunk_size_used))
|
|
338
|
-
filelist_chunks = [
|
|
339
|
-
|
|
340
|
-
|
|
389
|
+
filelist_chunks = [
|
|
390
|
+
sorted_filelist[i * chunk_size_used:min(num_files, (i + 1) * chunk_size_used)]
|
|
391
|
+
for i in range(num_chunks)
|
|
392
|
+
]
|
|
393
|
+
infostr = (
|
|
394
|
+
f"Processing {num_chunks} partial file lists of {chunk_size_used} files each"
|
|
395
|
+
)
|
|
396
|
+
infostr += f", based on a total of {num_files} files."
|
|
341
397
|
logging.info(infostr)
|
|
398
|
+
# fmt: on
|
|
342
399
|
|
|
343
400
|
# Get config from first file in list
|
|
344
401
|
xstats = load_stats(sorted_filelist[0])
|
|
345
402
|
settings = steps_from_xstats(xstats)
|
|
346
|
-
prefix_out = os.path.basename(settings[
|
|
403
|
+
prefix_out = os.path.basename(settings["steps"]["output"]["output_datafile"])
|
|
347
404
|
encoding = setup_xstats_encoding(xstats)
|
|
348
405
|
|
|
349
406
|
def process_store(i, filelist_):
|
|
350
|
-
output_name = os.path.join(path_to_data, f
|
|
407
|
+
output_name = os.path.join(path_to_data, f"part-{i:04d}-{prefix_out}-STATS.nc")
|
|
351
408
|
|
|
352
409
|
# Skip this chunk if the merged output file exists and overwrite is set to False
|
|
353
410
|
if os.path.exists(output_name) and not overwrite_existing_partials:
|
|
354
|
-
logging.info(f
|
|
411
|
+
logging.info(f"File exists ({output_name}), skipping")
|
|
355
412
|
return output_name
|
|
356
413
|
|
|
357
414
|
# Load the individual datasets
|
|
358
415
|
xstats, image_stats = concat_stats_netcdf_files(filelist_)
|
|
359
416
|
|
|
360
417
|
# Save the particle statistics (xstats) to NetCDF
|
|
361
|
-
logging.info(f
|
|
418
|
+
logging.info(f"Writing {output_name}")
|
|
362
419
|
if xstats is not None:
|
|
363
|
-
xstats.to_netcdf(
|
|
420
|
+
xstats.to_netcdf(
|
|
421
|
+
output_name,
|
|
422
|
+
mode="w",
|
|
423
|
+
encoding=encoding,
|
|
424
|
+
engine=NETCDF_ENGINE,
|
|
425
|
+
format="NETCDF4",
|
|
426
|
+
)
|
|
364
427
|
|
|
365
428
|
# If summary data for each raw image are available (image_stats), save this into the image_stats group
|
|
366
429
|
if image_stats is not None:
|
|
367
|
-
image_stats.to_netcdf(
|
|
368
|
-
|
|
430
|
+
image_stats.to_netcdf(
|
|
431
|
+
output_name, group="image_stats", mode="a", engine=NETCDF_ENGINE
|
|
432
|
+
)
|
|
433
|
+
logging.info(f"Writing {output_name} done.")
|
|
369
434
|
|
|
370
435
|
return output_name
|
|
371
436
|
|
|
@@ -376,19 +441,27 @@ def merge_and_save_mfdataset(path_to_data, prefix='*', overwrite_existing_partia
|
|
|
376
441
|
merged_files.append(output_name)
|
|
377
442
|
|
|
378
443
|
# Finally, merge the partially merged files
|
|
379
|
-
logging.info(
|
|
380
|
-
output_name = os.path.join(path_to_data, prefix_out +
|
|
381
|
-
with xr.open_mfdataset(merged_files, concat_dim=
|
|
382
|
-
ds.to_netcdf(
|
|
444
|
+
logging.info("Doing final merge of partially merged files")
|
|
445
|
+
output_name = os.path.join(path_to_data, prefix_out + "-STATS.nc")
|
|
446
|
+
with xr.open_mfdataset(merged_files, concat_dim="index", combine="nested") as ds:
|
|
447
|
+
ds.to_netcdf(
|
|
448
|
+
output_name,
|
|
449
|
+
mode="w",
|
|
450
|
+
encoding=encoding,
|
|
451
|
+
engine=NETCDF_ENGINE,
|
|
452
|
+
format="NETCDF4",
|
|
453
|
+
)
|
|
383
454
|
|
|
384
|
-
with xr.open_mfdataset(
|
|
385
|
-
|
|
455
|
+
with xr.open_mfdataset(
|
|
456
|
+
merged_files, group="image_stats", concat_dim="timestamp", combine="nested"
|
|
457
|
+
) as ds:
|
|
458
|
+
ds.to_netcdf(output_name, mode="a", group="image_stats", engine=NETCDF_ENGINE)
|
|
386
459
|
|
|
387
|
-
logging.info(f
|
|
460
|
+
logging.info(f"Writing {output_name} done.")
|
|
388
461
|
|
|
389
462
|
|
|
390
463
|
def steps_from_xstats(xstats):
|
|
391
|
-
|
|
464
|
+
"""Get the steps attribute from xarray version of the particle stats into a dictionary
|
|
392
465
|
|
|
393
466
|
Parameters
|
|
394
467
|
----------
|
|
@@ -399,13 +472,13 @@ def steps_from_xstats(xstats):
|
|
|
399
472
|
-------
|
|
400
473
|
steps : dict
|
|
401
474
|
TOML-formatted dictionary of pipeline steps
|
|
402
|
-
|
|
403
|
-
steps = toml.loads(xstats.__getattr__(
|
|
475
|
+
"""
|
|
476
|
+
steps = toml.loads(xstats.__getattr__("steps"))
|
|
404
477
|
return steps
|
|
405
478
|
|
|
406
479
|
|
|
407
480
|
def load_stats_as_dataframe(stats_file):
|
|
408
|
-
|
|
481
|
+
"""A loading function for stats files that forces stats into a pandas DataFrame
|
|
409
482
|
|
|
410
483
|
Parameters
|
|
411
484
|
----------
|
|
@@ -416,38 +489,40 @@ def load_stats_as_dataframe(stats_file):
|
|
|
416
489
|
-------
|
|
417
490
|
stats : DataFrame
|
|
418
491
|
stats pandas dataframe
|
|
419
|
-
|
|
492
|
+
"""
|
|
420
493
|
# obtain particle statistics from the stats file
|
|
421
494
|
stats = load_stats(stats_file)
|
|
422
495
|
try:
|
|
423
496
|
stats = stats.to_dataframe()
|
|
424
497
|
except AttributeError:
|
|
425
|
-
logger.info(
|
|
426
|
-
|
|
498
|
+
logger.info(
|
|
499
|
+
"STATS was likely loaded from an old h5 format, \
|
|
500
|
+
which will be deprecated in future. Please use NetCDF in future."
|
|
501
|
+
)
|
|
427
502
|
pass
|
|
428
503
|
return stats
|
|
429
504
|
|
|
430
505
|
|
|
431
506
|
def show_h5_meta(h5file):
|
|
432
|
-
|
|
507
|
+
"""
|
|
433
508
|
prints metadata from an exported hdf5 file created from pyopia.process
|
|
434
509
|
|
|
435
510
|
Parameters
|
|
436
511
|
----------
|
|
437
512
|
h5file : str
|
|
438
513
|
h5 filename from exported data from pyopia.process
|
|
439
|
-
|
|
514
|
+
"""
|
|
440
515
|
|
|
441
|
-
with h5py.File(h5file,
|
|
442
|
-
keys = list(f[
|
|
516
|
+
with h5py.File(h5file, "r") as f:
|
|
517
|
+
keys = list(f["Meta"].attrs.keys())
|
|
443
518
|
|
|
444
519
|
for k in keys:
|
|
445
|
-
logger.info(k +
|
|
446
|
-
logger.info(
|
|
520
|
+
logger.info(k + ":")
|
|
521
|
+
logger.info(" " + f["Meta"].attrs[k])
|
|
447
522
|
|
|
448
523
|
|
|
449
|
-
class StatsToDisc
|
|
450
|
-
|
|
524
|
+
class StatsToDisc:
|
|
525
|
+
"""PyOpia pipline-compatible class for calling write_stats() that created NetCDF files.
|
|
451
526
|
|
|
452
527
|
Parameters
|
|
453
528
|
----------
|
|
@@ -456,7 +531,7 @@ class StatsToDisc():
|
|
|
456
531
|
dataformat : str
|
|
457
532
|
either 'nc' or 'h5
|
|
458
533
|
export_name_len : int
|
|
459
|
-
max number of chars allowed for col '
|
|
534
|
+
max number of chars allowed for col 'export_name'. Defaults to 40
|
|
460
535
|
append : bool
|
|
461
536
|
Append all processed data into one nc file.
|
|
462
537
|
Defaults to True.
|
|
@@ -480,31 +555,32 @@ class StatsToDisc():
|
|
|
480
555
|
pipeline_class = 'pyopia.io.StatsToDisc'
|
|
481
556
|
output_datafile = './test' # prefix path for output nc file
|
|
482
557
|
append = true
|
|
483
|
-
|
|
484
|
-
def __init__(self,
|
|
485
|
-
output_datafile='data',
|
|
486
|
-
dataformat='nc',
|
|
487
|
-
export_name_len=40,
|
|
488
|
-
append=True):
|
|
558
|
+
"""
|
|
489
559
|
|
|
560
|
+
def __init__(
|
|
561
|
+
self, output_datafile="data", dataformat="nc", export_name_len=40, append=True
|
|
562
|
+
):
|
|
490
563
|
self.output_datafile = output_datafile
|
|
491
564
|
self.dataformat = dataformat
|
|
492
565
|
self.export_name_len = export_name_len
|
|
493
566
|
self.append = append
|
|
494
567
|
|
|
495
568
|
def __call__(self, data):
|
|
496
|
-
write_stats(
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
569
|
+
write_stats(
|
|
570
|
+
data["stats"],
|
|
571
|
+
self.output_datafile,
|
|
572
|
+
settings=data["settings"],
|
|
573
|
+
dataformat=self.dataformat,
|
|
574
|
+
export_name_len=self.export_name_len,
|
|
575
|
+
append=self.append,
|
|
576
|
+
image_stats=data["image_stats"],
|
|
577
|
+
)
|
|
502
578
|
|
|
503
579
|
return data
|
|
504
580
|
|
|
505
581
|
|
|
506
582
|
def load_toml(toml_file):
|
|
507
|
-
|
|
583
|
+
"""Load a TOML settings file from file
|
|
508
584
|
|
|
509
585
|
Parameters
|
|
510
586
|
----------
|
|
@@ -515,14 +591,14 @@ def load_toml(toml_file):
|
|
|
515
591
|
-------
|
|
516
592
|
settings : dict
|
|
517
593
|
TOML settings
|
|
518
|
-
|
|
519
|
-
with open(toml_file,
|
|
594
|
+
"""
|
|
595
|
+
with open(toml_file, "r") as f:
|
|
520
596
|
settings = toml.load(f)
|
|
521
597
|
return settings
|
|
522
598
|
|
|
523
599
|
|
|
524
600
|
def StatsH5(**kwargs):
|
|
525
|
-
|
|
601
|
+
""".. deprecated:: 2.4.8
|
|
526
602
|
:class:`pyopia.io.StatsH5` will be removed in version 3.0.0, it is replaced by
|
|
527
603
|
:class:`pyopia.io.StatsToDisc`.
|
|
528
604
|
|
|
@@ -535,7 +611,7 @@ def StatsH5(**kwargs):
|
|
|
535
611
|
dataformat : str
|
|
536
612
|
either 'nc' or 'h5
|
|
537
613
|
export_name_len : int
|
|
538
|
-
max number of chars allowed for col '
|
|
614
|
+
max number of chars allowed for col 'export_name'. Defaults to 40
|
|
539
615
|
append : bool
|
|
540
616
|
Append all processed data into one nc file.
|
|
541
617
|
Defaults to True.
|
|
@@ -559,6 +635,59 @@ def StatsH5(**kwargs):
|
|
|
559
635
|
pipeline_class = 'pyopia.io.StatsH5'
|
|
560
636
|
output_datafile = './test' # prefix path for output nc file
|
|
561
637
|
append = true
|
|
562
|
-
|
|
563
|
-
logger.warning(
|
|
638
|
+
"""
|
|
639
|
+
logger.warning(
|
|
640
|
+
"StatsH5 will be removed in version 3.0.0, it is replaced by pyopia.io.StatsToDisc"
|
|
641
|
+
)
|
|
564
642
|
return StatsToDisc(**kwargs)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def get_cf_metadata_spec():
|
|
646
|
+
path_to_cf_json = Path(__file__).parent / Path("cf_metadata.json")
|
|
647
|
+
with open(path_to_cf_json, "r") as file:
|
|
648
|
+
return json.load(file)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def add_cf_attributes(xstats):
|
|
652
|
+
"""
|
|
653
|
+
Adds CF-compliant global attributes and units to the xarray Dataset.
|
|
654
|
+
|
|
655
|
+
Parameters
|
|
656
|
+
----------
|
|
657
|
+
xstats : xarray.Dataset
|
|
658
|
+
The dataset to which CF-compliant attributes will be added.
|
|
659
|
+
"""
|
|
660
|
+
|
|
661
|
+
# Read in CF Metadata from .json specification file
|
|
662
|
+
cf_metadata = get_cf_metadata_spec()
|
|
663
|
+
|
|
664
|
+
# Add CF-compliant global attributes
|
|
665
|
+
xstats.attrs["Conventions"] = "CF-1.8"
|
|
666
|
+
xstats.attrs["license"] = "CC-BY-4.0"
|
|
667
|
+
xstats.attrs["title"] = "PyOPIA Particle Statistics Dataset"
|
|
668
|
+
xstats.attrs["summary"] = (
|
|
669
|
+
"This dataset contains particle statistics generated by the PyOPIA pipeline."
|
|
670
|
+
)
|
|
671
|
+
xstats.attrs["institution"] = ""
|
|
672
|
+
xstats.attrs["source"] = "PyOPIA pipeline"
|
|
673
|
+
|
|
674
|
+
# Apply metadata from CF_METADATA if available
|
|
675
|
+
for var in xstats.data_vars:
|
|
676
|
+
if var in cf_metadata:
|
|
677
|
+
metadata = cf_metadata[var]
|
|
678
|
+
xstats[var].attrs.update(metadata)
|
|
679
|
+
|
|
680
|
+
# Variables starting with probability_ are handled separately
|
|
681
|
+
if var.startswith("probability_"):
|
|
682
|
+
class_name = "_".join(var.split("_")[1:])
|
|
683
|
+
xstats[var].attrs["long_name"] = (
|
|
684
|
+
f"Probability of particle belonging to class {class_name}"
|
|
685
|
+
)
|
|
686
|
+
xstats[var].attrs["units"] = "dimensionless fraction"
|
|
687
|
+
xstats[var].attrs["calculation_method"] = (
|
|
688
|
+
"Determined by neural network classifier"
|
|
689
|
+
)
|
|
690
|
+
xstats[var].attrs["standard_name"] = f"probability_of_{class_name}"
|
|
691
|
+
xstats[var].attrs["pyopia_process_level"] = 1
|
|
692
|
+
|
|
693
|
+
return xstats
|
|
@@ -337,7 +337,7 @@ def extract_particles(imc, timestamp, Classification, region_properties,
|
|
|
337
337
|
|
|
338
338
|
# add the filenames of the HDF5 file and particle number tag to the
|
|
339
339
|
# particle statistics data
|
|
340
|
-
stats['
|
|
340
|
+
stats['export_name'] = pd.Series(index=stats.index, data=filenames, dtype=str)
|
|
341
341
|
|
|
342
342
|
return stats
|
|
343
343
|
|
|
@@ -476,7 +476,7 @@ def gen_roifiles(stats, auto_scaler=500):
|
|
|
476
476
|
a list of string of filenames that can be passed to montage_maker() for making nice montages
|
|
477
477
|
'''
|
|
478
478
|
|
|
479
|
-
roifiles = stats['
|
|
479
|
+
roifiles = stats['export_name'][stats['export_name'] != 'not_exported'].values
|
|
480
480
|
|
|
481
481
|
# subsample the particles if necessary
|
|
482
482
|
logger.info('rofiles: {0}'.format(len(roifiles)))
|
|
@@ -699,17 +699,17 @@ def add_depth_to_stats(stats, time, depth):
|
|
|
699
699
|
|
|
700
700
|
|
|
701
701
|
def roi_from_export_name(exportname, path):
|
|
702
|
-
'''Returns an image from the
|
|
702
|
+
'''Returns an image from the export_name string in the -STATS.h5 file
|
|
703
703
|
|
|
704
704
|
Get the exportname like this:
|
|
705
705
|
```python
|
|
706
|
-
exportname = stats['
|
|
706
|
+
exportname = stats['export_name'].values[0]
|
|
707
707
|
```
|
|
708
708
|
|
|
709
709
|
Parameters
|
|
710
710
|
----------
|
|
711
711
|
exportname : str
|
|
712
|
-
string containing the name of the exported particle e.g. stats['
|
|
712
|
+
string containing the name of the exported particle e.g. stats['export_name'].values[0]
|
|
713
713
|
path : str
|
|
714
714
|
path to exported h5 files
|
|
715
715
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import pytest
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from pyopia.io import write_stats, load_stats, get_cf_metadata_spec
|
|
6
|
+
from pyopia.instrument.silcam import generate_config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
pipeline_config = generate_config(
|
|
10
|
+
raw_files="images/*.silc",
|
|
11
|
+
model_path="None",
|
|
12
|
+
outfolder="processed",
|
|
13
|
+
output_prefix="test",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_write_and_load_stats(tmp_path: Path):
|
|
18
|
+
# Create a temporary directory for testing
|
|
19
|
+
temp_dir = tmp_path / "test_data"
|
|
20
|
+
temp_dir.mkdir()
|
|
21
|
+
|
|
22
|
+
# Create a sample DataFrame to write
|
|
23
|
+
data = {
|
|
24
|
+
"major_axis_length": [10.5, 20.3],
|
|
25
|
+
"minor_axis_length": [5.2, 10.1],
|
|
26
|
+
"equivalent_diameter": [7.8, 15.2],
|
|
27
|
+
"saturation": [50.0, 75.0],
|
|
28
|
+
}
|
|
29
|
+
# Convert timestamp to datetime format
|
|
30
|
+
data["timestamp"] = pd.to_datetime(["2025-04-25T10:00:00", "2025-04-25T10:05:00"])
|
|
31
|
+
stats_df = pd.DataFrame(data)
|
|
32
|
+
|
|
33
|
+
# Define the output file path
|
|
34
|
+
output_file = os.path.join(temp_dir, "test")
|
|
35
|
+
|
|
36
|
+
# Provide a minimal valid settings object
|
|
37
|
+
config = pipeline_config
|
|
38
|
+
|
|
39
|
+
# Write the stats to a NetCDF file
|
|
40
|
+
write_stats(stats_df, output_file, settings=config, dataformat="nc", append=True)
|
|
41
|
+
|
|
42
|
+
# Check if the file was created
|
|
43
|
+
assert os.path.exists(output_file + "-STATS.nc")
|
|
44
|
+
|
|
45
|
+
# Load the stats back
|
|
46
|
+
loaded_stats = load_stats(output_file + "-STATS.nc")
|
|
47
|
+
|
|
48
|
+
# Verify the data matches
|
|
49
|
+
for var in data.keys():
|
|
50
|
+
assert var in loaded_stats.data_vars
|
|
51
|
+
assert all(loaded_stats[var].values == stats_df[var].values)
|
|
52
|
+
|
|
53
|
+
# Verify CF_METADATA attributes
|
|
54
|
+
for var, metadata in get_cf_metadata_spec().items():
|
|
55
|
+
if var in loaded_stats.data_vars:
|
|
56
|
+
for attr, value in metadata.items():
|
|
57
|
+
assert loaded_stats[var].attrs.get(attr) == value
|
|
58
|
+
|
|
59
|
+
# Check that version tag is in the attributes
|
|
60
|
+
assert "PyOPIA_version" in loaded_stats.attrs
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
pytest.main()
|
pyopia-2.9.2/pyopia/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.9.2"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|