PyOPIA 2.14.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.14.0 → pyopia-2.16.0}/PKG-INFO +5 -2
- pyopia-2.16.0/pyopia/__init__.py +1 -0
- pyopia-2.16.0/pyopia/classify_torch.py +276 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/cli.py +24 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/pipeline.py +8 -1
- pyopia-2.16.0/pyopia/realtime.py +229 -0
- pyopia-2.16.0/pyopia/tests/test_realtime.py +331 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyproject.toml +6 -1
- pyopia-2.14.0/pyopia/__init__.py +0 -1
- {pyopia-2.14.0 → pyopia-2.16.0}/.gitignore +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/LICENSE +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/README.md +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/auxillarydata.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/background.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/cf_metadata.json +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/classify.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/dataexport/__init__.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/dataexport/ecotaxa.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/exampledata.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/instrument/__init__.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/instrument/common.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/instrument/holo.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/instrument/silcam.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/instrument/uvp.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/io.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/metadata.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/plotting.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/process.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/simulator/__init__.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/simulator/silcam.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/statistics.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/tests/__init__.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/tests/test_auxillarydata.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/tests/test_classify.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/tests/test_io.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/tests/test_notebooks.py +0 -0
- {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/tests/test_pipeline.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
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
|
|
7
7
|
Author-email: Emlyn Davies <emlyn.davies@sintef.no>, "Alex Nimmo Smith@plymouth.ac.uk" <alex.nimmo.smith@plymouth.ac.uk>
|
|
8
8
|
License-File: LICENSE
|
|
9
9
|
Keywords: Imaging,Measurement,Ocean,Particles,Size distribution
|
|
10
|
-
Requires-Python: ~=3.12
|
|
10
|
+
Requires-Python: ~=3.12.0
|
|
11
11
|
Requires-Dist: click<8.2.0
|
|
12
12
|
Requires-Dist: cmocean<4,>=3.0.3
|
|
13
13
|
Requires-Dist: dask>=2024.8.1
|
|
@@ -41,10 +41,13 @@ Requires-Dist: toml<0.11,>=0.10.2
|
|
|
41
41
|
Requires-Dist: tqdm<5,>=4.66.1
|
|
42
42
|
Requires-Dist: typer[all]<0.10,>=0.9.0
|
|
43
43
|
Requires-Dist: urllib3<2.0
|
|
44
|
+
Requires-Dist: watchdog>=6.0.0
|
|
44
45
|
Requires-Dist: xarray<2024,>=2023.12.0
|
|
45
46
|
Provides-Extra: classification
|
|
46
47
|
Requires-Dist: keras==3.9.1; extra == 'classification'
|
|
47
48
|
Requires-Dist: tensorflow>=2.19.0; extra == 'classification'
|
|
49
|
+
Provides-Extra: classification-torch
|
|
50
|
+
Requires-Dist: torch>=2.1.0; extra == 'classification-torch'
|
|
48
51
|
Description-Content-Type: text/markdown
|
|
49
52
|
|
|
50
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
|
|
@@ -42,6 +42,7 @@ import pyopia.auxillarydata
|
|
|
42
42
|
import pyopia.exampledata
|
|
43
43
|
import pyopia.metadata
|
|
44
44
|
import pyopia.dataexport.ecotaxa
|
|
45
|
+
import pyopia.realtime
|
|
45
46
|
|
|
46
47
|
app = typer.Typer()
|
|
47
48
|
|
|
@@ -350,6 +351,29 @@ def process(config_filename: str, num_chunks: int = 1, strategy: str = "block"):
|
|
|
350
351
|
progress.console.print(f"[blue]PROCESSING COMPLETED IN {time_total}")
|
|
351
352
|
|
|
352
353
|
|
|
354
|
+
@app.command()
|
|
355
|
+
def process_realtime(config_filename: str, watch_folder: str = None):
|
|
356
|
+
"""Run a PyOPIA processing pipeline in realtime by watching a folder.
|
|
357
|
+
|
|
358
|
+
Parameters
|
|
359
|
+
----------
|
|
360
|
+
config_filename : str
|
|
361
|
+
Config filename
|
|
362
|
+
|
|
363
|
+
watch_folder : str, optional
|
|
364
|
+
Folder to monitor. If not provided, inferred from `general.raw_files` in config.
|
|
365
|
+
|
|
366
|
+
Notes
|
|
367
|
+
-----
|
|
368
|
+
- Single-core only: files are processed sequentially by a single worker thread.
|
|
369
|
+
- Uses `watchdog` moved events only (rename into place) to avoid half-written files.
|
|
370
|
+
"""
|
|
371
|
+
# Load config and setup logging
|
|
372
|
+
pipeline_config = pyopia.io.load_toml(config_filename)
|
|
373
|
+
setup_logging(pipeline_config)
|
|
374
|
+
pyopia.realtime.run_realtime(pipeline_config, watch_folder=watch_folder)
|
|
375
|
+
|
|
376
|
+
|
|
353
377
|
@app.command()
|
|
354
378
|
def merge_mfdata(
|
|
355
379
|
path_to_data: str,
|
|
@@ -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()
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Utilities for running PyOPIA pipelines in realtime mode."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import logging
|
|
5
|
+
import pathlib
|
|
6
|
+
import queue
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from rich import print as rich_print
|
|
12
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
13
|
+
from watchdog.events import FileSystemEventHandler
|
|
14
|
+
from watchdog.observers import Observer
|
|
15
|
+
|
|
16
|
+
import pyopia.pipeline
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_watch_settings(raw_files_pattern: str, watch_folder: str | None) -> tuple[str, str]:
|
|
20
|
+
raw_path = pathlib.Path(raw_files_pattern)
|
|
21
|
+
file_pattern = raw_path.name if raw_path.name else "*"
|
|
22
|
+
inferred_watch = str(raw_path.parent) if str(raw_path.parent) != "." else "."
|
|
23
|
+
|
|
24
|
+
if watch_folder is None:
|
|
25
|
+
watch_folder = inferred_watch
|
|
26
|
+
|
|
27
|
+
return watch_folder, file_pattern
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _enqueue_file_if_new(
|
|
31
|
+
file_path: pathlib.Path,
|
|
32
|
+
file_queue: queue.Queue,
|
|
33
|
+
file_pattern: str,
|
|
34
|
+
seen_files: set[str],
|
|
35
|
+
seen_lock: threading.Lock,
|
|
36
|
+
) -> bool:
|
|
37
|
+
if not file_path.exists() or file_path.is_dir():
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
if not fnmatch.fnmatch(file_path.name, file_pattern):
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
file_key = file_path.as_posix()
|
|
44
|
+
with seen_lock:
|
|
45
|
+
if file_key in seen_files:
|
|
46
|
+
return False
|
|
47
|
+
seen_files.add(file_key)
|
|
48
|
+
|
|
49
|
+
file_queue.put(file_path)
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _enqueue_existing_files(
|
|
54
|
+
watch_folder: str,
|
|
55
|
+
file_pattern: str,
|
|
56
|
+
file_queue: queue.Queue,
|
|
57
|
+
seen_files: set[str],
|
|
58
|
+
seen_lock: threading.Lock,
|
|
59
|
+
logger: logging.Logger,
|
|
60
|
+
):
|
|
61
|
+
for file_path in sorted(pathlib.Path(watch_folder).glob(file_pattern)):
|
|
62
|
+
if _enqueue_file_if_new(file_path, file_queue, file_pattern, seen_files, seen_lock):
|
|
63
|
+
logger.info(f"Existing file queued: {file_path}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_event_handler(
|
|
67
|
+
file_queue: queue.Queue,
|
|
68
|
+
file_pattern: str,
|
|
69
|
+
seen_files: set[str],
|
|
70
|
+
seen_lock: threading.Lock,
|
|
71
|
+
logger: logging.Logger,
|
|
72
|
+
):
|
|
73
|
+
class NewFileHandler(FileSystemEventHandler):
|
|
74
|
+
def _handle_path(self, path):
|
|
75
|
+
try:
|
|
76
|
+
file_path = pathlib.Path(path)
|
|
77
|
+
if _enqueue_file_if_new(
|
|
78
|
+
file_path,
|
|
79
|
+
file_queue,
|
|
80
|
+
file_pattern,
|
|
81
|
+
seen_files,
|
|
82
|
+
seen_lock,
|
|
83
|
+
):
|
|
84
|
+
logger.info(f"New file detected: {file_path}")
|
|
85
|
+
except Exception:
|
|
86
|
+
logger.exception("Error handling filesystem event")
|
|
87
|
+
|
|
88
|
+
def on_moved(self, event):
|
|
89
|
+
destination = getattr(event, "dest_path", None)
|
|
90
|
+
if destination:
|
|
91
|
+
self._handle_path(destination)
|
|
92
|
+
|
|
93
|
+
return NewFileHandler()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _worker_loop(
|
|
97
|
+
stop_event: threading.Event,
|
|
98
|
+
file_queue: queue.Queue,
|
|
99
|
+
processing_pipeline: pyopia.pipeline.Pipeline,
|
|
100
|
+
logger: logging.Logger,
|
|
101
|
+
runtime_state: dict,
|
|
102
|
+
state_lock: threading.Lock,
|
|
103
|
+
):
|
|
104
|
+
while not stop_event.is_set():
|
|
105
|
+
try:
|
|
106
|
+
filepath = file_queue.get(timeout=1)
|
|
107
|
+
except queue.Empty:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
with state_lock:
|
|
112
|
+
runtime_state["current_file"] = filepath.name
|
|
113
|
+
|
|
114
|
+
start = time.time()
|
|
115
|
+
logger.info(f"Starting processing: {filepath.name}")
|
|
116
|
+
processing_pipeline.run(filepath.as_posix())
|
|
117
|
+
elapsed = time.time() - start
|
|
118
|
+
logger.info(f"Completed {filepath.name} in {elapsed:.1f}s")
|
|
119
|
+
with state_lock:
|
|
120
|
+
runtime_state["processed_count"] += 1
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
logger.exception(f"Error processing {filepath}: {exc}")
|
|
123
|
+
finally:
|
|
124
|
+
with state_lock:
|
|
125
|
+
runtime_state["current_file"] = "idle"
|
|
126
|
+
file_queue.task_done()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_realtime(pipeline_config: dict, watch_folder: str | None = None):
|
|
130
|
+
"""Run a PyOPIA processing pipeline in realtime by watching a folder.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
pipeline_config : dict
|
|
135
|
+
Loaded PyOPIA pipeline config.
|
|
136
|
+
watch_folder : str, optional
|
|
137
|
+
Folder to monitor. If not provided, inferred from ``general.raw_files``.
|
|
138
|
+
"""
|
|
139
|
+
logger = logging.getLogger("rich")
|
|
140
|
+
logger.info(f"PyOPIA realtime process started {pd.Timestamp.now()}")
|
|
141
|
+
t1 = time.time()
|
|
142
|
+
|
|
143
|
+
raw_files_pattern = pipeline_config["general"].get("raw_files", "*")
|
|
144
|
+
watch_folder, file_pattern = _resolve_watch_settings(raw_files_pattern, watch_folder)
|
|
145
|
+
|
|
146
|
+
processing_pipeline = pyopia.pipeline.Pipeline(pipeline_config)
|
|
147
|
+
|
|
148
|
+
file_queue = queue.Queue()
|
|
149
|
+
stop_event = threading.Event()
|
|
150
|
+
seen_files: set[str] = set()
|
|
151
|
+
seen_lock = threading.Lock()
|
|
152
|
+
runtime_state = {"processed_count": 0, "current_file": "idle"}
|
|
153
|
+
state_lock = threading.Lock()
|
|
154
|
+
|
|
155
|
+
observer = Observer()
|
|
156
|
+
handler = _build_event_handler(
|
|
157
|
+
file_queue,
|
|
158
|
+
file_pattern,
|
|
159
|
+
seen_files,
|
|
160
|
+
seen_lock,
|
|
161
|
+
logger,
|
|
162
|
+
)
|
|
163
|
+
observer.schedule(handler, path=watch_folder, recursive=False)
|
|
164
|
+
observer.start()
|
|
165
|
+
|
|
166
|
+
_enqueue_existing_files(
|
|
167
|
+
watch_folder,
|
|
168
|
+
file_pattern,
|
|
169
|
+
file_queue,
|
|
170
|
+
seen_files,
|
|
171
|
+
seen_lock,
|
|
172
|
+
logger,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
worker_thread = threading.Thread(
|
|
176
|
+
target=_worker_loop,
|
|
177
|
+
args=(
|
|
178
|
+
stop_event,
|
|
179
|
+
file_queue,
|
|
180
|
+
processing_pipeline,
|
|
181
|
+
logger,
|
|
182
|
+
runtime_state,
|
|
183
|
+
state_lock,
|
|
184
|
+
),
|
|
185
|
+
daemon=True,
|
|
186
|
+
)
|
|
187
|
+
worker_thread.start()
|
|
188
|
+
|
|
189
|
+
logger.info(
|
|
190
|
+
f"Watching folder {watch_folder} for moved files matching '{file_pattern}'"
|
|
191
|
+
)
|
|
192
|
+
rich_print(
|
|
193
|
+
f"[blue]Realtime started. Watching {watch_folder} for moved files matching '{file_pattern}'"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
with Progress(
|
|
197
|
+
SpinnerColumn(),
|
|
198
|
+
TextColumn("{task.description}"),
|
|
199
|
+
BarColumn(),
|
|
200
|
+
TimeElapsedColumn(),
|
|
201
|
+
transient=True,
|
|
202
|
+
) as progress:
|
|
203
|
+
task_id = progress.add_task("[blue]Realtime active", total=None)
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
while True:
|
|
207
|
+
with state_lock:
|
|
208
|
+
processed_count = runtime_state["processed_count"]
|
|
209
|
+
current_file = runtime_state["current_file"]
|
|
210
|
+
progress.update(
|
|
211
|
+
task_id,
|
|
212
|
+
description=(
|
|
213
|
+
"[blue]Realtime active"
|
|
214
|
+
f" | processed: {processed_count}"
|
|
215
|
+
f" | queued: {file_queue.qsize()}"
|
|
216
|
+
f" | current: {current_file}"
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
time.sleep(1)
|
|
220
|
+
except KeyboardInterrupt:
|
|
221
|
+
logger.info("Shutdown requested, stopping observer and worker...")
|
|
222
|
+
finally:
|
|
223
|
+
stop_event.set()
|
|
224
|
+
observer.stop()
|
|
225
|
+
observer.join()
|
|
226
|
+
worker_thread.join(timeout=5)
|
|
227
|
+
|
|
228
|
+
time_total = pd.to_timedelta(time.time() - t1, "seconds")
|
|
229
|
+
progress.console.print(f"[blue]REALTIME PROCESSING STOPPED AFTER {time_total}")
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import queue
|
|
3
|
+
import threading
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pyopia.realtime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _DummyObserver:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.scheduled_path = None
|
|
12
|
+
self.recursive = None
|
|
13
|
+
self.started = False
|
|
14
|
+
self.stopped = False
|
|
15
|
+
self.joined = False
|
|
16
|
+
|
|
17
|
+
def schedule(self, _handler, path, recursive):
|
|
18
|
+
self.scheduled_path = path
|
|
19
|
+
self.recursive = recursive
|
|
20
|
+
|
|
21
|
+
def start(self):
|
|
22
|
+
self.started = True
|
|
23
|
+
|
|
24
|
+
def stop(self):
|
|
25
|
+
self.stopped = True
|
|
26
|
+
|
|
27
|
+
def join(self):
|
|
28
|
+
self.joined = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _DummyThread:
|
|
32
|
+
def __init__(self, target, args, daemon):
|
|
33
|
+
self.target = target
|
|
34
|
+
self.args = args
|
|
35
|
+
self.daemon = daemon
|
|
36
|
+
self.started = False
|
|
37
|
+
self.join_timeout = None
|
|
38
|
+
|
|
39
|
+
def start(self):
|
|
40
|
+
self.started = True
|
|
41
|
+
|
|
42
|
+
def join(self, timeout=None):
|
|
43
|
+
self.join_timeout = timeout
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _DummyProgress:
|
|
47
|
+
def __init__(self, *args, transient=True):
|
|
48
|
+
self.args = args
|
|
49
|
+
self.transient = transient
|
|
50
|
+
self.console = self
|
|
51
|
+
self.task_id = 1
|
|
52
|
+
self.descriptions = []
|
|
53
|
+
|
|
54
|
+
def __enter__(self):
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, exc_type, exc, tb):
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def print(self, *_args, **_kwargs):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def add_task(self, description, total=None):
|
|
64
|
+
self.descriptions.append(description)
|
|
65
|
+
return self.task_id
|
|
66
|
+
|
|
67
|
+
def update(self, _task_id, description=None):
|
|
68
|
+
if description is not None:
|
|
69
|
+
self.descriptions.append(description)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _DummyPipeline:
|
|
73
|
+
def __init__(self, config):
|
|
74
|
+
self.config = config
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _patch_realtime_runtime(monkeypatch, created):
|
|
78
|
+
def observer_factory():
|
|
79
|
+
created["observer"] = _DummyObserver()
|
|
80
|
+
return created["observer"]
|
|
81
|
+
|
|
82
|
+
def thread_factory(target, args, daemon):
|
|
83
|
+
created["thread"] = _DummyThread(target, args, daemon)
|
|
84
|
+
return created["thread"]
|
|
85
|
+
|
|
86
|
+
def raise_keyboard_interrupt(_seconds):
|
|
87
|
+
raise KeyboardInterrupt()
|
|
88
|
+
|
|
89
|
+
monkeypatch.setattr(pyopia.realtime, "Observer", observer_factory)
|
|
90
|
+
monkeypatch.setattr(pyopia.realtime.threading, "Thread", thread_factory)
|
|
91
|
+
monkeypatch.setattr(pyopia.realtime, "Progress", _DummyProgress)
|
|
92
|
+
monkeypatch.setattr(pyopia.realtime.pyopia.pipeline, "Pipeline", _DummyPipeline)
|
|
93
|
+
monkeypatch.setattr(pyopia.realtime.time, "sleep", raise_keyboard_interrupt)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_resolve_watch_settings_infers_from_raw_pattern(tmp_path: Path):
|
|
97
|
+
images_dir = tmp_path / "images"
|
|
98
|
+
images_dir.mkdir()
|
|
99
|
+
|
|
100
|
+
raw_pattern = str(images_dir / "*.silc")
|
|
101
|
+
watch_folder, file_pattern = pyopia.realtime._resolve_watch_settings(raw_pattern, None)
|
|
102
|
+
|
|
103
|
+
assert watch_folder == str(images_dir)
|
|
104
|
+
assert file_pattern == "*.silc"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_resolve_watch_settings_prefers_explicit_watch_folder(tmp_path: Path):
|
|
108
|
+
images_dir = tmp_path / "images"
|
|
109
|
+
images_dir.mkdir()
|
|
110
|
+
|
|
111
|
+
raw_pattern = str(images_dir / "*.silc")
|
|
112
|
+
watch_folder, file_pattern = pyopia.realtime._resolve_watch_settings(
|
|
113
|
+
raw_pattern,
|
|
114
|
+
watch_folder="/custom/watch",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert watch_folder == "/custom/watch"
|
|
118
|
+
assert file_pattern == "*.silc"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_event_handler_enqueues_only_matching_moved_files(tmp_path: Path):
|
|
122
|
+
file_queue = queue.Queue()
|
|
123
|
+
logger = logging.getLogger("test")
|
|
124
|
+
seen_files = set()
|
|
125
|
+
seen_lock = threading.Lock()
|
|
126
|
+
handler = pyopia.realtime._build_event_handler(
|
|
127
|
+
file_queue,
|
|
128
|
+
"*.silc",
|
|
129
|
+
seen_files,
|
|
130
|
+
seen_lock,
|
|
131
|
+
logger,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
matched = tmp_path / "image.silc"
|
|
135
|
+
matched.write_text("ok")
|
|
136
|
+
ignored = tmp_path / "other.txt"
|
|
137
|
+
ignored.write_text("skip")
|
|
138
|
+
|
|
139
|
+
moved_event_match = type("Event", (), {"dest_path": str(matched)})()
|
|
140
|
+
moved_event_no_match = type("Event", (), {"dest_path": str(ignored)})()
|
|
141
|
+
|
|
142
|
+
handler.on_moved(moved_event_match)
|
|
143
|
+
handler.on_moved(moved_event_no_match)
|
|
144
|
+
|
|
145
|
+
queued = file_queue.get_nowait()
|
|
146
|
+
assert queued == matched
|
|
147
|
+
assert file_queue.empty()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_event_handler_deduplicates_same_moved_file(tmp_path: Path):
|
|
151
|
+
file_queue = queue.Queue()
|
|
152
|
+
logger = logging.getLogger("test")
|
|
153
|
+
seen_files = set()
|
|
154
|
+
seen_lock = threading.Lock()
|
|
155
|
+
handler = pyopia.realtime._build_event_handler(
|
|
156
|
+
file_queue,
|
|
157
|
+
"*.silc",
|
|
158
|
+
seen_files,
|
|
159
|
+
seen_lock,
|
|
160
|
+
logger,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
matched = tmp_path / "image.silc"
|
|
164
|
+
matched.write_text("ok")
|
|
165
|
+
moved_event = type("Event", (), {"dest_path": str(matched)})()
|
|
166
|
+
|
|
167
|
+
handler.on_moved(moved_event)
|
|
168
|
+
handler.on_moved(moved_event)
|
|
169
|
+
|
|
170
|
+
queued = file_queue.get_nowait()
|
|
171
|
+
assert queued == matched
|
|
172
|
+
assert file_queue.empty()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_enqueue_existing_files_matches_pattern_and_deduplicates(tmp_path: Path):
|
|
176
|
+
file_queue = queue.Queue()
|
|
177
|
+
seen_files = set()
|
|
178
|
+
seen_lock = threading.Lock()
|
|
179
|
+
logger = logging.getLogger("test")
|
|
180
|
+
|
|
181
|
+
matched = tmp_path / "a.silc"
|
|
182
|
+
matched.write_text("ok")
|
|
183
|
+
ignored = tmp_path / "b.txt"
|
|
184
|
+
ignored.write_text("skip")
|
|
185
|
+
|
|
186
|
+
pyopia.realtime._enqueue_existing_files(
|
|
187
|
+
str(tmp_path),
|
|
188
|
+
"*.silc",
|
|
189
|
+
file_queue,
|
|
190
|
+
seen_files,
|
|
191
|
+
seen_lock,
|
|
192
|
+
logger,
|
|
193
|
+
)
|
|
194
|
+
pyopia.realtime._enqueue_existing_files(
|
|
195
|
+
str(tmp_path),
|
|
196
|
+
"*.silc",
|
|
197
|
+
file_queue,
|
|
198
|
+
seen_files,
|
|
199
|
+
seen_lock,
|
|
200
|
+
logger,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
queued = file_queue.get_nowait()
|
|
204
|
+
assert queued == matched
|
|
205
|
+
assert file_queue.empty()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_integration_existing_then_moved_files_processed_once(tmp_path: Path):
|
|
209
|
+
file_queue = queue.Queue()
|
|
210
|
+
seen_files = set()
|
|
211
|
+
seen_lock = threading.Lock()
|
|
212
|
+
logger = logging.getLogger("test")
|
|
213
|
+
|
|
214
|
+
existing = tmp_path / "existing.silc"
|
|
215
|
+
existing.write_text("old")
|
|
216
|
+
moved_in = tmp_path / "moved.silc"
|
|
217
|
+
moved_in.write_text("new")
|
|
218
|
+
|
|
219
|
+
pyopia.realtime._enqueue_existing_files(
|
|
220
|
+
str(tmp_path),
|
|
221
|
+
"*.silc",
|
|
222
|
+
file_queue,
|
|
223
|
+
seen_files,
|
|
224
|
+
seen_lock,
|
|
225
|
+
logger,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
handler = pyopia.realtime._build_event_handler(
|
|
229
|
+
file_queue,
|
|
230
|
+
"*.silc",
|
|
231
|
+
seen_files,
|
|
232
|
+
seen_lock,
|
|
233
|
+
logger,
|
|
234
|
+
)
|
|
235
|
+
handler.on_moved(type("Event", (), {"dest_path": str(moved_in)})())
|
|
236
|
+
handler.on_moved(type("Event", (), {"dest_path": str(existing)})())
|
|
237
|
+
|
|
238
|
+
stop_event = threading.Event()
|
|
239
|
+
processed = []
|
|
240
|
+
runtime_state = {"processed_count": 0, "current_file": "idle"}
|
|
241
|
+
state_lock = threading.Lock()
|
|
242
|
+
|
|
243
|
+
class DummyPipeline:
|
|
244
|
+
def run(self, filename):
|
|
245
|
+
processed.append(filename)
|
|
246
|
+
if len(processed) == 2:
|
|
247
|
+
stop_event.set()
|
|
248
|
+
|
|
249
|
+
pyopia.realtime._worker_loop(
|
|
250
|
+
stop_event=stop_event,
|
|
251
|
+
file_queue=file_queue,
|
|
252
|
+
processing_pipeline=DummyPipeline(),
|
|
253
|
+
logger=logger,
|
|
254
|
+
runtime_state=runtime_state,
|
|
255
|
+
state_lock=state_lock,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
assert set(processed) == {existing.as_posix(), moved_in.as_posix()}
|
|
259
|
+
assert runtime_state["processed_count"] == 2
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_worker_loop_processes_one_file_and_uses_posix_path(tmp_path: Path):
|
|
263
|
+
class DummyPipeline:
|
|
264
|
+
def __init__(self):
|
|
265
|
+
self.processed = []
|
|
266
|
+
|
|
267
|
+
def run(self, filename):
|
|
268
|
+
self.processed.append(filename)
|
|
269
|
+
stop_event.set()
|
|
270
|
+
|
|
271
|
+
image_file = tmp_path / "sample.silc"
|
|
272
|
+
image_file.write_text("content")
|
|
273
|
+
|
|
274
|
+
stop_event = threading.Event()
|
|
275
|
+
file_queue = queue.Queue()
|
|
276
|
+
file_queue.put(image_file)
|
|
277
|
+
pipeline = DummyPipeline()
|
|
278
|
+
runtime_state = {"processed_count": 0, "current_file": "idle"}
|
|
279
|
+
state_lock = threading.Lock()
|
|
280
|
+
|
|
281
|
+
pyopia.realtime._worker_loop(
|
|
282
|
+
stop_event=stop_event,
|
|
283
|
+
file_queue=file_queue,
|
|
284
|
+
processing_pipeline=pipeline,
|
|
285
|
+
logger=logging.getLogger("test"),
|
|
286
|
+
runtime_state=runtime_state,
|
|
287
|
+
state_lock=state_lock,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
assert pipeline.processed == [image_file.as_posix()]
|
|
291
|
+
assert runtime_state["processed_count"] == 1
|
|
292
|
+
assert runtime_state["current_file"] == "idle"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_run_realtime_schedules_observer_and_stops_on_keyboard_interrupt(monkeypatch, tmp_path: Path):
|
|
296
|
+
images_dir = tmp_path / "incoming"
|
|
297
|
+
images_dir.mkdir()
|
|
298
|
+
pipeline_config = {
|
|
299
|
+
"general": {
|
|
300
|
+
"raw_files": str(images_dir / "*.silc"),
|
|
301
|
+
},
|
|
302
|
+
"steps": {},
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
created = {}
|
|
306
|
+
progress_calls = {"added": 0, "updated": 0}
|
|
307
|
+
|
|
308
|
+
class TrackingProgress(_DummyProgress):
|
|
309
|
+
def add_task(self, description, total=None):
|
|
310
|
+
progress_calls["added"] += 1
|
|
311
|
+
return super().add_task(description, total)
|
|
312
|
+
|
|
313
|
+
def update(self, _task_id, description=None):
|
|
314
|
+
progress_calls["updated"] += 1
|
|
315
|
+
return super().update(_task_id, description)
|
|
316
|
+
|
|
317
|
+
_patch_realtime_runtime(monkeypatch, created)
|
|
318
|
+
monkeypatch.setattr(pyopia.realtime, "Progress", TrackingProgress)
|
|
319
|
+
|
|
320
|
+
pyopia.realtime.run_realtime(pipeline_config)
|
|
321
|
+
|
|
322
|
+
assert created["observer"].scheduled_path == str(images_dir)
|
|
323
|
+
assert created["observer"].recursive is False
|
|
324
|
+
assert created["observer"].started is True
|
|
325
|
+
assert created["observer"].stopped is True
|
|
326
|
+
assert created["observer"].joined is True
|
|
327
|
+
assert created["thread"].started is True
|
|
328
|
+
assert created["thread"].daemon is True
|
|
329
|
+
assert created["thread"].join_timeout == 5
|
|
330
|
+
assert progress_calls["added"] == 1
|
|
331
|
+
assert progress_calls["updated"] >= 1
|
|
@@ -6,7 +6,7 @@ authors = [
|
|
|
6
6
|
{ name = "Emlyn Davies", email = "emlyn.davies@sintef.no" },
|
|
7
7
|
{ name = "Alex Nimmo Smith@plymouth.ac.uk", email = "alex.nimmo.smith@plymouth.ac.uk" },
|
|
8
8
|
]
|
|
9
|
-
requires-python = "~=3.12"
|
|
9
|
+
requires-python = "~=3.12.0"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
keywords = [
|
|
12
12
|
"Ocean",
|
|
@@ -50,6 +50,7 @@ dependencies = [
|
|
|
50
50
|
"click<8.2.0",
|
|
51
51
|
"seaborn>=0.13.2",
|
|
52
52
|
"pydantic>=2.11.7",
|
|
53
|
+
"watchdog>=6.0.0",
|
|
53
54
|
]
|
|
54
55
|
|
|
55
56
|
[project.optional-dependencies]
|
|
@@ -59,6 +60,10 @@ classification = [
|
|
|
59
60
|
"keras==3.9.1",
|
|
60
61
|
]
|
|
61
62
|
|
|
63
|
+
classification-torch = [
|
|
64
|
+
"torch>=2.1.0",
|
|
65
|
+
]
|
|
66
|
+
|
|
62
67
|
[project.urls]
|
|
63
68
|
Repository = "https://github.com/sintef/pyopia"
|
|
64
69
|
Documentation = "https://pyopia.readthedocs.io"
|
pyopia-2.14.0/pyopia/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.14.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
|