pivtools 0.1.3__cp311-cp311-win_amd64.whl

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 (127) hide show
  1. pivtools-0.1.3.dist-info/METADATA +222 -0
  2. pivtools-0.1.3.dist-info/RECORD +127 -0
  3. pivtools-0.1.3.dist-info/WHEEL +5 -0
  4. pivtools-0.1.3.dist-info/entry_points.txt +3 -0
  5. pivtools-0.1.3.dist-info/top_level.txt +3 -0
  6. pivtools_cli/__init__.py +5 -0
  7. pivtools_cli/_build_marker.c +25 -0
  8. pivtools_cli/_build_marker.cp311-win_amd64.pyd +0 -0
  9. pivtools_cli/cli.py +225 -0
  10. pivtools_cli/example.py +139 -0
  11. pivtools_cli/lib/PIV_2d_cross_correlate.c +334 -0
  12. pivtools_cli/lib/PIV_2d_cross_correlate.h +22 -0
  13. pivtools_cli/lib/common.h +36 -0
  14. pivtools_cli/lib/interp2custom.c +146 -0
  15. pivtools_cli/lib/interp2custom.h +48 -0
  16. pivtools_cli/lib/peak_locate_gsl.c +711 -0
  17. pivtools_cli/lib/peak_locate_gsl.h +40 -0
  18. pivtools_cli/lib/peak_locate_gsl_print.c +736 -0
  19. pivtools_cli/lib/peak_locate_lm.c +751 -0
  20. pivtools_cli/lib/peak_locate_lm.h +27 -0
  21. pivtools_cli/lib/xcorr.c +342 -0
  22. pivtools_cli/lib/xcorr.h +31 -0
  23. pivtools_cli/lib/xcorr_cache.c +78 -0
  24. pivtools_cli/lib/xcorr_cache.h +26 -0
  25. pivtools_cli/piv/interp2custom/interp2custom.py +69 -0
  26. pivtools_cli/piv/piv.py +240 -0
  27. pivtools_cli/piv/piv_backend/base.py +825 -0
  28. pivtools_cli/piv/piv_backend/cpu_instantaneous.py +1005 -0
  29. pivtools_cli/piv/piv_backend/factory.py +28 -0
  30. pivtools_cli/piv/piv_backend/gpu_instantaneous.py +15 -0
  31. pivtools_cli/piv/piv_backend/infilling.py +445 -0
  32. pivtools_cli/piv/piv_backend/outlier_detection.py +306 -0
  33. pivtools_cli/piv/piv_backend/profile_cpu_instantaneous.py +230 -0
  34. pivtools_cli/piv/piv_result.py +40 -0
  35. pivtools_cli/piv/save_results.py +342 -0
  36. pivtools_cli/piv_cluster/cluster.py +108 -0
  37. pivtools_cli/preprocessing/filters.py +399 -0
  38. pivtools_cli/preprocessing/preprocess.py +79 -0
  39. pivtools_cli/tests/helpers.py +107 -0
  40. pivtools_cli/tests/instantaneous_piv/test_piv_integration.py +167 -0
  41. pivtools_cli/tests/instantaneous_piv/test_piv_integration_multi.py +553 -0
  42. pivtools_cli/tests/preprocessing/test_filters.py +41 -0
  43. pivtools_core/__init__.py +5 -0
  44. pivtools_core/config.py +703 -0
  45. pivtools_core/config.yaml +135 -0
  46. pivtools_core/image_handling/__init__.py +0 -0
  47. pivtools_core/image_handling/load_images.py +464 -0
  48. pivtools_core/image_handling/readers/__init__.py +53 -0
  49. pivtools_core/image_handling/readers/generic_readers.py +50 -0
  50. pivtools_core/image_handling/readers/lavision_reader.py +190 -0
  51. pivtools_core/image_handling/readers/registry.py +24 -0
  52. pivtools_core/paths.py +49 -0
  53. pivtools_core/vector_loading.py +248 -0
  54. pivtools_gui/__init__.py +3 -0
  55. pivtools_gui/app.py +687 -0
  56. pivtools_gui/calibration/__init__.py +0 -0
  57. pivtools_gui/calibration/app/__init__.py +0 -0
  58. pivtools_gui/calibration/app/views.py +1186 -0
  59. pivtools_gui/calibration/calibration_planar/planar_calibration_production.py +570 -0
  60. pivtools_gui/calibration/vector_calibration_production.py +544 -0
  61. pivtools_gui/config.py +703 -0
  62. pivtools_gui/image_handling/__init__.py +0 -0
  63. pivtools_gui/image_handling/load_images.py +464 -0
  64. pivtools_gui/image_handling/readers/__init__.py +53 -0
  65. pivtools_gui/image_handling/readers/generic_readers.py +50 -0
  66. pivtools_gui/image_handling/readers/lavision_reader.py +190 -0
  67. pivtools_gui/image_handling/readers/registry.py +24 -0
  68. pivtools_gui/masking/__init__.py +0 -0
  69. pivtools_gui/masking/app/__init__.py +0 -0
  70. pivtools_gui/masking/app/views.py +123 -0
  71. pivtools_gui/paths.py +49 -0
  72. pivtools_gui/piv_runner.py +261 -0
  73. pivtools_gui/pivtools.py +58 -0
  74. pivtools_gui/plotting/__init__.py +0 -0
  75. pivtools_gui/plotting/app/__init__.py +0 -0
  76. pivtools_gui/plotting/app/views.py +1671 -0
  77. pivtools_gui/plotting/plot_maker.py +220 -0
  78. pivtools_gui/post_processing/POD/__init__.py +0 -0
  79. pivtools_gui/post_processing/POD/app/__init__.py +0 -0
  80. pivtools_gui/post_processing/POD/app/views.py +647 -0
  81. pivtools_gui/post_processing/POD/pod_decompose.py +979 -0
  82. pivtools_gui/post_processing/POD/views.py +1096 -0
  83. pivtools_gui/post_processing/__init__.py +0 -0
  84. pivtools_gui/static/404.html +1 -0
  85. pivtools_gui/static/_next/static/chunks/117-d5793c8e79de5511.js +2 -0
  86. pivtools_gui/static/_next/static/chunks/484-cfa8b9348ce4f00e.js +1 -0
  87. pivtools_gui/static/_next/static/chunks/869-320a6b9bdafbb6d3.js +1 -0
  88. pivtools_gui/static/_next/static/chunks/app/_not-found/page-12f067ceb7415e55.js +1 -0
  89. pivtools_gui/static/_next/static/chunks/app/layout-b907d5f31ac82e9d.js +1 -0
  90. pivtools_gui/static/_next/static/chunks/app/page-334cc4e8444cde2f.js +1 -0
  91. pivtools_gui/static/_next/static/chunks/fd9d1056-ad15f396ddf9b7e5.js +1 -0
  92. pivtools_gui/static/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
  93. pivtools_gui/static/_next/static/chunks/main-a1b3ced4d5f6d998.js +1 -0
  94. pivtools_gui/static/_next/static/chunks/main-app-8a63c6f5e7baee11.js +1 -0
  95. pivtools_gui/static/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
  96. pivtools_gui/static/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
  97. pivtools_gui/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  98. pivtools_gui/static/_next/static/chunks/webpack-4a8ca7c99e9bb3d8.js +1 -0
  99. pivtools_gui/static/_next/static/css/7d3f2337d7ea12a5.css +3 -0
  100. pivtools_gui/static/_next/static/vQeR20OUdSSKlK4vukC4q/_buildManifest.js +1 -0
  101. pivtools_gui/static/_next/static/vQeR20OUdSSKlK4vukC4q/_ssgManifest.js +1 -0
  102. pivtools_gui/static/file.svg +1 -0
  103. pivtools_gui/static/globe.svg +1 -0
  104. pivtools_gui/static/grid.svg +8 -0
  105. pivtools_gui/static/index.html +1 -0
  106. pivtools_gui/static/index.txt +8 -0
  107. pivtools_gui/static/next.svg +1 -0
  108. pivtools_gui/static/vercel.svg +1 -0
  109. pivtools_gui/static/window.svg +1 -0
  110. pivtools_gui/stereo_reconstruction/__init__.py +0 -0
  111. pivtools_gui/stereo_reconstruction/app/__init__.py +0 -0
  112. pivtools_gui/stereo_reconstruction/app/views.py +1985 -0
  113. pivtools_gui/stereo_reconstruction/stereo_calibration_production.py +606 -0
  114. pivtools_gui/stereo_reconstruction/stereo_reconstruction_production.py +544 -0
  115. pivtools_gui/utils.py +63 -0
  116. pivtools_gui/vector_loading.py +248 -0
  117. pivtools_gui/vector_merging/__init__.py +1 -0
  118. pivtools_gui/vector_merging/app/__init__.py +1 -0
  119. pivtools_gui/vector_merging/app/views.py +759 -0
  120. pivtools_gui/vector_statistics/app/__init__.py +1 -0
  121. pivtools_gui/vector_statistics/app/views.py +710 -0
  122. pivtools_gui/vector_statistics/ensemble_statistics.py +49 -0
  123. pivtools_gui/vector_statistics/instantaneous_statistics.py +311 -0
  124. pivtools_gui/video_maker/__init__.py +0 -0
  125. pivtools_gui/video_maker/app/__init__.py +0 -0
  126. pivtools_gui/video_maker/app/views.py +436 -0
  127. pivtools_gui/video_maker/video_maker.py +662 -0
