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.
Files changed (38) hide show
  1. {pyopia-2.16.3 → pyopia-2.16.4}/PKG-INFO +1 -1
  2. pyopia-2.16.4/pyopia/__init__.py +1 -0
  3. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/statistics.py +119 -0
  4. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_pipeline.py +71 -0
  5. pyopia-2.16.3/pyopia/__init__.py +0 -1
  6. {pyopia-2.16.3 → pyopia-2.16.4}/.gitignore +0 -0
  7. {pyopia-2.16.3 → pyopia-2.16.4}/LICENSE +0 -0
  8. {pyopia-2.16.3 → pyopia-2.16.4}/README.md +0 -0
  9. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/auxillarydata.py +0 -0
  10. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/background.py +0 -0
  11. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/cf_metadata.json +0 -0
  12. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/classify.py +0 -0
  13. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/classify_torch.py +0 -0
  14. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/cli.py +0 -0
  15. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/dataexport/__init__.py +0 -0
  16. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/dataexport/ecotaxa.py +0 -0
  17. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/exampledata.py +0 -0
  18. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/instrument/__init__.py +0 -0
  19. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/instrument/common.py +0 -0
  20. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/instrument/holo.py +0 -0
  21. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/instrument/silcam.py +0 -0
  22. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/instrument/uvp.py +0 -0
  23. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/io.py +0 -0
  24. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/metadata.py +0 -0
  25. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/pipeline.py +0 -0
  26. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/plotting.py +0 -0
  27. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/process.py +0 -0
  28. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/realtime.py +0 -0
  29. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/simulator/__init__.py +0 -0
  30. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/simulator/silcam.py +0 -0
  31. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/__init__.py +0 -0
  32. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_auxillarydata.py +0 -0
  33. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_classify.py +0 -0
  34. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_io.py +0 -0
  35. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_notebooks.py +0 -0
  36. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_process.py +0 -0
  37. {pyopia-2.16.3 → pyopia-2.16.4}/pyopia/tests/test_realtime.py +0 -0
  38. {pyopia-2.16.3 → pyopia-2.16.4}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyOPIA
3
- Version: 2.16.3
3
+ Version: 2.16.4
4
4
  Summary: A Python Ocean Particle Image Analysis toolbox.
5
5
  Project-URL: Repository, https://github.com/sintef/pyopia
6
6
  Project-URL: Documentation, https://pyopia.readthedocs.io
@@ -0,0 +1 @@
1
+ __version__ = "2.16.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()
@@ -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