PyOPIA 2.12.0__tar.gz → 2.13.0__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.12.0 → pyopia-2.13.0}/PKG-INFO +20 -2
- {pyopia-2.12.0 → pyopia-2.13.0}/README.md +19 -1
- pyopia-2.13.0/pyopia/__init__.py +1 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/cli.py +44 -0
- pyopia-2.13.0/pyopia/dataexport/__init__.py +0 -0
- pyopia-2.13.0/pyopia/dataexport/ecotaxa.py +239 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/statistics.py +238 -127
- pyopia-2.12.0/pyopia/__init__.py +0 -1
- {pyopia-2.12.0 → pyopia-2.13.0}/.gitignore +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/LICENSE +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/auxillarydata.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/background.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/cf_metadata.json +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/classify.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/exampledata.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/instrument/__init__.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/instrument/common.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/instrument/holo.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/instrument/silcam.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/instrument/uvp.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/io.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/metadata.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/pipeline.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/plotting.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/process.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/simulator/__init__.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/simulator/silcam.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/tests/__init__.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/tests/test_auxillarydata.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/tests/test_classify.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/tests/test_io.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/tests/test_notebooks.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyopia/tests/test_pipeline.py +0 -0
- {pyopia-2.12.0 → pyopia-2.13.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyOPIA
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.13.0
|
|
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
|
|
@@ -57,11 +57,29 @@ A Python Ocean Particle Image Analysis toolbox
|
|
|
57
57
|
1) Install [uv](https://docs.astral.sh/uv/getting-started/installation)
|
|
58
58
|
2) Initialize PyOPIA project with a small example image dataset and run processing
|
|
59
59
|
```bash
|
|
60
|
-
uvx --python 3.12 --from pyopia[classification] pyopia
|
|
60
|
+
uvx --python 3.12 --from pyopia[classification] pyopia init-project pyopiatest --example-data
|
|
61
|
+
```
|
|
62
|
+
```bash
|
|
61
63
|
cd pyopiatest
|
|
64
|
+
```
|
|
65
|
+
```bash
|
|
62
66
|
uvx --python 3.12 --from pyopia[classification] pyopia process config.toml
|
|
63
67
|
```
|
|
64
68
|
3) Inspect the processed particle statistics in the processed/ folder
|
|
69
|
+
4) Merge the individual processed image STATS files into a single STATS.nc file, then create a montage of the processed STATS.nc
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uvx --python 3.12 --from pyopia[classification] pyopia merge-mfdata processed
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
uvx --python 3.12 --from pyopia[classification] pyopia make-montage processed\pyopiatest-STATS.nc
|
|
77
|
+
```
|
|
78
|
+
5) Visualise the monatge of all processed singular particle images in one
|
|
79
|
+
```bash
|
|
80
|
+
montage.png
|
|
81
|
+
```
|
|
82
|
+
Will show you a montage of all the processed particle images in one.
|
|
65
83
|
|
|
66
84
|
See the documentation for more information on how to install and use PyOPIA.
|
|
67
85
|
|
|
@@ -8,11 +8,29 @@ A Python Ocean Particle Image Analysis toolbox
|
|
|
8
8
|
1) Install [uv](https://docs.astral.sh/uv/getting-started/installation)
|
|
9
9
|
2) Initialize PyOPIA project with a small example image dataset and run processing
|
|
10
10
|
```bash
|
|
11
|
-
uvx --python 3.12 --from pyopia[classification] pyopia
|
|
11
|
+
uvx --python 3.12 --from pyopia[classification] pyopia init-project pyopiatest --example-data
|
|
12
|
+
```
|
|
13
|
+
```bash
|
|
12
14
|
cd pyopiatest
|
|
15
|
+
```
|
|
16
|
+
```bash
|
|
13
17
|
uvx --python 3.12 --from pyopia[classification] pyopia process config.toml
|
|
14
18
|
```
|
|
15
19
|
3) Inspect the processed particle statistics in the processed/ folder
|
|
20
|
+
4) Merge the individual processed image STATS files into a single STATS.nc file, then create a montage of the processed STATS.nc
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uvx --python 3.12 --from pyopia[classification] pyopia merge-mfdata processed
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uvx --python 3.12 --from pyopia[classification] pyopia make-montage processed\pyopiatest-STATS.nc
|
|
28
|
+
```
|
|
29
|
+
5) Visualise the monatge of all processed singular particle images in one
|
|
30
|
+
```bash
|
|
31
|
+
montage.png
|
|
32
|
+
```
|
|
33
|
+
Will show you a montage of all the processed particle images in one.
|
|
16
34
|
|
|
17
35
|
See the documentation for more information on how to install and use PyOPIA.
|
|
18
36
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.13.0"
|
|
@@ -41,6 +41,7 @@ import pyopia.statistics
|
|
|
41
41
|
import pyopia.auxillarydata
|
|
42
42
|
import pyopia.exampledata
|
|
43
43
|
import pyopia.metadata
|
|
44
|
+
import pyopia.dataexport.ecotaxa
|
|
44
45
|
|
|
45
46
|
app = typer.Typer()
|
|
46
47
|
|
|
@@ -473,6 +474,49 @@ def make_montage(
|
|
|
473
474
|
plt.savefig(output_filename, dpi=300, bbox_inches="tight")
|
|
474
475
|
|
|
475
476
|
|
|
477
|
+
@app.command()
|
|
478
|
+
def export_to_ecotaxa(
|
|
479
|
+
stats_filename: pathlib.Path,
|
|
480
|
+
export_filename: pathlib.Path,
|
|
481
|
+
make_label_folders: bool = False,
|
|
482
|
+
filter_variable: Annotated[Tuple[str, float, float], typer.Option()] = [
|
|
483
|
+
None,
|
|
484
|
+
None,
|
|
485
|
+
None,
|
|
486
|
+
],
|
|
487
|
+
):
|
|
488
|
+
"""EcoTaxa export: Create a zip file with particle images and statistics csv
|
|
489
|
+
|
|
490
|
+
Parameters
|
|
491
|
+
----------
|
|
492
|
+
config_filename : pathlib.Path
|
|
493
|
+
Config filename
|
|
494
|
+
output_filename: pathlib.Path
|
|
495
|
+
Name of zip file to contain exported particle images and statistics
|
|
496
|
+
make_label_folders : bool, optional, default False
|
|
497
|
+
If True, store particle images in sub-folders with label names.
|
|
498
|
+
NB: This must be False to create an EcoTaxa compatible zip file.
|
|
499
|
+
filter_variable: list
|
|
500
|
+
Variables to filter on (name, min, max), e.g. ['depth', 5, None]
|
|
501
|
+
"""
|
|
502
|
+
print("[blue]LOAD STATS")
|
|
503
|
+
xstats = pyopia.io.load_stats(str(stats_filename))
|
|
504
|
+
|
|
505
|
+
# Filter the stats
|
|
506
|
+
if filter_variable[0] is not None:
|
|
507
|
+
print(filter_variable)
|
|
508
|
+
xstats = xstats.where(
|
|
509
|
+
(xstats[filter_variable[0]] <= filter_variable[2])
|
|
510
|
+
& (xstats[filter_variable[0]] >= filter_variable[1])
|
|
511
|
+
).dropna(dim="index")
|
|
512
|
+
|
|
513
|
+
print("[blue]CREATING ECOTAXA BUNDLE")
|
|
514
|
+
ecotaxa_export = pyopia.dataexport.ecotaxa.EcotaxaExporter()
|
|
515
|
+
ecotaxa_export.create_bundle(
|
|
516
|
+
xstats, export_filename, make_label_folders=make_label_folders
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
476
520
|
def process_file_list(file_list, pipeline_config, c):
|
|
477
521
|
"""Run a PyOPIA processing pipeline for a chuncked list of files based on a given config.toml
|
|
478
522
|
|
|
File without changes
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Create data bundle from PyOPIA stats that can be imported into EcoTaxa.
|
|
3
|
+
|
|
4
|
+
EcoTaxa: https://ecotaxa.obs-vlfr.fr/
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pyopia
|
|
9
|
+
import pyopia.io
|
|
10
|
+
import pyopia.statistics
|
|
11
|
+
import zipfile
|
|
12
|
+
import io
|
|
13
|
+
import xarray as xr
|
|
14
|
+
import pandas as pd
|
|
15
|
+
import matplotlib.pyplot as plt
|
|
16
|
+
from typing import Optional
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from tqdm.rich import tqdm
|
|
19
|
+
|
|
20
|
+
# Define the mapping from EcoTaxa variable names to PyOPIA stats column names.
|
|
21
|
+
# The type expected by EcoTaxa is also defined.
|
|
22
|
+
# Direct mapping from PyOPIA stats row values (statsrows) is possible in many cases.
|
|
23
|
+
# Where no direct mapping from a single PyOPIA stats variable exists, a combination
|
|
24
|
+
# or retrieval of other relevant metadata is used to construct the EcoTaxa value.
|
|
25
|
+
_ecotaxa_dict = {
|
|
26
|
+
"img_file_name": ("export_name", "str"), # source: statsrow
|
|
27
|
+
"img_rank": ("img_rank", "float"), # source: we will make it exist
|
|
28
|
+
"object_id": ("export_name", "str"), # source: statsrow
|
|
29
|
+
"object_lat": (
|
|
30
|
+
"latitude",
|
|
31
|
+
"float",
|
|
32
|
+
), # needs to be in decimal degrees, source: auxillary data
|
|
33
|
+
"object_lon": (
|
|
34
|
+
"longitude",
|
|
35
|
+
"float",
|
|
36
|
+
), # needs to be in decimal degrees, source: auxillary data
|
|
37
|
+
"object_date": (
|
|
38
|
+
"timestamp",
|
|
39
|
+
"str",
|
|
40
|
+
), # UTC, format: YYYYMMDD, source: statsrow timestamp.dt.strftime(%Y%m%d)
|
|
41
|
+
"object_time": (
|
|
42
|
+
"timestamp",
|
|
43
|
+
"str",
|
|
44
|
+
), # UTC, format: HHMMSS, source: statsrow timestamp.dt.strftime(%H%M%S)
|
|
45
|
+
"object_depth_min": ("depth", "float"), # source: statsrow
|
|
46
|
+
"object_depth_max": ("depth", "float"), # source: statsrow
|
|
47
|
+
"object_major": ("major_axis_length", "float"), # source: statsrow
|
|
48
|
+
"object_minor": ("minor_axis_length", "float"), # source: statsrow
|
|
49
|
+
"object_circ.": ("equivalent_diameter", "float"), # source: statsrow
|
|
50
|
+
"process_id": (
|
|
51
|
+
"process_id",
|
|
52
|
+
"float",
|
|
53
|
+
), # If missing will be added by EcoTaxa
|
|
54
|
+
"process_img_software_version": ("pyopia.__version__", "str"),
|
|
55
|
+
"process_img_resolution": ("process_img_resolution", "float"),
|
|
56
|
+
"process_particle_pixel_size_?m": (
|
|
57
|
+
"pixel_size",
|
|
58
|
+
"float",
|
|
59
|
+
), # Needs to be converted from pixels to micrometers, the ? is a choice of EcoTaxa
|
|
60
|
+
"process_date": (
|
|
61
|
+
"process_date",
|
|
62
|
+
"str",
|
|
63
|
+
), # Added by custom step in create_bundle, from Modified time in PyOPIA netcdf (UTC)
|
|
64
|
+
"process_time": (
|
|
65
|
+
"process_time",
|
|
66
|
+
"str",
|
|
67
|
+
), # Same as process_date. datetime.now(timezone.utc).strftime("%H%M%S").
|
|
68
|
+
"acq_id": ("acq_id", "float"), # If missing will be added by EcoTaxa
|
|
69
|
+
"sample_id": ("sample_id", "float"), # If missing will be added by EcoTaxa
|
|
70
|
+
"sample_stationid": (
|
|
71
|
+
"sample_stationid",
|
|
72
|
+
"str",
|
|
73
|
+
), # Will be constructed from project name and station (PyOPIA netcdf)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Some columns requires formatting or transformations
|
|
77
|
+
_ecotaxa_formatters = {
|
|
78
|
+
"object_date": lambda x: x.strftime("%Y%m%d"),
|
|
79
|
+
"object_time": lambda x: x.strftime("%H%M%S"),
|
|
80
|
+
"img_file_name": lambda x: x + ".png",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_ecotaxa_types = {
|
|
84
|
+
"float": "[f]",
|
|
85
|
+
"str": "[t]",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class EcotaxaExporter:
|
|
90
|
+
"""Export particle statistics (xstats) and images (ROIs) to a zip file for EcoTaxa import"""
|
|
91
|
+
|
|
92
|
+
def statsrow_to_ecotaxarow(self, statsrow, xstats_attrs):
|
|
93
|
+
"""Create a row in the EcoTaxa import file from PyOPIA stats row"""
|
|
94
|
+
ecotaxarow = dict()
|
|
95
|
+
for k, (statsname, statstype) in _ecotaxa_dict.items():
|
|
96
|
+
formatter = _ecotaxa_formatters.get(k, lambda x: x)
|
|
97
|
+
if statsname in statsrow.index:
|
|
98
|
+
ecotaxarow[k] = formatter(statsrow.get(statsname))
|
|
99
|
+
elif statsname in xstats_attrs:
|
|
100
|
+
ecotaxarow[k] = formatter(xstats_attrs.get(statsname))
|
|
101
|
+
else:
|
|
102
|
+
ecotaxarow[k] = np.nan
|
|
103
|
+
# type casting
|
|
104
|
+
return ecotaxarow
|
|
105
|
+
|
|
106
|
+
def create_bundle(
|
|
107
|
+
self,
|
|
108
|
+
xstats: xr.Dataset,
|
|
109
|
+
export_filename: Path,
|
|
110
|
+
roi_dir: Optional[Path] = None,
|
|
111
|
+
make_label_folders=False,
|
|
112
|
+
):
|
|
113
|
+
"""
|
|
114
|
+
Create an EcoTaxa import bundle file containing particle images and stats csv.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
xstats : xr.Dataset
|
|
119
|
+
A dataset containing particle statistics and metadata.
|
|
120
|
+
export_filename : pathlib.Path
|
|
121
|
+
Path to the output `.zip` file that will contain the bundle.
|
|
122
|
+
roi_dir : pathlib.Path, optional
|
|
123
|
+
Directory containing particle image files to include in the bundle.
|
|
124
|
+
If None, the roi_dir in the xstats steps metadata will be used.
|
|
125
|
+
make_label_folders : bool, optional, default False
|
|
126
|
+
If True, store particle images in sub-folders with label names.
|
|
127
|
+
NB: This must be False to create an EcoTaxa compatible zip file.
|
|
128
|
+
Returns
|
|
129
|
+
-------
|
|
130
|
+
None
|
|
131
|
+
This method does not return a value. It writes a zip file.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
# Reconstruct PyOPIA config from xstats
|
|
135
|
+
self.config = pyopia.io.steps_from_xstats(xstats)
|
|
136
|
+
|
|
137
|
+
self.export_filename = export_filename
|
|
138
|
+
|
|
139
|
+
# Name of particle roi directory from config, unless overridden by input argument
|
|
140
|
+
self.roi_dir = (
|
|
141
|
+
Path(self.config["steps"]["statextract"]["export_outputpath"])
|
|
142
|
+
if roi_dir is None
|
|
143
|
+
else roi_dir
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Create statistics DataFrame, add classification best guess info for all particles
|
|
147
|
+
self.df_stats_export = pyopia.statistics.add_best_guesses_to_stats(
|
|
148
|
+
xstats.to_pandas()
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
buffer = io.BytesIO()
|
|
152
|
+
with zipfile.ZipFile(self.export_filename, mode="w") as zip:
|
|
153
|
+
# Export particle statistics dataframe to buffer as csv
|
|
154
|
+
buffer.seek(0)
|
|
155
|
+
buffer.truncate(0)
|
|
156
|
+
self.df_stats_export.to_csv(buffer)
|
|
157
|
+
|
|
158
|
+
ecotaxarows = []
|
|
159
|
+
|
|
160
|
+
# Write particle statistics csv to zip file, only if labelled folders are enabled
|
|
161
|
+
# This file is not used by EcoTaxa import
|
|
162
|
+
if make_label_folders:
|
|
163
|
+
buffer.seek(0)
|
|
164
|
+
zip.writestr("particle_statistics.csv", buffer.read())
|
|
165
|
+
|
|
166
|
+
# create it outside here
|
|
167
|
+
for idx, (_, row) in enumerate(
|
|
168
|
+
tqdm(
|
|
169
|
+
self.df_stats_export.iterrows(), total=self.df_stats_export.shape[0]
|
|
170
|
+
)
|
|
171
|
+
):
|
|
172
|
+
# Get particle image
|
|
173
|
+
export_name = row["export_name"]
|
|
174
|
+
if export_name == "not_exported":
|
|
175
|
+
continue
|
|
176
|
+
particle_image = pyopia.statistics.roi_from_export_name(
|
|
177
|
+
export_name, self.roi_dir
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Convert PyOPIA stats row to EcoTaxa import row
|
|
181
|
+
ecotaxarows.append(self.statsrow_to_ecotaxarow(row, xstats.attrs))
|
|
182
|
+
|
|
183
|
+
# Save the particle image to the buffer
|
|
184
|
+
buffer.seek(0)
|
|
185
|
+
buffer.truncate(0)
|
|
186
|
+
plt.imsave(buffer, particle_image, format="png")
|
|
187
|
+
|
|
188
|
+
# Write the buffer to the zip file with a filename
|
|
189
|
+
label_folder = ""
|
|
190
|
+
if make_label_folders:
|
|
191
|
+
label_folder = row["best guess"].replace("probability_", "") + "/"
|
|
192
|
+
buffer.seek(0)
|
|
193
|
+
zip.writestr(f"{label_folder}{export_name}.png", buffer.read())
|
|
194
|
+
|
|
195
|
+
# Convert export table to Pandas DataFrame
|
|
196
|
+
df_ecotaxa_export_table = pd.DataFrame(ecotaxarows)
|
|
197
|
+
df_ecotaxa_types = pd.DataFrame(
|
|
198
|
+
[
|
|
199
|
+
{
|
|
200
|
+
k: _ecotaxa_types[ettype]
|
|
201
|
+
for k, (_, ettype) in _ecotaxa_dict.items()
|
|
202
|
+
}
|
|
203
|
+
]
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Add some EcoTaxa required information manually here.
|
|
207
|
+
# Construct from multiple PyOPIA data values or metadata as needed.
|
|
208
|
+
pixel_size = self.config["general"]["pixel_size"]
|
|
209
|
+
df_ecotaxa_export_table["process_img_software_version"] = (
|
|
210
|
+
f"PyOPIA {xstats.attrs['PyOPIA_version']}"
|
|
211
|
+
)
|
|
212
|
+
df_ecotaxa_export_table["img_rank"] = 0
|
|
213
|
+
df_ecotaxa_export_table["process_particle_pixel_size_?m"] = pixel_size
|
|
214
|
+
df_ecotaxa_export_table["sample_stationid"] = "-".join(
|
|
215
|
+
[xstats.attrs.get(el, "") for el in ["project_name", "station"]]
|
|
216
|
+
)
|
|
217
|
+
processing_datetime = pd.to_datetime(xstats.attrs["Modified"])
|
|
218
|
+
df_ecotaxa_export_table["process_time"] = processing_datetime.strftime(
|
|
219
|
+
"%H%M%S"
|
|
220
|
+
)
|
|
221
|
+
df_ecotaxa_export_table["process_date"] = processing_datetime.strftime(
|
|
222
|
+
"%Y%m%d"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
for col in ["object_major", "object_minor", "object_circ."]:
|
|
226
|
+
df_ecotaxa_export_table[col] *= pixel_size
|
|
227
|
+
|
|
228
|
+
# Insert type defintion as first row in exported table
|
|
229
|
+
df_ecotaxa_export_table = pd.concat(
|
|
230
|
+
[df_ecotaxa_types, df_ecotaxa_export_table],
|
|
231
|
+
axis=0,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Write to zip archive
|
|
235
|
+
buffer.seek(0)
|
|
236
|
+
buffer.truncate(0)
|
|
237
|
+
df_ecotaxa_export_table.to_csv(buffer, index=False, sep="\t")
|
|
238
|
+
buffer.seek(0)
|
|
239
|
+
zip.writestr("ecotaxa_particle_statistics.tsv", buffer.read())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
"""
|
|
2
2
|
Module containing tools for handling particle image statistics after processing
|
|
3
|
-
|
|
3
|
+
"""
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
import pandas as pd
|
|
@@ -11,11 +11,12 @@ from tqdm import tqdm
|
|
|
11
11
|
from pyopia.io import write_stats, load_stats_as_dataframe
|
|
12
12
|
|
|
13
13
|
import logging
|
|
14
|
+
|
|
14
15
|
logger = logging.getLogger()
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def d50_from_stats(stats, pixel_size):
|
|
18
|
-
|
|
19
|
+
"""Calculate the d50 from the stats and settings
|
|
19
20
|
|
|
20
21
|
Parameters
|
|
21
22
|
----------
|
|
@@ -28,7 +29,7 @@ def d50_from_stats(stats, pixel_size):
|
|
|
28
29
|
-------
|
|
29
30
|
d50 : float
|
|
30
31
|
the 50th percentile of the cumulative sum of the volume distributon, in microns
|
|
31
|
-
|
|
32
|
+
"""
|
|
32
33
|
# the volume distribution needs calculating first
|
|
33
34
|
dias, vd = vd_from_stats(stats, pixel_size)
|
|
34
35
|
|
|
@@ -38,7 +39,7 @@ def d50_from_stats(stats, pixel_size):
|
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
def d50_from_vd(volume_distribution, dias):
|
|
41
|
-
|
|
42
|
+
"""
|
|
42
43
|
Calculate d50 from a volume distribution
|
|
43
44
|
|
|
44
45
|
Parameters
|
|
@@ -53,7 +54,7 @@ def d50_from_vd(volume_distribution, dias):
|
|
|
53
54
|
-------
|
|
54
55
|
d50 : float
|
|
55
56
|
The 50th percentile of the cumulative sum of the volume distributon, in microns
|
|
56
|
-
|
|
57
|
+
"""
|
|
57
58
|
# calculate cumulative sum of the volume distribution
|
|
58
59
|
csvd = np.cumsum(volume_distribution / np.sum(volume_distribution))
|
|
59
60
|
|
|
@@ -63,7 +64,7 @@ def d50_from_vd(volume_distribution, dias):
|
|
|
63
64
|
|
|
64
65
|
|
|
65
66
|
def get_size_bins():
|
|
66
|
-
|
|
67
|
+
"""
|
|
67
68
|
Retrieve log-spaced size bins for PSD analysis by doing the same binning as LISST-100x, but with 53 size bins
|
|
68
69
|
|
|
69
70
|
Returns
|
|
@@ -72,7 +73,7 @@ def get_size_bins():
|
|
|
72
73
|
Mid-points of size bins in microns
|
|
73
74
|
bin_limits : array
|
|
74
75
|
Limits of size bins in microns
|
|
75
|
-
|
|
76
|
+
"""
|
|
76
77
|
# pre-allocate
|
|
77
78
|
bin_limits = np.zeros((53), dtype=np.float64)
|
|
78
79
|
|
|
@@ -99,7 +100,7 @@ def get_size_bins():
|
|
|
99
100
|
|
|
100
101
|
|
|
101
102
|
def crop_stats(stats, crop_stats):
|
|
102
|
-
|
|
103
|
+
"""Filters stats file based on whether the particles are within a rectangle specified by crop_stats.
|
|
103
104
|
|
|
104
105
|
Parameters
|
|
105
106
|
----------
|
|
@@ -112,9 +113,13 @@ def crop_stats(stats, crop_stats):
|
|
|
112
113
|
-------
|
|
113
114
|
cropped_stats : DataFrame
|
|
114
115
|
cropped silcam stats file
|
|
115
|
-
|
|
116
|
-
r = np.array(
|
|
117
|
-
|
|
116
|
+
"""
|
|
117
|
+
r = np.array(
|
|
118
|
+
((stats["maxr"] - stats["minr"]) / 2) + stats["minr"]
|
|
119
|
+
) # pixel row of middle of bounding box
|
|
120
|
+
c = np.array(
|
|
121
|
+
((stats["maxc"] - stats["minc"]) / 2) + stats["minc"]
|
|
122
|
+
) # pixel column of middle of bounding box
|
|
118
123
|
|
|
119
124
|
pts = np.array([[(r_, c_)] for r_, c_ in zip(r, c)])
|
|
120
125
|
pts = pts.squeeze()
|
|
@@ -127,8 +132,8 @@ def crop_stats(stats, crop_stats):
|
|
|
127
132
|
return cropped_stats
|
|
128
133
|
|
|
129
134
|
|
|
130
|
-
def vd_from_nd(number_distribution, dias, sample_volume=1.):
|
|
131
|
-
|
|
135
|
+
def vd_from_nd(number_distribution, dias, sample_volume=1.0):
|
|
136
|
+
"""Calculate volume concentration from particle count
|
|
132
137
|
|
|
133
138
|
Parameters
|
|
134
139
|
----------
|
|
@@ -143,17 +148,19 @@ def vd_from_nd(number_distribution, dias, sample_volume=1.):
|
|
|
143
148
|
-------
|
|
144
149
|
volume_distribution : array
|
|
145
150
|
Particle volume distribution
|
|
146
|
-
|
|
151
|
+
"""
|
|
147
152
|
dias = dias * 1e-6 # convert to m
|
|
148
|
-
particle_volume = 4 / 3 * np.pi * (dias / 2)**3 # volume in m^3
|
|
149
|
-
total_particle_volume =
|
|
153
|
+
particle_volume = 4 / 3 * np.pi * (dias / 2) ** 3 # volume in m^3
|
|
154
|
+
total_particle_volume = (
|
|
155
|
+
particle_volume * number_distribution * 1e9
|
|
156
|
+
) # volume in micro-litres
|
|
150
157
|
volume_distribution = total_particle_volume / sample_volume # micro-litres / litre
|
|
151
158
|
|
|
152
159
|
return volume_distribution
|
|
153
160
|
|
|
154
161
|
|
|
155
162
|
def nc_from_nd(number_distribution, sample_volume):
|
|
156
|
-
|
|
163
|
+
"""
|
|
157
164
|
Calculate the number concentration from the count and sample volume
|
|
158
165
|
|
|
159
166
|
Parameters
|
|
@@ -167,13 +174,13 @@ def nc_from_nd(number_distribution, sample_volume):
|
|
|
167
174
|
-------
|
|
168
175
|
number_concentration : float
|
|
169
176
|
Particle number concentration in #/L
|
|
170
|
-
|
|
177
|
+
"""
|
|
171
178
|
number_concentration = np.sum(number_distribution) / sample_volume
|
|
172
179
|
return number_concentration
|
|
173
180
|
|
|
174
181
|
|
|
175
182
|
def nc_vc_from_stats(stats, pix_size, path_length, imx=2048, imy=2448):
|
|
176
|
-
|
|
183
|
+
"""Calculates important summary statistics from a stats DataFrame
|
|
177
184
|
|
|
178
185
|
Parameters
|
|
179
186
|
----------
|
|
@@ -198,7 +205,7 @@ def nc_vc_from_stats(stats, pix_size, path_length, imx=2048, imy=2448):
|
|
|
198
205
|
Total volume of water sampled in L
|
|
199
206
|
junge_slope : float
|
|
200
207
|
Slope of a fitted juge distribution between 150-300um
|
|
201
|
-
|
|
208
|
+
"""
|
|
202
209
|
# calculate the sample volume per image
|
|
203
210
|
sample_volume = get_sample_volume(pix_size, path_length, imx=imx, imy=imy)
|
|
204
211
|
|
|
@@ -234,7 +241,7 @@ def nc_vc_from_stats(stats, pix_size, path_length, imx=2048, imy=2448):
|
|
|
234
241
|
|
|
235
242
|
|
|
236
243
|
def nd_from_stats_scaled(stats, pix_size, path_length):
|
|
237
|
-
|
|
244
|
+
"""Calcualte a scaled number distribution from stats.
|
|
238
245
|
units of nd are in number per micron per litre
|
|
239
246
|
|
|
240
247
|
Parameters
|
|
@@ -252,7 +259,7 @@ def nd_from_stats_scaled(stats, pix_size, path_length):
|
|
|
252
259
|
mid-points of size bins
|
|
253
260
|
number_distribution : array
|
|
254
261
|
number distribution in number/micron/litre
|
|
255
|
-
|
|
262
|
+
"""
|
|
256
263
|
# calculate the number distirbution (number per bin per sample volume)
|
|
257
264
|
dias, necd = nd_from_stats(stats, pix_size)
|
|
258
265
|
|
|
@@ -277,7 +284,7 @@ def nd_from_stats_scaled(stats, pix_size, path_length):
|
|
|
277
284
|
|
|
278
285
|
|
|
279
286
|
def nd_from_stats(stats, pix_size):
|
|
280
|
-
|
|
287
|
+
"""Calculate number distirbution from stats
|
|
281
288
|
units are number per bin per sample volume
|
|
282
289
|
|
|
283
290
|
Parameters
|
|
@@ -293,10 +300,10 @@ def nd_from_stats(stats, pix_size):
|
|
|
293
300
|
mid-points of size bins
|
|
294
301
|
number_distribution : array
|
|
295
302
|
number distribution in number/size-bin/sample-volume
|
|
296
|
-
|
|
303
|
+
"""
|
|
297
304
|
|
|
298
305
|
# convert the equiv diameter from pixels into microns
|
|
299
|
-
ecd = stats[
|
|
306
|
+
ecd = stats["equivalent_diameter"] * pix_size
|
|
300
307
|
|
|
301
308
|
# ignore nans
|
|
302
309
|
ecd = ecd[~np.isnan(ecd.values)]
|
|
@@ -314,7 +321,7 @@ def nd_from_stats(stats, pix_size):
|
|
|
314
321
|
|
|
315
322
|
|
|
316
323
|
def vd_from_stats(stats, pix_size):
|
|
317
|
-
|
|
324
|
+
"""Calculate volume distribution from stats
|
|
318
325
|
units of miro-litres per sample volume
|
|
319
326
|
|
|
320
327
|
Parameters
|
|
@@ -330,7 +337,7 @@ def vd_from_stats(stats, pix_size):
|
|
|
330
337
|
mid-points of size bins
|
|
331
338
|
volume_distribution : array
|
|
332
339
|
volume distribution in micro-litres/sample-volume
|
|
333
|
-
|
|
340
|
+
"""
|
|
334
341
|
|
|
335
342
|
# obtain the number distribution
|
|
336
343
|
dias, necd = nd_from_stats(stats, pix_size)
|
|
@@ -342,9 +349,18 @@ def vd_from_stats(stats, pix_size):
|
|
|
342
349
|
return dias, volume_distribution
|
|
343
350
|
|
|
344
351
|
|
|
345
|
-
def make_montage(
|
|
346
|
-
|
|
347
|
-
|
|
352
|
+
def make_montage(
|
|
353
|
+
stats_file_or_df,
|
|
354
|
+
pixel_size,
|
|
355
|
+
roidir,
|
|
356
|
+
auto_scaler=500,
|
|
357
|
+
msize=1024,
|
|
358
|
+
maxlength=100000,
|
|
359
|
+
crop_stats=None,
|
|
360
|
+
brightness=1,
|
|
361
|
+
eyecandy=True,
|
|
362
|
+
):
|
|
363
|
+
"""Makes nice looking montage from a directory of extracted particle images
|
|
348
364
|
|
|
349
365
|
Parameters
|
|
350
366
|
----------
|
|
@@ -372,7 +388,7 @@ def make_montage(stats_file_or_df, pixel_size, roidir,
|
|
|
372
388
|
-------
|
|
373
389
|
montage_image : array
|
|
374
390
|
montage image that can be plotted with :func:`pyopia.plotting.montage_plot`
|
|
375
|
-
|
|
391
|
+
"""
|
|
376
392
|
if isinstance(stats_file_or_df, str):
|
|
377
393
|
stats = load_stats_as_dataframe(stats_file_or_df)
|
|
378
394
|
else:
|
|
@@ -382,11 +398,11 @@ def make_montage(stats_file_or_df, pixel_size, roidir,
|
|
|
382
398
|
stats = crop_stats(stats, crop_stats)
|
|
383
399
|
|
|
384
400
|
# remove nans because concentrations are not important here
|
|
385
|
-
stats = stats[~np.isnan(stats[
|
|
386
|
-
stats = stats[(stats[
|
|
401
|
+
stats = stats[~np.isnan(stats["major_axis_length"])]
|
|
402
|
+
stats = stats[(stats["major_axis_length"] * pixel_size) < maxlength]
|
|
387
403
|
|
|
388
404
|
# sort the particles based on their length
|
|
389
|
-
stats.sort_values(by=[
|
|
405
|
+
stats.sort_values(by=["major_axis_length"], ascending=False, inplace=True)
|
|
390
406
|
|
|
391
407
|
roifiles = gen_roifiles(stats, auto_scaler=auto_scaler)
|
|
392
408
|
|
|
@@ -394,7 +410,7 @@ def make_montage(stats_file_or_df, pixel_size, roidir,
|
|
|
394
410
|
montage = np.zeros((msize, msize, 3), dtype=np.float64())
|
|
395
411
|
# pre-allocate an empty test canvas
|
|
396
412
|
immap_test = np.zeros_like(montage[:, :, 0])
|
|
397
|
-
logger.info(
|
|
413
|
+
logger.info("making a montage - this might take some time....")
|
|
398
414
|
|
|
399
415
|
# loop through each extracted particle and attempt to add it to the canvas
|
|
400
416
|
for files in tqdm(roifiles):
|
|
@@ -427,41 +443,43 @@ def make_montage(stats_file_or_df, pixel_size, roidir,
|
|
|
427
443
|
|
|
428
444
|
# try five times to fit the particle to the canvas by randomly moving
|
|
429
445
|
# it around
|
|
430
|
-
while
|
|
446
|
+
while counter < 5:
|
|
431
447
|
r = np.random.randint(1, msize - height)
|
|
432
448
|
c = np.random.randint(1, msize - width)
|
|
433
|
-
test = np.max(immap_test[r:r + height, c:c + width, None] + 1)
|
|
449
|
+
test = np.max(immap_test[r: r + height, c: c + width, None] + 1)
|
|
434
450
|
|
|
435
451
|
# if the new particle is overlapping an existing object in the
|
|
436
452
|
# canvas, then try again and increment the counter
|
|
437
|
-
if
|
|
453
|
+
if test > 1:
|
|
438
454
|
counter += 1
|
|
439
455
|
else:
|
|
440
456
|
break
|
|
441
457
|
|
|
442
458
|
# if we reach this point and there is still an overlap, then forget
|
|
443
459
|
# this particle, and move on
|
|
444
|
-
if
|
|
460
|
+
if test > 1:
|
|
445
461
|
continue
|
|
446
462
|
|
|
447
463
|
# if we reach here, then the particle has found a position in the
|
|
448
464
|
# canvas with no overlap, and can then be inserted into the canvas
|
|
449
|
-
montage[r:r + height, c:c + width, :] = particle_image
|
|
465
|
+
montage[r: r + height, c: c + width, :] = particle_image
|
|
450
466
|
|
|
451
|
-
immap_test[r:r + height, c:c + width, None] =
|
|
467
|
+
immap_test[r: r + height, c: c + width, None] = (
|
|
468
|
+
immap_test[r: r + height, c: c + width, None] + 1
|
|
469
|
+
)
|
|
452
470
|
|
|
453
471
|
# now the montage is finished
|
|
454
472
|
# here are some small eye-candy scaling things to tidy up
|
|
455
473
|
montage_image = np.copy(montage)
|
|
456
474
|
montage_image[montage > 1] = 1
|
|
457
475
|
montage_image[montage == 0] = 1
|
|
458
|
-
logger.info(
|
|
476
|
+
logger.info("montage complete")
|
|
459
477
|
|
|
460
478
|
return montage_image
|
|
461
479
|
|
|
462
480
|
|
|
463
481
|
def gen_roifiles(stats, auto_scaler=500):
|
|
464
|
-
|
|
482
|
+
"""Generates a list of filenames suitable for making montages with
|
|
465
483
|
|
|
466
484
|
Parameters
|
|
467
485
|
----------
|
|
@@ -474,22 +492,22 @@ def gen_roifiles(stats, auto_scaler=500):
|
|
|
474
492
|
----------
|
|
475
493
|
roifiles : list
|
|
476
494
|
a list of string of filenames that can be passed to montage_maker() for making nice montages
|
|
477
|
-
|
|
495
|
+
"""
|
|
478
496
|
|
|
479
|
-
roifiles = stats[
|
|
497
|
+
roifiles = stats["export_name"][stats["export_name"] != "not_exported"].values
|
|
480
498
|
|
|
481
499
|
# subsample the particles if necessary
|
|
482
|
-
logger.info(
|
|
500
|
+
logger.info("rofiles: {0}".format(len(roifiles)))
|
|
483
501
|
IMSTEP = np.max([int(np.round(len(roifiles) / auto_scaler)), 1])
|
|
484
|
-
logger.info(
|
|
502
|
+
logger.info("reducing particles by factor of {0}".format(IMSTEP))
|
|
485
503
|
roifiles = roifiles[np.arange(0, len(roifiles), IMSTEP)]
|
|
486
|
-
logger.info(
|
|
504
|
+
logger.info("rofiles: {0}".format(len(roifiles)))
|
|
487
505
|
|
|
488
506
|
return roifiles
|
|
489
507
|
|
|
490
508
|
|
|
491
509
|
def get_sample_volume(pix_size, path_length, imx=2048, imy=2448):
|
|
492
|
-
|
|
510
|
+
"""calculate the sample volume of one image
|
|
493
511
|
|
|
494
512
|
Parameters
|
|
495
513
|
----------
|
|
@@ -506,14 +524,16 @@ def get_sample_volume(pix_size, path_length, imx=2048, imy=2448):
|
|
|
506
524
|
-------
|
|
507
525
|
sample_volume_litres : float
|
|
508
526
|
Volume of the sample volume in litres
|
|
509
|
-
|
|
510
|
-
sample_volume_litres =
|
|
527
|
+
"""
|
|
528
|
+
sample_volume_litres = (
|
|
529
|
+
imx * pix_size / 1000 * imy * pix_size / 1000 * path_length * 1e-6
|
|
530
|
+
)
|
|
511
531
|
|
|
512
532
|
return sample_volume_litres
|
|
513
533
|
|
|
514
534
|
|
|
515
535
|
def get_j(dias, number_distribution):
|
|
516
|
-
|
|
536
|
+
"""Calculates the junge slope from a correctly-scale number distribution
|
|
517
537
|
(number per micron per litre must be the units of nd)
|
|
518
538
|
|
|
519
539
|
Parameters
|
|
@@ -527,20 +547,29 @@ def get_j(dias, number_distribution):
|
|
|
527
547
|
-------
|
|
528
548
|
junge_slope : float
|
|
529
549
|
Junge slope from fitting of psd between 150 and 300um
|
|
530
|
-
|
|
550
|
+
"""
|
|
531
551
|
# conduct this calculation only on the part of the size distribution where
|
|
532
552
|
# LISST-100 and SilCam data overlap
|
|
533
|
-
ind =
|
|
553
|
+
ind = (
|
|
554
|
+
np.isfinite(dias)
|
|
555
|
+
& np.isfinite(number_distribution)
|
|
556
|
+
& (dias < 300)
|
|
557
|
+
& (dias > 150)
|
|
558
|
+
)
|
|
534
559
|
|
|
535
560
|
# use polyfit to obtain the slope of the ditriubtion in log-space (which is
|
|
536
561
|
# assumed near-linear in most parts of the ocean)
|
|
537
|
-
p = np.polyfit(
|
|
562
|
+
p = np.polyfit(
|
|
563
|
+
np.log(dias[ind]),
|
|
564
|
+
np.log(number_distribution[ind], where=number_distribution[ind] > 0),
|
|
565
|
+
1,
|
|
566
|
+
)
|
|
538
567
|
junge_slope = p[0]
|
|
539
568
|
return junge_slope
|
|
540
569
|
|
|
541
570
|
|
|
542
571
|
def count_images_in_stats(stats):
|
|
543
|
-
|
|
572
|
+
"""count the number of raw images used to generate stats
|
|
544
573
|
|
|
545
574
|
Parameters
|
|
546
575
|
----------
|
|
@@ -551,15 +580,15 @@ def count_images_in_stats(stats):
|
|
|
551
580
|
-------
|
|
552
581
|
n_images : int
|
|
553
582
|
number of images in the stats data
|
|
554
|
-
|
|
555
|
-
u = pd.to_datetime(stats[
|
|
583
|
+
"""
|
|
584
|
+
u = pd.to_datetime(stats["timestamp"]).unique()
|
|
556
585
|
n_images = len(u)
|
|
557
586
|
|
|
558
587
|
return n_images
|
|
559
588
|
|
|
560
589
|
|
|
561
590
|
def extract_nth_largest(stats, n=0):
|
|
562
|
-
|
|
591
|
+
"""Return statistics of the nth largest particle
|
|
563
592
|
|
|
564
593
|
Parameters
|
|
565
594
|
----------
|
|
@@ -572,14 +601,72 @@ def extract_nth_largest(stats, n=0):
|
|
|
572
601
|
-------
|
|
573
602
|
stats_extract
|
|
574
603
|
statistics of the nth largest particle
|
|
575
|
-
|
|
576
|
-
stats_sorted = stats.sort_values(
|
|
604
|
+
"""
|
|
605
|
+
stats_sorted = stats.sort_values(
|
|
606
|
+
by=["equivalent_diameter"], ascending=False, inplace=False
|
|
607
|
+
)
|
|
577
608
|
stats_extract = stats_sorted.iloc[n]
|
|
578
609
|
return stats_extract
|
|
579
610
|
|
|
580
611
|
|
|
612
|
+
def extract_oil(
|
|
613
|
+
stats, probability_threshold=0.85, solidity_threshold=0.95, feret_threshold=0.3
|
|
614
|
+
):
|
|
615
|
+
"""Creates a new stats dataframe containing only oil, based on some thresholds on calculated statistics
|
|
616
|
+
|
|
617
|
+
Parameters
|
|
618
|
+
----------
|
|
619
|
+
stats : DataFrame
|
|
620
|
+
particle statistics
|
|
621
|
+
probability_threshold : float, optional
|
|
622
|
+
Threshold applied to probability_oil (from the classifier), by default 0.85
|
|
623
|
+
solidity_threshold : float, optional
|
|
624
|
+
Threshold applied to the solidity statistic (area of object / convex hull).
|
|
625
|
+
For droplets, this threshold is used as a crude way of removing operlapping droplets
|
|
626
|
+
by ensuring there are no substantial indents in the alpha shape, by default 0.95
|
|
627
|
+
feret_threshold : float, optional
|
|
628
|
+
Threshold of deformation (minor/major axis) beyond which the droplet is considered
|
|
629
|
+
significantly deformed or at risk of breakup., by default 0.3
|
|
630
|
+
|
|
631
|
+
Returns
|
|
632
|
+
-------
|
|
633
|
+
oilstats: DataFrame
|
|
634
|
+
particle statistics for just oil (a new stats DataFrame containing only oil).
|
|
635
|
+
.. Warning: this returned DataFrame will likely have a shorter length than the original
|
|
636
|
+
and can even be empty for single-image DataFrames if no particles satisfy the thresholds,
|
|
637
|
+
so be carefull to include all analyzed images when calculating volume concentraitons
|
|
638
|
+
"""
|
|
639
|
+
|
|
640
|
+
# Select only particles with limited deformation (smaller number -> higher deformation)
|
|
641
|
+
# Select only particles with solidity above threshold
|
|
642
|
+
ma = stats["minor_axis_length"] / stats["major_axis_length"]
|
|
643
|
+
oil_stats = stats[
|
|
644
|
+
np.logical_and(ma > feret_threshold, stats["solidity"] > solidity_threshold)
|
|
645
|
+
]
|
|
646
|
+
|
|
647
|
+
# If probability_threshold > 0.5, no need to compare against other particle types,
|
|
648
|
+
# since probabilities always sum to 1
|
|
649
|
+
if probability_threshold > 0.5:
|
|
650
|
+
# Select those particles that satisfy the probability threshold
|
|
651
|
+
oil_stats = oil_stats[oil_stats["probability_oil"] > probability_threshold]
|
|
652
|
+
|
|
653
|
+
else:
|
|
654
|
+
# Check probability threshold
|
|
655
|
+
# and
|
|
656
|
+
# Select only particles where the probability of oil is larger than
|
|
657
|
+
# the probabilities of bubble _and_ oily gas
|
|
658
|
+
mask = (
|
|
659
|
+
(oil_stats["probability_oil"] > probability_threshold)
|
|
660
|
+
& (oil_stats["probability_oil"] > oil_stats["probability_bubble"])
|
|
661
|
+
& (oil_stats["probability_oil"] > oil_stats["probability_oily_gas"])
|
|
662
|
+
)
|
|
663
|
+
oil_stats = oil_stats[mask]
|
|
664
|
+
|
|
665
|
+
return oil_stats
|
|
666
|
+
|
|
667
|
+
|
|
581
668
|
def extract_nth_longest(stats, n=0):
|
|
582
|
-
|
|
669
|
+
"""Return statistics of the nth longest particle
|
|
583
670
|
|
|
584
671
|
Parameters
|
|
585
672
|
----------
|
|
@@ -592,14 +679,16 @@ def extract_nth_longest(stats, n=0):
|
|
|
592
679
|
-------
|
|
593
680
|
stats_extract
|
|
594
681
|
statistics of the nth largest particle
|
|
595
|
-
|
|
596
|
-
stats_sorted = stats.sort_values(
|
|
682
|
+
"""
|
|
683
|
+
stats_sorted = stats.sort_values(
|
|
684
|
+
by=["major_axis_length"], ascending=False, inplace=False
|
|
685
|
+
)
|
|
597
686
|
stats_extract = stats_sorted.iloc[n]
|
|
598
687
|
return stats_extract
|
|
599
688
|
|
|
600
689
|
|
|
601
690
|
def explode_contrast(im):
|
|
602
|
-
|
|
691
|
+
"""Eye-candy function for exploding the contrast of a particle iamge (roi)
|
|
603
692
|
|
|
604
693
|
Parameters
|
|
605
694
|
----------
|
|
@@ -610,7 +699,7 @@ def explode_contrast(im):
|
|
|
610
699
|
-------
|
|
611
700
|
im_mod : array
|
|
612
701
|
image following exploded contrast
|
|
613
|
-
|
|
702
|
+
"""
|
|
614
703
|
# re-scale the instensities in the image to chop off some ends
|
|
615
704
|
p1, p2 = np.percentile(im, (0, 80))
|
|
616
705
|
im_mod = rescale_intensity(im, in_range=(p1, p2))
|
|
@@ -623,8 +712,8 @@ def explode_contrast(im):
|
|
|
623
712
|
return im_mod
|
|
624
713
|
|
|
625
714
|
|
|
626
|
-
def bright_norm(im, brightness=1.):
|
|
627
|
-
|
|
715
|
+
def bright_norm(im, brightness=1.0):
|
|
716
|
+
"""Eye-candy function for normalising the image brightness
|
|
628
717
|
|
|
629
718
|
Parameters
|
|
630
719
|
----------
|
|
@@ -637,7 +726,7 @@ def bright_norm(im, brightness=1.):
|
|
|
637
726
|
-------
|
|
638
727
|
im : array
|
|
639
728
|
image with modified brightness
|
|
640
|
-
|
|
729
|
+
"""
|
|
641
730
|
peak = np.median(im.flatten())
|
|
642
731
|
bm = brightness - peak
|
|
643
732
|
|
|
@@ -648,7 +737,7 @@ def bright_norm(im, brightness=1.):
|
|
|
648
737
|
|
|
649
738
|
|
|
650
739
|
def nd_rescale(dias, number_distribution, sample_volume):
|
|
651
|
-
|
|
740
|
+
"""Rescale a number distribution from number per bin per sample volume to number per micron per litre.
|
|
652
741
|
|
|
653
742
|
Parameters
|
|
654
743
|
----------
|
|
@@ -663,19 +752,23 @@ def nd_rescale(dias, number_distribution, sample_volume):
|
|
|
663
752
|
-------
|
|
664
753
|
number_distribution_scaled : array
|
|
665
754
|
scaled number distribution (number per micron per litre)
|
|
666
|
-
|
|
755
|
+
"""
|
|
667
756
|
|
|
668
|
-
number_distribution_scaled =
|
|
757
|
+
number_distribution_scaled = (
|
|
758
|
+
np.float64(number_distribution) / sample_volume
|
|
759
|
+
) # nc per size bin per litre
|
|
669
760
|
|
|
670
761
|
# convert nd to units of nc per micron per litre
|
|
671
762
|
dd = np.gradient(dias)
|
|
672
763
|
number_distribution_scaled /= dd
|
|
673
|
-
number_distribution_scaled[number_distribution_scaled < 0] =
|
|
764
|
+
number_distribution_scaled[number_distribution_scaled < 0] = (
|
|
765
|
+
np.nan
|
|
766
|
+
) # and nan impossible values!
|
|
674
767
|
return number_distribution_scaled
|
|
675
768
|
|
|
676
769
|
|
|
677
770
|
def add_depth_to_stats(stats, time, depth):
|
|
678
|
-
|
|
771
|
+
"""If you have a depth time-series, use this function to find the depth of each line in stats
|
|
679
772
|
|
|
680
773
|
Parameters
|
|
681
774
|
----------
|
|
@@ -690,16 +783,16 @@ def add_depth_to_stats(stats, time, depth):
|
|
|
690
783
|
-------
|
|
691
784
|
stats : DataFrame
|
|
692
785
|
particle statistics now with a 'Depth' column for each particle
|
|
693
|
-
|
|
786
|
+
"""
|
|
694
787
|
# get times
|
|
695
|
-
sctime = pd.to_datetime(stats[
|
|
788
|
+
sctime = pd.to_datetime(stats["timestamp"])
|
|
696
789
|
# interpolate depths into the SilCam times
|
|
697
|
-
stats[
|
|
790
|
+
stats["Depth"] = np.interp(np.float64(sctime), np.float64(time), depth)
|
|
698
791
|
return stats
|
|
699
792
|
|
|
700
793
|
|
|
701
794
|
def roi_from_export_name(exportname, path):
|
|
702
|
-
|
|
795
|
+
"""Returns an image from the export_name string in the -STATS.h5 file
|
|
703
796
|
|
|
704
797
|
Get the exportname like this:
|
|
705
798
|
```python
|
|
@@ -717,17 +810,17 @@ def roi_from_export_name(exportname, path):
|
|
|
717
810
|
-------
|
|
718
811
|
im : array
|
|
719
812
|
particle ROI image
|
|
720
|
-
|
|
813
|
+
"""
|
|
721
814
|
# the particle number is defined after the time info
|
|
722
|
-
pn = exportname.split(
|
|
815
|
+
pn = exportname.split("-")[1]
|
|
723
816
|
# the name is the first bit
|
|
724
|
-
name = exportname.split(
|
|
817
|
+
name = exportname.split("-")[0] + ".h5"
|
|
725
818
|
|
|
726
819
|
# combine the name with the location of the exported HDF5 files
|
|
727
820
|
fullname = os.path.join(path, name)
|
|
728
821
|
|
|
729
822
|
# open the H5 file
|
|
730
|
-
fh = h5py.File(fullname,
|
|
823
|
+
fh = h5py.File(fullname, "r")
|
|
731
824
|
|
|
732
825
|
if (fh[pn].dtype) == np.uint8:
|
|
733
826
|
im = np.float64(fh[pn]) / 255
|
|
@@ -738,7 +831,7 @@ def roi_from_export_name(exportname, path):
|
|
|
738
831
|
|
|
739
832
|
|
|
740
833
|
def extract_latest_stats(stats, window_size):
|
|
741
|
-
|
|
834
|
+
"""Extracts the stats data from within the last number of seconds specified by window_size.
|
|
742
835
|
|
|
743
836
|
Parameters
|
|
744
837
|
----------
|
|
@@ -751,15 +844,15 @@ def extract_latest_stats(stats, window_size):
|
|
|
751
844
|
-------
|
|
752
845
|
stats_selected : DataFrame
|
|
753
846
|
particle statistics after specified time window (given by `window_size`)
|
|
754
|
-
|
|
755
|
-
end = np.max(pd.to_datetime(stats[
|
|
756
|
-
start = end - pd.to_timedelta(
|
|
757
|
-
stats_selected = stats[pd.to_datetime(stats[
|
|
847
|
+
"""
|
|
848
|
+
end = np.max(pd.to_datetime(stats["timestamp"]))
|
|
849
|
+
start = end - pd.to_timedelta("00:00:" + str(window_size))
|
|
850
|
+
stats_selected = stats[pd.to_datetime(stats["timestamp"]) > start]
|
|
758
851
|
return stats_selected
|
|
759
852
|
|
|
760
853
|
|
|
761
854
|
def make_timeseries_vd(stats, pixel_size, path_length, time_reference):
|
|
762
|
-
|
|
855
|
+
"""Makes a dataframe of time-series volume distribution and d50
|
|
763
856
|
similar to Sequoia LISST-100 output,
|
|
764
857
|
and exportable to things like Excel or csv.
|
|
765
858
|
|
|
@@ -813,15 +906,15 @@ def make_timeseries_vd(stats, pixel_size, path_length, time_reference):
|
|
|
813
906
|
|
|
814
907
|
# time-series of total volume concentration
|
|
815
908
|
volume_concentration = np.sum(vdarray, axis=1)
|
|
816
|
-
|
|
909
|
+
"""
|
|
817
910
|
sample_volume = get_sample_volume(pixel_size, path_length=path_length)
|
|
818
911
|
|
|
819
912
|
vdts = np.zeros((len(time_reference), len(get_size_bins()[0])), dtype=np.float64)
|
|
820
913
|
d50 = np.zeros((len(time_reference)), dtype=np.float64) * np.nan
|
|
821
914
|
for i, s in enumerate(tqdm(time_reference)):
|
|
822
|
-
nims = count_images_in_stats(stats[stats[
|
|
915
|
+
nims = count_images_in_stats(stats[stats["timestamp"] == s])
|
|
823
916
|
if nims > 0:
|
|
824
|
-
dias, vd = vd_from_stats(stats[stats[
|
|
917
|
+
dias, vd = vd_from_stats(stats[stats["timestamp"] == s], pixel_size)
|
|
825
918
|
sv = sample_volume * nims
|
|
826
919
|
vd /= sv
|
|
827
920
|
d50[i] = d50_from_vd(vd, dias)
|
|
@@ -833,36 +926,38 @@ def make_timeseries_vd(stats, pixel_size, path_length, time_reference):
|
|
|
833
926
|
|
|
834
927
|
time_series = pd.DataFrame(data=[np.squeeze(vdts)], columns=dias)
|
|
835
928
|
|
|
836
|
-
time_series[
|
|
837
|
-
time_series[
|
|
929
|
+
time_series["D50"] = np.nan
|
|
930
|
+
time_series["Time"] = np.nan
|
|
838
931
|
|
|
839
932
|
return time_series
|
|
840
933
|
|
|
841
934
|
time_series = pd.DataFrame(data=np.squeeze(vdts), columns=dias)
|
|
842
935
|
|
|
843
|
-
time_series[
|
|
844
|
-
time_series[
|
|
936
|
+
time_series["D50"] = d50
|
|
937
|
+
time_series["Time"] = pd.to_datetime(time_reference)
|
|
845
938
|
|
|
846
|
-
time_series.sort_values(by=
|
|
939
|
+
time_series.sort_values(by="Time", inplace=True, ascending=True)
|
|
847
940
|
|
|
848
941
|
return time_series
|
|
849
942
|
|
|
850
943
|
|
|
851
944
|
def statscsv_to_statshdf(stats_file):
|
|
852
|
-
|
|
945
|
+
"""Convert old STATS.csv file to a STATS.h5 file
|
|
853
946
|
|
|
854
947
|
Parameters
|
|
855
948
|
----------
|
|
856
949
|
stats_file : str
|
|
857
950
|
filename of stats file
|
|
858
|
-
|
|
951
|
+
"""
|
|
859
952
|
stats = pd.read_csv(stats_file, index_col=False)
|
|
860
|
-
assert stats_file[-10:] ==
|
|
953
|
+
assert stats_file[-10:] == "-STATS.csv", (
|
|
954
|
+
f"Stats file {stats_file} should end in '-STATS.csv'."
|
|
955
|
+
)
|
|
861
956
|
write_stats(stats_file[:-10], stats, append=False)
|
|
862
957
|
|
|
863
958
|
|
|
864
959
|
def trim_stats(stats_file, start_time, end_time, write_new=False, stats=[]):
|
|
865
|
-
|
|
960
|
+
"""Chops a STATS.h5 file given a start and end time
|
|
866
961
|
|
|
867
962
|
Parameters
|
|
868
963
|
----------
|
|
@@ -884,28 +979,40 @@ def trim_stats(stats_file, start_time, end_time, write_new=False, stats=[]):
|
|
|
884
979
|
particle statistics
|
|
885
980
|
outname : str
|
|
886
981
|
name of new stats csv file written to disc
|
|
887
|
-
|
|
982
|
+
"""
|
|
888
983
|
if len(stats) == 0:
|
|
889
|
-
stats = pd.read_hdf(stats_file,
|
|
984
|
+
stats = pd.read_hdf(stats_file, "ParticleStats/stats")
|
|
890
985
|
|
|
891
986
|
start_time = pd.to_datetime(start_time)
|
|
892
987
|
end_time = pd.to_datetime(end_time)
|
|
893
988
|
|
|
894
989
|
trimmed_stats = stats[
|
|
895
|
-
(pd.to_datetime(stats[
|
|
990
|
+
(pd.to_datetime(stats["timestamp"]) > start_time)
|
|
991
|
+
& (pd.to_datetime(stats["timestamp"]) < end_time)
|
|
992
|
+
]
|
|
896
993
|
|
|
897
994
|
if np.isnan(trimmed_stats.equivalent_diameter.max()) or len(trimmed_stats) == 0:
|
|
898
|
-
logger.info(
|
|
899
|
-
outname =
|
|
995
|
+
logger.info("No data in specified time range!")
|
|
996
|
+
outname = ""
|
|
900
997
|
return trimmed_stats, outname
|
|
901
998
|
|
|
902
|
-
actual_start = pd.to_datetime(trimmed_stats[
|
|
903
|
-
|
|
999
|
+
actual_start = pd.to_datetime(trimmed_stats["timestamp"].min()).strftime(
|
|
1000
|
+
"D%Y%m%dT%H%M%S.%f"
|
|
1001
|
+
)
|
|
1002
|
+
actual_end = pd.to_datetime(trimmed_stats["timestamp"].max()).strftime(
|
|
1003
|
+
"D%Y%m%dT%H%M%S.%f"
|
|
1004
|
+
)
|
|
904
1005
|
|
|
905
1006
|
path, name = os.path.split(stats_file)
|
|
906
1007
|
|
|
907
|
-
outname =
|
|
908
|
-
|
|
1008
|
+
outname = (
|
|
1009
|
+
os.path.join(path, name.replace("-STATS.h5", ""))
|
|
1010
|
+
+ "-Start"
|
|
1011
|
+
+ str(actual_start)
|
|
1012
|
+
+ "-End"
|
|
1013
|
+
+ str(actual_end)
|
|
1014
|
+
+ "-STATS.h5"
|
|
1015
|
+
)
|
|
909
1016
|
|
|
910
1017
|
if write_new:
|
|
911
1018
|
trimmed_stats.to_csv(outname)
|
|
@@ -914,7 +1021,7 @@ def trim_stats(stats_file, start_time, end_time, write_new=False, stats=[]):
|
|
|
914
1021
|
|
|
915
1022
|
|
|
916
1023
|
def add_best_guesses_to_stats(stats):
|
|
917
|
-
|
|
1024
|
+
"""
|
|
918
1025
|
Calculates the most likely tensorflow classification and adds best guesses
|
|
919
1026
|
to stats dataframe.
|
|
920
1027
|
|
|
@@ -927,41 +1034,41 @@ def add_best_guesses_to_stats(stats):
|
|
|
927
1034
|
-------
|
|
928
1035
|
stats : DataFrame
|
|
929
1036
|
particle statistics from silcam process with new columns for best guess and best guess value
|
|
930
|
-
|
|
1037
|
+
"""
|
|
931
1038
|
cols = stats.columns
|
|
932
1039
|
|
|
933
1040
|
p = np.zeros_like(cols) != 0
|
|
934
1041
|
for i, c in enumerate(cols):
|
|
935
|
-
p[i] = str(c).startswith(
|
|
1042
|
+
p[i] = str(c).startswith("probability")
|
|
936
1043
|
|
|
937
1044
|
pinds = np.squeeze(np.argwhere(p))
|
|
938
1045
|
|
|
939
1046
|
parray = np.array(stats.iloc[:, pinds[:]])
|
|
940
1047
|
|
|
941
|
-
stats[
|
|
942
|
-
stats[
|
|
1048
|
+
stats["best guess"] = cols[pinds.min() + np.argmax(parray, axis=1)]
|
|
1049
|
+
stats["best guess value"] = np.max(parray, axis=1)
|
|
943
1050
|
return stats
|
|
944
1051
|
|
|
945
1052
|
|
|
946
1053
|
def show_h5_meta(h5file):
|
|
947
|
-
|
|
1054
|
+
"""
|
|
948
1055
|
prints metadata from an exported hdf5 file created from silcam process
|
|
949
1056
|
|
|
950
1057
|
Parameters
|
|
951
1058
|
----------
|
|
952
1059
|
h5file : str
|
|
953
1060
|
h5 filename from exported data from silcam process
|
|
954
|
-
|
|
955
|
-
with h5py.File(h5file,
|
|
956
|
-
keys = list(f[
|
|
1061
|
+
"""
|
|
1062
|
+
with h5py.File(h5file, "r") as f:
|
|
1063
|
+
keys = list(f["Meta"].attrs.keys())
|
|
957
1064
|
|
|
958
1065
|
for k in keys:
|
|
959
|
-
logger.info(k +
|
|
960
|
-
logger.info(
|
|
1066
|
+
logger.info(k + ":")
|
|
1067
|
+
logger.info(" " + f["Meta"].attrs[k])
|
|
961
1068
|
|
|
962
1069
|
|
|
963
1070
|
def vd_to_nd(volume_distribution, dias):
|
|
964
|
-
|
|
1071
|
+
"""convert volume distribution to number distribution
|
|
965
1072
|
|
|
966
1073
|
Parameters
|
|
967
1074
|
----------
|
|
@@ -974,14 +1081,18 @@ def vd_to_nd(volume_distribution, dias):
|
|
|
974
1081
|
-------
|
|
975
1082
|
number_distribution : array
|
|
976
1083
|
number distribution as number per micron per bin (scaling is the same unit as the input vd)
|
|
977
|
-
|
|
978
|
-
DropletVolume = (
|
|
979
|
-
|
|
1084
|
+
"""
|
|
1085
|
+
DropletVolume = (
|
|
1086
|
+
(4 / 3) * np.pi * ((dias * 1e-6) / 2) ** 3
|
|
1087
|
+
) # the volume of each droplet in m3
|
|
1088
|
+
number_distribution = volume_distribution / (
|
|
1089
|
+
DropletVolume * 1e9
|
|
1090
|
+
) # the number distribution in each bin
|
|
980
1091
|
return number_distribution
|
|
981
1092
|
|
|
982
1093
|
|
|
983
1094
|
def vd_to_nc(volume_distribution, dias):
|
|
984
|
-
|
|
1095
|
+
"""calculate number concentration from volume distribution
|
|
985
1096
|
|
|
986
1097
|
Parameters
|
|
987
1098
|
----------
|
|
@@ -995,7 +1106,7 @@ def vd_to_nc(volume_distribution, dias):
|
|
|
995
1106
|
number_concentration : float
|
|
996
1107
|
number concentration (scaling is the same unit as the input vd).
|
|
997
1108
|
If vd is a 2d array [time, vd_bins], nc will be the concentration for row
|
|
998
|
-
|
|
1109
|
+
"""
|
|
999
1110
|
number_distribution = vd_to_nd(dias, volume_distribution)
|
|
1000
1111
|
if np.ndim(number_distribution) > 1:
|
|
1001
1112
|
number_concentration = np.sum(number_distribution, axis=1)
|
pyopia-2.12.0/pyopia/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.12.0"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|