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,190 @@
1
+ import os
2
+ from typing import Optional
3
+ from loguru import logger
4
+ import numpy as np
5
+
6
+ from . import register_reader
7
+
8
+
9
+ def read_lavision_im7(
10
+ file_path: str, camera_no: int = 1, frames: int = 2
11
+ ) -> np.ndarray:
12
+ """Read LaVision .im7 files.
13
+
14
+ LaVision .im7 files store all cameras in a single file per time instance.
15
+ Each file contains frame pairs (A and B) for all cameras.
16
+
17
+ Structure: For N cameras, the file contains 2*N frames in sequence:
18
+ - Frames 0,1: Camera 1, frames A and B
19
+ - Frames 2,3: Camera 2, frames A and B
20
+ - etc.
21
+
22
+ Args:
23
+ file_path: Path to the .im7 file
24
+ camera_no: Camera number (1-based indexing)
25
+ frames: Number of frames to read (typically 2 for PIV)
26
+
27
+ Returns:
28
+ np.ndarray: Array of shape (frames, H, W) containing the image data
29
+ """
30
+ import sys
31
+ if sys.platform == "darwin":
32
+ raise ImportError(
33
+ "lvpyio is not shipped or supported on macOS (darwin). Please use a supported platform for LaVision .im7 reading."
34
+ )
35
+ try:
36
+ import lvpyio as lv
37
+ except ImportError:
38
+ raise ImportError(
39
+ "LaVision library not available. Please install."
40
+ )
41
+
42
+ if not os.path.exists(file_path):
43
+ raise FileNotFoundError(f"Image file not found: {file_path}")
44
+
45
+ # Read the buffer as a generator
46
+ buffer = lv.read_buffer(file_path)
47
+
48
+ # Calculate which frames we need for this camera
49
+ start_frame = (camera_no - 1) * 2
50
+ end_frame = start_frame + frames
51
+
52
+ # Iterate through the generator, only processing the frames we need
53
+ data = None
54
+ for idx, img in enumerate(buffer):
55
+ if idx < start_frame:
56
+ # Skip frames before our camera
57
+ continue
58
+ elif idx < end_frame:
59
+ # This is one of our frames
60
+ if data is None:
61
+ # Initialize array on first needed frame
62
+ height, width = img.components["PIXEL"].planes[0].shape
63
+ data = np.zeros((frames, height, width), dtype=np.float64)
64
+
65
+ i_scale = img.scales.i.slope
66
+ i_offset = img.scales.i.offset
67
+ u_arr = img.components["PIXEL"].planes[0] * i_scale + i_offset
68
+ data[idx - start_frame, :, :] = u_arr
69
+ else:
70
+ # We've got all our frames, stop iterating
71
+ break
72
+
73
+ if data is None:
74
+ raise ValueError(f"Camera {camera_no} not found in file {file_path}")
75
+
76
+ return data.astype(np.float32)
77
+
78
+
79
+ def read_lavision_pair(file_path: str, camera_no: int = 1) -> np.ndarray:
80
+ """Read LaVision .im7 file and return as frame pair.
81
+
82
+ Args:
83
+ file_path: Path to the .im7 file (contains all cameras for one time instance)
84
+ camera_no: Camera number (1-based) to extract from the file
85
+
86
+ Returns:
87
+ np.ndarray: Array of shape (2, H, W) containing frame A and B for the specified camera
88
+ """
89
+ return read_lavision_im7(file_path, camera_no, frames=2)
90
+
91
+
92
+ def read_lavision_ims(file_path: str, camera_no: Optional[int] = None, im_no: Optional[int] = None) -> np.ndarray:
93
+ """Read LaVision images from a .set file.
94
+
95
+ LaVision .set files contain all cameras and frames in a single container.
96
+ This function reads the set file and extracts the appropriate frames for the
97
+ specified camera and image number.
98
+
99
+ Args:
100
+ file_path: Path to the .set file
101
+ camera_no: Camera number (1-based). If None, extracted from file_path (legacy)
102
+ im_no: Image number (1-based). If None, extracted from file_path (legacy)
103
+
104
+ Returns:
105
+ np.ndarray: Array of shape (2, H, W) containing frame A and B
106
+ """
107
+ import sys
108
+ from pathlib import Path
109
+
110
+ if sys.platform == "darwin":
111
+ raise ImportError(
112
+ "LaVision libraries are not supported on macOS (darwin). Please use a supported platform."
113
+ )
114
+
115
+ try:
116
+ import lvpyio as lv
117
+ except ImportError:
118
+ raise ImportError(
119
+ "LaVision library not available. Please install lvpyio."
120
+ )
121
+
122
+ path = Path(file_path)
123
+
124
+ # For .set files, camera_no and im_no must be provided
125
+ if path.suffix.lower() == '.set' or (camera_no is not None and im_no is not None):
126
+ # Modern format: file_path is the .set file
127
+ set_file_path = file_path
128
+ if camera_no is None or im_no is None:
129
+ raise ValueError("camera_no and im_no must be provided for .set files")
130
+ else:
131
+ # Legacy path parsing for backward compatibility
132
+ # Extract camera number from path (e.g., "Cam1" -> 1)
133
+ if camera_no is None:
134
+ camera_match = None
135
+ for part in path.parts:
136
+ if part.startswith("Cam") and part[3:].isdigit():
137
+ camera_match = int(part[3:])
138
+ break
139
+ if camera_match is None:
140
+ raise ValueError(f"Could not extract camera number from path: {file_path}")
141
+ camera_no = camera_match
142
+
143
+ # Extract image number from filename
144
+ if im_no is None:
145
+ stem = path.stem
146
+ if stem.isdigit():
147
+ im_no = int(stem)
148
+ else:
149
+ raise ValueError(f"Could not extract image number from filename: {path.name}")
150
+
151
+ # Source directory is typically the parent of the CamX directory
152
+ source_dir = path.parent.parent
153
+ set_file_path = str(source_dir)
154
+
155
+ if not Path(set_file_path).exists():
156
+ raise FileNotFoundError(f"Set file path not found: {set_file_path}")
157
+
158
+ # Read the set file
159
+ try:
160
+ set_file = lv.read_set(set_file_path)
161
+ im = set_file[im_no - 1] # 0-based indexing in Python
162
+ except Exception as e:
163
+ raise RuntimeError(f"Failed to read set file from {set_file_path}: {e}")
164
+
165
+ # Extract frames for this camera
166
+ data = np.zeros((2, *im.frames[0].components["PIXEL"].planes[0].shape), dtype=np.float64)
167
+
168
+ for j in range(2):
169
+ # Frame indexing: 2*cameraNo-(2-j)
170
+ frame_idx = 2 * camera_no - (2 - j)
171
+ frame = im.frames[frame_idx]
172
+
173
+ # Apply scaling
174
+ i_scale = frame.scales.i.slope
175
+ i_offset = frame.scales.i.offset
176
+ u_arr = frame.components["PIXEL"].planes[0] * i_scale + i_offset
177
+
178
+ data[j, :, :] = u_arr
179
+
180
+ set_file.close()
181
+ return data.astype(np.float32)
182
+
183
+
184
+ def read_lavision_ims_pair(file_path: str, **kwargs) -> np.ndarray:
185
+ """Read LaVision .set file and return as frame pair."""
186
+ return read_lavision_ims(file_path, **kwargs)
187
+
188
+
189
+ register_reader([".im7"], read_lavision_pair)
190
+ register_reader([".set"], read_lavision_ims_pair)
@@ -0,0 +1,24 @@
1
+ from pathlib import Path
2
+ from typing import Callable, Dict, List
3
+
4
+ # Registry for image readers
5
+ _reader_registry: Dict[str, Callable] = {}
6
+
7
+
8
+ def register_reader(extensions: List[str], reader_func: Callable):
9
+ """Register an image reader for specific file extensions."""
10
+ for ext in extensions:
11
+ _reader_registry[ext.lower()] = reader_func
12
+
13
+
14
+ def get_reader(file_path: str) -> Callable:
15
+ """Get appropriate reader for a file based on its extension."""
16
+ ext = Path(file_path).suffix.lower()
17
+ if ext not in _reader_registry:
18
+ raise ValueError(f"No reader registered for file type: {ext}")
19
+ return _reader_registry[ext]
20
+
21
+
22
+ def list_supported_formats() -> List[str]:
23
+ """List all supported file formats."""
24
+ return list(_reader_registry.keys())
pivtools_core/paths.py ADDED
@@ -0,0 +1,49 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def get_data_paths(
5
+ base_dir,
6
+ num_images,
7
+ cam,
8
+ type_name,
9
+ endpoint="",
10
+ use_merged=False,
11
+ use_uncalibrated=False,
12
+ calibration=False, # New argument
13
+ ):
14
+ """
15
+ Construct directories for data, statistics, and videos.
16
+ endpoint: optional subfolder ('' ignored).
17
+ use_uncalibrated: if True, return paths for uncalibrated data
18
+ calibration: if True, return calibration directory
19
+ """
20
+ base_dir = Path(base_dir)
21
+ cam = f"Cam{cam}"
22
+ # Calibration data
23
+ if calibration:
24
+ calib_dir = base_dir / "calibration" / cam
25
+ if endpoint:
26
+ calib_dir = calib_dir / endpoint
27
+ return dict(calib_dir=calib_dir)
28
+ # Uncalibrated data
29
+ if use_uncalibrated:
30
+ data_dir = base_dir / "uncalibrated_piv" / str(num_images) / cam / type_name
31
+ stats_dir = (
32
+ base_dir / "statistics" / "uncalibrated" / str(num_images) / cam / type_name
33
+ )
34
+ video_dir = base_dir / "videos" / "uncalibrated" / str(num_images) / cam
35
+ # Merged data
36
+ elif use_merged:
37
+ data_dir = base_dir / "merged" / str(num_images) / cam / type_name
38
+ stats_dir = base_dir / "statistics" / "merged" / cam / type_name
39
+ video_dir = base_dir / "videos" / "merged" / cam / type_name
40
+ # Regular calibrated data
41
+ else:
42
+ data_dir = base_dir / "calibrated_piv" / str(num_images) / cam / type_name
43
+ stats_dir = base_dir / "statistics" / str(num_images) / cam / type_name
44
+ video_dir = base_dir / "videos" / str(num_images) / cam
45
+ if endpoint:
46
+ data_dir = data_dir / endpoint
47
+ stats_dir = stats_dir / endpoint
48
+ video_dir = video_dir / endpoint
49
+ return dict(data_dir=data_dir, stats_dir=stats_dir, video_dir=video_dir)
@@ -0,0 +1,248 @@
1
+ import warnings
2
+ from pathlib import Path
3
+ from typing import Optional, Sequence, Tuple
4
+
5
+ import dask
6
+ import dask.array as da
7
+ import numpy as np
8
+ import scipy.io
9
+
10
+ from .config import Config
11
+
12
+
13
+ def read_mat_contents(
14
+ file_path: str, run_index: Optional[int] = None, return_all_runs: bool = False
15
+ ) -> np.ndarray:
16
+ """
17
+ Reads piv_result from a .mat file.
18
+ If multiple runs are present, selects the specified run_index (0-based).
19
+ If run_index is None, selects the first run with valid (non-empty) data.
20
+ If return_all_runs is True, returns all runs in shape (R, 3, H, W).
21
+ Otherwise returns shape (1, 3, H, W) for the selected run.
22
+ """
23
+ mat = scipy.io.loadmat(file_path, struct_as_record=False, squeeze_me=True)
24
+ piv_result = mat["piv_result"]
25
+
26
+ # Multiple runs case: numpy array of structs
27
+ if isinstance(piv_result, np.ndarray) and piv_result.dtype == object:
28
+ total_runs = piv_result.size
29
+
30
+ if return_all_runs:
31
+ # Return all runs
32
+ all_runs = []
33
+ for idx in range(total_runs):
34
+ pr = piv_result[idx]
35
+ ux = np.asarray(pr.ux)
36
+ uy = np.asarray(pr.uy)
37
+ b_mask = (
38
+ np.asarray(pr.b_mask).astype(ux.dtype, copy=False)
39
+ if ux.size > 0
40
+ else np.array([])
41
+ )
42
+ if ux.size > 0 and uy.size > 0:
43
+ stacked = np.stack([ux, uy, b_mask], axis=0) # (3, H, W)
44
+ else:
45
+ # Empty run - create placeholder with consistent shape if possible
46
+ stacked = np.array([[], [], []]) # Will be reshaped later
47
+ all_runs.append(stacked)
48
+ return np.array(all_runs) # (R, 3, H, W)
49
+
50
+ # Single run selection (existing logic)
51
+ if run_index is None:
52
+ # Find first valid run (non-empty ux, uy)
53
+ for idx in range(total_runs):
54
+ pr = piv_result[idx]
55
+ ux = np.asarray(pr.ux)
56
+ uy = np.asarray(pr.uy)
57
+ if ux.size > 0 and uy.size > 0:
58
+ run_index = idx
59
+ break
60
+ else:
61
+ raise ValueError(f"No valid runs found in {file_path}")
62
+ if run_index < 0 or run_index >= total_runs:
63
+ raise ValueError(
64
+ f"Invalid run_index {run_index} for {file_path} (total_runs={total_runs})"
65
+ )
66
+ pr = piv_result[run_index]
67
+ ux = np.asarray(pr.ux)
68
+ uy = np.asarray(pr.uy)
69
+ b_mask = np.asarray(pr.b_mask).astype(ux.dtype, copy=False)
70
+ stacked = np.stack([ux, uy, b_mask], axis=0)[None, ...] # (1, 3, H, W)
71
+ return stacked
72
+
73
+ # Single run struct
74
+ if run_index is not None and run_index != 0:
75
+ raise ValueError(
76
+ f"Invalid run_index {run_index} for single-run file {file_path}"
77
+ )
78
+ pr = piv_result
79
+ ux = np.asarray(pr.ux)
80
+ uy = np.asarray(pr.uy)
81
+ b_mask = np.asarray(pr.b_mask).astype(ux.dtype, copy=False)
82
+
83
+ if return_all_runs:
84
+ stacked = np.stack([ux, uy, b_mask], axis=0)[None, ...] # (1, 3, H, W)
85
+ return stacked
86
+ else:
87
+ stacked = np.stack([ux, uy, b_mask], axis=0)[None, ...] # (1, 3, H, W)
88
+ return stacked
89
+
90
+
91
+ def load_vectors_from_directory(
92
+ data_dir: Path, config: Config, runs: Optional[Sequence[int]] = None
93
+ ) -> da.Array:
94
+ """
95
+ Load .mat vector files for requested runs.
96
+ - runs: list of 1-based run numbers to include; if None or empty, include all runs in the files.
97
+ Returns Dask array with shape (N_existing, R, 3, H, W).
98
+ """
99
+ data_dir = Path(data_dir)
100
+ fmt = config.vector_format # e.g. "B%05d.mat"
101
+ expected_paths = [data_dir / (fmt % i) for i in range(1, config.num_images + 1)]
102
+ existing_paths = [p for p in expected_paths if p.exists()]
103
+
104
+ missing_count = len(expected_paths) - len(existing_paths)
105
+ if missing_count == len(expected_paths):
106
+ raise FileNotFoundError(
107
+ f"No vector files found using pattern {fmt} in {data_dir}"
108
+ )
109
+ if missing_count:
110
+ warnings.warn(
111
+ f"{missing_count} vector files missing in {data_dir} (loaded {len(existing_paths)})"
112
+ )
113
+
114
+ # Convert runs (1-based) to zero-based indices for reading
115
+ zero_based_runs: Optional[Sequence[int]] = None
116
+ if runs:
117
+ zero_based_runs = [r - 1 for r in runs]
118
+
119
+ # Detect shape/dtype from first readable file
120
+ first_arr = None
121
+ for p in existing_paths:
122
+ try:
123
+ first_arr = read_mat_contents(
124
+ str(p), run_index=zero_based_runs[0] if zero_based_runs else None
125
+ )
126
+ # Debugging: print shape, dtype, and file info
127
+ if first_arr.ndim != 4:
128
+ warnings.warn(
129
+ f"[DEBUG] Unexpected array ndim={first_arr.ndim} in {p.name}"
130
+ )
131
+ break
132
+ except Exception as e:
133
+ warnings.warn(f"Failed to read {p.name} during probing: {e}")
134
+ raise
135
+ if first_arr is None:
136
+ raise FileNotFoundError(f"Could not read any valid vector files in {data_dir}")
137
+
138
+ shape, dtype = first_arr.shape, first_arr.dtype # (R, 3, H, W), dtype
139
+
140
+ delayed_items = [
141
+ dask.delayed(read_mat_contents)(
142
+ str(p), run_index=zero_based_runs[0] if zero_based_runs else None
143
+ )
144
+ for p in existing_paths
145
+ ]
146
+ arrays = [da.from_delayed(di, shape=shape, dtype=dtype) for di in delayed_items]
147
+ stacked = da.stack(arrays, axis=0) # (N, R, 3, H, W)
148
+ return stacked.rechunk({0: config.piv_chunk_size})
149
+
150
+
151
+ def load_coords_from_directory(
152
+ data_dir: Path, runs: Optional[Sequence[int]] = None
153
+ ) -> Tuple[Sequence[np.ndarray], Sequence[np.ndarray]]:
154
+ """
155
+ Locate and read the coordinates.mat file in data_dir and return (x_list, y_list).
156
+ - runs: list of 1-based run numbers to include; if None or empty, include all runs present in the coords file.
157
+ - Returns:
158
+ x_list: list of x arrays in the same order as 'runs' (or all runs if None)
159
+ y_list: list of y arrays in the same order as 'runs' (or all runs if None)
160
+ """
161
+ data_dir = Path(data_dir)
162
+ coords_path = data_dir / "coordinates.mat"
163
+ if not coords_path.exists():
164
+ raise FileNotFoundError(f"No coordinates.mat file found in {data_dir}")
165
+
166
+ mat = scipy.io.loadmat(coords_path, struct_as_record=False, squeeze_me=True)
167
+ if "coordinates" not in mat:
168
+ raise KeyError(f"'coordinates' variable not found in {coords_path.name}")
169
+ coords = mat["coordinates"]
170
+
171
+ def _xy_from_struct(obj):
172
+ return np.asarray(obj.x), np.asarray(obj.y)
173
+
174
+ x_list, y_list = [], []
175
+
176
+ if isinstance(coords, np.ndarray) and coords.dtype == object:
177
+ if runs:
178
+ zero_based = [r - 1 for r in runs if 1 <= r <= coords.size]
179
+ if len(zero_based) != len(runs):
180
+ missing = sorted(set(runs) - set([z + 1 for z in zero_based]))
181
+ warnings.warn(
182
+ f"Skipping out-of-range run indices {missing} for coordinates"
183
+ )
184
+ else:
185
+ zero_based = list(range(coords.size))
186
+
187
+ for idx in zero_based:
188
+ x, y = _xy_from_struct(coords[idx])
189
+ x_list.append(x)
190
+ y_list.append(y)
191
+ else:
192
+ if runs and 1 not in runs:
193
+ warnings.warn(
194
+ "Requested runs do not include run 1 present in coordinates; returning empty coords"
195
+ )
196
+ return [], []
197
+ x, y = _xy_from_struct(coords)
198
+ x_list.append(x)
199
+ y_list.append(y)
200
+
201
+ return x_list, y_list
202
+
203
+
204
+ def save_mask_to_mat(file_path: str, mask: np.ndarray, polygons):
205
+ """
206
+ Save the given mask array to a .mat file.
207
+ """
208
+ scipy.io.savemat(file_path, {"mask": mask, "polygons": polygons}, do_compression=True)
209
+
210
+
211
+ def read_mask_from_mat(file_path: str):
212
+ """
213
+ Read the mask and polygons from a .mat file.
214
+ Returns:
215
+ mask: np.ndarray
216
+ polygons: list of dicts with fields 'index', 'name', 'points'
217
+ """
218
+ # Load without squeeze_me to avoid 0-d array issues with single-element cells
219
+ # Use struct_as_record=True (default) so structs become record arrays with dict-like access
220
+ mat = scipy.io.loadmat(file_path, squeeze_me=False, struct_as_record=True)
221
+ mask = mat.get("mask", None)
222
+ polygons_raw = mat.get("polygons", None)
223
+ if mask is None or polygons_raw is None:
224
+ raise ValueError(f"Missing 'mask' or 'polygons' in {file_path}")
225
+
226
+ # Squeeze the mask manually if needed
227
+ mask = np.squeeze(mask)
228
+
229
+ # polygons_raw is a numpy object array (MATLAB cell array)
230
+ # Flatten it to iterate (it might be [[obj1], [obj2]] or [[obj]])
231
+ polygons_flat = polygons_raw.flatten()
232
+
233
+ polygons = []
234
+ for poly in polygons_flat:
235
+ # poly is a structured array (record) with named fields accessible via indexing
236
+ # Extract scalar values from 0-d arrays
237
+ idx_raw = poly['index'] if isinstance(poly, np.void) else poly['index'][0, 0]
238
+ name_raw = poly['name'] if isinstance(poly, np.void) else poly['name'][0, 0]
239
+ pts_raw = poly['points'] if isinstance(poly, np.void) else poly['points'][0, 0]
240
+
241
+ idx = int(idx_raw.item() if hasattr(idx_raw, 'item') else idx_raw)
242
+ name = str(name_raw.item() if hasattr(name_raw, 'item') else name_raw)
243
+
244
+ # pts might be a 2D array, convert to list of lists
245
+ points = pts_raw.tolist() if isinstance(pts_raw, np.ndarray) else list(pts_raw)
246
+ polygons.append({"index": idx, "name": name, "points": points})
247
+
248
+ return mask, polygons
@@ -0,0 +1,3 @@
1
+ """PIVTOOLs GUI - Web interface for Particle Image Velocimetry Tools"""
2
+
3
+ __version__ = "0.1.1"