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
@@ -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")