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
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Tuple, Optional, List
|
|
3
|
+
from loguru import logger
|
|
4
|
+
|
|
5
|
+
import dask
|
|
6
|
+
import dask.array as da
|
|
7
|
+
import numpy as np
|
|
8
|
+
from dask.delayed import Delayed
|
|
9
|
+
from scipy.ndimage import convolve
|
|
10
|
+
|
|
11
|
+
from pivtools_core.config import Config
|
|
12
|
+
from pivtools_core.vector_loading import read_mask_from_mat
|
|
13
|
+
|
|
14
|
+
# Import all readers to register them
|
|
15
|
+
from pivtools_core.image_handling.readers import get_reader
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from line_profiler import profile
|
|
19
|
+
except ImportError:
|
|
20
|
+
profile = lambda f: f
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_image(file_path: str, **kwargs) -> np.ndarray:
|
|
24
|
+
"""Read an image file using appropriate reader based on file extension.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
file_path (str): Path to the image file
|
|
28
|
+
**kwargs: Additional arguments passed to the specific reader
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
np.ndarray: The image data
|
|
32
|
+
"""
|
|
33
|
+
reader_func = get_reader(file_path)
|
|
34
|
+
return reader_func(file_path, **kwargs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_pair(idx: int, camera_path: Path, camera: int, config: Config) -> np.ndarray:
|
|
38
|
+
"""Read a pair of images (A and B frames).
|
|
39
|
+
|
|
40
|
+
This function handles three main file organization strategies:
|
|
41
|
+
|
|
42
|
+
1. Multi-camera container files (.set, .im7):
|
|
43
|
+
- All cameras stored in ONE file per time instance
|
|
44
|
+
- .set: source_dir/xxx.set contains all cameras and all time instances
|
|
45
|
+
- .im7: source_dir/B00001.im7 contains all cameras for time instance 1
|
|
46
|
+
- No camera subdirectories (Cam1/, Cam2/, etc.)
|
|
47
|
+
|
|
48
|
+
2. Camera-specific directories with standard formats (.tif, .png, .jpg):
|
|
49
|
+
- Organized as: source_dir/Cam1/00001.tif, source_dir/Cam2/00001.tif
|
|
50
|
+
- Each camera has its own subdirectory
|
|
51
|
+
|
|
52
|
+
3. Time-resolved formats (.cine):
|
|
53
|
+
- Camera-specific directories with video files
|
|
54
|
+
- Organized as: source_dir/Cam1/recording.cine
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
idx (int): Index of the image pair to read (1-based)
|
|
58
|
+
camera_path (Path): Path to camera directory or source directory (for .set/.im7)
|
|
59
|
+
camera (int): Camera number (1-based)
|
|
60
|
+
config (Config): Configuration object
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
np.ndarray: Stacked array of shape (2, H, W) containing frame A and B
|
|
64
|
+
"""
|
|
65
|
+
# Get image format - handle both time-resolved (single format) and non-time-resolved (A/B pair)
|
|
66
|
+
image_format = config.image_format
|
|
67
|
+
|
|
68
|
+
# Special handling for .set and .im7 files (all cameras in one file per time instance)
|
|
69
|
+
# Check if image_format (string or tuple) contains '.set' or '.im7'
|
|
70
|
+
if isinstance(image_format, tuple):
|
|
71
|
+
format_str = image_format[0] # Check first element for tuple
|
|
72
|
+
else:
|
|
73
|
+
format_str = image_format # Single string for time-resolved
|
|
74
|
+
|
|
75
|
+
if '.set' in str(format_str):
|
|
76
|
+
# For .set files, camera_path is the source directory
|
|
77
|
+
set_file_path = camera_path / format_str
|
|
78
|
+
return read_image(str(set_file_path), camera_no=camera, im_no=idx)
|
|
79
|
+
|
|
80
|
+
if '.im7' in str(format_str):
|
|
81
|
+
# For .im7 files, camera_path is the source directory
|
|
82
|
+
# Each .im7 file contains all cameras for one time instance
|
|
83
|
+
im7_file_path = camera_path / (format_str % idx)
|
|
84
|
+
return read_image(str(im7_file_path), camera_no=camera)
|
|
85
|
+
|
|
86
|
+
if isinstance(image_format, tuple):
|
|
87
|
+
# Non-time-resolved: separate A and B formats
|
|
88
|
+
image_format_A, image_format_B = image_format
|
|
89
|
+
file_paths = [
|
|
90
|
+
camera_path / (image_format_A % idx),
|
|
91
|
+
camera_path / (image_format_B % idx),
|
|
92
|
+
]
|
|
93
|
+
else:
|
|
94
|
+
file_paths = [
|
|
95
|
+
camera_path / (image_format % idx),
|
|
96
|
+
camera_path / (image_format % (idx + 1)),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
# Check if it's a proprietary format that reads pairs natively
|
|
100
|
+
file_ext = Path(file_paths[0]).suffix.lower()
|
|
101
|
+
if file_ext == ".cine":
|
|
102
|
+
return read_image(str(file_paths[0]), idx=idx - 1, frames=2)
|
|
103
|
+
else:
|
|
104
|
+
# Read individual frames (e.g., .tif, .png, .jpg)
|
|
105
|
+
frame_a = read_image(str(file_paths[0]))
|
|
106
|
+
frame_b = read_image(str(file_paths[1]))
|
|
107
|
+
return np.stack([frame_a, frame_b], axis=0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def delayed_image_pair(idx: int, camera_path: Path, camera: int, config: Config) -> Delayed:
|
|
111
|
+
"""Create a delayed task to read a pair of images.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
idx (int): Index of the image pair to read
|
|
115
|
+
camera_path (Path): Path to camera directory or set file
|
|
116
|
+
camera (int): Camera number
|
|
117
|
+
config (Config): Configuration object
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Delayed: A delayed task representing the image pair
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
return dask.delayed(read_pair)(idx, camera_path, camera, config)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def to_dask_array(delayed_pair: Delayed, config: Config) -> da.Array:
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
delayed_pair (dask.delayed): _description_
|
|
131
|
+
config (Config): _description_
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
dask.array.Array: _description_
|
|
135
|
+
"""
|
|
136
|
+
arr = dask.array.from_delayed(
|
|
137
|
+
delayed_pair,
|
|
138
|
+
shape=(2, *config.image_shape),
|
|
139
|
+
dtype=config.image_dtype,
|
|
140
|
+
)
|
|
141
|
+
return arr
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@profile
|
|
145
|
+
def load_images(camera: int, config: Config, source: Path = None) -> da.Array:
|
|
146
|
+
"""Load images for a specific camera using pure lazy loading.
|
|
147
|
+
|
|
148
|
+
This function creates one delayed task per image pair. Each task is
|
|
149
|
+
completely independent and only loads when computed on a worker.
|
|
150
|
+
|
|
151
|
+
Memory Efficiency - True Lazy Loading:
|
|
152
|
+
- Creates N delayed objects (~1 KB each) for N images
|
|
153
|
+
- Main process memory: ~N KB (minimal, just task graph)
|
|
154
|
+
- Worker memory: Only 1 image pair at a time (~80 MB)
|
|
155
|
+
- Each worker: load → process → save → free → next
|
|
156
|
+
- Peak worker memory: ~280 MB (1 image + PIV overhead)
|
|
157
|
+
|
|
158
|
+
This is the OPTIMAL Dask pattern:
|
|
159
|
+
- No pre-loading of batches
|
|
160
|
+
- No memory accumulation
|
|
161
|
+
- Workers process images one-by-one
|
|
162
|
+
- Dask scheduler handles distribution naturally
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
camera (int): The camera number.
|
|
166
|
+
config (Config): The configuration object.
|
|
167
|
+
source (Path, optional): The root directory for camera folders.
|
|
168
|
+
If None, uses first source_path from config.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
da.Array: A Dask array containing the loaded image pairs.
|
|
172
|
+
Shape: (num_images, 2, H, W)
|
|
173
|
+
Note: This is a lazy array - no actual image data loaded yet.
|
|
174
|
+
Each element is an independent delayed task.
|
|
175
|
+
"""
|
|
176
|
+
if source is None:
|
|
177
|
+
source = config.source_paths[0]
|
|
178
|
+
|
|
179
|
+
# For .set and .im7 files, there are no camera subdirectories
|
|
180
|
+
# All cameras are stored in a single file per time instance in the source directory
|
|
181
|
+
# File format: source_directory/B00001.im7 (contains all cameras for time instance 1)
|
|
182
|
+
if '.set' in str(config.image_format):
|
|
183
|
+
camera_path = source # No camera subdirectory for set files
|
|
184
|
+
elif '.im7' in str(config.image_format):
|
|
185
|
+
camera_path = source # No camera subdirectory for set files
|
|
186
|
+
else:
|
|
187
|
+
camera_path = source / f"Cam{camera}"
|
|
188
|
+
|
|
189
|
+
num_images = config.num_images
|
|
190
|
+
|
|
191
|
+
logger.info(f"Creating {num_images} delayed tasks for lazy loading (Cam{camera})")
|
|
192
|
+
|
|
193
|
+
# Create one delayed task per image pair (pure lazy loading)
|
|
194
|
+
delayed_image_pairs = [
|
|
195
|
+
delayed_image_pair(idx, camera_path, camera, config)
|
|
196
|
+
for idx in range(1, num_images + 1)
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
# Convert each delayed task to a Dask array
|
|
200
|
+
dask_pairs = [to_dask_array(pair, config) for pair in delayed_image_pairs]
|
|
201
|
+
|
|
202
|
+
# Stack into single array - still lazy, no computation yet!
|
|
203
|
+
pairs_stack = da.stack(dask_pairs, axis=0)
|
|
204
|
+
|
|
205
|
+
logger.info(
|
|
206
|
+
f"Lazy loading complete: {num_images} independent delayed tasks created "
|
|
207
|
+
f"(~{num_images} KB memory footprint)"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return pairs_stack
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def create_rectangular_mask(config: Config) -> np.ndarray:
|
|
214
|
+
"""
|
|
215
|
+
Create a rectangular edge mask based on config settings.
|
|
216
|
+
|
|
217
|
+
Parameters
|
|
218
|
+
----------
|
|
219
|
+
config : Config
|
|
220
|
+
Configuration object containing image shape and rectangular mask settings
|
|
221
|
+
|
|
222
|
+
Returns
|
|
223
|
+
-------
|
|
224
|
+
np.ndarray
|
|
225
|
+
Boolean mask array of shape (H, W) where True = masked region
|
|
226
|
+
"""
|
|
227
|
+
H, W = config.image_shape
|
|
228
|
+
mask = np.zeros((H, W), dtype=bool)
|
|
229
|
+
|
|
230
|
+
rect_settings = config.mask_rectangular_settings
|
|
231
|
+
top = rect_settings.get("top", 0)
|
|
232
|
+
bottom = rect_settings.get("bottom", 0)
|
|
233
|
+
left = rect_settings.get("left", 0)
|
|
234
|
+
right = rect_settings.get("right", 0)
|
|
235
|
+
|
|
236
|
+
# Apply edge masks
|
|
237
|
+
if top > 0:
|
|
238
|
+
mask[:top, :] = True
|
|
239
|
+
if bottom > 0:
|
|
240
|
+
mask[-bottom:, :] = True
|
|
241
|
+
if left > 0:
|
|
242
|
+
mask[:, :left] = True
|
|
243
|
+
if right > 0:
|
|
244
|
+
mask[:, -right:] = True
|
|
245
|
+
|
|
246
|
+
masked_pixels = np.sum(mask)
|
|
247
|
+
total_pixels = mask.size
|
|
248
|
+
mask_fraction = masked_pixels / total_pixels if total_pixels > 0 else 0
|
|
249
|
+
|
|
250
|
+
logger.debug(
|
|
251
|
+
"Created rectangular mask: top={}, bottom={}, left={}, right={} "
|
|
252
|
+
"({}/{:.0f} pixels = {:.1f}%)",
|
|
253
|
+
top, bottom, left, right, masked_pixels, total_pixels, mask_fraction * 100
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return mask
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def load_mask_for_camera(
|
|
260
|
+
camera_num: int, config: Config, source_path_idx: int = 0
|
|
261
|
+
) -> Optional[np.ndarray]:
|
|
262
|
+
"""
|
|
263
|
+
Load or create a mask for a specific camera.
|
|
264
|
+
|
|
265
|
+
The mask is a boolean array of shape (H, W) where True indicates
|
|
266
|
+
regions to mask out (invalid regions).
|
|
267
|
+
|
|
268
|
+
Supports two modes:
|
|
269
|
+
- 'file': Load mask from .mat file (created by Flask masking endpoint)
|
|
270
|
+
- 'rectangular': Create mask from edge pixel specifications
|
|
271
|
+
|
|
272
|
+
Parameters
|
|
273
|
+
----------
|
|
274
|
+
camera_num : int
|
|
275
|
+
Camera number (e.g., 1 for Cam1)
|
|
276
|
+
config : Config
|
|
277
|
+
Configuration object
|
|
278
|
+
source_path_idx : int, optional
|
|
279
|
+
Index into source_paths list, defaults to 0
|
|
280
|
+
|
|
281
|
+
Returns
|
|
282
|
+
-------
|
|
283
|
+
Optional[np.ndarray]
|
|
284
|
+
Boolean mask array of shape (H, W) where True = masked region,
|
|
285
|
+
or None if masking is disabled or mask cannot be loaded
|
|
286
|
+
"""
|
|
287
|
+
if not config.masking_enabled:
|
|
288
|
+
logger.debug("Masking is disabled in config")
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
mask_mode = config.mask_mode
|
|
292
|
+
|
|
293
|
+
# Rectangular mode: create mask from edge specifications
|
|
294
|
+
if mask_mode == "rectangular":
|
|
295
|
+
logger.debug("Using rectangular edge masking")
|
|
296
|
+
return create_rectangular_mask(config)
|
|
297
|
+
|
|
298
|
+
# File mode: load from .mat file
|
|
299
|
+
elif mask_mode == "file":
|
|
300
|
+
try:
|
|
301
|
+
mask_path = config.get_mask_path(camera_num, source_path_idx)
|
|
302
|
+
|
|
303
|
+
if not mask_path.exists():
|
|
304
|
+
logger.warning(
|
|
305
|
+
"Mask file not found for Cam{} at {}. Proceeding without mask.",
|
|
306
|
+
camera_num, mask_path
|
|
307
|
+
)
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
logger.debug("Loading mask for Cam{} from {}", camera_num, mask_path)
|
|
311
|
+
mask, polygons = read_mask_from_mat(str(mask_path))
|
|
312
|
+
|
|
313
|
+
# Ensure mask is boolean
|
|
314
|
+
mask = np.asarray(mask, dtype=bool)
|
|
315
|
+
|
|
316
|
+
# Log mask statistics
|
|
317
|
+
masked_pixels = np.sum(mask)
|
|
318
|
+
total_pixels = mask.size
|
|
319
|
+
mask_fraction = masked_pixels / total_pixels if total_pixels > 0 else 0
|
|
320
|
+
|
|
321
|
+
logger.debug(
|
|
322
|
+
"Mask loaded: {}/{} pixels masked ({:.1f}%)",
|
|
323
|
+
masked_pixels, total_pixels, mask_fraction * 100
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return mask
|
|
327
|
+
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logger.error(
|
|
330
|
+
"Failed to load mask for Cam{}: {}. Proceeding without mask.",
|
|
331
|
+
camera_num, e
|
|
332
|
+
)
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
else:
|
|
336
|
+
logger.warning(
|
|
337
|
+
"Unknown mask mode '{}'. Must be 'file' or 'rectangular'. "
|
|
338
|
+
"Proceeding without mask.", mask_mode
|
|
339
|
+
)
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
@profile
|
|
343
|
+
def compute_vector_mask(
|
|
344
|
+
pixel_mask: np.ndarray,
|
|
345
|
+
config: Config,
|
|
346
|
+
) -> List[np.ndarray]:
|
|
347
|
+
"""
|
|
348
|
+
Compute binary vector masks for each PIV pass based on pixel mask.
|
|
349
|
+
|
|
350
|
+
This function is analogous to MATLAB's compute_b_mask. It convolves the
|
|
351
|
+
pixel mask with box filters matching the interrogation window size for
|
|
352
|
+
each pass, then interpolates at window center positions and applies a
|
|
353
|
+
threshold to determine which vectors should be masked.
|
|
354
|
+
|
|
355
|
+
The process:
|
|
356
|
+
1. For each pass, get the window size and overlap
|
|
357
|
+
2. Compute window center positions (same as PIV does)
|
|
358
|
+
3. Convolve pixel mask with box filter of window size
|
|
359
|
+
4. Interpolate the filtered mask at window centers
|
|
360
|
+
5. Apply threshold to create binary mask (True = masked)
|
|
361
|
+
|
|
362
|
+
Parameters
|
|
363
|
+
----------
|
|
364
|
+
pixel_mask : np.ndarray
|
|
365
|
+
Boolean pixel mask of shape (H, W) where True indicates masked regions
|
|
366
|
+
config : Config
|
|
367
|
+
Configuration object containing window sizes, overlap, and mask threshold
|
|
368
|
+
|
|
369
|
+
Returns
|
|
370
|
+
-------
|
|
371
|
+
List[np.ndarray]
|
|
372
|
+
List of binary masks, one per pass. Each mask has shape (n_win_y, n_win_x)
|
|
373
|
+
where True indicates this vector should be masked (set to 0/NaN)
|
|
374
|
+
|
|
375
|
+
Notes
|
|
376
|
+
-----
|
|
377
|
+
The mask threshold (config.mask_threshold) determines the sensitivity:
|
|
378
|
+
- 0.0: mask vector if any pixel in window is masked
|
|
379
|
+
- 0.5: mask vector if >50% of pixels in window are masked
|
|
380
|
+
- 1.0: only mask vector if all pixels in window are masked
|
|
381
|
+
|
|
382
|
+
A typical value is 0.5, meaning vectors are masked if more than half
|
|
383
|
+
of the interrogation window overlaps with masked regions.
|
|
384
|
+
"""
|
|
385
|
+
if pixel_mask is None:
|
|
386
|
+
return []
|
|
387
|
+
|
|
388
|
+
# Ensure mask is float for convolution
|
|
389
|
+
im_mask = pixel_mask.astype(np.float32)
|
|
390
|
+
H, W = im_mask.shape
|
|
391
|
+
|
|
392
|
+
vector_masks = []
|
|
393
|
+
threshold = config.mask_threshold
|
|
394
|
+
|
|
395
|
+
for pass_idx in range(config.num_passes):
|
|
396
|
+
# Get window size and overlap for this pass
|
|
397
|
+
# config.window_sizes is in (H, W) format = (win_y, win_x)
|
|
398
|
+
win_y, win_x = config.window_sizes[pass_idx]
|
|
399
|
+
overlap = config.overlap[pass_idx]
|
|
400
|
+
|
|
401
|
+
# Calculate window spacing
|
|
402
|
+
win_spacing_x = round((1 - overlap / 100) * win_x)
|
|
403
|
+
win_spacing_y = round((1 - overlap / 100) * win_y)
|
|
404
|
+
|
|
405
|
+
# Calculate window center positions (matching PIV computation exactly)
|
|
406
|
+
# For a 128-pixel window (indices 0-127), center is at 63.5
|
|
407
|
+
# First window center in X (width dimension) - 0-based array indexing
|
|
408
|
+
first_ctr_x = (win_x - 1) / 2.0 # For 128: (127)/2 = 63.5
|
|
409
|
+
# Last possible window center in X
|
|
410
|
+
last_ctr_x = W - (win_x + 1) / 2.0 # For W=4872, win=128: 4872 - 64.5 = 4807.5
|
|
411
|
+
|
|
412
|
+
# First window center in Y (height dimension) - 0-based array indexing
|
|
413
|
+
first_ctr_y = (win_y - 1) / 2.0
|
|
414
|
+
# Last possible window center in Y
|
|
415
|
+
last_ctr_y = H - (win_y + 1) / 2.0
|
|
416
|
+
|
|
417
|
+
# Calculate number of windows
|
|
418
|
+
n_win_x = int(np.floor((last_ctr_x - first_ctr_x) / win_spacing_x)) + 1
|
|
419
|
+
n_win_y = int(np.floor((last_ctr_y - first_ctr_y) / win_spacing_y)) + 1
|
|
420
|
+
|
|
421
|
+
# Ensure at least one window
|
|
422
|
+
n_win_x = max(1, n_win_x)
|
|
423
|
+
n_win_y = max(1, n_win_y)
|
|
424
|
+
|
|
425
|
+
# Window center positions using linspace (matches MATLAB's colon operator)
|
|
426
|
+
win_ctrs_x = np.linspace(
|
|
427
|
+
first_ctr_x, first_ctr_x + win_spacing_x * (n_win_x - 1), n_win_x
|
|
428
|
+
)
|
|
429
|
+
win_ctrs_y = np.linspace(
|
|
430
|
+
first_ctr_y, first_ctr_y + win_spacing_y * (n_win_y - 1), n_win_y
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
box_filter_y = np.ones((win_y, 1), dtype=np.float32) / win_y
|
|
434
|
+
f_mask = convolve(im_mask, box_filter_y, mode='constant', cval=0.0)
|
|
435
|
+
|
|
436
|
+
# Convolve along x (columns)
|
|
437
|
+
box_filter_x = np.ones((1, win_x), dtype=np.float32) / win_x
|
|
438
|
+
f_mask = convolve(f_mask, box_filter_x, mode='constant', cval=0.0)
|
|
439
|
+
|
|
440
|
+
# Interpolate at window center positions using nearest neighbor
|
|
441
|
+
# Create grid of window centers
|
|
442
|
+
win_y_grid, win_x_grid = np.meshgrid(win_ctrs_y, win_ctrs_x, indexing='ij')
|
|
443
|
+
|
|
444
|
+
# Convert to integer indices for nearest neighbor
|
|
445
|
+
win_y_idx = np.clip(np.round(win_y_grid).astype(int), 0, H - 1)
|
|
446
|
+
win_x_idx = np.clip(np.round(win_x_grid).astype(int), 0, W - 1)
|
|
447
|
+
|
|
448
|
+
# Sample the filtered mask
|
|
449
|
+
b_mask_pass = f_mask[win_y_idx, win_x_idx] > threshold
|
|
450
|
+
|
|
451
|
+
vector_masks.append(b_mask_pass)
|
|
452
|
+
|
|
453
|
+
# Log statistics for this pass (debug level only)
|
|
454
|
+
masked_vectors = np.sum(b_mask_pass)
|
|
455
|
+
total_vectors = b_mask_pass.size
|
|
456
|
+
mask_fraction = masked_vectors / total_vectors if total_vectors > 0 else 0
|
|
457
|
+
|
|
458
|
+
logger.debug(
|
|
459
|
+
"Pass {}: {}/{} vectors masked ({:.1f}%), window size: ({}, {})",
|
|
460
|
+
pass_idx + 1, masked_vectors, total_vectors,
|
|
461
|
+
mask_fraction * 100, win_y, win_x
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
return vector_masks
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Callable, Dict, List
|
|
3
|
+
|
|
4
|
+
from .generic_readers import read_png_jpeg, read_raw, read_tiff
|
|
5
|
+
|
|
6
|
+
# Registry for image readers
|
|
7
|
+
_READERS: Dict[str, Callable] = {}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_reader(extensions: List[str], reader_func: Callable):
|
|
11
|
+
"""Register an image reader for specific file extensions.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
extensions: List of file extensions (e.g., ['.tiff', '.tif'])
|
|
15
|
+
reader_func: Function that takes file_path and returns np.ndarray
|
|
16
|
+
"""
|
|
17
|
+
for ext in extensions:
|
|
18
|
+
_READERS[ext.lower()] = reader_func
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_reader(file_path: str) -> Callable:
|
|
22
|
+
"""Get appropriate reader for a file based on its extension.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
file_path: Path to the image file
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Reader function for the file type
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: If no reader is registered for the file type
|
|
32
|
+
"""
|
|
33
|
+
ext = Path(file_path).suffix.lower()
|
|
34
|
+
if ext not in _READERS:
|
|
35
|
+
raise ValueError(f"No reader registered for file type: {ext}")
|
|
36
|
+
return _READERS[ext]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def list_supported_formats() -> List[str]:
|
|
40
|
+
"""List all supported file formats."""
|
|
41
|
+
return list(_READERS.keys())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Import LaVision readers after functions are defined to avoid circular imports
|
|
45
|
+
from .lavision_reader import read_lavision_pair, read_lavision_ims_pair
|
|
46
|
+
|
|
47
|
+
# Register only lowercase variants for robustness
|
|
48
|
+
register_reader([".tiff", ".tif"], read_tiff)
|
|
49
|
+
register_reader([".png"], read_png_jpeg)
|
|
50
|
+
register_reader([".jpg", ".jpeg"], read_png_jpeg)
|
|
51
|
+
register_reader([".raw", ".cr2", ".nef", ".arw"], read_raw)
|
|
52
|
+
register_reader([".im7"], read_lavision_pair)
|
|
53
|
+
register_reader([".set"], read_lavision_ims_pair)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def read_tiff(file_path: str) -> np.ndarray:
|
|
7
|
+
"""Read TIFF images using tifffile."""
|
|
8
|
+
import tifffile
|
|
9
|
+
|
|
10
|
+
if not os.path.exists(file_path):
|
|
11
|
+
raise FileNotFoundError(f"Image file not found: {file_path}")
|
|
12
|
+
return tifffile.imread(file_path)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def read_png_jpeg(file_path: str) -> np.ndarray:
|
|
16
|
+
"""Read PNG/JPEG images using PIL or opencv."""
|
|
17
|
+
if not os.path.exists(file_path):
|
|
18
|
+
raise FileNotFoundError(f"Image file not found: {file_path}")
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from PIL import Image
|
|
22
|
+
|
|
23
|
+
img = Image.open(file_path)
|
|
24
|
+
return np.array(img)
|
|
25
|
+
except ImportError:
|
|
26
|
+
try:
|
|
27
|
+
import cv2
|
|
28
|
+
|
|
29
|
+
img = cv2.imread(file_path, cv2.IMREAD_UNCHANGED)
|
|
30
|
+
if img is None:
|
|
31
|
+
raise FileNotFoundError(f"Image file could not be read: {file_path}")
|
|
32
|
+
return img
|
|
33
|
+
except ImportError:
|
|
34
|
+
raise ImportError(
|
|
35
|
+
"Either PIL or opencv-python is required for PNG/JPEG support"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_raw(file_path: str) -> np.ndarray:
|
|
40
|
+
"""Read RAW images using rawpy."""
|
|
41
|
+
if not os.path.exists(file_path):
|
|
42
|
+
raise FileNotFoundError(f"Image file not found: {file_path}")
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
import rawpy
|
|
46
|
+
|
|
47
|
+
with rawpy.imread(file_path) as raw:
|
|
48
|
+
return raw.postprocess()
|
|
49
|
+
except ImportError:
|
|
50
|
+
raise ImportError("rawpy is required for RAW image support")
|