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())
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