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,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())
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from flask import Blueprint, jsonify, request
|
|
5
|
+
|
|
6
|
+
from ...config import get_config
|
|
7
|
+
from ...utils import camera_folder, camera_number
|
|
8
|
+
from ...vector_loading import read_mask_from_mat, save_mask_to_mat
|
|
9
|
+
|
|
10
|
+
masking_bp = Blueprint("masking", __name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _cfg():
|
|
14
|
+
return get_config()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@masking_bp.route("/save_mask_array", methods=["POST"])
|
|
18
|
+
def upload_mask():
|
|
19
|
+
"""
|
|
20
|
+
Expects JSON with: meta (basePathIdx, camera, index, frame), width, height, data (flat mask), polygons (optional).
|
|
21
|
+
Saves mask as .mat file.
|
|
22
|
+
"""
|
|
23
|
+
payload = request.get_json(silent=True) or {}
|
|
24
|
+
width, height, flat = (
|
|
25
|
+
payload.get("width"),
|
|
26
|
+
payload.get("height"),
|
|
27
|
+
payload.get("data"),
|
|
28
|
+
)
|
|
29
|
+
meta = payload.get("meta", {})
|
|
30
|
+
polygons = payload.get("polygons", None)
|
|
31
|
+
|
|
32
|
+
# Validate input
|
|
33
|
+
if not (
|
|
34
|
+
isinstance(width, int) and isinstance(height, int) and width > 0 and height > 0
|
|
35
|
+
):
|
|
36
|
+
return jsonify({"error": "width and height must be positive integers"}), 400
|
|
37
|
+
if not (isinstance(flat, list) and len(flat) == width * height):
|
|
38
|
+
return jsonify({"error": "data must be a list of length width*height"}), 400
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
mask = np.asarray(flat, dtype=bool).reshape((height, width))
|
|
42
|
+
except Exception as e:
|
|
43
|
+
return jsonify({"error": f"invalid mask data: {e}"}), 400
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
basePathIdx = meta["basePathIdx"]
|
|
47
|
+
camera = meta["camera"]
|
|
48
|
+
cfg = _cfg()
|
|
49
|
+
source_paths = cfg.source_paths
|
|
50
|
+
try:
|
|
51
|
+
camera_num = camera_number(camera)
|
|
52
|
+
except Exception:
|
|
53
|
+
camera_num = camera
|
|
54
|
+
mask_path = source_paths[basePathIdx] / f"mask_{camera_folder(camera_num)}.mat"
|
|
55
|
+
save_mask_to_mat(mask_path, mask, np.asarray(polygons))
|
|
56
|
+
except Exception:
|
|
57
|
+
return jsonify({"error": "invalid or missing meta fields"}), 400
|
|
58
|
+
|
|
59
|
+
true_count = int(mask.sum())
|
|
60
|
+
return jsonify(
|
|
61
|
+
{
|
|
62
|
+
"status": "ok",
|
|
63
|
+
"shape": [height, width],
|
|
64
|
+
"true_count": true_count,
|
|
65
|
+
"fraction_true": true_count / (width * height),
|
|
66
|
+
"meta": meta,
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@masking_bp.route("/load_mask", methods=["GET"])
|
|
72
|
+
def load_mask():
|
|
73
|
+
"""
|
|
74
|
+
Loads a mask and polygon data from a .mat file.
|
|
75
|
+
Query params:
|
|
76
|
+
- path: full path to mask .mat file (preferred)
|
|
77
|
+
- basepath_idx, camera: optional, used to construct path if 'path' not given
|
|
78
|
+
Returns: { mask: [0|1,...], width, height, polygons: [...] }
|
|
79
|
+
"""
|
|
80
|
+
cfg = _cfg()
|
|
81
|
+
path = request.args.get("path", default=None, type=str)
|
|
82
|
+
# Optionally reconstruct path if not provided
|
|
83
|
+
if not path or not Path(path).exists():
|
|
84
|
+
try:
|
|
85
|
+
basepath_idx = int(request.args.get("basepath_idx", 0))
|
|
86
|
+
camera = request.args.get("camera")
|
|
87
|
+
base_paths = cfg.source_paths
|
|
88
|
+
if basepath_idx < 0 or basepath_idx >= len(base_paths):
|
|
89
|
+
return jsonify({"error": "basepath_idx out of range"}), 400
|
|
90
|
+
camera = camera_number(camera)
|
|
91
|
+
mask_filename = f"mask_{camera_folder(camera)}.mat"
|
|
92
|
+
path = str(base_paths[basepath_idx] / mask_filename)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
return jsonify({"error": f"Could not resolve mask path: {e}"}), 400
|
|
95
|
+
|
|
96
|
+
if not Path(path).exists():
|
|
97
|
+
return jsonify({"error": f"Mask file not found: {path}"}), 404
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
mask, polygons = read_mask_from_mat(path)
|
|
101
|
+
|
|
102
|
+
def serialize_polygon(poly):
|
|
103
|
+
return {
|
|
104
|
+
"index": int(poly["index"]),
|
|
105
|
+
"name": str(poly["name"]),
|
|
106
|
+
"points": [list(map(float, pt)) for pt in poly["points"]],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
polygons_serializable = [serialize_polygon(p) for p in polygons]
|
|
110
|
+
mask_arr = np.asarray(mask)
|
|
111
|
+
mask_flat = mask_arr.astype(np.uint8).flatten().tolist()
|
|
112
|
+
height, width = mask_arr.shape
|
|
113
|
+
return jsonify(
|
|
114
|
+
{
|
|
115
|
+
"mask": mask_flat,
|
|
116
|
+
"width": width,
|
|
117
|
+
"height": height,
|
|
118
|
+
"polygons": polygons_serializable,
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
print("Exception in load_mask:", e)
|
|
123
|
+
return jsonify({"error": f"Failed to load mask: {e}"}), 500
|
pivtools_gui/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,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PIV Runner: Subprocess-based execution for full computational performance.
|
|
3
|
+
|
|
4
|
+
This module allows Flask to spawn PIV computations as separate subprocesses,
|
|
5
|
+
avoiding GIL limitations and keeping the server responsive while maintaining
|
|
6
|
+
full access to computational resources.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from loguru import logger
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PIVProcess:
|
|
21
|
+
"""Manages a single PIV computation subprocess."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, process: subprocess.Popen, job_id: str, log_file: Path):
|
|
24
|
+
self.process = process
|
|
25
|
+
self.job_id = job_id
|
|
26
|
+
self.log_file = log_file
|
|
27
|
+
self.start_time = datetime.now()
|
|
28
|
+
self.end_time: Optional[datetime] = None
|
|
29
|
+
self.return_code: Optional[int] = None
|
|
30
|
+
self._monitor_thread: Optional[threading.Thread] = None
|
|
31
|
+
|
|
32
|
+
def is_running(self) -> bool:
|
|
33
|
+
"""Check if the process is still running."""
|
|
34
|
+
if self.process.poll() is None:
|
|
35
|
+
return True
|
|
36
|
+
# Process has terminated
|
|
37
|
+
if self.return_code is None:
|
|
38
|
+
self.return_code = self.process.returncode
|
|
39
|
+
self.end_time = datetime.now()
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
def cancel(self) -> bool:
|
|
43
|
+
"""Attempt to terminate the PIV process."""
|
|
44
|
+
if self.is_running():
|
|
45
|
+
try:
|
|
46
|
+
self.process.terminate()
|
|
47
|
+
# Give it 5 seconds to terminate gracefully
|
|
48
|
+
try:
|
|
49
|
+
self.process.wait(timeout=5)
|
|
50
|
+
except subprocess.TimeoutExpired:
|
|
51
|
+
# Force kill if it doesn't terminate
|
|
52
|
+
self.process.kill()
|
|
53
|
+
self.process.wait()
|
|
54
|
+
self.return_code = self.process.returncode
|
|
55
|
+
self.end_time = datetime.now()
|
|
56
|
+
logger.info(f"PIV job {self.job_id} cancelled")
|
|
57
|
+
return True
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.error(f"Error cancelling PIV job {self.job_id}: {e}")
|
|
60
|
+
return False
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
def get_status(self) -> dict:
|
|
64
|
+
"""Get current status information."""
|
|
65
|
+
is_running = self.is_running()
|
|
66
|
+
elapsed = (
|
|
67
|
+
(self.end_time or datetime.now()) - self.start_time
|
|
68
|
+
).total_seconds()
|
|
69
|
+
|
|
70
|
+
# Try to read recent log lines
|
|
71
|
+
log_tail = []
|
|
72
|
+
if self.log_file.exists():
|
|
73
|
+
try:
|
|
74
|
+
with open(self.log_file, "r") as f:
|
|
75
|
+
log_tail = f.readlines()[-20:] # Last 20 lines
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.warning(f"Could not read log file: {e}")
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"job_id": self.job_id,
|
|
81
|
+
"running": is_running,
|
|
82
|
+
"start_time": self.start_time.isoformat(),
|
|
83
|
+
"end_time": self.end_time.isoformat() if self.end_time else None,
|
|
84
|
+
"elapsed_seconds": elapsed,
|
|
85
|
+
"return_code": self.return_code,
|
|
86
|
+
"log_file": str(self.log_file),
|
|
87
|
+
"log_tail": log_tail,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PIVRunner:
|
|
92
|
+
"""Manages PIV subprocess execution and tracking."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, project_root: Path):
|
|
95
|
+
self.project_root = project_root
|
|
96
|
+
self.log_dir = project_root / "logs" / "piv_runs"
|
|
97
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
self.active_jobs: dict[str, PIVProcess] = {}
|
|
99
|
+
self._lock = threading.Lock()
|
|
100
|
+
|
|
101
|
+
def _generate_job_id(self) -> str:
|
|
102
|
+
"""Generate a unique job ID."""
|
|
103
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
104
|
+
return f"piv_{timestamp}"
|
|
105
|
+
|
|
106
|
+
def _get_python_executable(self) -> str:
|
|
107
|
+
"""Get the Python executable from the virtual environment."""
|
|
108
|
+
import platform
|
|
109
|
+
|
|
110
|
+
# Check for virtual environment
|
|
111
|
+
if platform.system() == "Windows":
|
|
112
|
+
venv_python = self.project_root / "env" / "Scripts" / "python.exe"
|
|
113
|
+
else:
|
|
114
|
+
venv_python = self.project_root / "env" / "bin" / "python"
|
|
115
|
+
|
|
116
|
+
if venv_python.exists():
|
|
117
|
+
return str(venv_python)
|
|
118
|
+
|
|
119
|
+
# Also check for "piv" environment (legacy)
|
|
120
|
+
if platform.system() == "Windows":
|
|
121
|
+
venv_python = self.project_root / "piv" / "Scripts" / "python.exe"
|
|
122
|
+
else:
|
|
123
|
+
venv_python = self.project_root / "piv" / "bin" / "python"
|
|
124
|
+
|
|
125
|
+
if venv_python.exists():
|
|
126
|
+
return str(venv_python)
|
|
127
|
+
|
|
128
|
+
# Fallback to current Python
|
|
129
|
+
return sys.executable
|
|
130
|
+
|
|
131
|
+
def start_piv_job(
|
|
132
|
+
self,
|
|
133
|
+
cameras: Optional[list[int]] = None,
|
|
134
|
+
source_path_idx: int = 0,
|
|
135
|
+
base_path_idx: int = 0,
|
|
136
|
+
config_overrides: Optional[dict] = None,
|
|
137
|
+
) -> dict:
|
|
138
|
+
"""
|
|
139
|
+
Start a new PIV computation job as a subprocess.
|
|
140
|
+
|
|
141
|
+
Note: Currently runs example.py which reads all settings from config.yaml.
|
|
142
|
+
The parameters are accepted for API compatibility but not yet used.
|
|
143
|
+
Future enhancement: Pass parameters via CLI args or environment variables.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
cameras : list[int], optional
|
|
148
|
+
List of camera numbers to process (future feature).
|
|
149
|
+
source_path_idx : int
|
|
150
|
+
Index of source path to use from config (future feature).
|
|
151
|
+
base_path_idx : int
|
|
152
|
+
Index of base path to use from config (future feature).
|
|
153
|
+
config_overrides : dict, optional
|
|
154
|
+
Configuration overrides to apply before running (future feature).
|
|
155
|
+
|
|
156
|
+
Returns
|
|
157
|
+
-------
|
|
158
|
+
dict
|
|
159
|
+
Job information including job_id and status.
|
|
160
|
+
"""
|
|
161
|
+
job_id = self._generate_job_id()
|
|
162
|
+
log_file = self.log_dir / f"{job_id}.log"
|
|
163
|
+
|
|
164
|
+
# Build command - runs example.py which uses config.yaml
|
|
165
|
+
python_exe = self._get_python_executable()
|
|
166
|
+
script_path = self.project_root / "pypivtools" / "example.py"
|
|
167
|
+
cmd = [python_exe, str(script_path)]
|
|
168
|
+
|
|
169
|
+
# Open log file
|
|
170
|
+
log_handle = open(log_file, "w", buffering=1) # Line buffered
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
# Start subprocess
|
|
174
|
+
process = subprocess.Popen(
|
|
175
|
+
cmd,
|
|
176
|
+
stdout=log_handle,
|
|
177
|
+
stderr=subprocess.STDOUT,
|
|
178
|
+
cwd=str(self.project_root),
|
|
179
|
+
env=None, # Inherit environment
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
piv_process = PIVProcess(process, job_id, log_file)
|
|
183
|
+
|
|
184
|
+
with self._lock:
|
|
185
|
+
self.active_jobs[job_id] = piv_process
|
|
186
|
+
|
|
187
|
+
logger.info(f"Started PIV job {job_id} with PID {process.pid}")
|
|
188
|
+
|
|
189
|
+
# Start a monitoring thread to clean up when done
|
|
190
|
+
def monitor():
|
|
191
|
+
process.wait()
|
|
192
|
+
log_handle.close()
|
|
193
|
+
logger.info(
|
|
194
|
+
f"PIV job {job_id} completed with return code {process.returncode}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
monitor_thread = threading.Thread(target=monitor, daemon=True)
|
|
198
|
+
monitor_thread.start()
|
|
199
|
+
piv_process._monitor_thread = monitor_thread
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
"status": "started",
|
|
203
|
+
"job_id": job_id,
|
|
204
|
+
"pid": process.pid,
|
|
205
|
+
"log_file": str(log_file),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
except Exception as e:
|
|
209
|
+
log_handle.close()
|
|
210
|
+
logger.error(f"Failed to start PIV job: {e}")
|
|
211
|
+
return {"status": "error", "message": str(e)}
|
|
212
|
+
|
|
213
|
+
def get_job_status(self, job_id: str) -> Optional[dict]:
|
|
214
|
+
"""Get status of a specific job."""
|
|
215
|
+
with self._lock:
|
|
216
|
+
piv_process = self.active_jobs.get(job_id)
|
|
217
|
+
if piv_process:
|
|
218
|
+
return piv_process.get_status()
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
def cancel_job(self, job_id: str) -> bool:
|
|
222
|
+
"""Cancel a running job."""
|
|
223
|
+
with self._lock:
|
|
224
|
+
piv_process = self.active_jobs.get(job_id)
|
|
225
|
+
if piv_process:
|
|
226
|
+
return piv_process.cancel()
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
def list_jobs(self) -> list[dict]:
|
|
230
|
+
"""List all tracked jobs."""
|
|
231
|
+
with self._lock:
|
|
232
|
+
return [p.get_status() for p in self.active_jobs.values()]
|
|
233
|
+
|
|
234
|
+
def cleanup_finished_jobs(self, keep_recent: int = 10):
|
|
235
|
+
"""Remove finished jobs from tracking, keeping only recent ones."""
|
|
236
|
+
with self._lock:
|
|
237
|
+
finished = [
|
|
238
|
+
(jid, p)
|
|
239
|
+
for jid, p in self.active_jobs.items()
|
|
240
|
+
if not p.is_running()
|
|
241
|
+
]
|
|
242
|
+
# Sort by end time
|
|
243
|
+
finished.sort(key=lambda x: x[1].end_time or datetime.min, reverse=True)
|
|
244
|
+
# Remove all but the most recent
|
|
245
|
+
for jid, _ in finished[keep_recent:]:
|
|
246
|
+
del self.active_jobs[jid]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# Global runner instance
|
|
250
|
+
_runner: Optional[PIVRunner] = None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def get_runner(project_root: Optional[Path] = None) -> PIVRunner:
|
|
254
|
+
"""Get or create the global PIV runner instance."""
|
|
255
|
+
global _runner
|
|
256
|
+
if _runner is None:
|
|
257
|
+
if project_root is None:
|
|
258
|
+
# Try to infer from current file location
|
|
259
|
+
project_root = Path(__file__).parent.parent
|
|
260
|
+
_runner = PIVRunner(project_root)
|
|
261
|
+
return _runner
|