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.
Files changed (37) hide show
  1. {pyopia-2.15.0 → pyopia-2.16.0}/PKG-INFO +3 -1
  2. pyopia-2.16.0/pyopia/__init__.py +1 -0
  3. pyopia-2.16.0/pyopia/classify_torch.py +276 -0
  4. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/pipeline.py +8 -1
  5. {pyopia-2.15.0 → pyopia-2.16.0}/pyproject.toml +4 -0
  6. pyopia-2.15.0/pyopia/__init__.py +0 -1
  7. {pyopia-2.15.0 → pyopia-2.16.0}/.gitignore +0 -0
  8. {pyopia-2.15.0 → pyopia-2.16.0}/LICENSE +0 -0
  9. {pyopia-2.15.0 → pyopia-2.16.0}/README.md +0 -0
  10. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/auxillarydata.py +0 -0
  11. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/background.py +0 -0
  12. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/cf_metadata.json +0 -0
  13. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/classify.py +0 -0
  14. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/cli.py +0 -0
  15. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/dataexport/__init__.py +0 -0
  16. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/dataexport/ecotaxa.py +0 -0
  17. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/exampledata.py +0 -0
  18. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/instrument/__init__.py +0 -0
  19. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/instrument/common.py +0 -0
  20. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/instrument/holo.py +0 -0
  21. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/instrument/silcam.py +0 -0
  22. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/instrument/uvp.py +0 -0
  23. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/io.py +0 -0
  24. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/metadata.py +0 -0
  25. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/plotting.py +0 -0
  26. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/process.py +0 -0
  27. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/realtime.py +0 -0
  28. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/simulator/__init__.py +0 -0
  29. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/simulator/silcam.py +0 -0
  30. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/statistics.py +0 -0
  31. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/tests/__init__.py +0 -0
  32. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/tests/test_auxillarydata.py +0 -0
  33. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/tests/test_classify.py +0 -0
  34. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/tests/test_io.py +0 -0
  35. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/tests/test_notebooks.py +0 -0
  36. {pyopia-2.15.0 → pyopia-2.16.0}/pyopia/tests/test_pipeline.py +0 -0
  37. {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.15.0
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()
@@ -60,6 +60,10 @@ classification = [
60
60
  "keras==3.9.1",
61
61
  ]
62
62
 
63
+ classification-torch = [
64
+ "torch>=2.1.0",
65
+ ]
66
+
63
67
  [project.urls]
64
68
  Repository = "https://github.com/sintef/pyopia"
65
69
  Documentation = "https://pyopia.readthedocs.io"
@@ -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