pivtools_gui/config.py ADDED
@@ -0,0 +1,703 @@
1
+ from pathlib import Path
2
+ import logging
3
+ import os
4
+ import shutil
5
+
6
+ import yaml
7
+
8
+ _CONFIG = None # singleton cache
9
+ _LOGGING_INITIALIZED = False # Track if logging has been set up
10
+
11
+
12
+ class Config:
13
+ def __init__(self, path=None):
14
+ if path is None:
15
+ path = self._get_config_path()
16
+ with open(path, "r") as f:
17
+ self.data = yaml.safe_load(f)
18
+ # Use the first base_path, first camera, and image_format for dtype detection
19
+ # source_path = Path(self.source_paths[0])
20
+ # camera_folder = f"Cam{self.camera_numbers[0]}"
21
+ # # Use correct image format for dtype detection
22
+ # if self.time_resolved:
23
+ # file_path = source_path / camera_folder / (self.image_format % 1)
24
+ # else:
25
+ # file_path = source_path / camera_folder / (self.image_format[0] % 1)
26
+ # img = tifffile.imread(file_path) # bye bye
27
+ # self.image_dtype = img.dtype
28
+
29
+ # Cache for auto-detected image shape
30
+ self._detected_image_shape = None
31
+
32
+ # Cache for auto-computed parameters
33
+ self._auto_compute_cache = None
34
+
35
+ # Setup logging only once globally
36
+ self._setup_logging()
37
+
38
+ # Store the config path for saving
39
+ self._config_path = path if path is not None else self._get_config_path()
40
+
41
+ def _get_config_path(self):
42
+ """Get the path to the config file, preferring user config over package default."""
43
+ # Determine user config directory
44
+ if os.name == 'nt': # Windows
45
+ user_config_dir = Path(os.environ.get('APPDATA', '')) / 'pivtools'
46
+ else: # Unix-like (macOS, Linux)
47
+ user_config_dir = Path.home() / '.config' / 'pivtools'
48
+
49
+ user_config_dir.mkdir(parents=True, exist_ok=True)
50
+ user_config_path = user_config_dir / 'config.yaml'
51
+
52
+ # If user config doesn't exist, copy from package default
53
+ if not user_config_path.exists():
54
+ package_default = Path(__file__).parent.parent / 'pivtools_core' / 'config.yaml'
55
+ if package_default.exists():
56
+ shutil.copy2(package_default, user_config_path)
57
+
58
+ return user_config_path
59
+
60
+ @property
61
+ def config_path(self):
62
+ """Get the path to the config file."""
63
+ return self._config_path
64
+
65
+ @property
66
+ def config_dict(self):
67
+ """Access to raw config dictionary for advanced usage."""
68
+ return self.data
69
+
70
+ @property
71
+ def time_resolved(self):
72
+ return self.data["images"].get("time_resolved", False)
73
+
74
+ @property
75
+ def image_format(self):
76
+ if self.time_resolved:
77
+ return self.data["images"].get("image_format", "B%05d.tiff")
78
+ else:
79
+ # Expect a list of two formats in the config for A and B images
80
+ fmts = self.data["images"].get(
81
+ "image_format", ["B%05d_A.tiff", "B%05d_B.tiff"]
82
+ )
83
+ return tuple(fmts)
84
+
85
+ @property
86
+ def base_paths(self):
87
+ return [Path(p) for p in self.data["paths"]["base_paths"]]
88
+
89
+ @property
90
+ def source_paths(self):
91
+ return [Path(s) for s in self.data["paths"]["source_paths"]]
92
+
93
+ @property
94
+ def camera_count(self):
95
+ """Return the total number of cameras."""
96
+ return self.data["paths"].get("camera_count", 1)
97
+
98
+ @property
99
+ def camera_numbers(self):
100
+ """Return list of camera numbers to process."""
101
+ numbers = self.data["paths"]["camera_numbers"]
102
+ max_allowed = self.camera_count
103
+ if any(n > max_allowed or n < 1 for n in numbers):
104
+ raise ValueError(f"Camera numbers {numbers} must be between 1 and {max_allowed}")
105
+ return numbers
106
+
107
+ @property
108
+ def camera_folders(self):
109
+ return [f"Cam{n}" for n in self.camera_numbers]
110
+
111
+ @property
112
+ def num_images(self):
113
+ return self.data["images"]["num_images"]
114
+
115
+ @property
116
+ def image_shape(self):
117
+ """
118
+ Return image shape (H, W).
119
+
120
+ If shape is specified in config, use that.
121
+ Otherwise, auto-detect from first image and cache the result.
122
+ """
123
+ # First check if explicitly set in config
124
+ if "shape" in self.data.get("images", {}):
125
+ return tuple(self.data["images"]["shape"])
126
+
127
+ # Otherwise, auto-detect and cache
128
+ if self._detected_image_shape is None:
129
+ self._detected_image_shape = self._detect_image_shape()
130
+ logging.info("Auto-detected image shape: %s", self._detected_image_shape)
131
+
132
+ return self._detected_image_shape
133
+
134
+ def _detect_image_shape(self) -> tuple:
135
+ """
136
+ Detect image shape by reading the first image.
137
+
138
+ Returns
139
+ -------
140
+ tuple
141
+ (H, W) shape of images
142
+ """
143
+ from pivtools_core.image_handling.load_images import read_image
144
+
145
+ source_path = self.source_paths[0]
146
+ camera_num = self.camera_numbers[0]
147
+ image_format = self.image_format
148
+
149
+ try:
150
+ # Handle different image format cases
151
+ if '.set' in str(image_format):
152
+ # For .set files, they're in the source directory
153
+ if isinstance(image_format, tuple):
154
+ file_path = source_path / image_format[0]
155
+ else:
156
+ file_path = source_path / image_format
157
+ img = read_image(str(file_path), camera_no=camera_num, im_no=1)
158
+ elif '.im7' in str(image_format):
159
+ # For .im7 files, they're in the source directory (all cameras in one file)
160
+ if isinstance(image_format, tuple):
161
+ file_path = source_path / (image_format[0] % 1)
162
+ else:
163
+ file_path = source_path / (image_format % 1)
164
+ img = read_image(str(file_path), camera_no=camera_num)
165
+ else:
166
+ # Regular files in camera subdirectories
167
+ camera_path = source_path / f"Cam{camera_num}"
168
+
169
+ if isinstance(image_format, tuple):
170
+ # Non-time-resolved: use first format (A frame)
171
+ file_path = camera_path / (image_format[0] % 1)
172
+ else:
173
+ # Time-resolved: single format
174
+ file_path = camera_path / (image_format % 1)
175
+
176
+ img = read_image(str(file_path))
177
+
178
+ # Handle both single images and image pairs
179
+ if img.ndim == 3 and img.shape[0] == 2:
180
+ # Image pair returned (e.g., from .im7)
181
+ return tuple(img.shape[1:])
182
+ else:
183
+ # Single image
184
+ return tuple(img.shape)
185
+
186
+ except Exception as e:
187
+ logging.error("Failed to auto-detect image shape: %s", e)
188
+ logging.error("Please specify 'shape' in config.yaml under 'images' section")
189
+ raise ValueError(
190
+ f"Could not auto-detect image shape. Please add 'shape: [H, W]' "
191
+ f"to the 'images' section of your config.yaml. Error: {e}"
192
+ )
193
+
194
+ @property
195
+ def piv_chunk_size(self):
196
+ # Updated to use batches.size from config.yaml
197
+ return self.data["batches"]["size"]
198
+
199
+ @property
200
+ def batch_size(self):
201
+ """Batch size for image processing."""
202
+ return self.data.get("batches", {}).get("size", 30)
203
+
204
+ @property
205
+ def filter_type(self):
206
+ # This is now optional, as filters block is used
207
+ return self.data.get("pre_procesing", {}).get("filter_type", None)
208
+
209
+ @property
210
+ def filters(self):
211
+ # Returns the list of filter dicts from config.yaml
212
+ return self.data.get("filters", [])
213
+
214
+ @property
215
+ def vector_format(self):
216
+ # Returns a single format string like "B%05d.mat"
217
+ vf = self.data["images"].get("vector_format", ["B%05d.mat"])
218
+ if isinstance(vf, (list, tuple)):
219
+ return vf[0]
220
+ return vf
221
+
222
+ @property
223
+ def statistics_extraction(self):
224
+ # Returns the statistics_extraction block as a list, or empty list if not present
225
+ return self.data.get("statistics_extraction", [])
226
+
227
+ @property
228
+ def instantaneous_runs(self):
229
+ return self.data.get("instantaneous_piv", {}).get("runs", [])
230
+
231
+ @property
232
+ def instantaneous_runs_0based(self):
233
+ runs = self.instantaneous_runs
234
+ if runs:
235
+ return [r - 1 for r in runs]
236
+ else:
237
+ # Default to last pass if runs is empty
238
+ return [self.num_passes - 1]
239
+
240
+ @property
241
+ def instantaneous_window_sizes(self):
242
+ return self.data.get("instantaneous_piv", {}).get("window_size", [])
243
+
244
+ @property
245
+ def instantaneous_overlaps(self):
246
+ return self.data.get("instantaneous_piv", {}).get("overlap", [])
247
+
248
+ @property
249
+ def plots(self):
250
+ # Return the 'plots' dict from config.yaml
251
+ return self.data.get("plots", {})
252
+
253
+ @property
254
+ def plot_save_extension(self):
255
+ return self.plots.get("save_extension", ".png")
256
+
257
+ @property
258
+ def plot_save_pickle(self):
259
+ return self.plots.get("save_pickle", True)
260
+
261
+ @property
262
+ def plot_fontsize(self):
263
+ return self.plots.get("fontsize", 14)
264
+
265
+ @property
266
+ def plot_title_fontsize(self):
267
+ return self.plots.get("title_fontsize", 16)
268
+
269
+ @property
270
+ def videos(self):
271
+ """
272
+ Returns the 'videos' list from config.yaml. Ensures a list is returned.
273
+ Each entry may contain: type, endpoint, use_merged, video_length, variable.
274
+ """
275
+ vids = self.data.get("videos", [])
276
+ if vids is None:
277
+ return []
278
+ if isinstance(vids, dict):
279
+ return [vids]
280
+ return list(vids)
281
+
282
+ @property
283
+ def post_processing(self):
284
+ # Returns the post_processing block as a list, or empty list if not present
285
+ return self.data.get("post_processing", [])
286
+
287
+ # --- Calibration specific settings ---
288
+ @property
289
+ def calibration_image_format(self):
290
+ """Return calibration image filename pattern.
291
+ Default 'Calib%05d.tif'. If user supplies a plain filename (no %), it
292
+ is used directly. If a dict block calibration: { image_format: ... }
293
+ exists use that, else look for images.calibration_image_format for
294
+ backward compatibility.
295
+ """
296
+ # Preferred location
297
+ calib_block = self.data.get("calibration_format", {}) or {}
298
+ fmt = calib_block.get("image_format", None)
299
+ if not fmt:
300
+ # fallback legacy key
301
+ fmt = self.data.get("images", {}).get("calibration_image_format", None)
302
+ if not fmt:
303
+ fmt = "calib%05d.tif"
304
+ return fmt
305
+
306
+ def calibration_filename(self, index: int = 1):
307
+ fmt = self.calibration_image_format
308
+ try:
309
+ if "%" in fmt:
310
+ return fmt % index
311
+ return fmt
312
+ except Exception:
313
+ # On formatting error, just return fmt
314
+ return fmt
315
+
316
+ @property
317
+ def calibration(self):
318
+ """Return the full calibration block (dict) from config."""
319
+ return self.data.get("calibration", {})
320
+
321
+ @property
322
+ def active_calibration_method(self):
323
+ """Return the active calibration method name (e.g., 'pinhole', 'scale_factor')."""
324
+ cal = self.calibration
325
+ return cal.get("active", "pinhole")
326
+
327
+ @property
328
+ def active_calibration_params(self):
329
+ """Return the parameters dict for the active calibration method."""
330
+ cal = self.calibration
331
+ active = cal.get("active", "pinhole")
332
+ return cal.get(active, {})
333
+
334
+ @property
335
+ def scale_factor_calibration(self):
336
+ """Return scale factor calibration parameters."""
337
+ return self.calibration.get("scale_factor", {})
338
+
339
+ @property
340
+ def pinhole_calibration(self):
341
+ """Return pinhole calibration parameters."""
342
+ return self.calibration.get("pinhole", {})
343
+
344
+ @property
345
+ def stereo_calibration(self):
346
+ """Return stereo calibration parameters."""
347
+ return self.calibration.get("stereo", {})
348
+
349
+ def get_calibration_method_params(self, method: str):
350
+ """Get parameters for a specific calibration method."""
351
+ return self.calibration.get(method, {})
352
+
353
+ def set_active_calibration_method(self, method: str):
354
+ """Set the active calibration method."""
355
+ if method in ["scale_factor", "pinhole", "stereo"]:
356
+ self.data["calibration"]["active"] = method
357
+ else:
358
+ raise ValueError(f"Unknown calibration method: {method}")
359
+
360
+ # --- PIV-specific properties from pypivtools ---
361
+ @property
362
+ def window_sizes(self):
363
+ """Return PIV window sizes from instantaneous_piv configuration."""
364
+ return self.data.get("instantaneous_piv", {}).get("window_size", [])
365
+
366
+ @property
367
+ def overlap(self):
368
+ """Return PIV overlap percentages."""
369
+ overlaps = self.data.get("instantaneous_piv", {}).get("overlap", [])
370
+ # Ensure we have as many overlaps as window sizes
371
+ if overlaps and len(overlaps) == 1 and len(self.window_sizes) > 1:
372
+ overlaps = overlaps * len(self.window_sizes)
373
+ return overlaps
374
+
375
+ @property
376
+ def num_peaks(self):
377
+ """Return number of peaks to detect in correlation."""
378
+ return self.data.get("instantaneous_piv", {}).get("num_peaks", 1)
379
+
380
+ @property
381
+ def dt(self):
382
+ """Return time difference between frames."""
383
+ # Check active calibration method
384
+ active_method = self.active_calibration_method
385
+ if active_method == "stereo":
386
+ return self.stereo_calibration.get("dt", 1)
387
+ elif active_method == "pinhole":
388
+ return self.pinhole_calibration.get("dt", 1)
389
+ elif active_method == "scale_factor":
390
+ return self.scale_factor_calibration.get("dt", 1)
391
+ return 1
392
+
393
+ @property
394
+ def window_type(self):
395
+ """Return PIV window type (e.g., 'gaussian', 'A')."""
396
+ return self.data.get("instantaneous_piv", {}).get("window_type", "A")
397
+
398
+ @property
399
+ def backend(self):
400
+ """Return processing backend ('cpu' or 'gpu')."""
401
+ return self.data.get("processing", {}).get("backend", "cpu").lower()
402
+
403
+ @property
404
+ def num_passes(self):
405
+ """Return number of PIV passes."""
406
+ return len(self.window_sizes)
407
+
408
+ @property
409
+ def debug(self):
410
+ """Return debug flag."""
411
+ return self.data.get("processing", {}).get("debug", False)
412
+
413
+ @property
414
+ def auto_compute_params(self):
415
+ """Return True if compute parameters should be auto-detected."""
416
+ return self.data.get("processing", {}).get("auto_compute_params", False)
417
+
418
+ def _get_auto_compute_params(self):
419
+ """
420
+ Auto-detect optimal compute parameters based on system resources.
421
+ Results are cached to avoid repeated detection.
422
+
423
+ Returns
424
+ -------
425
+ dict
426
+ Dictionary with keys: omp_threads, dask_workers_per_node,
427
+ dask_threads_per_worker, dask_memory_limit
428
+ """
429
+ # Return cached result if available
430
+ if self._auto_compute_cache is not None:
431
+ return self._auto_compute_cache
432
+
433
+ import psutil
434
+ import os
435
+
436
+ # Get number of CPU cores
437
+ cpu_count = os.cpu_count() or 4
438
+
439
+ # Get total system memory in GB
440
+ total_memory_gb = psutil.virtual_memory().total / (1024**3)
441
+
442
+ # Workers per node = number of CPUs
443
+ workers_per_node = cpu_count
444
+
445
+ # OMP threads = 2 (as requested)
446
+ omp_threads = 2
447
+
448
+ # Dask memory = (total memory - 10%) / cpu_count
449
+ # Reserve 10% for system overhead
450
+ available_memory_gb = total_memory_gb * 0.9
451
+ memory_per_worker_gb = available_memory_gb / cpu_count
452
+ dask_memory_limit = f"{memory_per_worker_gb:.2f}GB"
453
+
454
+ # Threads per worker = 1 (standard for CPU-bound tasks)
455
+ threads_per_worker = 1
456
+
457
+ logging.info("Auto-detected compute parameters:")
458
+ logging.info(" CPU cores: %d", cpu_count)
459
+ logging.info(" Total memory: %.2f GB", total_memory_gb)
460
+ logging.info(" Workers per node: %d", workers_per_node)
461
+ logging.info(" OMP threads: %d", omp_threads)
462
+ logging.info(" Memory per worker: %s", dask_memory_limit)
463
+ logging.info(" Threads per worker: %d", threads_per_worker)
464
+
465
+ # Cache the result
466
+ self._auto_compute_cache = {
467
+ "omp_threads": omp_threads,
468
+ "dask_workers_per_node": workers_per_node,
469
+ "dask_threads_per_worker": threads_per_worker,
470
+ "dask_memory_limit": dask_memory_limit,
471
+ }
472
+
473
+ return self._auto_compute_cache
474
+
475
+ @property
476
+ def omp_threads(self):
477
+ """Return number of OMP threads as string."""
478
+ if self.auto_compute_params:
479
+ return str(self._get_auto_compute_params()["omp_threads"])
480
+ return str(self.data.get("processing", {}).get("omp_threads", 1))
481
+
482
+ @property
483
+ def dask_workers_per_node(self):
484
+ """Return number of Dask workers per node."""
485
+ if self.auto_compute_params:
486
+ return self._get_auto_compute_params()["dask_workers_per_node"]
487
+ return self.data.get("processing", {}).get("dask_workers_per_node", 1)
488
+
489
+ @property
490
+ def dask_threads_per_worker(self):
491
+ """Return number of threads per Dask worker."""
492
+ if self.auto_compute_params:
493
+ return self._get_auto_compute_params()["dask_threads_per_worker"]
494
+ return self.data.get("processing", {}).get("dask_threads_per_worker", 1)
495
+
496
+ @property
497
+ def dask_memory_limit(self):
498
+ """Return memory limit per Dask worker."""
499
+ if self.auto_compute_params:
500
+ return self._get_auto_compute_params()["dask_memory_limit"]
501
+ return self.data.get("processing", {}).get("dask_memory_limit", "4GB")
502
+
503
+ @property
504
+ def peak_finder(self):
505
+ """Return peak finder method (converted to numeric code)."""
506
+ peak_finder = self.data.get("instantaneous_piv", {}).get("peak_finder", "gauss3").lower()
507
+ if peak_finder == "gauss3":
508
+ return 3
509
+ elif peak_finder == "gauss4":
510
+ return 4
511
+ elif peak_finder == "gauss5":
512
+ return 5
513
+ elif peak_finder == "gauss6":
514
+ return 6
515
+ else:
516
+ raise ValueError(
517
+ f"Invalid peak_finder: {peak_finder}. Must be 'gauss3', 'gauss4', 'gauss5', or 'gauss6'."
518
+ )
519
+
520
+ @property
521
+ def ensemble_piv(self):
522
+ """Return True if ensemble PIV is enabled."""
523
+ return self.data.get("processing", {}).get("ensemble", False)
524
+
525
+ @property
526
+ def outlier_detection_enabled(self):
527
+ """Return True if outlier detection is enabled."""
528
+ return self.data.get("outlier_detection", {}).get("enabled", True)
529
+
530
+ @property
531
+ def outlier_detection_methods(self):
532
+ """Return list of outlier detection methods with their parameters."""
533
+ return self.data.get("outlier_detection", {}).get("methods", [])
534
+
535
+ @property
536
+ def infilling_mid_pass(self):
537
+ """Return mid-pass infilling configuration."""
538
+ return self.data.get("infilling", {}).get("mid_pass", {
539
+ "method": "local_median",
540
+ "parameters": {"ksize": 3}
541
+ })
542
+
543
+ @property
544
+ def infilling_final_pass(self):
545
+ """Return final-pass infilling configuration."""
546
+ return self.data.get("infilling", {}).get("final_pass", {
547
+ "enabled": True,
548
+ "method": "local_median",
549
+ "parameters": {"ksize": 3}
550
+ })
551
+
552
+ @property
553
+ def secondary_peak(self):
554
+ """Return True if secondary peak detection is enabled."""
555
+ return self.data.get("instantaneous_piv", {}).get("secondary_peak", False)
556
+
557
+ # --- Logging properties ---
558
+ @property
559
+ def log_file(self) -> str:
560
+ """Return log file path."""
561
+ return self.data.get("logging", {}).get("file", "pypiv.log")
562
+
563
+ @property
564
+ def log_level(self) -> str:
565
+ """Return log level as string."""
566
+ return self.data.get("logging", {}).get("level", "INFO").upper()
567
+
568
+ @property
569
+ def log_console(self) -> bool:
570
+ """Return True if console logging is enabled."""
571
+ return self.data.get("logging", {}).get("console", True)
572
+
573
+ def _setup_logging(self):
574
+ """Setup logging based on configuration. Only runs once globally."""
575
+ global _LOGGING_INITIALIZED
576
+
577
+ if _LOGGING_INITIALIZED:
578
+ return
579
+
580
+ _LOGGING_INITIALIZED = True
581
+
582
+ log_level = getattr(logging, self.log_level, logging.INFO)
583
+
584
+ # Get root logger
585
+ root_logger = logging.getLogger()
586
+ root_logger.setLevel(log_level)
587
+
588
+ # Clear any existing handlers to avoid duplicates
589
+ root_logger.handlers.clear()
590
+
591
+ # Add file handler
592
+ file_handler = logging.FileHandler(self.log_file)
593
+ file_handler.setLevel(log_level)
594
+ file_formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
595
+ file_handler.setFormatter(file_formatter)
596
+ root_logger.addHandler(file_handler)
597
+
598
+ # Add console handler if requested
599
+ if self.log_console:
600
+ console_handler = logging.StreamHandler()
601
+ console_handler.setLevel(log_level)
602
+ console_formatter = logging.Formatter(
603
+ "%(asctime)s [%(levelname)s] %(message)s"
604
+ )
605
+ console_handler.setFormatter(console_formatter)
606
+ root_logger.addHandler(console_handler)
607
+
608
+ logging.info(
609
+ "Logging initialized. Level: %s, File: %s", self.log_level, self.log_file
610
+ )
611
+
612
+ @property
613
+ def image_dtype(self):
614
+ """Return image data type as numpy dtype."""
615
+ import numpy as np
616
+ dtype_str = self.data.get("images", {}).get("dtype", "uint16")
617
+ return np.dtype(dtype_str)
618
+
619
+ # --- Masking properties ---
620
+ @property
621
+ def masking_enabled(self):
622
+ """Return whether masking is enabled."""
623
+ return self.data.get("masking", {}).get("enabled", False)
624
+
625
+ @property
626
+ def mask_file_pattern(self):
627
+ """Return mask filename pattern. Default 'mask_Cam%d.mat'."""
628
+ return self.data.get("masking", {}).get("mask_file_pattern", "mask_Cam%d.mat")
629
+
630
+ @property
631
+ def mask_mode(self):
632
+ """
633
+ Return masking mode: 'file' or 'rectangular'.
634
+
635
+ Returns
636
+ -------
637
+ str
638
+ 'file' to load mask from .mat file, 'rectangular' for edge masking
639
+ """
640
+ return self.data.get("masking", {}).get("mode", "file")
641
+
642
+ @property
643
+ def mask_rectangular_settings(self):
644
+ """
645
+ Return rectangular masking settings (pixels to mask from each edge).
646
+
647
+ Returns
648
+ -------
649
+ dict
650
+ Dictionary with keys: top, bottom, left, right (all in pixels)
651
+ """
652
+ default = {"top": 0, "bottom": 0, "left": 0, "right": 0}
653
+ return self.data.get("masking", {}).get("rectangular", default)
654
+
655
+ @property
656
+ def mask_threshold(self):
657
+ """
658
+ Return mask threshold for vector masking.
659
+
660
+ This threshold determines when a vector is masked based on the fraction
661
+ of masked pixels within its interrogation window:
662
+ - 0.0: mask vector if any pixel in window is masked
663
+ - 0.5: mask vector if >50% of pixels in window are masked (default)
664
+ - 1.0: only mask vector if all pixels in window are masked
665
+
666
+ Returns
667
+ -------
668
+ float
669
+ Threshold value between 0.0 and 1.0
670
+ """
671
+ return self.data.get("masking", {}).get("mask_threshold", 0.5)
672
+
673
+ def get_mask_path(self, camera_num: int, source_path_idx: int = 0):
674
+ """
675
+ Get the full path to the mask file for a given camera.
676
+
677
+ Parameters
678
+ ----------
679
+ camera_num : int
680
+ Camera number (e.g., 1 for Cam1)
681
+ source_path_idx : int, optional
682
+ Index into source_paths list, defaults to 0
683
+
684
+ Returns
685
+ -------
686
+ Path
687
+ Full path to the mask .mat file
688
+ """
689
+ mask_filename = self.mask_file_pattern % camera_num
690
+ return self.source_paths[source_path_idx] / mask_filename
691
+
692
+
693
+ def get_config(refresh: bool = False) -> Config:
694
+ """Return shared Config instance. Pass refresh=True to reload from disk."""
695
+ global _CONFIG
696
+ if refresh or _CONFIG is None:
697
+ _CONFIG = Config()
698
+ return _CONFIG
699
+
700
+
701
+ def reload_config() -> Config:
702
+ """Explicit convenience to force reload."""
703
+ return get_config(refresh=True)
File without changes