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.
Files changed (37) hide show
  1. {pyopia-2.14.0 → pyopia-2.16.0}/PKG-INFO +5 -2
  2. pyopia-2.16.0/pyopia/__init__.py +1 -0
  3. pyopia-2.16.0/pyopia/classify_torch.py +276 -0
  4. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/cli.py +24 -0
  5. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/pipeline.py +8 -1
  6. pyopia-2.16.0/pyopia/realtime.py +229 -0
  7. pyopia-2.16.0/pyopia/tests/test_realtime.py +331 -0
  8. {pyopia-2.14.0 → pyopia-2.16.0}/pyproject.toml +6 -1
  9. pyopia-2.14.0/pyopia/__init__.py +0 -1
  10. {pyopia-2.14.0 → pyopia-2.16.0}/.gitignore +0 -0
  11. {pyopia-2.14.0 → pyopia-2.16.0}/LICENSE +0 -0
  12. {pyopia-2.14.0 → pyopia-2.16.0}/README.md +0 -0
  13. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/auxillarydata.py +0 -0
  14. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/background.py +0 -0
  15. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/cf_metadata.json +0 -0
  16. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/classify.py +0 -0
  17. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/dataexport/__init__.py +0 -0
  18. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/dataexport/ecotaxa.py +0 -0
  19. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/exampledata.py +0 -0
  20. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/instrument/__init__.py +0 -0
  21. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/instrument/common.py +0 -0
  22. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/instrument/holo.py +0 -0
  23. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/instrument/silcam.py +0 -0
  24. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/instrument/uvp.py +0 -0
  25. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/io.py +0 -0
  26. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/metadata.py +0 -0
  27. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/plotting.py +0 -0
  28. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/process.py +0 -0
  29. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/simulator/__init__.py +0 -0
  30. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/simulator/silcam.py +0 -0
  31. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/statistics.py +0 -0
  32. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/tests/__init__.py +0 -0
  33. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/tests/test_auxillarydata.py +0 -0
  34. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/tests/test_classify.py +0 -0
  35. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/tests/test_io.py +0 -0
  36. {pyopia-2.14.0 → pyopia-2.16.0}/pyopia/tests/test_notebooks.py +0 -0
  37. {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.14.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
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"
@@ -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