PyOPIA 2.15.0__tar.gz → 2.16.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.15.0 → pyopia-2.16.0}/PKG-INFO +3 -1
- pyopia-2.16.0/pyopia/__init__.py +1 -0
- pyopia-2.16.0/pyopia/classify_torch.py +276 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/pipeline.py +8 -1
- {pyopia-2.15.0 → pyopia-2.16.0}/pyproject.toml +4 -0
- pyopia-2.15.0/pyopia/__init__.py +0 -1
- {pyopia-2.15.0 → pyopia-2.16.0}/.gitignore +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/LICENSE +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/README.md +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/auxillarydata.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/background.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/cf_metadata.json +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/classify.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/cli.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/dataexport/__init__.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/dataexport/ecotaxa.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/exampledata.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/instrument/__init__.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/instrument/common.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/instrument/holo.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/instrument/silcam.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/instrument/uvp.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/io.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/metadata.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/plotting.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/process.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/realtime.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/simulator/__init__.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/simulator/silcam.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/statistics.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/tests/__init__.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/tests/test_auxillarydata.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/tests/test_classify.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/tests/test_io.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/tests/test_notebooks.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/tests/test_pipeline.py +0 -0
- {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/tests/test_realtime.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyOPIA
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.16.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
|
|
@@ -46,6 +46,8 @@ Requires-Dist: xarray<2024,>=2023.12.0
|
|
|
46
46
|
Provides-Extra: classification
|
|
47
47
|
Requires-Dist: keras==3.9.1; extra == 'classification'
|
|
48
48
|
Requires-Dist: tensorflow>=2.19.0; extra == 'classification'
|
|
49
|
+
Provides-Extra: classification-torch
|
|
50
|
+
Requires-Dist: torch>=2.1.0; extra == 'classification-torch'
|
|
49
51
|
Description-Content-Type: text/markdown
|
|
50
52
|
|
|
51
53
|
PyOPIA
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.16.0"
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Classifier for PyOPIA using PyTorch TorchScript models.
|
|
3
|
+
|
|
4
|
+
Loads a TorchScript archive (``.pt``) containing a traced model and a
|
|
5
|
+
``metadata.json`` with class labels, input dimensions, normalization
|
|
6
|
+
stats, and optional preprocessing config. Compatible with models
|
|
7
|
+
exported by ``pyopia-train export-pyopia-torch`` and by
|
|
8
|
+
``pyopia.train_torch.save_pytorch_model``.
|
|
9
|
+
|
|
10
|
+
The TorchScript archive is self-contained, so only extra dependency
|
|
11
|
+
is PyTorch (optional dependency).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from skimage.exposure import rescale_intensity
|
|
20
|
+
from skimage.transform import resize
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger()
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import torch
|
|
26
|
+
except ImportError:
|
|
27
|
+
info_str = "ERROR: Could not import PyTorch. classify_torch will not work"
|
|
28
|
+
info_str += " until you install torch.\n"
|
|
29
|
+
info_str += "Use: uv sync --extra classification-torch\n"
|
|
30
|
+
raise ImportError(info_str)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_device():
|
|
34
|
+
"""Select the best available device: CUDA > MPS > CPU."""
|
|
35
|
+
if torch.cuda.is_available():
|
|
36
|
+
return torch.device("cuda")
|
|
37
|
+
elif torch.backends.mps.is_available():
|
|
38
|
+
return torch.device("mps")
|
|
39
|
+
return torch.device("cpu")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_pytorch_model(
|
|
43
|
+
model, class_labels, img_height, img_width, output_path, preprocess=None
|
|
44
|
+
):
|
|
45
|
+
"""Save a PyTorch model with metadata as a TorchScript archive.
|
|
46
|
+
|
|
47
|
+
The model is expected to accept inputs in the [0, 255] range and
|
|
48
|
+
bake any per-channel normalisation into its own forward pass (see
|
|
49
|
+
notebook export examples). This mirrors the convention in
|
|
50
|
+
:mod:`pyopia.classify` where the TF model has a Rescaling layer
|
|
51
|
+
as its first op.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
model : torch.nn.Module
|
|
56
|
+
Trained PyTorch model. Must accept ``(B, 3, H, W)`` floats in
|
|
57
|
+
``[0, 255]`` and produce class logits.
|
|
58
|
+
class_labels : list of str
|
|
59
|
+
Class name strings.
|
|
60
|
+
img_height : int
|
|
61
|
+
Expected input image height.
|
|
62
|
+
img_width : int
|
|
63
|
+
Expected input image width.
|
|
64
|
+
output_path : str
|
|
65
|
+
Path to save the .pt file.
|
|
66
|
+
preprocess : dict or None
|
|
67
|
+
Optional preprocessing config (e.g.
|
|
68
|
+
``{"type": "percentile", "lower_pct": 1.0, "upper_pct": 99.0}``).
|
|
69
|
+
Stored in metadata so inference applies matching preprocessing.
|
|
70
|
+
"""
|
|
71
|
+
model.eval()
|
|
72
|
+
try:
|
|
73
|
+
scripted = torch.jit.script(model)
|
|
74
|
+
except Exception:
|
|
75
|
+
example_input = torch.randn(1, 3, int(img_height), int(img_width)) * 255
|
|
76
|
+
scripted = torch.jit.trace(model, example_input)
|
|
77
|
+
|
|
78
|
+
meta = {
|
|
79
|
+
"class_labels": list(class_labels),
|
|
80
|
+
"img_height": int(img_height),
|
|
81
|
+
"img_width": int(img_width),
|
|
82
|
+
}
|
|
83
|
+
if preprocess is not None:
|
|
84
|
+
meta["preprocess"] = preprocess
|
|
85
|
+
|
|
86
|
+
torch.jit.save(
|
|
87
|
+
scripted, output_path, _extra_files={"metadata.json": json.dumps(meta)}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Classify:
|
|
92
|
+
"""PyTorch TorchScript classifier for the PyOPIA pipeline.
|
|
93
|
+
|
|
94
|
+
Drop-in replacement for :class:`pyopia.classify.Classify`.
|
|
95
|
+
Loads a TorchScript ``.pt`` archive with metadata.
|
|
96
|
+
|
|
97
|
+
Supports models from:
|
|
98
|
+
- ``pyopia-train export-pyopia-torch``
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
model_path : str
|
|
103
|
+
Path to a ``.pt`` TorchScript archive.
|
|
104
|
+
normalize_intensity : bool
|
|
105
|
+
Scale input ROI intensity to [0-1] before classification.
|
|
106
|
+
correct_whitebalance : bool
|
|
107
|
+
Per-channel histogram correction before classification.
|
|
108
|
+
|
|
109
|
+
Example
|
|
110
|
+
-------
|
|
111
|
+
.. code-block:: python
|
|
112
|
+
|
|
113
|
+
cl = Classify(model_path='model.pt')
|
|
114
|
+
prediction = cl.proc_predict(roi)
|
|
115
|
+
|
|
116
|
+
TOML config:
|
|
117
|
+
|
|
118
|
+
.. code-block:: toml
|
|
119
|
+
|
|
120
|
+
[steps.classifier]
|
|
121
|
+
pipeline_class = 'pyopia.classify_torch.Classify'
|
|
122
|
+
model_path = 'model.pt'
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
model_path=None,
|
|
128
|
+
normalize_intensity=True,
|
|
129
|
+
correct_whitebalance=False,
|
|
130
|
+
):
|
|
131
|
+
self.model_path = model_path
|
|
132
|
+
self.correct_whitebalance = correct_whitebalance
|
|
133
|
+
self.normalize_intensity = normalize_intensity
|
|
134
|
+
self.load_model()
|
|
135
|
+
|
|
136
|
+
def __call__(self):
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def load_model(self):
|
|
140
|
+
"""Load a TorchScript model and metadata from a .pt archive."""
|
|
141
|
+
model_path = self.model_path
|
|
142
|
+
|
|
143
|
+
extra_files = {"metadata.json": ""}
|
|
144
|
+
self.model = torch.jit.load(
|
|
145
|
+
model_path, map_location="cpu", _extra_files=extra_files
|
|
146
|
+
)
|
|
147
|
+
self.model.eval()
|
|
148
|
+
|
|
149
|
+
metadata = json.loads(extra_files["metadata.json"])
|
|
150
|
+
self.class_labels = metadata["class_labels"]
|
|
151
|
+
self.img_height = metadata["img_height"]
|
|
152
|
+
self.img_width = metadata["img_width"]
|
|
153
|
+
self.preprocess = metadata.get("preprocess", None)
|
|
154
|
+
|
|
155
|
+
self.device = _get_device()
|
|
156
|
+
self.model = self.model.to(self.device)
|
|
157
|
+
logger.info(f"PyTorch classifier loaded on device: {self.device}")
|
|
158
|
+
|
|
159
|
+
with open(model_path, "rb") as f:
|
|
160
|
+
digest = hashlib.file_digest(f, "sha256")
|
|
161
|
+
self.model_hash = digest.hexdigest()
|
|
162
|
+
|
|
163
|
+
logger.info(self.class_labels)
|
|
164
|
+
|
|
165
|
+
def preprocessing(self, img_input):
|
|
166
|
+
"""Preprocess an ROI for prediction.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
img_input : ndarray
|
|
171
|
+
Particle ROI, float, range 0-1, shape (H, W, 3) or (H, W).
|
|
172
|
+
|
|
173
|
+
Returns
|
|
174
|
+
-------
|
|
175
|
+
img_preprocessed : ndarray
|
|
176
|
+
Shape (1, 3, img_height, img_width), float32, ready for the model.
|
|
177
|
+
"""
|
|
178
|
+
whitebalanced = img_input.astype(np.float64)
|
|
179
|
+
|
|
180
|
+
if self.correct_whitebalance:
|
|
181
|
+
p = 99
|
|
182
|
+
for c in range(3):
|
|
183
|
+
whitebalanced[:, :, c] += (p / 100) - np.percentile(
|
|
184
|
+
whitebalanced[:, :, c], p
|
|
185
|
+
)
|
|
186
|
+
whitebalanced[whitebalanced > 1] = 1
|
|
187
|
+
whitebalanced[whitebalanced < 0] = 0
|
|
188
|
+
|
|
189
|
+
if self.normalize_intensity:
|
|
190
|
+
whitebalanced = rescale_intensity(whitebalanced)
|
|
191
|
+
|
|
192
|
+
# Optional percentile preprocessing (from pyopia-train checkpoints).
|
|
193
|
+
# Applied after rescale_intensity, before resize, matching the
|
|
194
|
+
# training pipeline's transform order.
|
|
195
|
+
if self.preprocess is not None:
|
|
196
|
+
preprocess_type = self.preprocess.get("type", "none")
|
|
197
|
+
if preprocess_type == "percentile":
|
|
198
|
+
lower = float(self.preprocess.get("lower_pct", 1.0))
|
|
199
|
+
upper = float(self.preprocess.get("upper_pct", 99.0))
|
|
200
|
+
whitebalanced = self._percentile_normalize(whitebalanced, lower, upper)
|
|
201
|
+
|
|
202
|
+
# Convert to [0, 255] floats — matches the convention in
|
|
203
|
+
# pyopia.classify.preprocessing. Any per-channel normalisation
|
|
204
|
+
# is baked into the model itself (see notebook export).
|
|
205
|
+
img = (whitebalanced * 255).astype(np.float32)
|
|
206
|
+
|
|
207
|
+
# Bilinear resize without anti-aliasing — matches the original
|
|
208
|
+
# pyopia.classify (TF) convention and the notebook training
|
|
209
|
+
# pipeline (PIL bilinear).
|
|
210
|
+
img = resize(
|
|
211
|
+
img,
|
|
212
|
+
(self.img_height, self.img_width),
|
|
213
|
+
preserve_range=True,
|
|
214
|
+
anti_aliasing=False,
|
|
215
|
+
).astype(np.float32)
|
|
216
|
+
|
|
217
|
+
# HWC → CHW, add batch dim
|
|
218
|
+
img_preprocessed = np.transpose(img, (2, 0, 1))
|
|
219
|
+
img_preprocessed = np.expand_dims(img_preprocessed, axis=0)
|
|
220
|
+
return img_preprocessed
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _percentile_normalize(img, lower_pct=1.0, upper_pct=99.0):
|
|
224
|
+
"""Robust per-image percentile intensity rescaling.
|
|
225
|
+
|
|
226
|
+
Matches pyopia-train's ``PercentileNormalize``: computes a single
|
|
227
|
+
low/high range from the per-pixel RGB mean, then applies it
|
|
228
|
+
identically to all channels.
|
|
229
|
+
"""
|
|
230
|
+
arr = img.astype(np.float64)
|
|
231
|
+
if arr.ndim == 2:
|
|
232
|
+
intensity = arr
|
|
233
|
+
else:
|
|
234
|
+
intensity = arr.mean(axis=2) if arr.shape[2] > 1 else arr[:, :, 0]
|
|
235
|
+
lo = float(np.percentile(intensity, lower_pct))
|
|
236
|
+
hi = float(np.percentile(intensity, upper_pct))
|
|
237
|
+
if hi <= lo + 1e-6:
|
|
238
|
+
return np.clip(arr, 0.0, 1.0)
|
|
239
|
+
arr = (arr - lo) / (hi - lo)
|
|
240
|
+
return np.clip(arr, 0.0, 1.0)
|
|
241
|
+
|
|
242
|
+
def predict(self, img_preprocessed):
|
|
243
|
+
"""Run the model on a preprocessed ROI.
|
|
244
|
+
|
|
245
|
+
Parameters
|
|
246
|
+
----------
|
|
247
|
+
img_preprocessed : ndarray
|
|
248
|
+
Shape (1, 3, H, W), float32.
|
|
249
|
+
|
|
250
|
+
Returns
|
|
251
|
+
-------
|
|
252
|
+
prediction : ndarray
|
|
253
|
+
Probability distribution, shape (num_classes,).
|
|
254
|
+
"""
|
|
255
|
+
with torch.no_grad():
|
|
256
|
+
tensor = torch.from_numpy(img_preprocessed).to(self.device)
|
|
257
|
+
output = self.model(tensor)
|
|
258
|
+
prediction = torch.nn.functional.softmax(output[0], dim=0)
|
|
259
|
+
return prediction.cpu().numpy()
|
|
260
|
+
|
|
261
|
+
def proc_predict(self, img_input):
|
|
262
|
+
"""Preprocess and classify a particle ROI.
|
|
263
|
+
|
|
264
|
+
Parameters
|
|
265
|
+
----------
|
|
266
|
+
img_input : ndarray
|
|
267
|
+
Particle ROI, float, range 0-1.
|
|
268
|
+
|
|
269
|
+
Returns
|
|
270
|
+
-------
|
|
271
|
+
prediction : ndarray
|
|
272
|
+
Probability distribution, shape (num_classes,).
|
|
273
|
+
"""
|
|
274
|
+
img_preprocessed = self.preprocessing(img_input)
|
|
275
|
+
prediction = self.predict(img_preprocessed)
|
|
276
|
+
return prediction
|
|
@@ -139,7 +139,6 @@ class Pipeline():
|
|
|
139
139
|
Name of the step defined in the settings
|
|
140
140
|
'''
|
|
141
141
|
if stepname == 'classifier':
|
|
142
|
-
import pyopia.classify # noqa: E(F410)
|
|
143
142
|
callobj = self.step_callobj(stepname)
|
|
144
143
|
self.data['cl'] = callobj()
|
|
145
144
|
else:
|
|
@@ -160,10 +159,18 @@ class Pipeline():
|
|
|
160
159
|
callable object for use in run_step()
|
|
161
160
|
'''
|
|
162
161
|
|
|
162
|
+
import importlib
|
|
163
|
+
|
|
163
164
|
pipeline_class = self.settings['steps'][stepname]['pipeline_class']
|
|
164
165
|
classname = pipeline_class.split('.')[-1]
|
|
165
166
|
modulename = pipeline_class.replace(classname, '')[:-1]
|
|
166
167
|
|
|
168
|
+
# Import the module on demand rather than relying on sys.modules.
|
|
169
|
+
# This allows pipeline steps to reference any importable module
|
|
170
|
+
# (e.g. pyopia.classify_torch) without needing a hardcoded import
|
|
171
|
+
# in cli.py or run_step().
|
|
172
|
+
importlib.import_module(modulename)
|
|
173
|
+
|
|
167
174
|
keys = [k for k in self.settings['steps'][stepname] if k != 'pipeline_class']
|
|
168
175
|
|
|
169
176
|
arguments = dict()
|
pyopia-2.15.0/pyopia/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.15.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|