PyOPIA 2.16.3__tar.gz → 2.16.4__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.16.3 → pyopia-2.16.4}/PKG-INFO +1 -1
- pyopia-2.16.4/pyopia/__init__.py +1 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/statistics.py +119 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_pipeline.py +71 -0
- pyopia-2.16.3/pyopia/__init__.py +0 -1
- {pyopia-2.16.3 → pyopia-2.16.4}/.gitignore +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/LICENSE +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/README.md +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/auxillarydata.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/background.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/cf_metadata.json +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/classify.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/classify_torch.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/cli.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/dataexport/__init__.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/dataexport/ecotaxa.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/exampledata.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/instrument/__init__.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/instrument/common.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/instrument/holo.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/instrument/silcam.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/instrument/uvp.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/io.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/metadata.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/pipeline.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/plotting.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/process.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/realtime.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/simulator/__init__.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/simulator/silcam.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/__init__.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_auxillarydata.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_classify.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_io.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_notebooks.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_process.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_realtime.py +0 -0
- {pyopia-2.16.3 → pyopia-2.16.4}/pyproject.toml +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.16.4"
|
|
@@ -1091,6 +1091,125 @@ def vd_to_nd(volume_distribution, dias):
|
|
|
1091
1091
|
return number_distribution
|
|
1092
1092
|
|
|
1093
1093
|
|
|
1094
|
+
class PerClassConcentration:
|
|
1095
|
+
"""PyOpia pipeline-compatible class for computing per-class number concentrations
|
|
1096
|
+
(in numbers/litre) for each processed image, and appending the result as a
|
|
1097
|
+
timestamp-indexed row to a CSV file.
|
|
1098
|
+
|
|
1099
|
+
Required keys in :class:`pyopia.pipeline.Data`:
|
|
1100
|
+
- :attr:`pyopia.pipeline.Data.stats` (with ``probability_<class>`` columns)
|
|
1101
|
+
- :attr:`pyopia.pipeline.Data.timestamp`
|
|
1102
|
+
|
|
1103
|
+
For every particle in the current image's stats, the most likely class is
|
|
1104
|
+
selected as ``argmax`` over the ``probability_<class>`` columns. Particles
|
|
1105
|
+
whose best-guess probability is below ``probability_threshold`` are counted
|
|
1106
|
+
in an ``unclassified`` column. Counts are divided by the per-image sample
|
|
1107
|
+
volume (computed from pixel size, path length and image dimensions) to give
|
|
1108
|
+
a concentration in numbers/litre.
|
|
1109
|
+
|
|
1110
|
+
A row is appended to ``output_csv`` per image, with the image timestamp as
|
|
1111
|
+
the index. Columns are ``<class_name>`` for each classifier class, plus
|
|
1112
|
+
``unclassified``, ``total`` and ``sample_volume_L``.
|
|
1113
|
+
|
|
1114
|
+
Both ``pixel_size`` and ``path_length`` are read from the ``general``
|
|
1115
|
+
section of the settings dict (``data['settings']['general']``).
|
|
1116
|
+
|
|
1117
|
+
Parameters
|
|
1118
|
+
----------
|
|
1119
|
+
output_csv : str
|
|
1120
|
+
Path to the CSV file to write. Parent directory is created if missing.
|
|
1121
|
+
probability_threshold : float, optional
|
|
1122
|
+
Minimum best-guess probability for a particle to count toward its class.
|
|
1123
|
+
Particles below this are counted in the ``unclassified`` column. Default 0.0.
|
|
1124
|
+
overwrite : bool, optional
|
|
1125
|
+
If True, remove any existing ``output_csv`` at construction time so the
|
|
1126
|
+
run starts with a fresh file. Default False (append).
|
|
1127
|
+
|
|
1128
|
+
Returns
|
|
1129
|
+
-------
|
|
1130
|
+
data : :class:`pyopia.pipeline.Data`
|
|
1131
|
+
The pipeline data dict, unchanged.
|
|
1132
|
+
|
|
1133
|
+
Example
|
|
1134
|
+
-------
|
|
1135
|
+
Example config for pipeline usage:
|
|
1136
|
+
|
|
1137
|
+
.. code-block:: toml
|
|
1138
|
+
|
|
1139
|
+
[general]
|
|
1140
|
+
pixel_size = 28
|
|
1141
|
+
path_length = 40
|
|
1142
|
+
|
|
1143
|
+
[steps.classconcentration]
|
|
1144
|
+
pipeline_class = 'pyopia.statistics.PerClassConcentration'
|
|
1145
|
+
output_csv = 'proc/per_class_concentration.csv'
|
|
1146
|
+
probability_threshold = 0.5
|
|
1147
|
+
overwrite = false
|
|
1148
|
+
"""
|
|
1149
|
+
|
|
1150
|
+
def __init__(self,
|
|
1151
|
+
output_csv,
|
|
1152
|
+
probability_threshold=0.0,
|
|
1153
|
+
overwrite=False):
|
|
1154
|
+
self.output_csv = output_csv
|
|
1155
|
+
self.probability_threshold = probability_threshold
|
|
1156
|
+
|
|
1157
|
+
outdir = os.path.dirname(self.output_csv)
|
|
1158
|
+
if outdir:
|
|
1159
|
+
os.makedirs(outdir, exist_ok=True)
|
|
1160
|
+
|
|
1161
|
+
if overwrite and os.path.exists(self.output_csv):
|
|
1162
|
+
os.remove(self.output_csv)
|
|
1163
|
+
|
|
1164
|
+
def __call__(self, data):
|
|
1165
|
+
stats = data['stats']
|
|
1166
|
+
timestamp = pd.to_datetime(data['timestamp'])
|
|
1167
|
+
general = data['settings']['general']
|
|
1168
|
+
pixel_size = general['pixel_size']
|
|
1169
|
+
path_length = general['path_length']
|
|
1170
|
+
|
|
1171
|
+
image = data.get('imraw')
|
|
1172
|
+
if image is not None:
|
|
1173
|
+
imy, imx = image.shape[:2]
|
|
1174
|
+
else:
|
|
1175
|
+
imx, imy = 2048, 2448
|
|
1176
|
+
|
|
1177
|
+
sample_volume = get_sample_volume(pixel_size, path_length, imx=imx, imy=imy)
|
|
1178
|
+
|
|
1179
|
+
prob_cols = [c for c in stats.columns if str(c).startswith('probability_')]
|
|
1180
|
+
class_labels = [c[len('probability_'):] for c in prob_cols]
|
|
1181
|
+
|
|
1182
|
+
if len(prob_cols) == 0:
|
|
1183
|
+
logger.warning('PerClassConcentration: no probability_* columns found in stats; '
|
|
1184
|
+
'writing empty per-class row.')
|
|
1185
|
+
|
|
1186
|
+
counts = {label: 0 for label in class_labels}
|
|
1187
|
+
unclassified = 0
|
|
1188
|
+
|
|
1189
|
+
if not stats.empty and len(prob_cols) > 0:
|
|
1190
|
+
valid = stats.dropna(subset=prob_cols)
|
|
1191
|
+
if len(valid) > 0:
|
|
1192
|
+
probs = valid[prob_cols].to_numpy(dtype=np.float64)
|
|
1193
|
+
best_idx = np.argmax(probs, axis=1)
|
|
1194
|
+
best_value = np.max(probs, axis=1)
|
|
1195
|
+
above = best_value >= self.probability_threshold
|
|
1196
|
+
for i, label in enumerate(class_labels):
|
|
1197
|
+
counts[label] = int(np.sum((best_idx == i) & above))
|
|
1198
|
+
unclassified = int(np.sum(~above))
|
|
1199
|
+
|
|
1200
|
+
row = {label: counts[label] / sample_volume for label in class_labels}
|
|
1201
|
+
row['unclassified'] = unclassified / sample_volume
|
|
1202
|
+
row['total'] = (sum(counts.values()) + unclassified) / sample_volume
|
|
1203
|
+
row['sample_volume_L'] = sample_volume
|
|
1204
|
+
|
|
1205
|
+
df_row = pd.DataFrame([row], index=pd.DatetimeIndex([timestamp], name='timestamp'))
|
|
1206
|
+
|
|
1207
|
+
write_header = not os.path.exists(self.output_csv)
|
|
1208
|
+
df_row.to_csv(self.output_csv, mode='a', header=write_header)
|
|
1209
|
+
|
|
1210
|
+
return data
|
|
1211
|
+
|
|
1212
|
+
|
|
1094
1213
|
def vd_to_nc(volume_distribution, dias):
|
|
1095
1214
|
"""calculate number concentration from volume distribution
|
|
1096
1215
|
|
|
@@ -189,6 +189,77 @@ def test_silcam_pipeline():
|
|
|
189
189
|
' Something has altered the number of particles detected')
|
|
190
190
|
|
|
191
191
|
|
|
192
|
+
def test_per_class_concentration():
|
|
193
|
+
'''Verifies PerClassConcentration writes timestamp-indexed per-class
|
|
194
|
+
number concentrations (numbers/L) to CSV across multiple images.
|
|
195
|
+
'''
|
|
196
|
+
import pandas as pd
|
|
197
|
+
from pyopia.statistics import PerClassConcentration, get_sample_volume
|
|
198
|
+
|
|
199
|
+
pixel_size = 28.0
|
|
200
|
+
path_length = 40.0
|
|
201
|
+
imy, imx = 2048, 2448
|
|
202
|
+
sample_volume = get_sample_volume(pixel_size, path_length, imx=imx, imy=imy)
|
|
203
|
+
|
|
204
|
+
with tempfile.TemporaryDirectory() as tempdir:
|
|
205
|
+
output_csv = os.path.join(tempdir, 'sub', 'per_class.csv')
|
|
206
|
+
|
|
207
|
+
step = PerClassConcentration(
|
|
208
|
+
output_csv=output_csv,
|
|
209
|
+
probability_threshold=0.5,
|
|
210
|
+
overwrite=True,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Image 1: 2 oil, 1 bubble, 1 below-threshold (unclassified)
|
|
214
|
+
stats_1 = pd.DataFrame({
|
|
215
|
+
'equivalent_diameter': [10.0, 12.0, 14.0, 9.0],
|
|
216
|
+
'probability_oil': [0.9, 0.8, 0.1, 0.4],
|
|
217
|
+
'probability_bubble': [0.05, 0.15, 0.85, 0.35],
|
|
218
|
+
'probability_other': [0.05, 0.05, 0.05, 0.25],
|
|
219
|
+
})
|
|
220
|
+
ts_1 = pd.Timestamp('2026-05-07T12:00:00')
|
|
221
|
+
|
|
222
|
+
# Image 2: empty (placeholder NaN row, like pyopia.process.extract_particles)
|
|
223
|
+
stats_2 = pd.DataFrame({
|
|
224
|
+
'equivalent_diameter': [np.nan],
|
|
225
|
+
'probability_oil': [np.nan],
|
|
226
|
+
'probability_bubble': [np.nan],
|
|
227
|
+
'probability_other': [np.nan],
|
|
228
|
+
})
|
|
229
|
+
ts_2 = pd.Timestamp('2026-05-07T12:00:01')
|
|
230
|
+
|
|
231
|
+
data = {
|
|
232
|
+
'settings': {'general': {'pixel_size': pixel_size, 'path_length': path_length}},
|
|
233
|
+
'imraw': np.zeros((imy, imx, 3), dtype=np.uint8),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
data['stats'] = stats_1
|
|
237
|
+
data['timestamp'] = ts_1
|
|
238
|
+
step(data)
|
|
239
|
+
|
|
240
|
+
data['stats'] = stats_2
|
|
241
|
+
data['timestamp'] = ts_2
|
|
242
|
+
step(data)
|
|
243
|
+
|
|
244
|
+
result = pd.read_csv(output_csv, index_col='timestamp', parse_dates=True)
|
|
245
|
+
|
|
246
|
+
# Expected concentrations (counts / sample_volume in numbers/L)
|
|
247
|
+
assert result.shape[0] == 2
|
|
248
|
+
assert list(result.index) == [ts_1, ts_2]
|
|
249
|
+
np.testing.assert_allclose(result.loc[ts_1, 'oil'], 2.0 / sample_volume)
|
|
250
|
+
np.testing.assert_allclose(result.loc[ts_1, 'bubble'], 1.0 / sample_volume)
|
|
251
|
+
np.testing.assert_allclose(result.loc[ts_1, 'other'], 0.0)
|
|
252
|
+
np.testing.assert_allclose(result.loc[ts_1, 'unclassified'], 1.0 / sample_volume)
|
|
253
|
+
np.testing.assert_allclose(result.loc[ts_1, 'total'], 4.0 / sample_volume)
|
|
254
|
+
np.testing.assert_allclose(result.loc[ts_1, 'sample_volume_L'], sample_volume)
|
|
255
|
+
|
|
256
|
+
# Empty image: zero concentrations across the board
|
|
257
|
+
for col in ['oil', 'bubble', 'other', 'unclassified', 'total']:
|
|
258
|
+
assert result.loc[ts_2, col] == 0.0
|
|
259
|
+
np.testing.assert_allclose(result.loc[ts_2, 'sample_volume_L'], sample_volume)
|
|
260
|
+
|
|
261
|
+
|
|
192
262
|
if __name__ == "__main__":
|
|
193
263
|
test_holo_pipeline()
|
|
194
264
|
test_silcam_pipeline()
|
|
265
|
+
test_per_class_concentration()
|
pyopia-2.16.3/pyopia/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.16.3"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|