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.
- pivtools-0.1.3.dist-info/METADATA +222 -0
- pivtools-0.1.3.dist-info/RECORD +127 -0
- pivtools-0.1.3.dist-info/WHEEL +5 -0
- pivtools-0.1.3.dist-info/entry_points.txt +3 -0
- pivtools-0.1.3.dist-info/top_level.txt +3 -0
- pivtools_cli/__init__.py +5 -0
- pivtools_cli/_build_marker.c +25 -0
- pivtools_cli/_build_marker.cp311-win_amd64.pyd +0 -0
- pivtools_cli/cli.py +225 -0
- pivtools_cli/example.py +139 -0
- pivtools_cli/lib/PIV_2d_cross_correlate.c +334 -0
- pivtools_cli/lib/PIV_2d_cross_correlate.h +22 -0
- pivtools_cli/lib/common.h +36 -0
- pivtools_cli/lib/interp2custom.c +146 -0
- pivtools_cli/lib/interp2custom.h +48 -0
- pivtools_cli/lib/peak_locate_gsl.c +711 -0
- pivtools_cli/lib/peak_locate_gsl.h +40 -0
- pivtools_cli/lib/peak_locate_gsl_print.c +736 -0
- pivtools_cli/lib/peak_locate_lm.c +751 -0
- pivtools_cli/lib/peak_locate_lm.h +27 -0
- pivtools_cli/lib/xcorr.c +342 -0
- pivtools_cli/lib/xcorr.h +31 -0
- pivtools_cli/lib/xcorr_cache.c +78 -0
- pivtools_cli/lib/xcorr_cache.h +26 -0
- pivtools_cli/piv/interp2custom/interp2custom.py +69 -0
- pivtools_cli/piv/piv.py +240 -0
- pivtools_cli/piv/piv_backend/base.py +825 -0
- pivtools_cli/piv/piv_backend/cpu_instantaneous.py +1005 -0
- pivtools_cli/piv/piv_backend/factory.py +28 -0
- pivtools_cli/piv/piv_backend/gpu_instantaneous.py +15 -0
- pivtools_cli/piv/piv_backend/infilling.py +445 -0
- pivtools_cli/piv/piv_backend/outlier_detection.py +306 -0
- pivtools_cli/piv/piv_backend/profile_cpu_instantaneous.py +230 -0
- pivtools_cli/piv/piv_result.py +40 -0
- pivtools_cli/piv/save_results.py +342 -0
- pivtools_cli/piv_cluster/cluster.py +108 -0
- pivtools_cli/preprocessing/filters.py +399 -0
- pivtools_cli/preprocessing/preprocess.py +79 -0
- pivtools_cli/tests/helpers.py +107 -0
- pivtools_cli/tests/instantaneous_piv/test_piv_integration.py +167 -0
- pivtools_cli/tests/instantaneous_piv/test_piv_integration_multi.py +553 -0
- pivtools_cli/tests/preprocessing/test_filters.py +41 -0
- pivtools_core/__init__.py +5 -0
- pivtools_core/config.py +703 -0
- pivtools_core/config.yaml +135 -0
- pivtools_core/image_handling/__init__.py +0 -0
- pivtools_core/image_handling/load_images.py +464 -0
- pivtools_core/image_handling/readers/__init__.py +53 -0
- pivtools_core/image_handling/readers/generic_readers.py +50 -0
- pivtools_core/image_handling/readers/lavision_reader.py +190 -0
- pivtools_core/image_handling/readers/registry.py +24 -0
- pivtools_core/paths.py +49 -0
- pivtools_core/vector_loading.py +248 -0
- pivtools_gui/__init__.py +3 -0
- pivtools_gui/app.py +687 -0
- pivtools_gui/calibration/__init__.py +0 -0
- pivtools_gui/calibration/app/__init__.py +0 -0
- pivtools_gui/calibration/app/views.py +1186 -0
- pivtools_gui/calibration/calibration_planar/planar_calibration_production.py +570 -0
- pivtools_gui/calibration/vector_calibration_production.py +544 -0
- pivtools_gui/config.py +703 -0
- pivtools_gui/image_handling/__init__.py +0 -0
- pivtools_gui/image_handling/load_images.py +464 -0
- pivtools_gui/image_handling/readers/__init__.py +53 -0
- pivtools_gui/image_handling/readers/generic_readers.py +50 -0
- pivtools_gui/image_handling/readers/lavision_reader.py +190 -0
- pivtools_gui/image_handling/readers/registry.py +24 -0
- pivtools_gui/masking/__init__.py +0 -0
- pivtools_gui/masking/app/__init__.py +0 -0
- pivtools_gui/masking/app/views.py +123 -0
- pivtools_gui/paths.py +49 -0
- pivtools_gui/piv_runner.py +261 -0
- pivtools_gui/pivtools.py +58 -0
- pivtools_gui/plotting/__init__.py +0 -0
- pivtools_gui/plotting/app/__init__.py +0 -0
- pivtools_gui/plotting/app/views.py +1671 -0
- pivtools_gui/plotting/plot_maker.py +220 -0
- pivtools_gui/post_processing/POD/__init__.py +0 -0
- pivtools_gui/post_processing/POD/app/__init__.py +0 -0
- pivtools_gui/post_processing/POD/app/views.py +647 -0
- pivtools_gui/post_processing/POD/pod_decompose.py +979 -0
- pivtools_gui/post_processing/POD/views.py +1096 -0
- pivtools_gui/post_processing/__init__.py +0 -0
- pivtools_gui/static/404.html +1 -0
- pivtools_gui/static/_next/static/chunks/117-d5793c8e79de5511.js +2 -0
- pivtools_gui/static/_next/static/chunks/484-cfa8b9348ce4f00e.js +1 -0
- pivtools_gui/static/_next/static/chunks/869-320a6b9bdafbb6d3.js +1 -0
- pivtools_gui/static/_next/static/chunks/app/_not-found/page-12f067ceb7415e55.js +1 -0
- pivtools_gui/static/_next/static/chunks/app/layout-b907d5f31ac82e9d.js +1 -0
- pivtools_gui/static/_next/static/chunks/app/page-334cc4e8444cde2f.js +1 -0
- pivtools_gui/static/_next/static/chunks/fd9d1056-ad15f396ddf9b7e5.js +1 -0
- pivtools_gui/static/_next/static/chunks/framework-f66176bb897dc684.js +1 -0
- pivtools_gui/static/_next/static/chunks/main-a1b3ced4d5f6d998.js +1 -0
- pivtools_gui/static/_next/static/chunks/main-app-8a63c6f5e7baee11.js +1 -0
- pivtools_gui/static/_next/static/chunks/pages/_app-72b849fbd24ac258.js +1 -0
- pivtools_gui/static/_next/static/chunks/pages/_error-7ba65e1336b92748.js +1 -0
- pivtools_gui/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- pivtools_gui/static/_next/static/chunks/webpack-4a8ca7c99e9bb3d8.js +1 -0
- pivtools_gui/static/_next/static/css/7d3f2337d7ea12a5.css +3 -0
- pivtools_gui/static/_next/static/vQeR20OUdSSKlK4vukC4q/_buildManifest.js +1 -0
- pivtools_gui/static/_next/static/vQeR20OUdSSKlK4vukC4q/_ssgManifest.js +1 -0
- pivtools_gui/static/file.svg +1 -0
- pivtools_gui/static/globe.svg +1 -0
- pivtools_gui/static/grid.svg +8 -0
- pivtools_gui/static/index.html +1 -0
- pivtools_gui/static/index.txt +8 -0
- pivtools_gui/static/next.svg +1 -0
- pivtools_gui/static/vercel.svg +1 -0
- pivtools_gui/static/window.svg +1 -0
- pivtools_gui/stereo_reconstruction/__init__.py +0 -0
- pivtools_gui/stereo_reconstruction/app/__init__.py +0 -0
- pivtools_gui/stereo_reconstruction/app/views.py +1985 -0
- pivtools_gui/stereo_reconstruction/stereo_calibration_production.py +606 -0
- pivtools_gui/stereo_reconstruction/stereo_reconstruction_production.py +544 -0
- pivtools_gui/utils.py +63 -0
- pivtools_gui/vector_loading.py +248 -0
- pivtools_gui/vector_merging/__init__.py +1 -0
- pivtools_gui/vector_merging/app/__init__.py +1 -0
- pivtools_gui/vector_merging/app/views.py +759 -0
- pivtools_gui/vector_statistics/app/__init__.py +1 -0
- pivtools_gui/vector_statistics/app/views.py +710 -0
- pivtools_gui/vector_statistics/ensemble_statistics.py +49 -0
- pivtools_gui/vector_statistics/instantaneous_statistics.py +311 -0
- pivtools_gui/video_maker/__init__.py +0 -0
- pivtools_gui/video_maker/app/__init__.py +0 -0
- pivtools_gui/video_maker/app/views.py +436 -0
- 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
|