PyOPIA 2.15.0__tar.gz → 2.16.1__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.1}/PKG-INFO +71 -1
  2. {pyopia-2.15.0 → pyopia-2.16.1}/README.md +68 -0
  3. pyopia-2.16.1/pyopia/__init__.py +1 -0
  4. pyopia-2.16.1/pyopia/classify_torch.py +276 -0
  5. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/pipeline.py +8 -1
  6. {pyopia-2.15.0 → pyopia-2.16.1}/pyproject.toml +4 -0
  7. pyopia-2.15.0/pyopia/__init__.py +0 -1
  8. {pyopia-2.15.0 → pyopia-2.16.1}/.gitignore +0 -0
  9. {pyopia-2.15.0 → pyopia-2.16.1}/LICENSE +0 -0
  10. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/auxillarydata.py +0 -0
  11. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/background.py +0 -0
  12. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/cf_metadata.json +0 -0
  13. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/classify.py +0 -0
  14. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/cli.py +0 -0
  15. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/dataexport/__init__.py +0 -0
  16. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/dataexport/ecotaxa.py +0 -0
  17. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/exampledata.py +0 -0
  18. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/instrument/__init__.py +0 -0
  19. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/instrument/common.py +0 -0
  20. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/instrument/holo.py +0 -0
  21. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/instrument/silcam.py +0 -0
  22. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/instrument/uvp.py +0 -0
  23. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/io.py +0 -0
  24. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/metadata.py +0 -0
  25. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/plotting.py +0 -0
  26. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/process.py +0 -0
  27. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/realtime.py +0 -0
  28. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/simulator/__init__.py +0 -0
  29. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/simulator/silcam.py +0 -0
  30. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/statistics.py +0 -0
  31. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/tests/__init__.py +0 -0
  32. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/tests/test_auxillarydata.py +0 -0
  33. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/tests/test_classify.py +0 -0
  34. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/tests/test_io.py +0 -0
  35. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/tests/test_notebooks.py +0 -0
  36. {pyopia-2.15.0 → pyopia-2.16.1}/pyopia/tests/test_pipeline.py +0 -0
  37. {pyopia-2.15.0 → pyopia-2.16.1}/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.1
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
@@ -84,6 +86,74 @@ Will show you a montage of all the processed particle images in one.
84
86
 
85
87
  See the documentation for more information on how to install and use PyOPIA.
86
88
 
89
+ # Running with Docker
90
+
91
+ A prebuilt container image is published to GitHub Container Registry on every release, for users who prefer not to install PyOPIA's dependencies directly.
92
+
93
+ ## One-off invocation
94
+
95
+ From any directory that contains a `config.toml`:
96
+
97
+ ```bash
98
+ docker run --rm \
99
+ --user $(id -u):$(id -g) \
100
+ -v "$PWD:$PWD" -w "$PWD" \
101
+ ghcr.io/sintef/pyopia:latest \
102
+ process config.toml
103
+ ```
104
+
105
+ The 1:1 volume mount (`-v "$PWD:$PWD"`) makes the container see your current directory at the same path as the host, so an existing `config.toml` with absolute paths under `$PWD` works unchanged.
106
+
107
+ **If your config references paths outside `$PWD`** — most commonly a classifier weights file — add a matching bind mount for each of them.
108
+ The target (right of the colon) must equal the source (left) so the path in the config resolves unchanged.
109
+ For a config like
110
+
111
+ ```toml
112
+ [steps.classifier]
113
+ pipeline_class = "pyopia.classify_torch.Classify"
114
+ model_path = "/home/you/models/classifier.pt"
115
+ ```
116
+
117
+ the invocation becomes
118
+
119
+ ```bash
120
+ docker run --rm \
121
+ --user $(id -u):$(id -g) \
122
+ -v "$PWD:$PWD" -w "$PWD" \
123
+ -v /home/you/models:/home/you/models:ro \
124
+ ghcr.io/sintef/pyopia:latest \
125
+ process config.toml
126
+ ```
127
+
128
+ Any PyOPIA CLI command works, e.g. `docker run --rm ghcr.io/sintef/pyopia:latest --help`.
129
+
130
+ ## Using docker compose
131
+
132
+ Each GitHub release attaches a `compose.yaml` as an asset.
133
+ Download it once per project:
134
+
135
+ ```bash
136
+ curl -LO https://github.com/SINTEF/pyopia/releases/latest/download/compose.yaml
137
+ docker compose run --rm pyopia # runs `pyopia process config.toml`
138
+ docker compose run --rm pyopia --help # or any other CLI argument
139
+ ```
140
+
141
+ The compose file has commented-out stubs for paths that live outside `$PWD` — in particular the classifier weights file.
142
+ Edit `compose.yaml` to uncomment the line and point it at your actual host path, e.g.
143
+
144
+ ```yaml
145
+ volumes:
146
+ - ${PWD}:${PWD}
147
+ - /home/you/models:/home/you/models:ro # classifier weights
148
+ ```
149
+
150
+ The target must match the source so the config-referenced path resolves unchanged.
151
+ Override the default config filename with `PYOPIA_CONFIG`:
152
+
153
+ ```bash
154
+ PYOPIA_CONFIG=my_run.toml docker compose run --rm pyopia
155
+ ```
156
+
87
157
  # Documentation:
88
158
 
89
159
  [![Jupyter Book Badge](https://jupyterbook.org/badge.svg)](https://pyopia.readthedocs.io) [![Documentation](https://readthedocs.org/projects/pyopia/badge/?version=latest)](https://pyopia.readthedocs.io/en/latest/?badge=latest)
@@ -34,6 +34,74 @@ Will show you a montage of all the processed particle images in one.
34
34
 
35
35
  See the documentation for more information on how to install and use PyOPIA.
36
36
 
37
+ # Running with Docker
38
+
39
+ A prebuilt container image is published to GitHub Container Registry on every release, for users who prefer not to install PyOPIA's dependencies directly.
40
+
41
+ ## One-off invocation
42
+
43
+ From any directory that contains a `config.toml`:
44
+
45
+ ```bash
46
+ docker run --rm \
47
+ --user $(id -u):$(id -g) \
48
+ -v "$PWD:$PWD" -w "$PWD" \
49
+ ghcr.io/sintef/pyopia:latest \
50
+ process config.toml
51
+ ```
52
+
53
+ The 1:1 volume mount (`-v "$PWD:$PWD"`) makes the container see your current directory at the same path as the host, so an existing `config.toml` with absolute paths under `$PWD` works unchanged.
54
+
55
+ **If your config references paths outside `$PWD`** — most commonly a classifier weights file — add a matching bind mount for each of them.
56
+ The target (right of the colon) must equal the source (left) so the path in the config resolves unchanged.
57
+ For a config like
58
+
59
+ ```toml
60
+ [steps.classifier]
61
+ pipeline_class = "pyopia.classify_torch.Classify"
62
+ model_path = "/home/you/models/classifier.pt"
63
+ ```
64
+
65
+ the invocation becomes
66
+
67
+ ```bash
68
+ docker run --rm \
69
+ --user $(id -u):$(id -g) \
70
+ -v "$PWD:$PWD" -w "$PWD" \
71
+ -v /home/you/models:/home/you/models:ro \
72
+ ghcr.io/sintef/pyopia:latest \
73
+ process config.toml
74
+ ```
75
+
76
+ Any PyOPIA CLI command works, e.g. `docker run --rm ghcr.io/sintef/pyopia:latest --help`.
77
+
78
+ ## Using docker compose
79
+
80
+ Each GitHub release attaches a `compose.yaml` as an asset.
81
+ Download it once per project:
82
+
83
+ ```bash
84
+ curl -LO https://github.com/SINTEF/pyopia/releases/latest/download/compose.yaml
85
+ docker compose run --rm pyopia # runs `pyopia process config.toml`
86
+ docker compose run --rm pyopia --help # or any other CLI argument
87
+ ```
88
+
89
+ The compose file has commented-out stubs for paths that live outside `$PWD` — in particular the classifier weights file.
90
+ Edit `compose.yaml` to uncomment the line and point it at your actual host path, e.g.
91
+
92
+ ```yaml
93
+ volumes:
94
+ - ${PWD}:${PWD}
95
+ - /home/you/models:/home/you/models:ro # classifier weights
96
+ ```
97
+
98
+ The target must match the source so the config-referenced path resolves unchanged.
99
+ Override the default config filename with `PYOPIA_CONFIG`:
100
+
101
+ ```bash
102
+ PYOPIA_CONFIG=my_run.toml docker compose run --rm pyopia
103
+ ```
104
+
37
105
  # Documentation:
38
106
 
39
107
  [![Jupyter Book Badge](https://jupyterbook.org/badge.svg)](https://pyopia.readthedocs.io) [![Documentation](https://readthedocs.org/projects/pyopia/badge/?version=latest)](https://pyopia.readthedocs.io/en/latest/?badge=latest)
@@ -0,0 +1 @@
1
+ __version__ = "2.16.1"
@@ -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