diff --git a/.gitignore b/.gitignore index 05ab1608..ad1115fe 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,11 @@ crates/**/target/ dist/ *.whl /piv_explainer +/.agents +/.claude +skills-lock.json + +# artifacts written by the tests into the working directory +/minimized +/saved_params +/dummy diff --git a/CLAUDE.md b/CLAUDE.md index 18b50e6d..08d6a20d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,12 +10,18 @@ OpenPIV is a Python library for Particle Image Velocimetry (PIV) analysis of flu ### Bootstrap and Install Dependencies - Use Poetry: `pip install poetry` then `poetry install` (~10 seconds). All development commands should use `poetry run `. +- The Rust acceleration is optional: `maturin develop --release -m crates/openpiv_rust/Cargo.toml` builds it in place. Without it the SciPy backend is used. ### Build and Test -- Run tests: `poetry run pytest openpiv -v` — ~10 seconds, 216 tests pass. +- **Always run the tests and ruff before committing.** Both are required, a green + test suite alone is not enough. +- Run tests: `poetry run pytest openpiv -v` — ~10 seconds, ~350 tests pass. - Run a single test file: `poetry run pytest openpiv/test/test_process.py -v` +- Lint: `poetry run ruff check .` (config in `pyproject.toml`, `[tool.ruff]`). + `poetry run ruff check . --fix` applies the safe fixes; the unsafe ones + (`--unsafe-fixes`) can change runtime behaviour and need a test run afterwards. - Import test: `poetry run python -c "import openpiv; print('OpenPIV imported successfully')"` -- No linting tools are configured (no black, flake8, etc.). +- No formatting tool is configured; ruff's linter is the style gate. - `python setup.py build_ext --inplace` is a legacy no-op: all Cython (`.pyx`) files have been removed and converted to pure Python, despite the package description still mentioning "Cython modules". ### Run Example Workflows @@ -38,6 +44,8 @@ OpenPIV is a Python library for Particle Image Velocimetry (PIV) analysis of flu - `preprocess.py` — image preprocessing (background subtraction, masking). - `smoothn.py` — robust spline smoothing; a Python port of `smoothn.m` (D. Garcia / Prof. Lewis) bundled for convenience — it is not covered by the OpenPIV license, see README for attribution. - `phase_separation.py` — solid-phase / liquid-tracer separation utilities. +- `calibration/` — camera calibration: `dlt_model/`, `pinhole_model/`, `poly_model/` (Soloff) and `stereo.py` (common world grid, dewarp, disparity self-calibration, 3C reconstruction). The sub-package uses duck typing: a camera needs `project_points`, `project_to_z`, `resolution` and `dtype`. +- `examples/stereo/` — runnable stereo-PIV example on DaVis data: `config.py` (`StereoConfig`), `davis.py` (DaVis XML exports), `frames.py` (`.ims`/`.im7` via the optional `readim7` dependency), `pipeline.py` (the steps), `__main__.py` (CLI: `info`, `calibrate`, `dewarp`, `disparity`, `piv`, `all`). ### Typical call chains - Quick path: `piv.simple_piv(frame_a, frame_b, plot=False)` → `(x, y, u, v, s2n)`. @@ -46,7 +54,7 @@ OpenPIV is a Python library for Particle Image Velocimetry (PIV) analysis of flu - Batch/multi-pass: build a `PIVSettings`, then `windef.piv(settings)`. ### Tests -- `openpiv/test/` (~216 tests). `conftest.py` forces the `Agg` matplotlib backend and patches `plt.show` so tests run headless — keep that in mind if adding plotting code. +- `openpiv/test/` (~350 tests). `conftest.py` forces the `Agg` matplotlib backend and patches `plt.show` so tests run headless — keep that in mind if adding plotting code. ### Packaging notes - Package name on PyPI is `OpenPIV`; import name is lowercase `openpiv`. diff --git a/benchmarks/fft_backends/bench_repeated_calls.py b/benchmarks/fft_backends/bench_repeated_calls.py index edb7a6d9..85a86671 100644 --- a/benchmarks/fft_backends/bench_repeated_calls.py +++ b/benchmarks/fft_backends/bench_repeated_calls.py @@ -4,6 +4,7 @@ its upfront planning cost. Throwaway script for explore/fft-backends. """ import time + import numpy as np N_CALLS = 40 # ~ processing 40 image pairs at one pass/window size diff --git a/benchmarks/fft_backends/bench_single_call.py b/benchmarks/fft_backends/bench_single_call.py index d9ebc081..354c3948 100644 --- a/benchmarks/fft_backends/bench_single_call.py +++ b/benchmarks/fft_backends/bench_single_call.py @@ -4,6 +4,7 @@ branch. Run with: uv run python scripts_bench_fft.py """ import time + import numpy as np @@ -95,7 +96,7 @@ def main(): dt, out = bench(a, b, run) results[backend_name] = (dt, out) print(f" {backend_name:20s} {dt*1000:8.2f} ms") - except Exception as e: # noqa: BLE001 - benchmark script, report and continue + except Exception as e: print(f" {backend_name:20s} FAILED: {e}") if "scipy.fft" in results: diff --git a/benchmarks/fft_backends/benchmark_windef.py b/benchmarks/fft_backends/benchmark_windef.py index 863381a5..6bd84602 100644 --- a/benchmarks/fft_backends/benchmark_windef.py +++ b/benchmarks/fft_backends/benchmark_windef.py @@ -1,12 +1,15 @@ -import time -import tempfile import pathlib +import tempfile +import time + import numpy as np from imageio.v3 import imwrite + from openpiv import windef from openpiv.settings import PIVSettings from openpiv.test import test_process + def run_multigrid(frame_a, frame_b, backend, n_runs=3): settings = PIVSettings() settings.windowsizes = (64, 32, 16) @@ -16,10 +19,10 @@ def run_multigrid(frame_a, frame_b, backend, n_runs=3): settings.sig2noise_validate = False settings.show_all_plots = False settings.show_plot = False - + # Warmup windef.multigrid_windef(frame_a, frame_b, settings) - + times = [] for _ in range(n_runs): t0 = time.perf_counter() @@ -52,14 +55,14 @@ def run_mp(frame_a, frame_b, backend): settings.save_plot = False settings.show_all_plots = False settings.sig2noise_validate = False - + t0 = time.perf_counter() windef.piv(settings) return (time.perf_counter() - t0) * 1000.0 def main(): frame_a, frame_b = test_process.create_pair(image_size=256) - + print("=" * 60) print(" BENCHMARK: Multigrid Window Deformation (256x256, 3 passes)") print("=" * 60) diff --git a/benchmarks/fft_backends/run_piv_quiver_demo.py b/benchmarks/fft_backends/run_piv_quiver_demo.py index b5473f78..848124e2 100644 --- a/benchmarks/fft_backends/run_piv_quiver_demo.py +++ b/benchmarks/fft_backends/run_piv_quiver_demo.py @@ -1,12 +1,14 @@ import os import time -import numpy as np -import matplotlib.pyplot as plt -from pathlib import Path from importlib.resources import files -from openpiv import pyprocess, tools, validation, filters +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np import openpiv_rust +from openpiv import filters, pyprocess, tools, validation + ARTIFACT_DIR = Path(r"C:\Users\alex\.gemini\antigravity-cli\brain\371a4578-c887-4c4a-bba3-7bbfb7f1997c") ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) @@ -27,7 +29,7 @@ def run_real_piv_demo(): # 1. Scipy correlation t0 = time.perf_counter() - scipy_corr = pyprocess.fft_correlate_images(aa, bb, correlation_method="circular", normalized_correlation=False) + pyprocess.fft_correlate_images(aa, bb, correlation_method="circular", normalized_correlation=False) t_scipy_corr = time.perf_counter() - t0 # 2. Rust correlation @@ -71,7 +73,7 @@ def run_real_piv_demo(): # Plot Quiver Comparison fig, axes = plt.subplots(1, 3, figsize=(18, 5.5), facecolor="white") - + # Panel 1: Scipy ax = axes[0] ax.imshow(frame_a, cmap="gray", alpha=0.5, origin="upper") @@ -111,7 +113,7 @@ def run_synthetic_vortex_demo(): print("\n=== Running Synthetic Vortex Quiver Demo ===") np.random.seed(42) img_h, img_w = 256, 256 - + n_particles = 4000 px = np.random.uniform(0, img_w, n_particles) py = np.random.uniform(0, img_h, n_particles) @@ -124,7 +126,7 @@ def run_synthetic_vortex_demo(): ry = py - cy r = np.sqrt(rx**2 + ry**2) + 1e-6 v_theta = gamma * r / (r0**2 + r**2) - + dx_particles = -v_theta * (ry / r) dy_particles = v_theta * (rx / r) @@ -132,7 +134,7 @@ def run_synthetic_vortex_demo(): frame_b = np.zeros((img_h, img_w), dtype=np.float32) for i in range(n_particles): - x0, y0 = int(round(px[i])), int(round(py[i])) + x0, y0 = round(px[i]), round(py[i]) if 1 <= x0 < img_w - 1 and 1 <= y0 < img_h - 1: frame_a[y0, x0] += 200.0 frame_a[y0+1, x0] += 100.0 @@ -140,7 +142,7 @@ def run_synthetic_vortex_demo(): frame_a[y0, x0+1] += 100.0 frame_a[y0, x0-1] += 100.0 - x1, y1 = int(round(px[i] + dx_particles[i])), int(round(py[i] + dy_particles[i])) + x1, y1 = round(px[i] + dx_particles[i]), round(py[i] + dy_particles[i]) if 1 <= x1 < img_w - 1 and 1 <= y1 < img_h - 1: frame_b[y1, x1] += 200.0 frame_b[y1+1, x1] += 100.0 @@ -148,7 +150,6 @@ def run_synthetic_vortex_demo(): frame_b[y1, x1+1] += 100.0 frame_b[y1, x1-1] += 100.0 - window_size = 32 overlap = 16 search_area_size = 32 @@ -158,7 +159,7 @@ def run_synthetic_vortex_demo(): # Scipy correlation t0 = time.perf_counter() - scipy_corr = pyprocess.fft_correlate_images(aa, bb, correlation_method="circular", normalized_correlation=False) + pyprocess.fft_correlate_images(aa, bb, correlation_method="circular", normalized_correlation=False) t_scipy_corr = time.perf_counter() - t0 # Rust correlation @@ -173,7 +174,7 @@ def run_synthetic_vortex_demo(): x, y = pyprocess.get_coordinates(image_size=frame_a.shape, search_area_size=search_area_size, overlap=overlap) fig, axes = plt.subplots(1, 2, figsize=(14, 6), facecolor="white") - + # Panel 1: Rust Quiver Field ax = axes[0] ax.imshow(frame_a, cmap="gray", alpha=0.5, origin="upper") diff --git a/benchmarks/fft_backends/test_rust_fft.py b/benchmarks/fft_backends/test_rust_fft.py index e50baa19..dd34f2a2 100644 --- a/benchmarks/fft_backends/test_rust_fft.py +++ b/benchmarks/fft_backends/test_rust_fft.py @@ -1,9 +1,11 @@ import sys import time + import numpy as np -from openpiv import pyprocess import openpiv_rust +from openpiv import pyprocess + print('=== OpenPIV Rust vs scipy.fft Benchmark & Validation ===', flush=True) # Shapes representing real PIV interrogation window batches diff --git a/benchmarks/run_benchmarks.py b/benchmarks/run_benchmarks.py index 70089563..71a6d327 100644 --- a/benchmarks/run_benchmarks.py +++ b/benchmarks/run_benchmarks.py @@ -7,7 +7,9 @@ python benchmarks/run_benchmarks.py """ import time + import numpy as np + from openpiv import pyprocess, validation, windef from openpiv.settings import PIVSettings diff --git a/dummy b/dummy new file mode 100644 index 00000000..cd333e7f --- /dev/null +++ b/dummy @@ -0,0 +1,42 @@ +dummy +512 512 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +1.0 1.0 +0.0 1.0 +2.0 3.0 +4.0 5.0 +6.0 7.0 +8.0 9.0 +10.0 11.0 +12.0 13.0 +14.0 15.0 +16.0 17.0 +18.0 19.0 +20.0 21.0 +22.0 23.0 +24.0 25.0 +26.0 27.0 +28.0 29.0 +30.0 31.0 +32.0 33.0 +34.0 35.0 +36.0 37.0 +38.0 39.0 +float64 diff --git a/openpiv/PIV_3D_plotting.py b/openpiv/PIV_3D_plotting.py index f2b59295..21a19b85 100644 --- a/openpiv/PIV_3D_plotting.py +++ b/openpiv/PIV_3D_plotting.py @@ -3,11 +3,11 @@ """ +from itertools import chain + import matplotlib import matplotlib.pyplot as plt import numpy as np -from itertools import chain -from mpl_toolkits.mplot3d import Axes3D def set_axes_equal(ax): @@ -21,8 +21,8 @@ def set_axes_equal(ax): Parameters ---------- ax: matplotlib.axes object - - + + """ x_limits = ax.get_xlim3d() @@ -202,7 +202,7 @@ def quiver_3D( arrow_scale=0.15, equal_ax=True, ): - """ + """ Displaying 3D deformation fields vector arrows Parameters @@ -217,33 +217,33 @@ def quiver_3D( mask_filtered, boolean 3d ndarray or 1d ndarray Array, or list with same dimensions as the deformations. Defines the area where deformations are drawn - + filter_def: float Filter that prevents the display of deformations arrows with length < filter_def - + filter_reg: tuple,list or int Filter that prevents the display of every i-th deformations arrows separatly alon each axis. filter_reg=(2,2,2) means that only every second arrow along x,y z axis is displayed leading to a total reduction of displayed arrows by a factor of 8. filter_reg=3 is interpreted as (3,3,3). - + cmap: string matplotlib colorbar that defines the coloring of the arrow - + quiv_args: dict Dictionary with kwargs passed on to the matplotlib quiver function. vmin,vmax: float Upper and lower bounds for the colormap. Works like vmin and vmax in plt.imshow(). - + arrow_scale: float - Automatic scaling of the quiver arrows so that the longest arrow has the + Automatic scaling of the quiver arrows so that the longest arrow has the length axis length * arrow_scale. Arrow length can alternatively be set by - passing a "lenght" argument in quiv_args. - + passing a "lenght" argument in quiv_args. + equal_axes: bool resize the figure axis so that they are have equal scaling. - + Returns ------- @@ -283,8 +283,7 @@ def quiver_3D( x, y, z = np.indices(u.shape) else: raise ValueError( - "displacement data has wrong number of dimensions (%s). Use 1d array, list, or 3d array." - % str(len(u.shape)) + f"displacement data has wrong number of dimensions ({len(u.shape)!s}). Use 1d array, list, or 3d array." ) # conversion to array diff --git a/openpiv/__init__.py b/openpiv/__init__.py index 22200226..efb52b8d 100644 --- a/openpiv/__init__.py +++ b/openpiv/__init__.py @@ -1,6 +1,5 @@ from importlib.metadata import version - __version__ = version("OpenPIV") diff --git a/openpiv/calibration/__init__.py b/openpiv/calibration/__init__.py new file mode 100644 index 00000000..f3e5a1d2 --- /dev/null +++ b/openpiv/calibration/__init__.py @@ -0,0 +1,60 @@ +""" +================== +Camera Calibration +================== + +This submodule contains functions and routines to calibrate a camera +system. + +DLT Model +========= + camera - Create an instance of a DLT camera model + calibrate_dlt - Calibrate and return DLT coefficients and fitting error + line_intersect - Using two lines, locate where those lines intersect + multi_line_intersect - Using multiple lines, approximate their intersection + +Pinhole Model +============= + calibrate_intrinsics - Calculate the intrinsic parameters using Zang's algorithm + camera - Create an instance of a Pinhole camera model + calibrate_dlt - Calibrate and return DLT coefficients and fitting error + line_intersect - Using two lines, locate where those lines intersect + multi_line_intersect - Using multiple lines, approximate their intersection + +Polynomial Model +================ + camera - Create an instance of a Soloff camera model + multi_line_intersect - Using multiple lines, approximate their intersection + +Marker Detection (Marker Grid) +============================== + detect_markers_template - Detect markers via template correlation + detect_markers_blobs - Detect markers via labeling blobs + get_circular_template - Generate a circular template + get_cross_template - Generate a cross template + preprocess_image - Preprocess calibration image + +Calibration Grid +================ + get_asymmetric_grid - Create an asymmetric rectangular calibration grid + get_simple_grid - Create a simple rectangular calibration grid + +Match Calibration Points +======================== + find_corners - Locate 4 or 6 corners of a calibration grid + find_nearest_points - Find the closest point to a cursor + get_pairs_dlt - Match marker pairs using 4 corners and the DLT algorithm + get_pairs_proj - Match marker pairs using a rough projection estimate + reorder_image_points - Reorder image points in ascending order + show_calibration_image - Plot calibration image and markers + +Utils +===== + homogenize - Homogenize array by appending ones to the last axis + get_image_mapping - Calculate the mappings to rectify a 2D image + get_los_error - Calculate the RMS error of a line of sight (LOS) at a z plane + get_reprojection_error - Calculate the root mean square (RMS) error + get_rmse - Calculate the root mean square error of the residuals + plot_epipolar_line - Plot a 3D representation of epipolar lines + +""" diff --git a/openpiv/calibration/_cal_doc_utils.py b/openpiv/calibration/_cal_doc_utils.py new file mode 100644 index 00000000..2fd985a7 --- /dev/null +++ b/openpiv/calibration/_cal_doc_utils.py @@ -0,0 +1,68 @@ +from scipy._lib import doccer + +__all__ = ['docfiller'] + + +# typing the same doc string multiple times gets annoying and error prone... +_obj_coords = ( +"""object_points : 2D np.ndarray + Real world coordinates stored in a ndarray structured like [X, Y, Z]'.""") + +_img_coords = ( +"""image_points : 2D np.ndarray + Image coordinates stored in a ndarray structured like [x, y].""") + +_cam_struct = ( +"""cam_struct : dict + A dictionary structure of camera parameters.""") + +_project_points_func = ( +"""project_points_func : function + Projection function with the following signature: + res = func(cam_struct, object_points).""") + +_project_to_z_func = ( +"""project_to_z_func : function + Projection function with the following signiture: + res = func(cam_struct, image_points, Z).""") + +_x_lab_coord = ( +"""X : 1D np.ndarray + Projected world x-coordinates.""") + +_y_lab_coord = ( +"""Y : 1D np.ndarray + Projected world y-coordinates.""") + +_z_lab_coord = ( +"""Z : 1D np.ndarray + Projected world z-coordinates.""") + +_x_img_coord = ( +"""x : 1D np.ndarray + Projected image x-coordinates.""") + +_y_img_coord = ( +"""y : 1D np.ndarray + Projected image y-coordinates.""") + +_project_z = ( +"""z : float + A float specifying the Z (depth) value to project to.""") + +docdict = { + "object_points": _obj_coords, + "image_points": _img_coords, + "cam_struct": _cam_struct, + "project_points_func": _project_points_func, + "project_to_z_func": _project_to_z_func, + "x_lab_coord": _x_lab_coord, + "y_lab_coord": _y_lab_coord, + "z_lab_coord": _z_lab_coord, + "x_img_coord": _x_img_coord, + "y_img_coord": _y_img_coord, + "project_z": _project_z +} + +# SciPy's nifty decorator (works better than my simple implementation) +docfiller = doccer.filldoc(docdict) diff --git a/openpiv/calibration/_calib_utils.py b/openpiv/calibration/_calib_utils.py new file mode 100644 index 00000000..64123fbf --- /dev/null +++ b/openpiv/calibration/_calib_utils.py @@ -0,0 +1,392 @@ + +from typing import Any + +import numpy as np + +from . import _cal_doc_utils + +__all__ = [ + 'get_image_mapping', + 'get_los_error', + 'get_reprojection_error', + 'get_rmse', + 'homogenize' +] + + +def homogenize( + points: np.ndarray +): + """Homogenize points. + + Homogenize points for further processing and correspondence matching. + Points are homogenized as such: + [0, 1, 2, 3, ...] + [0, 1, 2, 3, ...] + [1, 1, 1, 1, ...] <-- Appended ones + + Parameters + ---------- + points : np.ndarray + Points to which ones will be appended to the end of. The array + shape should be [M, N] where M in the number of dimensions and N is + the number of points. + + Returns + ------- + points : np.ndarray + Homogenized points of shape (M+1, N]. + + """ + a1 = np.ones( + (1, points.shape[1]), + dtype=points.dtype + ) + + return np.concatenate([ + points, + a1 + ]) + + +def get_rmse( + error: np.ndarray +): + """Get root mean square error (RMSE). + + Calculate the root mean square error for statistical purposes. + + Parameters + ---------- + error : np.ndarray + The residuals between the predicted value and the actual value. + + Returns + ------- + RMSE : float + The RMSE of the error + + """ + + if len(error.shape) == 2: + square_error = np.sum( + np.square(error), + axis=0 + ) + + elif len(error.shape) == 1: + square_error = np.square(error) + + else: + msg = 'Residuals (error) array must be of shape (n) or (2, n), ' + \ + f'recieved shape {error.shape}' + raise ValueError(msg) + + return np.sqrt(np.mean(square_error)) + + + +@_cal_doc_utils.docfiller +def get_reprojection_error( + cam: Any, + object_points: np.ndarray, + image_points: np.ndarray +): + """Calculate camera calibration error. + + Calculate the camera calibration error by projecting object points into image + points and calculating the root mean square (RMS) error. + + Parameters + ---------- + cam : camera + An instance of a camera object. + %(object_points)s + %(image_points)s + + Returns + ------- + RMSE : float + Root mean square (RMS) error of camera parameters. + + Examples + -------- + >>> import numpy as np + >>> from importlib_resources import files + >>> from openpiv.calibration import dlt_model, calib_utils + + >>> path_to_calib = files('openpiv.data').joinpath('test7/D_Cal.csv') + + >>> obj_x, obj_y, obj_z, img_x, img_y = np.loadtxt( + path_to_calib, + unpack=True, + skiprows=1, + usecols=range(5), + delimiter=',' + ) + + >>> obj_points = np.array([obj_x[0:3], obj_y[0:3], obj_z[0:3]], dtype=np.float64) + >>> img_points = np.array([img_x[0:3], img_y[0:3]], dtype=np.float64) + + >>> cam = dlt_model.camera( + 'cam1', + [4512, 800] + ) + + >>> cam.minimize_params( + [obj_x, obj_y, obj_z], + [img_x, img_y] + ) + + >>> calib_utils.get_reprojection_error( + cam, + [obj_x, obj_y, obj_z], + [img_x, img_y] + ) + 2.6181007833551034e-07 + + """ + res = cam.project_points( + object_points + ) + + error = res - image_points + + return get_rmse(error) + + + +@_cal_doc_utils.docfiller +def get_los_error( + cam: Any, + z: float +): + """Calculate camera LOS error. + + Calculate camera line of sight error at the selected volume depth. + + Parameters + ---------- + cam : camera + An instance of a camera object. + %(project_z)s + + Returns + ------- + RMSE : float + Root mean square (RMS) error of camera parameters. + + Examples + -------- + >>> import numpy as np + >>> from importlib_resources import files + >>> from openpiv.calibration import dlt_model, calib_utils + + >>> path_to_calib = files('openpiv.data').joinpath('test7/D_Cal.csv') + + >>> obj_x, obj_y, obj_z, img_x, img_y = np.loadtxt( + path_to_calib, + unpack=True, + skiprows=1, + usecols=range(5), + delimiter=',' + ) + + >>> obj_points = np.array([obj_x[0:3], obj_y[0:3], obj_z[0:3]], dtype=np.float64) + >>> img_points = np.array([img_x[0:3], img_y[0:3]], dtype=np.float64) + + >>> cam = dlt_model.camera( + 'cam1', + [4512, 800] + ) + + >>> cam.minimize_params( + [obj_x, obj_y, obj_z], + [img_x, img_y] + ) + + >>> calib_utils.get_los_error( + cam, + z = 0 + ) + 1.0097171287719555e-12 + + """ + # create a meshgrid for every x and y pixel for back projection. + py, px = np.meshgrid( + np.arange(0, cam.resolution[1]), + np.arange(0, cam.resolution[0]), + indexing="ij" + ) + + image_grid = np.concatenate( + [py.reshape(-1, 1), px.reshape(-1, 1)], + axis=1 + ) + + x = image_grid[:, 1] + y = image_grid[:, 0] + + # get depth + Z = np.zeros_like(x) + z + + # project image coordinates to world points + X, Y, Z = cam.project_to_z( + [x, y], + Z + ) + + # project world points back to image coordinates + res = cam.project_points( + [X, Y, Z] + ) + + error = res - np.array([x, y]) + + return get_rmse(error) + + + +# This script was originally from Theo's polynomial calibration repository. +@_cal_doc_utils.docfiller +def get_image_mapping( + cam, + z: float = 0.0, +): + """Get image Mapping. + + Get image mapping for rectifying 2D images. Fixed version: + correct world-Y bounds (previously used world_x), true + min/max extent scale (previously centered-abs heuristic) and no + off-by-``Scale`` linspace offset. Extra legacy func args are + accepted for backward compatibility and ignored (methods on + ``cam`` are used). + + Parameters + ---------- + cam : camera + An instance of a camera object. + + Returns + ------- + x : 2D np.ndarray + Mappings for x-coordinates. + y : 2D np.ndarray + Mappings for y-coordinates. + scale : float + Image to world scale factor. + + Examples + -------- + >>> import numpy as np + >>> from importlib_resources import files + >>> from openpiv.calibration import dlt_model, calib_utils + + >>> path_to_calib = files('openpiv.data').joinpath('test7/D_Cal.csv') + + >>> obj_x, obj_y, obj_z, img_x, img_y = np.loadtxt( + path_to_calib, + unpack=True, + skiprows=1, + usecols=range(5), + delimiter=',' + ) + + >>> obj_points = np.array([obj_x[0:3], obj_y[0:3], obj_z[0:3]], dtype=np.float64) + >>> img_points = np.array([img_x[0:3], img_y[0:3]], dtype=np.float64) + + >>> cam = dlt_model.camera( + 'cam1', + [4512, 800] + ) + + >>> cam.minimize_params( + [obj_x, obj_y, obj_z], + [img_x, img_y] + ) + + >>> mappings, scale = calib_utils.get_image_mapping( + cam, + ) + + >>> mappings + + >>> scale + + """ + field_shape = ( + cam.resolution[1], + cam.resolution[0] + ) + + # create a meshgrid for every x and y pixel for back projection. + py, px = np.meshgrid( + np.arange(0, cam.resolution[1]), + np.arange(0, cam.resolution[0]), + indexing="ij" + ) + + image_grid = np.concatenate( + [py.reshape(-1, 1), px.reshape(-1, 1)], + axis=-1 + ).astype(np.float64) + + x = image_grid[:, 1] + y = image_grid[:, 0] + + # project to the requested sheet plane + Z = np.zeros_like(x, dtype=np.float64) + float(z) + + # project image coordinates to world points + world_x, world_y, _ = cam.project_to_z( + [x, y], + Z + ) + + world_x = np.asarray(world_x, dtype=np.float64).reshape(field_shape, order='C') + world_y = np.asarray(world_y, dtype=np.float64).reshape(field_shape, order='C') + + # get border limits (fixed: Y bounds from world_y, not world_x) + finite = np.isfinite(world_x) & np.isfinite(world_y) + if not np.any(finite): + raise ValueError('Back-projection produced no finite world points') + + min_X = float(np.min(world_x[finite])) + max_X = float(np.max(world_x[finite])) + min_Y = float(np.min(world_y[finite])) + max_Y = float(np.max(world_y[finite])) + + # true extent-based scale (world units per rectified pixel) + scale_X = (max_X - min_X) / cam.resolution[0] + scale_Y = (max_Y - min_Y) / cam.resolution[1] + Scale = float(min(scale_X, scale_Y)) + + # rectified world grid spanning the full extent (fixed: no +Scale offset) + X, Y = np.meshgrid( + np.linspace( + min_X, + max_X, + num=cam.resolution[0], + endpoint=True + ), + np.linspace( + min_Y, + max_Y, + num=cam.resolution[1], + endpoint=True + ) + ) + + X = np.squeeze(X.reshape(-1, 1)) + Y = np.squeeze(Y.reshape(-1, 1)) + Z = np.zeros_like(X, dtype=np.float64) + float(z) + + # project world points to image coordinates + mapped_grid = cam.project_points( + [X, Y, Z] + ) + + mapped_grid_x = np.asarray(mapped_grid[0], dtype=np.float64).reshape(field_shape) + mapped_grid_y = np.asarray(mapped_grid[1], dtype=np.float64).reshape(field_shape) + + return np.array([mapped_grid_x, mapped_grid_y]), Scale diff --git a/openpiv/calibration/_epipolar_utils.py b/openpiv/calibration/_epipolar_utils.py new file mode 100644 index 00000000..36630efb --- /dev/null +++ b/openpiv/calibration/_epipolar_utils.py @@ -0,0 +1,221 @@ +from collections.abc import Callable +from typing import Any + +import numpy as np +from matplotlib import pyplot as plt +from mpl_toolkits import mplot3d +from numpy.typing import DTypeLike + +from . import _cal_doc_utils + +__all__ = [ + 'plot_epipolar_line' +] + + +@_cal_doc_utils.docfiller +def plot_epipolar_line( + cam_struct: dict, + project_to_z: Callable, + image_points: np.ndarray, + zlims: tuple[int, int], + ax: Any | None = None, + color=None +): + """Plot 3D epipolar lines. + + Using the passed camera structure and projection function, plot the 3D + epipolar line(s). By passing ax, multiple epipolar lines can be plotted + as a visualization aid for camera alighnment and multi-camera system + performance. + + Parameters + ---------- + %(cam_struct)s + %(project_to_z_func)s + %(image_points)s + zlims : tuple[int, int] + The start and end values of the epipolar line. + ax : matplotlib.axes.Axes, optional + The axis of which to plot the epipolar line. + color : str, optional + The color of the epipolar line. + + Returns + ------- + fig, ax : matplotlib figure, optional + If an axis is not passed, a new figure and axis will be returned. + + Notes + ----- + This function is based on a similar utilitiy in MyPTV, which is referenced below. + https://github.com/ronshnapp/MyPTV + + """ + Z1, Z2 = zlims + + X1, Y1, Z1 = project_to_z( + cam_struct, + image_points, + z = Z1 + ) + + X2, Y2, Z2 = project_to_z( + cam_struct, + image_points, + z = Z2 + ) + + if ax is None: + fig = plt.figure() + ax = plt.axes(projection='3d') + + if color is None: + ax.plot3D([X1,X2], [Z1,Z2], [Y1,Y2]) + else: + ax.plot3D([X1,X2], [Z1,Z2], [Y1,Y2], c=color) + + return fig, ax + + if color is None: + ax.plot3D([X1,X2], [Z1,Z2], [Y1,Y2]) + else: + ax.plot3D([X1,X2], [Z1,Z2], [Y1,Y2], c=color) + + return None + + +def _line_intersect( + t1: np.ndarray, + r1: np.ndarray, + t2: np.ndarray, + r2: np.ndarray +): + """Calculate where two rays intersect. + + Using two cameras, calculate the world coordinates where two rays + intersect. This is done through an analytical solution based on + direction vectors (r) and camera origins/translations (O). + + Parameters + ---------- + t1: np.ndarray + Three element numpy arrays for camera 1 origin/translation. + r1 : np.ndarray + Three element numpy arrays for camera 1 direction vectors. + t2: np.ndarray + Three element numpy arrays for camera 2 origin/translation. + r2 : np.ndarray + Three element numpy arrays for camera 2 direction vectors. + + Returns + ------- + coords : np.ndarray + The world coordinate that is nearest to the two rays intersecting. + dist : float + The minimum dinstance between the two rays. + + Notes + ----- + Function taken from MyPTV; all rights reserved. The direct link to this + repository is provided below. + https://github.com/ronshnapp/MyPTV + + Examples + -------- + >>> _line_intersect( + t1, r1, + t2, r2 + ) + + """ + r1r2 = r1[0]*r2[0] + r1[1]*r2[1] + r1[2]*r2[2] + r12 = r1[0]**2 + r1[1]**2 + r1[2]**2 + r22 = r2[0]**2 + r2[1]**2 + r2[2]**2 + + dO = t2-t1 + B = [r1[0]*dO[0] + r1[1]*dO[1] + r1[2]*dO[2], + r2[0]*dO[0] + r2[1]*dO[1] + r2[2]*dO[2]] + + # invert matrix to get coefficients a and b + try: + a = (-r22*B[0] + r1r2*B[1])/(r1r2**2 - r12 * r22) + b = (-r1r2*B[0] + r12*B[1])/(r1r2**2 - r12 * r22) + except Exception: + a, b = 0.0, 0.0 + + # now use a and b to calculate the minimum distance + l1 = t1 + a*r1 # line 1 + l2 = t2 + b*r2 # line 2 + dist = sum((l1 - l2)**2)**0.5 # minimum distance + coords = (l1 + l2)*0.5 # coordinate location + + return coords, dist + + +def _multi_line_intersect( + lines: list, + dtype: DTypeLike=np.float64 +): + """Calculate where two rays intersect. + + Using two cameras, calculate the world coordinates where two rays + intersect. This is done through an analytical solution based on + direction vectors (r) and camera origins/translations (O). + + Parameters + ---------- + lines : list + A list of lines consisting of a point and direction vectors. + + Returns + ------- + coords : np.ndarray + The world coordinate that is nearest to the intersection of all rays. + + Examples + -------- + >>> _multi_line_intersect( + [ + [_t1, _r1], + [_t2, _r2], + [_t3, _r3] + ] + ) + + """ + # make sure that the direction vector array is 2D + assert(len(lines[0][1].shape) == 2) + + n_cams = len(lines) + n_points = lines[0][1].shape[1] + + A = np.zeros([3,3], dtype=dtype) + b = np.zeros(3, dtype=dtype) + + I = np.eye(3) + + object_points = np.zeros([3, n_points], dtype=dtype) + + # TODO: Optimize this loop as python loops would be slow if we had + # 100,000 particles to iterate + for particle in range(n_points): + A[:, :] = 0.0 + b[:] = 0.0 + + for cam in range(n_cams): + t = lines[cam][0] + r = lines[cam][1][:, particle] + + # This was very important, didn't work without it + r /= np.linalg.norm(r) + + temp = I - np.outer(r, r) + + # Update A matrix and b vector + A += temp + b += temp.dot(t) + + object_points[:, particle] = np.linalg.lstsq(A, b, rcond=None)[0] + + return object_points diff --git a/openpiv/calibration/_target_grids.py b/openpiv/calibration/_target_grids.py new file mode 100644 index 00000000..73ca55a1 --- /dev/null +++ b/openpiv/calibration/_target_grids.py @@ -0,0 +1,170 @@ + +import numpy as np + +__all__ = [ + 'get_asymmetric_grid', + 'get_simple_grid' +] + + +def get_simple_grid( + nx: int, + ny: int, + z: int, + x_ind: int, + y_ind: int, + flip_y: bool=True, + spacing: float=1.0 +): + """Make a simple rectangular grid. + + Create a simple rectangular grid with the origin based on the index of + the selected point. This allows an arbitrary size grid and location of + origin. + + Parameters + ---------- + nx : int + Number of markers in the x-axis. + ny : int + Number of markers in the y-axis. + z : float, optional + The z plane where the calibration plate is located. + x_ind : int + Index of the point to define the x-axis. + y_ind : int + Index of the point to define the y-axis. + flip_y : bool, optional + Flip the signs of the y-axis. This is use enabled by default, but + if the grid has an origin in the top-left corner, this should be + disabled. + spacing : float, optional + Grid spacing in millimeters. + + Returns + ------- + object_points : 2D np.ndarray + 2D object points of [X, Y, Z] in world coordinates. + + Examples + -------- + >> from openpiv.calib_utils import get_simple_grid + + >>> object_points = get_simple_grid( + nx = 9, + ny = 9, + z = 0, + x_ind = 4, + y_ind = 4 + + >>> object_points + + """ + range_x = np.arange(nx).astype(np.float64) + range_y = np.arange(ny).astype(np.float64) + + origin_x = range_x[x_ind] + origin_y = range_y[y_ind] + + range_x -= origin_x + range_y -= origin_y + + range_x *= spacing + range_y *= spacing + + x, y = np.meshgrid(range_x, range_y) + + if flip_y: + y = -y + + return np.array( + [x.ravel(), y.ravel(), np.zeros_like(x).ravel() + z], + dtype=np.float64 + ) + + + +def get_asymmetric_grid( + nx: int, + ny: int, + z: int, + x_ind: int, + y_ind: int, + flip_y: bool=True, + spacing: float=1.0 +): + """Make an asymmetrical grid. + + Create an asymmetrical grid with the origin based on the index of the + selected point. This allows an arbitrary size grid and location of + origin. + + Parameters + ---------- + nx : int + Number of markers in the x-axis. + ny : int + Number of markers in the y-axis. + z : float, optional + The z plane where the calibration plate is located. + x_ind : int + Index of the point to define the x-axis. + y_ind : int + Index of the point to define the y-axis. + flip_y : bool, optional + Flip the signs of the y-axis. This is use enabled by default, but + if the grid has an origin in the top-left corner, this should be + disabled. + spacing : float, optional + Grid spacing in millimeters. + + Returns + ------- + object_points : 2D np.ndarray + 2D object points of [X, Y, Z] in world coordinates. + + Examples + -------- + >> from openpiv.calib_utils import get_sasymmetric_grid + + >>> object_points = get_sasymmetric_grid( + nx = 9, + ny = 9, + z = 0, + x_ind = 4, + y_ind = 4 + + >>> object_points + + """ + + simple_grid = get_simple_grid( + nx, + ny, + z, + x_ind, + y_ind, + flip_y, + spacing + ) + + grid = simple_grid.reshape([3, ny, nx]) + + # TODO: optimize this loop + _grid_x = [] + _grid_y = [] + _grid_z = [] + + for i in range(ny): + _grid_x += (grid[0, i, i%2::2].tolist()) + _grid_y += (grid[1, i, i%2::2].tolist()) + _grid_z += (grid[2, i, i%2::2].tolist()) + + return np.array( + [ + _grid_x, + _grid_y, + _grid_z + ], + dtype=np.float64 + ) diff --git a/openpiv/calibration/calib_utils.py b/openpiv/calibration/calib_utils.py new file mode 100644 index 00000000..5906f79a --- /dev/null +++ b/openpiv/calibration/calib_utils.py @@ -0,0 +1,5 @@ +from ._calib_utils import * +from ._epipolar_utils import * +from ._target_grids import * + +__all__ = [s for s in dir() if not s.startswith('_')] diff --git a/openpiv/calibration/dlt_model/__init__.py b/openpiv/calibration/dlt_model/__init__.py new file mode 100644 index 00000000..ca265236 --- /dev/null +++ b/openpiv/calibration/dlt_model/__init__.py @@ -0,0 +1,31 @@ +""" +========= +DLT Model +========= + +This module contains an implementation of a direct linear transformation +model which is equivalent to a pinhole camera model under conditions with +no distortion. However, this condition is practically impossible to obtain +in laboratory conditions, so calibration errors are typically higher than +that of pinhole and polynomial calibration methods. + +Functions +========= + camera - Create an instance of a DLT camera model + calibrate_dlt - Calibrate and return DLT coefficients and fitting error + line_intersect - Using two lines, locate where those lines intersect + multi_line_intersect - Using multiple lines, approximate their intersection + +Note +==== +It is important to only import the submodule and not the functions that +are in the submodules. Explicitly importing a function from this submodule +could cause conflicts between other camera models due to similar naming +conventions that are normally protected behind namespaces. + +""" +from ._camera import * +from ._epipolar_geom import * +from ._minimization import * + +__all__ = [s for s in dir() if not s.startswith('_')] diff --git a/openpiv/calibration/dlt_model/_camera.py b/openpiv/calibration/dlt_model/_camera.py new file mode 100644 index 00000000..9dbb3e53 --- /dev/null +++ b/openpiv/calibration/dlt_model/_camera.py @@ -0,0 +1,62 @@ + +import numpy as np +from numpy.typing import DTypeLike + +from ._check_params import _check_parameters +from ._minimization import _minimize_params +from ._projection import _get_inverse_vector, _project_points, _project_to_z +from ._utils import _load_parameters, _save_parameters + +__all__ = ['camera'] + + +class camera: + """An instance of a DLT camera model. + + Create an instance of a DLT camera. The DLT camera model is based on + the direct linear transformation where image points and object points + are mapped through a 3x3 or 3x4 matrix. + + Attributes + ---------- + name : str + The name of the camera. + resolution : tuple[int, int] + Resolution of camera in x and y axes respectively. + coeffs : np.ndarray + The coefficients for a DLT matrix. + dtype : np.dtype + The dtype used in the projections. + + Methods + ------- + minimize_params + project_points + project_to_z + save_parameters + load_parameters + + """ + def __init__( + self, + name: str, + resolution: tuple[int, int], + coeffs: np.ndarray=np.identity(3), + dtype: DTypeLike=np.float64 + ): + self.name = name + self.resolution = resolution + self.coeffs = np.array(coeffs, copy=True) + self.dtype = np.dtype(dtype) + + # method definitions + camera._check_parameters = _check_parameters + camera._get_inverse_vector = _get_inverse_vector + camera.minimize_params = _minimize_params + camera.project_points = _project_points + camera.project_to_z = _project_to_z + camera.save_parameters = _save_parameters + camera.load_parameters = _load_parameters + + # check camera params at init + self._check_parameters() diff --git a/openpiv/calibration/dlt_model/_check_params.py b/openpiv/calibration/dlt_model/_check_params.py new file mode 100644 index 00000000..c42238e0 --- /dev/null +++ b/openpiv/calibration/dlt_model/_check_params.py @@ -0,0 +1,23 @@ +import numpy as np + + +def _check_parameters(self): + if not isinstance(self.name, str): + raise ValueError('Camera name must be a string') + + if len(self.resolution) != 2: + raise ValueError('Resolution must be a two element tuple') + + if len(self.coeffs.shape) != 2: + raise ValueError('DLT coefficients must be 2 dimensional') + + if self.coeffs.shape[0] != 3: + raise ValueError('DLT coefficients axis 0 must be of size 3') + + if self.coeffs.shape[1] not in [3, 4]: + msg = 'DLT coefficients axis 1 must be of size 3 for 2D ' + \ + 'and size 4 for 3D transformations' + raise ValueError(msg) + + if self.dtype not in [np.float32, np.float64]: + raise ValueError('Dtype is not supported for camera calibration') diff --git a/openpiv/calibration/dlt_model/_epipolar_geom.py b/openpiv/calibration/dlt_model/_epipolar_geom.py new file mode 100644 index 00000000..71d2bc7f --- /dev/null +++ b/openpiv/calibration/dlt_model/_epipolar_geom.py @@ -0,0 +1,126 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - import only for type hints + from .. import dlt_model + +import numpy as np + +from .._epipolar_utils import _line_intersect, _multi_line_intersect + +__all__ = [ + 'line_intersect', + 'multi_line_intersect' +] + + +def line_intersect( + cam_1: "dlt_model.camera", + cam_2: "dlt_model.camera", + img_points_1: np.ndarray, + img_points_2: np.ndarray +): + """Calculate where two rays intersect. + + Using two cameras, calculate the world coordinates where two rays + intersect. This is done through an analytical solution based on + direction vectors and camera origins/translations. + + Parameters + ---------- + cam_1, cam_2 : class + An instance of a DLT camera. + img_points_1, img_points_2 : np.ndarray + Image coordinates stored in a ndarray structured like [x, y]'. + + Returns + ------- + coords : np.ndarray + The world coordinate that is nearest to the two rays intersecting. + dist : float + The minimum dinstance between the two rays. + + """ + cam_1._check_parameters() + cam_2._check_parameters() + + dtype1 = cam_1.dtype + dtype2 = cam_2.dtype + + # all cameras should have the same dtype + if dtype1 != dtype2: + raise ValueError('Dtypes between camera structures must match') + + img_points_1 = np.array(img_points_1, dtype=dtype1) + img_points_2 = np.array(img_points_2, dtype=dtype2) + + t1, r1 = cam_1._get_inverse_vector( + img_points_1 + ) + + t2, r2 = cam_2._get_inverse_vector( + img_points_2 + ) + + return _line_intersect( + t1[:, np.newaxis], + r1, + t2[:, np.newaxis], + r2 + ) + + +def multi_line_intersect( + cameras: list, + img_points: list, +): + """Calculate where multiple rays intersect. + + Using at least two cameras, calculate the world coordinates where the + rays intersect. This is done through an least squares solution based on + direction vectors and camera origins/translations. + + Parameters + ---------- + cameras : list + A list of instances of DLT cameras. + img_points : list + A list of image coordinates for each canera structure. + + Returns + ------- + coords : np.ndarray + The world coordinate that is nearest to the intersection of all rays. + + """ + n_cams = len(cameras) + n_imgs = len(img_points) + + # make sure each camera has a set of images + if n_cams != n_imgs: + msg = f'Camera - image size mismatch. Got {n_cams} cameras and ' + \ + f'{n_imgs} images' + raise ValueError(msg) + + # check each camera structure + for cam in range(n_cams): + cameras[cam]._check_parameters() + + # all cameras should have the same dtype + dtype1 = cameras[0].dtype + for cam in range(1, n_cams): + dtype2 = cameras[cam].dtype + + if dtype1 != dtype2: + raise ValueError('Dtypes between camera structures must match') + + lines = [] + for cam in range(n_cams): + points = np.array(img_points[cam], dtype=dtype1) + + t, r = cameras[cam]._get_inverse_vector( + points + ) + + lines.append([t, r]) + + return _multi_line_intersect(lines, dtype1) diff --git a/openpiv/calibration/dlt_model/_minimization.py b/openpiv/calibration/dlt_model/_minimization.py new file mode 100644 index 00000000..4d0303f0 --- /dev/null +++ b/openpiv/calibration/dlt_model/_minimization.py @@ -0,0 +1,484 @@ +import numpy as np +from scipy.optimize import curve_fit, least_squares + +from .. import _cal_doc_utils +from .._calib_utils import get_rmse, homogenize +from ._normalization import _standardize_points_2d, _standardize_points_3d + +__all__ = [ + 'calibrate_dlt' +] + + +@_cal_doc_utils.docfiller +def calibrate_dlt( + object_points: np.ndarray, + image_points: np.ndarray, + enforce_coplanar: bool=False, + nonlinear: bool=False +): + """Normalized DLT with optional nonlinear refinement. + + Compute a 3x3 planar homography from 2D lab coordinates or a 3x4 camera + projection matrix from 3D lab coordinates using normalized direct linear + transformation. When enabled, nonlinear refinement minimizes image reprojection error + using Levenberg-Marquardt before denormalization. + + Parameters + ---------- + %(object_points)s + %(image_points)s + enforce_coplanar : bool, optional + If True, require the standard deviation of supplied Z coordinates + to be no greater than 1e-5 in lab-coordinate units. + Default is False. + nonlinear : bool, optional + If True, refine the initial DLT matrix by minimizing image + reprojection error. If False, return the normalized linear DLT + solution after denormalization. Default is False. + + Returns + ------- + H : 2D np.ndarray + A 3x3 planar homography or 3x4 camera projection matrix mapping + lab coordinates to homogeneous image coordinates. + error : float + RMSE of the image reprojection residuals for the returned matrix. + + Raises + ------ + ValueError + If the input shapes are invalid, correspondence counts differ, + inputs contain non-finite values, or there are insufficient points. + ValueError + If Z coordinates fail the constant-Z check when enforce_coplanar + is True. + ValueError + If normalization fails, the DLT configuration is degenerate, or + the fitted matrix is non-finite or rank deficient. + ValueError + If the fitted matrix produces non-finite homogeneous image + coordinates when computing RMSE. + RuntimeError + If nonlinear refinement fails to converge. + + Notes + ----- + Linear DLT requires at least four planar correspondences or six + noncoplanar 3D correspondences. The existing planar nonlinear refinement + requires at least five correspondences because it optimizes all nine + homography coefficients. + + """ + object_points = np.array(object_points, dtype=np.float64) + image_points = np.array(image_points, dtype=np.float64) + + if object_points.ndim != 2: + raise ValueError('Object points must be a two-dimensional array') + if image_points.ndim != 2 or image_points.shape[0] != 2: + raise ValueError('Image points must have shape (2, N)') + + ndims = object_points.shape[0] + + if ndims not in (2, 3): + raise ValueError('Object points must have shape (2, N) or (3, N)') + if object_points.shape[1] != image_points.shape[1]: + raise ValueError('Object point image point size mismatch') + + if not ( + np.all(np.isfinite(object_points)) + and np.all(np.isfinite(image_points)) + ): + raise ValueError('Calibration points must contain only finite values') + + min_points = 4 + + if ndims == 3 and not enforce_coplanar: + min_points += 2 + + if object_points.shape[1] < min_points: + raise ValueError( + f'Too little points to calibrate. Need at least {min_points} points' + ) + + if enforce_coplanar and ndims == 3: + z = object_points[2] + + if np.std(z) > 1e-5: + raise ValueError( + 'Object points must be co-planar' + ) + + ndims = 2 + object_points = object_points[:2, :] + + if ndims == 2: + X_raw, Y_raw = object_points + x_raw, y_raw = image_points + + # normalize for better dlt results + [X, Y], obj_norm_mat = _standardize_points_2d(X_raw, Y_raw) + [x, y], img_norm_mat = _standardize_points_2d(x_raw, y_raw) + + # mount constraints + A = np.zeros([x.shape[0] * 2, 9], dtype=np.float64) + A[0::2, 0] = -X + A[0::2, 1] = -Y + A[0::2, 2] = -1 + A[0::2, 6] = x * X + A[0::2, 7] = x * Y + A[0::2, 8] = x + + A[1::2, 3] = -X + A[1::2, 4] = -Y + A[1::2, 5] = -1 + A[1::2, 6] = y * X + A[1::2, 7] = y * Y + A[1::2, 8] = y + + else: + X_raw, Y_raw, Z_raw = object_points + x_raw, y_raw = image_points + + # normalize for better dlt results + [X, Y, Z], obj_norm_mat = _standardize_points_3d(X_raw, Y_raw, Z_raw) + [x, y], img_norm_mat = _standardize_points_2d(x_raw, y_raw) + + # mount constraints + A = np.zeros([x.shape[0] * 2, 12], dtype=np.float64) + A[0::2, 0] = -X + A[0::2, 1] = -Y + A[0::2, 2] = -Z + A[0::2, 3] = -1 + A[0::2, 8] = x * X + A[0::2, 9] = x * Y + A[0::2, 10] = x * Z + A[0::2, 11] = x + + A[1::2, 4] = -X + A[1::2, 5] = -Y + A[1::2, 6] = -Z + A[1::2, 7] = -1 + A[1::2, 8] = y * X + A[1::2, 9] = y * Y + A[1::2, 10] = y * Z + A[1::2, 11] = y + + # Solve Ah = 0. full_matrices=True is required for the 8x9 matrix + # produced by the minimal four-point planar case. The null-space vector + # is the final row of the full Vh matrix and has our linear estimate. + _, singular_values, vh = np.linalg.svd(A, full_matrices=True) + + rank_tolerance = ( + singular_values[0] + * max(A.shape) + * np.finfo(A.dtype).eps + ) + rank = np.count_nonzero(singular_values > rank_tolerance) + + if rank < A.shape[1] - 1: + raise ValueError( + 'Calibration points form a degenerate DLT configuration' + ) + + H = vh[-1, :] + + coefficient_tolerance = ( + np.finfo(H.dtype).eps * max(A.shape) * np.linalg.norm(H) + ) + homogeneous_scale = H[-1] + + if abs(homogeneous_scale) <= coefficient_tolerance: + homogeneous_scale = np.linalg.norm(H) + if abs(homogeneous_scale) <= np.finfo(H.dtype).tiny: + raise ValueError('Unable to determine finite DLT coefficients') + + H /= homogeneous_scale + H = H.reshape([3, ndims+1]) + + if nonlinear: + if ndims == 2: + H = _refine2D( + H, + np.array([X, Y]), + np.array([x, y]) + ) + else: + H = _refine3D( + H, + np.array([X, Y, Z]), + np.array([x, y]) + ) + + # denormalize DLT matrix + H = np.dot( + np.linalg.solve(img_norm_mat, H), + obj_norm_mat + ) + + if not np.all(np.isfinite(H)) or np.linalg.matrix_rank(H) < 3: + raise ValueError('The fitted DLT matrix is rank deficient') + + # compute RMSE error + homogeneous_object_points = homogenize(object_points) + xy2 = np.dot(H, homogeneous_object_points) + + if not np.all(np.isfinite(xy2)): + raise ValueError('The fitted DLT produced non-finite coordinates') + + denominator = xy2[2, :] + + res = xy2[:2, :] / denominator + + error = res - image_points + + RMSE = get_rmse(error) + + return H, RMSE + + +@_cal_doc_utils.docfiller +def _minimize_params( + self, + object_points: np.ndarray, + image_points: np.ndarray, + enforce_coplanar: bool=False +): + """Least squares wrapper for DLT calibration. + + A wrapper around the function 'calibrate_dlt' for use with camera structures. + In the future, a robust DLT calibration would be implemented and its + interface would be linked here too. + + Parameters + ---------- + %(object_points)s + %(image_points)s + enforce_coplanar : bool + If a Z plane is supplied in the object points, check whether or not + the Z planes are co-planar. + + Examples + -------- + >>> import numpy as np + >>> from importlib_resources import files + >>> from openpiv.calibration import dlt_model, calib_utils + + >>> path_to_calib = files('openpiv.data').joinpath('test7/D_Cal.csv') + + >>> obj_x, obj_y, obj_z, img_x, img_y = np.loadtxt( + path_to_calib, + unpack=True, + skiprows=1, + usecols=range(5), + delimiter=',' + ) + + >>> obj_points = np.array([obj_x[0:3], obj_y[0:3], obj_z[0:3]], dtype=np.float64) + >>> img_points = np.array([img_x[0:3], img_y[0:3]], dtype=np.float64) + + >>> cam = dlt_model.camera( + 'cam1', + [4512, 800] + ) + + >>> cam.minimize_params( + [obj_x, obj_y, obj_z], + [img_x, img_y] + ) + + >>> calib_utils.get_reprojection_error( + cam, + [obj_x, obj_y, obj_z], + [img_x, img_y] + ) + 2.6181007833551034e-07 + + """ + + self._check_parameters() + + dtype = self.dtype + + object_points = np.array(object_points, dtype=dtype) + image_points = np.array(image_points, dtype=dtype) + + H, error = calibrate_dlt( + object_points, + image_points, + enforce_coplanar + ) + + self.coeffs = H + + +def _refine_func2D( + data, + *H +): + h11, h12, h13, h21, h22, h23, h31, h32, h33 = H + + X = data[0::2] + Y = data[1::2] + + x = (h11 * X + h12 * Y + h13) / (h31 * X + h32 * Y + h33) + y = (h21 * X + h22 * Y + h23) / (h31 * X + h32 * Y + h33) + + res = np.zeros_like(data) + + res[0::2] = x + res[1::2] = y + + return res + + +def _refine_jac2D( + data, + *H +): + h11, h12, h13, h21, h22, h23, h31, h32, h33 = H + + X = data[0::2] + Y = data[1::2] + + N = data.shape[0] + + jac = np.zeros((N, 9), dtype=np.float64) + + s_x = h11 * X + h12 * Y + h13 + s_y = h21 * X + h22 * Y + h23 + w = h31 * X + h32 * Y + h33 + w_sq = w**2 + + jac[0::2, 0] = X / w + jac[0::2, 1] = Y / w + jac[0::2, 2] = 1. / w + jac[0::2, 6] = (-s_x * X) / w_sq + jac[0::2, 7] = (-s_x * Y) / w_sq + jac[0::2, 8] = -s_x / w_sq + + jac[1::2, 3] = X / w + jac[1::2, 4] = Y / w + jac[1::2, 5] = 1. / w + jac[1::2, 6] = (-s_y * X) / w_sq + jac[1::2, 7] = (-s_y * Y) / w_sq + jac[1::2, 8] = -s_y / w_sq + + return jac + + +def _refine2D( + H, + object_points, + image_points +): + + X, Y = object_points + x, y = image_points + + N = X.shape[0] + + init_guess = H.ravel() + + independent = np.zeros(N * 2) + independent[0::2] = X + independent[1::2] = Y + + dependent = np.zeros(N * 2) + dependent[0::2] = x + dependent[1::2] = y + + # curve_fit uses damped Newton-Raphson optimization (aka LM) by default + new_h, _ = curve_fit( + _refine_func2D, + independent, + dependent, + p0=init_guess, + jac=_refine_jac2D + ) + + new_h /= new_h[-1] + return new_h.reshape((3,3)) + + + +def _unpack3D( + parameters: np.ndarray, + gauge_index: int +): + coefficients = np.empty(12, dtype=np.float64) + + free_parameters = np.ones(12, dtype=bool) + free_parameters[gauge_index] = False + + coefficients[free_parameters] = parameters + coefficients[gauge_index] = 1.0 + + return coefficients.reshape((3, 4)) + + +def _refine_func3D( + parameters: np.ndarray, + homogeneous_object_points: np.ndarray, + image_points: np.ndarray, + gauge_index: int +): + H = _unpack3D(parameters, gauge_index) + + projected_points = np.dot( + H, + homogeneous_object_points + ) + + predicted_image_points = ( + projected_points[:2, :] / projected_points[2, :] + ) + + # Interleaved residual order: + # [x0, y0, x1, y1, ...] + residuals = predicted_image_points - image_points + + return residuals.T.ravel() + + +def _refine3D( + H: np.ndarray, + object_points: np.ndarray, + image_points: np.ndarray +): + initial_coefficients = H.ravel().copy() + + # Select a stable local projective gauge. + gauge_index = int( + np.argmax(np.abs(initial_coefficients)) + ) + + initial_coefficients /= initial_coefficients[gauge_index] + + initial_parameters = np.delete( + initial_coefficients, + gauge_index + ) + + result = least_squares( + _refine_func3D, + x0=initial_parameters, + jac='2-point', + method='lm', + x_scale='jac', + args=( + homogenize(object_points), + image_points, + gauge_index + ) + ) + + if not result.success: + raise RuntimeError( + f'3D DLT refinement failed: {result.message}' + ) + + return _unpack3D( + result.x, + gauge_index + ) diff --git a/openpiv/calibration/dlt_model/_normalization.py b/openpiv/calibration/dlt_model/_normalization.py new file mode 100644 index 00000000..e272c6ef --- /dev/null +++ b/openpiv/calibration/dlt_model/_normalization.py @@ -0,0 +1,107 @@ +import numpy as np + + +def _standardize_points_2d( + x: np.ndarray, + y: np.ndarray +): + """Normalize 2-D points with an isotropic similarity transform. + + Normalize the x and y coordinates through standardization. This allows + for a better fit when calibrating the direct linear transformation. + + Parameters + ========== + x, y : np.ndarray + The coordinates to normalize through standardization. + + Returns + ======= + x_n, y_n : np.ndarray + Normalized x-y coordinates. + norm_matrix : np.ndarray + The normalization matrix for normalization and denormalization. + + """ + centroid = np.array( + [np.mean(x), np.mean(y)], + dtype=np.float64 + ) + centered = np.array( + [x - centroid[0], y - centroid[1]], + dtype=np.float64 + ) + mean_distance = np.mean(np.linalg.norm(centered, axis=0)) + + if not np.isfinite(mean_distance): + raise ValueError('Cannot normalize coincident 2D points') + + scale = np.sqrt(2.0) / mean_distance + xy_norm = scale * centered + + norm_mat = np.array( + [ + [scale, 0.0, -scale * centroid[0]], + [0.0, scale, -scale * centroid[1]], + [0.0, 0.0, 1.0] + ], + dtype=np.float64 + ) + + return xy_norm, norm_mat + + +def _standardize_points_3d( + x: np.ndarray, + y: np.ndarray, + z: np.ndarray +): + """Normalize 3-D points with an isotropic similarity transform. + + Normalize the x, y and z coordinates through standardization. This allows + for a better fit when calibrating the direct linear transformation. + + Parameters + ========== + x, y, z : np.ndarray + The coordinates to normalize through standardization. + + Returns + ======= + x_n, y_n, z_n : np.ndarray + Normalized x, y, z coordinates. + norm_matrix : np.ndarray + The normalization matrix for further normalization and denormalization. + + """ + centroid = np.array( + [np.mean(x), np.mean(y), np.mean(z)], + dtype=np.float64 + ) + centered = np.array( + [ + x - centroid[0], + y - centroid[1], + z - centroid[2] + ], + dtype=np.float64 + ) + mean_distance = np.mean(np.linalg.norm(centered, axis=0)) + + if not np.isfinite(mean_distance): + raise ValueError('Cannot normalize coincident 3D points') + + scale = np.sqrt(3.0) / mean_distance + xyz_norm = scale * centered + + norm_mat = np.array( + [ + [scale, 0.0, 0.0, -scale * centroid[0]], + [0.0, scale, 0.0, -scale * centroid[1]], + [0.0, 0.0, scale, -scale * centroid[2]], + [0.0, 0.0, 0.0, 1.0] + ], + dtype=np.float64 + ) + + return xyz_norm, norm_mat diff --git a/openpiv/calibration/dlt_model/_projection.py b/openpiv/calibration/dlt_model/_projection.py new file mode 100644 index 00000000..37ea6b71 --- /dev/null +++ b/openpiv/calibration/dlt_model/_projection.py @@ -0,0 +1,234 @@ +import numpy as np + +from .. import _cal_doc_utils +from .._calib_utils import homogenize + + +@_cal_doc_utils.docfiller +def _project_points( + self, + object_points: np.ndarray +): + """Project lab coordinates to image points. + + Parameters + ---------- + camera : class + An instance of a DLT camera. + %(object_points)s + + Returns + ------- + %(x_img_coord)s + %(y_img_coord)s + + Examples + -------- + >>> import numpy as np + >>> from importlib_resources import files + >>> from openpiv.calibration import dlt_model + + >>> path_to_calib = files('openpiv.data').joinpath('test7/D_Cal.csv') + + >>> obj_x, obj_y, obj_z, img_x, img_y = np.loadtxt( + path_to_calib, + unpack=True, + skiprows=1, + usecols=range(5), # get first 5 columns of data + delimiter=',' + ) + + >>> obj_points = np.array([obj_x[0:3], obj_y[0:3], obj_z[0:3]], dtype=np.float64) + >>> img_points = np.array([img_x[0:3], img_y[0:3]], dtype=np.float64) + + >>> cam = dlt_model.camera( + 'cam1', + [4512, 800] + ) + + >>> cam.minimize_params( + [obj_x, obj_y, obj_z], + [img_x, img_y] + ) + + >>> cam.project_points( + obj_points + ) + array([[-44.33764399, -33.67518588, -22.97467733], + [ 89.61102874, 211.88636408, 334.59805555]]) + + >>> img_points + array([[-44.33764398, -33.67518587, -22.97467733], + [ 89.61102873, 211.8863641 , 334.5980555 ]]) + + """ + self._check_parameters() + + dtype = self.dtype + H = self.coeffs + + object_points = np.array(object_points, dtype=dtype) + + ndim = H.shape[1] - 1 + + if ndim == 2: + object_points = object_points[:2, :] + else: + object_points = object_points[:3, :] + + # compute RMSE error + xy = np.dot( + H, + homogenize(object_points) + ) + + xy /= xy[2, :] + img_points = xy[:2, :] + + return img_points.astype(dtype, copy=False) + + +@_cal_doc_utils.docfiller +def _get_inverse_vector( + self, + image_points: np.ndarray +): + """Get pixel to direction vector. + + Calculate a direction vector from a pixel. This vector can be used for + forward projection along a ray using y + ar where y is the origin of + the camera, a is the z plane along the ray, and r is the direction + vector. + + Parameters + ---------- + %(image_points)s + + Returns + ------- + tx, ty, tz : 1D np.ndarray + Direction vector for each respective axis. + dx, dy, dz : 1D np.ndarray + The camera center for each respective axis. + + Notes + ----- + The direction vector is not normalized. + + """ + dtype = self.dtype + p = self.coeffs + + if p.shape != (3, 4): + msg = 'DLT coefficients must be of shape (3, 4); recieved shape ' + \ + f'{p.shape}' + raise ValueError(msg) + + m = p[:, :3] + t_un = p[:, 3] + m_inv = np.linalg.inv(m) + + # direction vector + r = np.dot(m_inv, homogenize(image_points)).astype(dtype, copy=False) + + # camera center/translation + t = np.dot(-m_inv, t_un).astype(dtype, copy=False) + + return t, r + + +@_cal_doc_utils.docfiller +def _project_to_z( + self, + image_points: np.ndarray, + z: np.ndarray +): + """Project image points to world points. + + Project image points to world points at specified z-plane using a + closed form solution (when omiting distortion correction). This means + under ideal circumstances with no distortion, the forward project + coordinates would be accurate down to machine precision or numerical + round off errors. + + Parameters + ---------- + %(image_points)s + %(project_z)s + + Returns + ------- + %(x_lab_coord)s + %(y_lab_coord)s + %(z_lab_coord)s + + Examples + -------- + >>> import numpy as np + >>> from importlib_resources import files + >>> from openpiv.calibration import dlt_model + + >>> path_to_calib = files('openpiv.data').joinpath('test7/D_Cal.csv') + + >>> obj_x, obj_y, obj_z, img_x, img_y = np.loadtxt( + path_to_calib, + unpack=True, + skiprows=1, + usecols=range(5), # get first 5 columns of data + delimiter=',' + ) + + >>> obj_points = np.array([obj_x[0:3], obj_y[0:3], obj_z[0:3]], dtype=np.float64) + >>> img_points = np.array([img_x[0:3], img_y[0:3]], dtype=np.float64) + + >>> cam = dlt_model.camera( + 'cam1', + [4512, 800] + ) + + >>> cam.minimize_params( + [obj_x, obj_y, obj_z], + [img_x, img_y] + ) + + >>> ij = cam.project_points( + obj_points + ) + + >>> ij + array([[-44.33764399, -33.67518588, -22.97467733], + [ 89.61102874, 211.88636408, 334.59805555]]) + + >>> cam.project_to_z( + ij, + z=obj_points[2] + ) + array([[-105., -105., -105.], + [ -15., -10., -5.], + [ -10., -10., -10.]]) + + >>> obj_points + array([[-105., -105., -105.], + [ -15., -10., -5.], + [ -10., -10., -10.]]) + + """ + self._check_parameters() + + dtype = self.dtype + + image_points = np.array(image_points, dtype=dtype) + + t, r = self._get_inverse_vector( + image_points + ) + + tx, ty, tz = t + dx, dy, dz = r + + a = (z - tz) / dz + + X = a*dx + tx + Y = a*dy + ty + + return np.array([X, Y, np.zeros_like(X) + z], dtype=dtype) diff --git a/openpiv/calibration/dlt_model/_utils.py b/openpiv/calibration/dlt_model/_utils.py new file mode 100644 index 00000000..ba9da5e0 --- /dev/null +++ b/openpiv/calibration/dlt_model/_utils.py @@ -0,0 +1,96 @@ +from os.path import join + +import numpy as np + +from .. import _cal_doc_utils + + +def _save_parameters( + self, + file_path: str, + file_name: str | None=None +): + """Save DLT camera parameters. + + Save the DLT camera parameters to a text file. + + Parameters + ---------- + file_path : str + File path where the camera parameters are saved. + file_name : str, optional + If specified, override the default file name. + + Returns + ------- + None + + """ + if file_name is None: + file_name = self.name + + full_path = join(file_path, file_name) + + with open(full_path, 'w') as f: + f.write(self.name + '\n') + + _r = '' + for i in range(2): + _r += str(self.resolution[i]) + ' ' + + f.write(_r + '\n') + + for i in range(3): + _c = '' + for j in range(self.coeffs.shape[1]): + _c += str(self.coeffs[i, j]) + ' ' + + f.write(_c + '\n') + + f.write(np.dtype(self.dtype).name + '\n') + + +@_cal_doc_utils.docfiller +def _load_parameters( + self, + file_path: str, + file_name: str +): + """Load DLT camera parameters. + + Load the DLT camera parameters from a text file. + + Parameters + ---------- + file_path : str + File path where the camera parameters are saved. + file_name : str + Name of the file that contains the camera parameters. + + Returns + ------- + None + + """ + full_path = join(file_path, file_name) + + with open(full_path) as f: + + name = f.readline()[:-1] + + _r = f.readline()[:-2] + resolution = np.array([float(s) for s in _r.split()]) + + coefficients = [] + for _i in range(3): + _c = f.readline()[:-2] + coefficients.append(np.array([float(s) for s in _c.split()])) + + dtype = np.dtype(f.readline().strip()) + + coefficients = np.array(coefficients, dtype = dtype) + + self.name = name + self.resolution = resolution + self.coeffs = coefficients + self.dtype = np.dtype(dtype) diff --git a/openpiv/calibration/marker_detect/__init__.py b/openpiv/calibration/marker_detect/__init__.py new file mode 100644 index 00000000..b12f0839 --- /dev/null +++ b/openpiv/calibration/marker_detect/__init__.py @@ -0,0 +1,6 @@ +"""Marker and checkerboard detection for calibration images.""" + +from ._checkerboard_detection import * +from ._marker_detection import * + +__all__ = [s for s in dir() if not s.startswith('_')] diff --git a/openpiv/calibration/marker_detect/_checkerboard_detection.py b/openpiv/calibration/marker_detect/_checkerboard_detection.py new file mode 100644 index 00000000..167f6639 --- /dev/null +++ b/openpiv/calibration/marker_detect/_checkerboard_detection.py @@ -0,0 +1,495 @@ +import numpy as np +from scipy import ndimage + +__all__ = [ + 'detect_checkerboard' +] + + +def _rotate_image( + image: np.ndarray, + angle: float, + output_shape: tuple | None=None +) -> np.ndarray: + cosine, sine = np.cos(angle), np.sin(angle) + + matrix = np.array( + [ + [cosine, -sine], + [sine, cosine] + ] + ) + + center = (np.asarray(image.shape) - 1) / 2 + + if output_shape is None: + corners = np.array( + [ + [0, 0], + [0, image.shape[1] - 1], + [image.shape[0] - 1, 0], + [image.shape[0] - 1, image.shape[1] - 1] + ] + ) + + corners = np.dot( + corners - center, + matrix.T + ) + + output_shape = tuple( + np.ceil( + np.ptp(corners, axis=0) - 1e-12 + ).astype(int) + 1 + ) + + out_center = (np.asarray(output_shape) - 1) / 2 + + rows, cols = np.indices( + output_shape, + dtype=float + ) + + rows -= out_center[0] + cols -= out_center[1] + + source = np.array( + [ + cosine * rows + sine * cols + center[0], + -sine * rows + cosine * cols + center[1] + ] + ) + + rotated = np.zeros( + output_shape, + dtype=float + ) + + ndimage.map_coordinates( + image, + source, + output=rotated, + order=1, + mode="constant", + cval=0 + ) + + return rotated + + +def _corner_response( + image: np.ndarray, + length: int, + width: int +) -> np.ndarray: + """Create a response field using rotated images and blur filters. + + Create a response field of checkerboard corners using an approximated + radon filter. This is accomplished by rotating an image, applying a + box blur filter, and rotating the image back. + + """ + angles = [ + 0, + np.pi / 4, + np.pi / 2, + 3 * np.pi / 4 + ] + + averages = [] + + for angle in angles: + if angle == 0: + rotated = image + else: + rotated = _rotate_image( + image, + angle + ) + + filtered = ndimage.uniform_filter( + rotated, + (width, length), + mode="mirror" + ) + + if angle != 0: + filtered = _rotate_image( + filtered, + -angle, + image.shape + ) + + averages.append(filtered) + + averages = np.stack(averages) + + profile = np.rint(averages * 255) + + left, right = ( + np.roll(profile, 1, axis=0), + np.roll(profile, -1, axis=0) + ) + + maxima = (left <= profile) & (right < profile) + minima = (left > profile) & (right >= profile) + + junction = np.sum(maxima | minima, axis=0) == 2 + + response = np.where( + junction, + np.ptp(averages, axis=0)**2, + 0 + ) + + # As in OpenCV's FastX, suppress broad responses and smooth the corner impulses. + high = ndimage.uniform_filter( + response, + width, + mode="mirror" + ) + + low = ndimage.uniform_filter( + response, + length, + mode="mirror" + ) + + return np.maximum(high - 0.8 * low, 0) + + +## TODO: Use OpenPIV's 3 point subpixel estimators +def _gaussian_offset( + left: np.ndarray, + center: np.ndarray, + right: np.ndarray +) -> np.ndarray: + offset = np.zeros_like( + center, + dtype=float + ) + + valid = (left > 0) & (center > 0) & (right > 0) + + l, c, r = ( + np.log(values[valid]) + for values in (left, center, right) + ) + + denominator = 2 * (l - 2 * c + r) + + delta = np.divide( + l - r, + denominator, + out=np.zeros_like(c), + where=denominator < -np.finfo(float).eps + ) + + offset[valid] = np.clip(delta, -0.5, 0.5) + + return offset + + +def _mask_candidates( + response: np.ndarray, + border: int, + radius: int, + threshold_rel: float +) -> np.ndarray: + valid = np.zeros( + response.shape, + dtype=bool + ) + + valid[border:-border, border:-border] = True + peak = response[valid].max() + + if peak <= np.finfo(float).eps: + return np.zeros( + response.shape, + dtype=bool + ) + + local_maximum = ndimage.maximum_filter( + response, + size=2 * radius + 1 + ) + + minimum_response = peak * threshold_rel + + candidates = valid.copy() + candidates &= response >= minimum_response + candidates &= response > 0 + candidates &= response == local_maximum + + return candidates + + +def _refine_candidates( + response: np.ndarray, + candidates: np.ndarray +) -> np.ndarray: + labels, count = ndimage.label( + candidates, + structure=np.ones((3, 3)) + ) + + if count == 0: + return np.empty( + (2, 0), + dtype=float + ) + + positions = np.asarray( + ndimage.maximum_position( + response, + labels, + np.arange(1, count + 1) + ) + ) + + y, x = positions.T + + dx = _gaussian_offset( + response[y, x - 1], + response[y, x], + response[y, x + 1] + ) + + dy = _gaussian_offset( + response[y - 1, x], + response[y, x], + response[y + 1, x] + ) + + return np.array( + [ + x + dx, + y + dy + ] + ) + + +def _filter_candidates( + image: np.ndarray, + points: np.ndarray, + length: int +) -> np.ndarray: + if points.shape[1] == 0: + return points + + # Opposing samples agree at a checkerboard crossing, but not at an edge + angles = (np.arange(16) + 0.5) * (2 * np.pi / 16) + + ring = points[:, :, None] + (length / 2) * np.array( + [ + np.cos(angles), + np.sin(angles) + ] + )[:, None, :] + + samples = ndimage.map_coordinates( + image, + ring[::-1], + order=1, + mode="nearest", + prefilter=False + ) + + symmetry_error = np.mean( + np.abs( + samples - np.roll(samples, 8, axis=1) + ), + axis=1 + ) + + local_contrast = np.ptp( + samples, + axis=1 + ) + + valid = local_contrast > 0 + valid &= symmetry_error < 0.25 * local_contrast + + return points[:, valid] + + +def detect_checkerboard( + image: np.ndarray, + robust: bool=False, + filter_size: int=17, + min_distance: int | None=None, + threshold_rel: float=0.4 +) -> np.ndarray: + """Find subpixel checkerboard intersections for later point matching. + + Parameters + ---------- + image : (height, width) array_like + Finite, real grayscale intensities. Integer, floating and boolean + images are accepted; intensities are normalized internally. + robust : bool, optional + Upsample by two before filtering and peak fitting. The filter support + and peak separation are scaled too. Coordinates are returned in the + original pixel frame, including the half-pixel resize correction. + This reduces sampling error at the cost of memory and processing time. + filter_size : int, optional + Odd length of the directional box filter, at least 5, in original + pixels. The odd transverse width is approximately 2/7 of this length + (5 by 17 by default). Choose a length smaller than the tile spacing. + min_distance : int, optional + Peak suppression radius in original pixels (Chebyshev distance). + Defaults to half the filter size. Must be positive and smaller than + the distance between adjacent intersections. + threshold_rel : float, optional + Minimum response as a fraction of the strongest response, in (0, 1]. + + Returns + ------- + points : (2, n) ndarray + Floating-point image coordinates: x in the first row, y in the second, + consistent with the other calibration point functions. Pixel centers + start at (0, 0); y increases downward. + + Notes + ----- + Horizontal and vertical box averages are computed both before and after + an image rotation. Their angular contrast gives a localized Radon + response. Each local maximum is refined independently along x and y + using a three-point Gaussian fit. A point-symmetry check rejects ordinary + edges and tile outlines. Intersections within roughly one filter length + of the image boundary are excluded because their filter support is + incomplete. + + References + ---------- + .. [1] OpenCV contributors. FastX corner detector, implemented in + ``modules/calib3d/src/chessboard.cpp``. + https://github.com/opencv/opencv/blob/4.x/modules/calib3d/src/chessboard.cpp + + .. [2] Alexander Duda and Udo Frese. "Accurate Detection and Localization + of Checkerboard Corners for Calibration." Proceedings of the British + Machine Vision Conference (BMVC), 2018. + https://bmvc2018.org/contents/papers/0508.pdf + + Examples + -------- + >>> from openpiv.calibration.marker_detect import checkerboard_find_points + >>> points = checkerboard_find_points(calibration_image, robust=True) + >>> x, y = points + + """ + image = np.asarray(image) + + if image.ndim != 2 or image.size == 0: + raise ValueError( + "image must be a nonempty two-dimensional grayscale array" + ) + + if image.dtype.kind not in "buif" or not np.isfinite(image).all(): + raise ValueError( + "image must contain finite real intensities" + ) + + if ( + isinstance(filter_size, (bool, np.bool_)) or + not isinstance(filter_size, (int, np.integer)) or + filter_size < 5 or + filter_size % 2 != 1 + ): + raise ValueError( + "filter_size must be an odd integer of at least 5" + ) + + if min_distance is None: + min_distance = filter_size // 2 + + if ( + isinstance(min_distance, (bool, np.bool_)) or + not isinstance(min_distance, (int, np.integer)) or + min_distance < 1 + ): + raise ValueError( + "min_distance must be a positive integer" + ) + + if not np.isfinite(threshold_rel) or not 0 < threshold_rel <= 1: + raise ValueError( + "threshold_rel must be in (0, 1]" + ) + + empty = np.empty( + (2, 0), + dtype=float + ) + + image = image.astype(float) + + # Scaling before subtraction also handles very large finite float values. + magnitude = np.max(np.abs(image)) + + if magnitude: + image /= magnitude + + image -= image.min() + contrast = image.max() + + if contrast == 0: + return empty + + image /= contrast + + scale = 2 if robust else 1 + width = 2 * (filter_size // 7) + 1 + + # Include both post-filters and the Gaussian fit's neighbors. Keep the + # exclusion region fixed in original pixels when upsampling. + border = scale * ( + int( + np.ceil( + np.hypot(filter_size, width) / 2 + filter_size / 2 + ) + ) + 2 + ) + + length = (filter_size - 1) * scale + 1 + width = (width - 1) * scale + 1 + + if min(image.shape) * scale <= 2 * border: + return empty + + if robust: + image = ndimage.zoom( + image, + 2, + order=1, + mode="nearest", + grid_mode=True, + prefilter=False + ) + + response = _corner_response( + image, + length, + width + ) + + candidates = _mask_candidates( + response, + border, + min_distance * scale, + threshold_rel + ) + + points = _refine_candidates( + response, + candidates + ) + + points = _filter_candidates( + image, + points, + length + ) + + return (points + 0.5) / scale - 0.5 diff --git a/openpiv/calibration/marker_detect/_marker_detection.py b/openpiv/calibration/marker_detect/_marker_detection.py new file mode 100644 index 00000000..50b9f885 --- /dev/null +++ b/openpiv/calibration/marker_detect/_marker_detection.py @@ -0,0 +1,602 @@ +import numpy as np + +from scipy.ndimage import gaussian_filter, map_coordinates, label +from scipy.signal import fftconvolve +from scipy.ndimage import uniform_filter, maximum_filter +from .. import _cal_doc_utils + +__all__ = [ + 'detect_markers' +] + + +def _get_default_info_dict() -> dict[str, object]: + return { + 'status': 'no_contrast', + 'noise_sigma': 0., + 'min_distance': None, + 'spacing': None, + 'template_shape': None, + 'min_ncc': 0.55, + 'min_snr': 8., + 'scale_fallback': False + } + + +def _correlate_template( + image: np.ndarray, + template: np.ndarray, + eps: float=1e-6 +) -> np.ndarray: + centered = template - template.mean() + corr = fftconvolve(image, centered[::-1, ::-1], mode='same') + + mean = uniform_filter( + image, + size=template.shape, + mode='constant' + ) + variance = uniform_filter( + image**2, + size=template.shape, + mode='constant' + ) + variance -= mean**2 + np.maximum(variance, 0., out=variance) + + denominator = np.sqrt( + variance * template.size * np.sum(centered**2) + ) + valid = denominator > eps + corr[valid] /= denominator[valid] + corr[~valid] = 0. + + return corr + + +def _find_local_peaks( + image: np.ndarray, + min_distance: int=1, + threshold_abs: float=0., + exclude_border: bool | tuple[int, int]=False, + num_peaks: int | None=None +) -> np.ndarray: + local_max = maximum_filter( + image, size=2*min_distance + 1, mode='nearest' + ) + mask = image == local_max + + if mask.all(): + return np.empty((0, 2), dtype=int) + + mask &= image > threshold_abs + + if exclude_border: + border_y, border_x = exclude_border + + if border_y: + mask[:border_y] = False + mask[-border_y:] = False + if border_x: + mask[:, :border_x] = False + mask[:, -border_x:] = False + + coordinates = np.argwhere(mask) + order = np.argsort(-image[mask], kind='stable') + coordinates = coordinates[order] + limit = len(coordinates) if num_peaks is None else min(num_peaks, len(coordinates)) + + if min_distance <= 1 or limit == 0: + return coordinates[:limit] + + peaks = np.empty((limit, 2), dtype=int) + blocked = np.zeros(image.shape, dtype=bool) + count = 0 + radius = min_distance - 1 + + for y, x in coordinates: + if blocked[y, x]: + continue + + peaks[count] = y, x + count += 1 + + if count == limit: + break + + blocked[ + max(0, y-radius):y+radius+1, + max(0, x-radius):x+radius+1 + ] = True + + return peaks[:count] + + +def _subpixel_approximation( + corr: np.ndarray, + pos_x: np.ndarray, + pos_y: np.ndarray, + refine_radius: int=3, + refine_iter: int=5, + refine_cutoff: float=0.5, + eps: float=1e-6 +) -> tuple[np.ndarray, np.ndarray]: + """Iterative subpixel marker refinement. + + Subpixel iterative marker refinement using least squares 2D gaussian + peak fitting. Least squares minimization is performed by taking the + pseudo inverse of a matrix and multiplying it to the logarithm of the + correlation matrix. + + Paramters + --------- + corr : np.ndarray + A two dimensional array of marker response values of the shape (n, m). + pos_x, pos_y : np.ndarray + An one dimensional array containing local peak indexes of the shape (n,). + refine_radius : int, optional + Radius of the local correlation patch. Automatic detection uses 1. + refine_iter : int, optional + The amount of iterations to perform the gaussian subpixel estimation. + refine_cutoff : float, optional + The cutoff number to stop iterating. Should be between 0.25 to 0.5. + eps : float, optional + Small positive value used for the logarithm floor. Default is 1e-6. + + Returns + ------- + new_pos_x, new_pos_y : np.ndarray + An one dimensional array containing local peak indexes of the shape (n,). + + Notes + ----- + This function may become a nxn Gaussian subpixel interpolator for large + cross correlation peaks (e.g., usign PIV on non-particle images). Hence, + this function is documented to remind someone about this possiblity. + + """ + new_pos_x = np.array(pos_x, dtype=np.float64, copy=True) + new_pos_y = np.array(pos_y, dtype=np.float64, copy=True) + + _sx, _sy = np.meshgrid( + np.arange( + -refine_radius, + refine_radius + 1, + dtype=float + ), + np.arange( + -refine_radius, + refine_radius + 1, + dtype=float + ) + ) + + nx, ny = _sx.shape + + _sx = np.ravel(_sx) + _sy = np.ravel(_sy) + + s_arr = np.array( + [_sx, _sy, _sx**2, _sy**2, _sx*_sy, np.ones_like(_sx)], + dtype=float + ) + + s_arr = np.reshape( + np.concatenate(s_arr), + (6, nx * ny) + ).T + + # we use a psuedo-inverse via SVD so we can solve a system of equations. + # using iterative nonlinear methods is preferable here, though. + s_inv = np.linalg.pinv(s_arr) + + # TODO: optimize this loop + # TODO: See if iteration actually improves anything + for ind in range(pos_x.shape[0]): + x, y = pos_x[ind], pos_y[ind] + + for _ in range(refine_iter): + previous_x, previous_y = x, y + x = int(np.rint(x)) + y = int(np.rint(y)) + if (x - refine_radius < 0 or y - refine_radius < 0 or + x + refine_radius >= corr.shape[1] or + y + refine_radius >= corr.shape[0]): + x, y = previous_x, previous_y + break + + slices = ( + slice( + y - refine_radius, + y + refine_radius + 1), + slice( + x - refine_radius, + x + refine_radius + 1 + ) + ) + + corr_sec = corr[slices].copy() + corr_sec[corr_sec <= 0] = eps + + coef = np.dot( + s_inv, + np.log(np.ravel(corr_sec)) + ) + + determinant = 4 * coef[2] * coef[3] - coef[4]**2 + if coef[2] < 0.0 and coef[3] < 0.0 and determinant > 0.0: + # The xy term also fits tilted elliptical correlation peaks. + shift_x = (coef[4]*coef[1] - 2*coef[3]*coef[0]) / determinant + shift_y = (coef[4]*coef[0] - 2*coef[2]*coef[1]) / determinant + else: + shift_x = 0 + shift_y = 0 + + if (not np.isfinite([shift_x, shift_y]).all() or + abs(shift_x) > refine_radius or abs(shift_y) > refine_radius): + x, y = previous_x, previous_y + break + + new_x = x + shift_x + new_y = y + shift_y + + d = np.hypot(previous_x - new_x, previous_y - new_y) + + x = new_x + y = new_y + + if d < refine_cutoff: + break + + new_pos_x[ind] = x + new_pos_y[ind] = y + + return new_pos_x, new_pos_y + + + +def _get_auto_template( + image: np.ndarray, + noise_sigma: float + ) -> tuple[np.ndarray | None, int | None, float | None]: + # Bound autocorrelation memory for megapixel cameras. Use the most energetic + # of five representative tiles; peak extraction still uses the entire ROI. + if max(image.shape) > 768: + h, w = image.shape + th, tw = min(h, 768), min(w, 768) + tiles = [image[y:y+th, x:x+tw] for y, x in + ((0, 0), (0, w-tw), (h-th, 0), (h-th, w-tw), + ((h-th)//2, (w-tw)//2))] + image = max(tiles, key=lambda tile: np.var(tile)) + + window = np.outer(np.hanning(image.shape[0]), np.hanning(image.shape[1])) + centered = (image - image.mean()) * window + ac = fftconvolve(centered, centered[::-1, ::-1], mode="full") + cy, cx = np.array(image.shape) - 1 + + # Discount the white-noise spike at zero lag before measuring dot width. + ac[cy, cx] -= noise_sigma**2 * np.sum(window**2) + ac[cy, cx] = max(ac[cy, cx], ac[cy-1, cx], ac[cy+1, cx], + ac[cy, cx-1], ac[cy, cx+1]) + height = ac[cy, cx] + if height <= 0: + return None, None, None + + # Use a 2D component so elliptical/oblique dots retain separate x/y scales. + labels, _ = label(ac > 0.5 * height) + central_label = labels[cy, cx] + if central_label == 0: + return None, None, None + + yy, xx = np.nonzero(labels == central_label) + half_y = (yy.max() - yy.min()) / 2. + half_x = (xx.max() - xx.min()) / 2. + + if (min(half_y, half_x) < 1 or + max(half_y, half_x) > min(image.shape) / 8.): + return None, None, None + + # For a Gaussian dot, autocorrelation HWHM ~= 2*sqrt(log(2))*sigma. + sigma_y, sigma_x = np.maximum([half_y, half_x], 1.5) / 1.6651 + + # Mean removal narrows the measured lobe. + # So, flat-topped disks need more support than a Gaussian of this width. + sigma_y *= 1.5 + sigma_x *= 1.5 + ry, rx = np.maximum(3, np.ceil(3 * np.array([sigma_y, sigma_x]))).astype(int) + sy, sx = np.mgrid[-ry:ry+1, -rx:rx+1] + + template = np.exp(-0.5 * ((sx / sigma_x)**2 + (sy / sigma_y)**2)) + + # Side peaks are optional: irregular point fields do not need a lattice. + side_peaks = _find_local_peaks( + ac, min_distance=max(2, int(min(half_y, half_x))), + threshold_abs=0.2 * height, exclude_border=False, num_peaks=64 + ) + + distances = np.hypot(side_peaks[:, 0] - cy, side_peaks[:, 1] - cx) + distances = distances[distances > 2 * max(half_y, half_x) + 1] + spacing = float(distances.min()) if distances.size else None + min_distance = max(2, int(np.ceil(2 * min(sigma_y, sigma_x)))) + + if spacing is not None: + # For preventing accidental suppresion of markers + min_distance = max(2, min(int(spacing / 3), min_distance)) + + return template, min_distance, spacing + + +def _auto_marker_peaks( + image: np.ndarray, + template: np.ndarray, + noise_sigma: float, + min_distance: int, + eps: float=1e-6 +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + corr = _correlate_template(image, template, eps=eps) + + centered = template - template.mean() + + matched = fftconvolve( + image, + centered[::-1, ::-1], + mode="same" + ) + snr = matched.copy() + snr /= max(noise_sigma * np.linalg.norm(centered), eps) + + # NCC alone can assign high scores to nearly featureless noise patches + # So, try to filter out these random peaks + response = corr.copy() + response[snr < 8.] = -1. + border = tuple(int(n // 2) for n in template.shape) + peaks = _find_local_peaks( + response, min_distance=min_distance, threshold_abs=0.55, + exclude_border=border + ) + + if peaks.size: + strengths = matched[peaks[:, 0], peaks[:, 1]] + strong = strengths[:64] # Highest-scoring candidates first + floor = max(0.08 * np.median(strong), 0.005 * strengths.max()) + peaks = peaks[strengths >= floor] + + return peaks, corr, snr + + +@_cal_doc_utils.docfiller +def detect_markers( + image: np.ndarray, + invert: bool=False, + roi: list[int | None] | None=None, + template: np.ndarray | None=None, + refine_pos: bool=True, + return_corr: bool=False, + return_template: bool=False, + return_info: bool=False, + eps: float=1e-6 +) -> list[np.ndarray | dict[str, object]]: + """Detect candidate dot centers with an automatic template estimator. + + Taking a section of the image, generate a template and approximate + dot spacing based on autocorrelation of the calibration target. The + template is then correlated with the image, and marker positions are + extracted by locating local correlation peaks. Optionally, refine these + positions using a gaussian subpixel peak fit. + + Parameters + ---------- + image : 2D np.ndarray + A two dimensional array of pixel intensities of shape (n, m). + The image must contain finite values and is copied internally to + avoid modifying the original calibration image. + invert : bool, optional + If True, invert the image to detect dark markers on a light + background. Otherwise, detect bright markers on a dark background. + roi : list, optional + A four element list containing minimum x, y and maximum x, y in + pixels. Maximum bounds are exclusive. If None, use the full image. + The selected image region must be at least 16 pixels in each axis. + template : 2D np.ndarray, optional + A representative marker template. If None, estimate the template + automatically. A supplied template must have odd dimensions, + contain finite, nonconstant values, and fit inside the selected + image region. The template should represent a bright marker, + including when invert is True. + refine_pos : bool, optional + If True, refine the detected positions using a gaussian fit to + the local correlation peaks. Otherwise, return integer peak + locations stored as floating point coordinates. + return_corr : bool, optional + If True, append the normalized correlation field to the result. + The field has the same shape as the original image, with values + outside the selected image region set to zero. + return_template : bool, optional + If True, append the template used for marker detection. + return_info : bool, optional + If True, append a dictionary containing the detection status, + estimated noise, template shape, peak separation, approximate + dot spacing, and use of the alternative scale estimator. + eps : float, optional + Small positive value used for numerical floors. Default is 1e-6. + + Returns + ------- + %(image_points)s + + Notes + ----- + Image normalization and smooth background removal are performed + internally. Autocorrelation estimates marker size and separation. + The final template is constructed from observed marker images when + suitable candidates are available. If the calibration grid is black on + white (e.g., black dots), inverting the image is highly recommended. + + """ + image = np.array(image, dtype=np.float64, copy=True) + if image.ndim != 2 or image.size == 0 or not np.isfinite(image).all(): + raise ValueError('image must be a nonempty, finite 2D array') + + full_shape = image.shape + off_x = off_y = 0 + if roi is not None: + if len(roi) != 4: + raise ValueError('roi must contain xmin, ymin, xmax, ymax') + + x0, x1, _ = slice(roi[0], roi[2]).indices(image.shape[1]) + y0, y1, _ = slice(roi[1], roi[3]).indices(image.shape[0]) + + image = image[y0:y1, x0:x1] + off_x, off_y = x0, y0 + + if min(image.shape) < 32: + raise ValueError('image/roi must be at least 32 pixels on each axis') + + supplied = template is not None + if supplied: + template = np.array(template, dtype=np.float64, copy=True) + + # Check to make sure the template is odd, not larger than the image, + # and is not constant + flag = any(t > n for t, n in zip(template.shape, image.shape)) + flag |= any(n % 2 == 0 for n in template.shape) + flag |= np.ptp(template) == 0 + + if flag: + raise ValueError('Invalid template') + + positions = np.empty((2, 0), dtype=np.float64) + corr = np.zeros_like(image) + + # The info dict stores information about what occured during the marker + # detection process. This is usefull because sometimes the detector fails + # for seemingly no reason. The info dict can help figure out why it failed. + info = _get_default_info_dict() + + if np.ptp(image) > 0: + image -= image.min() + image /= image.max() + + if invert: + image = 1. - image + + differences = [np.diff(image, axis=axis).ravel() for axis in (0, 1)] + noise_sigma = min(np.median(np.abs(d - np.median(d))) + for d in differences) / (0.67448975 * np.sqrt(2.)) + noise_sigma = max(noise_sigma, eps) + + image -= gaussian_filter(image, sigma=max(3., min(image.shape) / 16.)) + + info.update(noise_sigma=noise_sigma, status="no_reliable_scale") + + if supplied: + min_distance = max(2, min(template.shape) // 4) + spacing = None + else: + template, min_distance, spacing = _get_auto_template(image, noise_sigma) + + if template is None: + # Strong smooth illumination can dominate the broad AC lobe. + # Retry scale estimation only at a narrower background scale. + scale_image = image - gaussian_filter(image, sigma=3.) + template, min_distance, spacing = _get_auto_template( + scale_image, + noise_sigma + ) + info['scale_fallback'] = True + + if template is not None: + peaks, corr, snr = _auto_marker_peaks( + image, + template, + noise_sigma, + min_distance, + eps=eps + ) + + if not supplied and peaks.size: + # Highest-scoring seeds; median resists a few unusual markers + order = np.argsort(corr[peaks[:, 0], peaks[:, 1]])[::-1][:64] + seeds = peaks[order] + pos_x, pos_y = _subpixel_approximation( + corr, + seeds[:, 1], + seeds[:, 0], + refine_radius=1, + refine_iter=1, + eps=eps + ) + + ry, rx = np.array(template.shape) // 2 + valid = ((pos_x >= rx) & (pos_y >= ry) & + (pos_x <= image.shape[1]-rx-1) & + (pos_y <= image.shape[0]-ry-1)) + pos_x, pos_y = pos_x[valid], pos_y[valid] + yy, xx = np.mgrid[-ry:ry+1, -rx:rx+1] + patches = [] + + for x, y in zip(pos_x, pos_y): + patch = map_coordinates(image, [y + yy, x + xx], order=1) + edge = np.concatenate( + [ + patch[0], + patch[-1], + patch[:, 0], + patch[:, -1] + ] + ) + patch = np.maximum(patch - np.median(edge), 0.) + + if patch.max() > 0: + patches.append(patch / patch.max()) + + if patches: + template = np.median(patches, axis=0) + peaks, corr, snr = _auto_marker_peaks( + image, + template, + noise_sigma, + min_distance, + eps=eps + ) + + positions = peaks[:, ::-1].T.astype(np.float64) + + if refine_pos and positions.shape[1]: + positions[0], positions[1] = _subpixel_approximation( + corr, + positions[0], + positions[1], + refine_radius=1, + refine_iter=1, + eps=eps + ) + + ry, rx = np.array(template.shape) // 2 + valid = ((positions[0] >= rx) & (positions[1] >= ry) & + (positions[0] <= image.shape[1]-rx-1) & + (positions[1] <= image.shape[0]-ry-1)) + positions = positions[:, valid] + + info.update( + status="ok" if positions.shape[1] else "no_peaks", + min_distance=min_distance, spacing=spacing, + template_shape=tuple(template.shape) + ) + + positions += np.array([[off_x], [off_y]]) + positions = positions[:, np.lexsort((positions[1], positions[0]))] + return_list = [positions] + + if return_corr: + full_corr = np.zeros(full_shape, dtype=np.float64) + full_corr[off_y:off_y+corr.shape[0], off_x:off_x+corr.shape[1]] = corr + return_list.append(full_corr) + + if return_template: + return_list.append(template if template is not None else np.empty((0, 0))) + + if return_info: + return_list.append(info) + + return return_list \ No newline at end of file diff --git a/openpiv/calibration/marker_match/__init__.py b/openpiv/calibration/marker_match/__init__.py new file mode 100644 index 00000000..c3964c27 --- /dev/null +++ b/openpiv/calibration/marker_match/__init__.py @@ -0,0 +1,7 @@ +"""Match calibration markers to object points.""" + +from ._match_manual import * +from ._match_marching import * +from ._match_points import * + +__all__ = [s for s in dir() if not s.startswith('_')] diff --git a/openpiv/calibration/marker_match/_match_manual.py b/openpiv/calibration/marker_match/_match_manual.py new file mode 100644 index 00000000..9400646a --- /dev/null +++ b/openpiv/calibration/marker_match/_match_manual.py @@ -0,0 +1,90 @@ +import numpy as np + +__all__ = [ + 'get_pairs_proj' +] + + +def get_pairs_proj( + cam: object, + object_points: np.ndarray, + image_points: np.ndarray, + tolerance: float=10 +): + """Match object points to image points via projection. + + Match object points to image points by projection with a rough + calibration of at least 6 points. This method is more reliable than + matching image points to object points based on homographies since + image points and object points are being projected and compared to + each other to find a best match. This allows non-planar calibration + plates to be used. + + Parameters + ---------- + cam : object + An instance of a camera. + proj_func : function + Projection function with the following signiture: + res = func(cam_struct, object_points). + image_points : 2D np.ndarray + 2D np.ndarray of [x, y]` image coordinates. + object_points : 2D np.ndarray + 2D np.ndarray of [X, Y, Z]` world coordinates. + tolerance : float, optional + The maximum RMS error between the image point and an object point. + + Returns + ------- + img_points : 2D np.ndarray + 2D matched image points of [x, y] in image coordinates. + obj_points : 2D np.ndarray + 2D matched object points of [x, y, z] in world coordinates. + + Notes + ----- + This function is used when a rough calibration is performed over some points of + the calibration plate. These points are usually manually selected and given + world point coordinates. At least 9 points for a pinhole model or 19 points for + a polynomial model are needed since this gives a good enough calibration to pair + the correct object points to image points. + + """ + object_points = np.array(object_points, dtype=np.float64) + image_points = np.array(image_points, dtype=np.float64) + + image_points_proj = cam.project_points( + object_points + ) + + obj_pairs = [] + img_pairs = [] + + for i in range(image_points.shape[1]): + min_j = -1 + min_rmse = 1000 + + for j in range(image_points_proj.shape[1]): + rmse = np.mean( + np.sqrt( + np.square( + image_points[:, i] - image_points_proj[:, j] + ), + ) + ) + + if rmse < min_rmse: + min_rmse = rmse + min_j = j + + if min_rmse < tolerance: + if min_j == -1: + continue + + obj_pairs.append(object_points[:, min_j]) + img_pairs.append(image_points[:, i]) + + return ( + np.array(img_pairs, dtype=np.float64).T, + np.array(obj_pairs, dtype=np.float64).T + ) diff --git a/openpiv/calibration/marker_match/_match_marching.py b/openpiv/calibration/marker_match/_match_marching.py new file mode 100644 index 00000000..8f76cf0d --- /dev/null +++ b/openpiv/calibration/marker_match/_match_marching.py @@ -0,0 +1,977 @@ +import numpy as np + +from .. import _cal_doc_utils +from ._match_points import find_median_point, find_nearest_points + +__all__ = [ + 'get_pairs_marching', + 'get_pairs_auto' +] + + +FORWARD = 1 +BACKWARD = -1 +HORIZONTAL = 0 +VERTICAL = 1 + + +def _initial_steps( + image_points: np.ndarray, + origin: np.ndarray, + origin_x: np.ndarray | None, + origin_y: np.ndarray | None +) -> np.ndarray: + selected_x = None + selected_y = None + + if origin_x is not None: + selected_x = find_nearest_points( + image_points, + origin_x + )[:, 0] - origin + + if origin_y is not None: + selected_y = find_nearest_points( + image_points, + origin_y + )[:, 0] - origin + + if selected_x is not None and selected_y is not None: + return np.column_stack([selected_x, selected_y]) + + # If the origin axes are not supplied, guestimate best candidate best + # candidate for marching spacing + + # Find closest neighboring points to origin + offsets = image_points - origin[:, None] + distances = np.linalg.norm(offsets, axis=0) + order = np.lexsort((image_points[1], image_points[0], distances)) + order = order[distances[order] > 1e-10][:8] + + neighbors = offsets[:, order] + angles = np.arctan2(neighbors[1], neighbors[0]) + neighbors = neighbors[:, np.argsort(angles)] + + best = None + best_score = None + + if selected_x is not None: + candidates = [(selected_x, point) for point in neighbors.T] + elif selected_y is not None: + candidates = [(point, selected_y) for point in neighbors.T] + else: + # Make a list of all possible axes pairs for testing + candidates = [ + (neighbors[:, i], neighbors[:, j]) + for i in range(neighbors.shape[1]) + for j in range( + i + 1, + neighbors.shape[1] + ) + ] + + for step_x, step_y in candidates: + step_x, step_y = step_x.copy(), step_y.copy() + + if selected_x is None and selected_y is None: + alignment_x = abs(step_x[0]) / np.linalg.norm(step_x) + alignment_y = abs(step_y[0]) / np.linalg.norm(step_y) + + if alignment_y > alignment_x: + step_x, step_y = step_y, step_x + + if step_x[0] < 0: + step_x = -step_x + + lengths = np.array([ + np.linalg.norm(step_x), + np.linalg.norm(step_y) + ]) + + basis = np.column_stack([step_x, step_y]) + determinant = np.linalg.det(basis) + + # Reject directions within about 17.5 degrees of parallel + if np.any(lengths == 0) or abs(determinant) < 0.3 * np.prod(lengths): + continue + + # Image x usually points right; positive grid y usually points up + if determinant > 0: + if selected_y is not None: + basis[:, 0] *= -1 + else: + basis[:, 1] *= -1 + + coordinates = np.linalg.solve(basis, neighbors) + residual = np.linalg.norm( + coordinates - np.rint(coordinates), + axis=0 + ) + support = np.count_nonzero(residual < 0.2) + + cosine = abs( + np.dot( + basis[:, 0], + basis[:, 1] + ) + ) / np.prod(lengths) + + score = (support, -cosine, -lengths.sum()) + + if best_score is None or score > best_score: + best, best_score = basis.copy(), score + + if best is None or best_score[0] < min( + 3, + neighbors.shape[1] + ): + msg = 'Cannot infer grid axes; select origin_x and origin_y' + raise ValueError(msg) + + return best + + +def _find_prediction( + image_points: np.ndarray, + prediction: np.ndarray, + basis: np.ndarray, + used: np.ndarray, + tolerance: float +) -> int: + available = np.flatnonzero(~used) + + if available.size == 0: + return -1 + + # Measure errors in local cells so unequal spacings and skew are allowed. + residual = np.linalg.solve( + basis, + image_points[:, available] - prediction[:, None] + ) + + nearest = find_nearest_points( + residual, + np.zeros(2), + threshold=tolerance, + flag_nans=True, + return_indices=True + )[0] + + return int(available[nearest]) if nearest >= 0 else -1 + + +def _search_line( + image_points: np.ndarray, + origin: np.ndarray, + step_x: np.ndarray, + step_y: np.ndarray, + axis: int, + sign: int, + tolerance: float=0.3, + max_missing: int=2, + used: np.ndarray | None=None, + stop_after_first: bool=False +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + basis = np.array( + [step_x, step_y], + dtype=np.float64 + ).T + + if used is None: + used = np.zeros(image_points.shape[1], dtype=bool) + index = find_nearest_points( + image_points, + origin, + return_indices=True + )[0] + + if np.allclose(image_points[:, index], origin): + used[index] = True + + current = np.asarray(origin, dtype=np.float64) + previous = current + previous_offset = 0 + missing = 0 + indices = [] + offsets = [] + steps = [] + + for offset in range( + 1, + image_points.shape[1] + max_missing + 2 + ): + prediction = current + sign * basis[:, axis] + index = _find_prediction( + image_points, + prediction, + basis, + used, + tolerance + ) + + if index < 0: + current = prediction + missing += 1 + + if missing > max_missing: + break + + continue + + point = image_points[:, index] + + # Average over intervening cells when detections are missing. + basis[:, axis] = (point - previous) / (sign * (offset - previous_offset)) + current = point + previous = point + previous_offset = offset + missing = 0 + used[index] = True + + indices.append(index) + offsets.append(sign * offset) + steps.append(basis[:, axis].copy()) + + if stop_after_first: + break + + return ( + np.asarray(indices, dtype=int), + np.asarray(offsets, dtype=int), + np.asarray(steps, dtype=np.float64).reshape(-1, 2).T + ) + + +def search_x_positive( + image_points: np.ndarray, + origin: np.ndarray, + step_x: np.ndarray, + step_y: np.ndarray, + **kwargs +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + return _search_line( + image_points, + origin, + step_x, + step_y, + HORIZONTAL, + FORWARD, + **kwargs + ) + + +def search_x_negative( + image_points: np.ndarray, + origin: np.ndarray, + step_x: np.ndarray, + step_y: np.ndarray, + **kwargs +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + return _search_line( + image_points, + origin, + step_x, + step_y, + HORIZONTAL, + BACKWARD, + **kwargs + ) + + +def search_y_positive( + image_points: np.ndarray, + origin: np.ndarray, + step_x: np.ndarray, + step_y: np.ndarray, + **kwargs +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + return _search_line( + image_points, + origin, + step_x, + step_y, + VERTICAL, + FORWARD, + stop_after_first=True, + **kwargs + ) + + +def search_y_negative( + image_points: np.ndarray, + origin: np.ndarray, + step_x: np.ndarray, + step_y: np.ndarray, + **kwargs +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + return _search_line( + image_points, + origin, + step_x, + step_y, + VERTICAL, + BACKWARD, + stop_after_first=True, + **kwargs + ) + + +def _fill_row( + image_points: np.ndarray, + origin: np.ndarray, + step_x: np.ndarray, + step_y: np.ndarray, + row: int, + indices: list[int], + grid: list[tuple[int, int]], + options: dict, + column: int=0 +) -> np.ndarray: + positive, positive_columns, positive_steps = search_x_positive( + image_points, + origin, + step_x, + step_y, + **options + ) + + negative, negative_columns, negative_steps = search_x_negative( + image_points, + origin, + step_x, + step_y, + **options + ) + + indices.extend(positive) + indices.extend(negative) + grid.extend((column + offset, row) for offset in positive_columns) + grid.extend((column + offset, row) for offset in negative_columns) + + if positive.size: + return positive_steps[:, 0] + + if negative.size: + return negative_steps[:, 0] + + return step_x + + +def _local_steps( + image_points: np.ndarray, + index: int, + coordinate: tuple[int, int], + indices: list[int], + grid: list[tuple[int, int]], + step_x: np.ndarray, + step_y: np.ndarray +) -> np.ndarray: + basis = np.array( + [step_x, step_y], + dtype=np.float64 + ).T + + for axis in (HORIZONTAL, VERTICAL): + other_axis = 1 - axis + neighbors = [ + (neighbor, cell[axis] - coordinate[axis]) + for neighbor, cell in zip(indices, grid) + if cell[other_axis] == coordinate[other_axis] + and cell[axis] != coordinate[axis] + ] + + if neighbors: + neighbor, offset = min( + neighbors, + key=lambda item: abs(item[1]) + ) + basis[:, axis] = ( + image_points[:, neighbor] - image_points[:, index] + ) / offset + + return basis + + +def _search_next_row( + image_points: np.ndarray, + column: int, + row: int, + step_x: np.ndarray, + step_y: np.ndarray, + direction: int, + indices: list[int], + grid: list[tuple[int, int]], + options: dict +) -> tuple[int, int, int, np.ndarray, np.ndarray] | None: + # Sometimes, the column used for expanding fails. So, try other columns. + seeds = [ + (index, cell) for index, cell in zip(indices, grid) if cell[1] == row + ] + seeds.sort(key=lambda item: abs(item[1][0] - column)) + + bases = [ + _local_steps( + image_points, + index, + cell, + indices, + grid, + step_x, + step_y + ) + for index, cell in seeds + ] + + for distance in range(1, options['max_missing'] + 2): + offset = direction * distance + + for (seed, cell), basis in zip(seeds, bases): + prediction = image_points[:, seed] + offset * basis[:, VERTICAL] + index = _find_prediction( + image_points, + prediction, + basis, + options['used'], + options['tolerance'] + ) + + if index < 0: + continue + + options['used'][index] = True + local_y = ( + image_points[:, index] - image_points[:, seed] + ) / offset + + return index, cell[0], row + offset, basis[:, HORIZONTAL], local_y + + return None + + +def _symmetric( + image_points: np.ndarray, + origin_index: int, + basis: np.ndarray, + spacing: np.ndarray, + tolerance: float=0.3, + max_missing: int=2 +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + origin = image_points[:, origin_index] + used = np.zeros( + image_points.shape[1], + dtype=bool + ) + used[origin_index] = True + indices = [origin_index] + grid = [(0, 0)] + + options = { + 'tolerance': tolerance, + 'max_missing': max_missing, + 'used': used + } + + step_x, step_y = basis[:, 0], basis[:, 1] + + row_step = _fill_row( + image_points, + origin, + step_x, + step_y, + 0, + indices, + grid, + options + ) + + for direction in (FORWARD, BACKWARD): + current = origin + local_x = row_step + local_y = step_y + column = 0 + row = 0 + + while True: + found = _search_next_row( + image_points, + column, + row, + local_x, + local_y, + direction, + indices, + grid, + options + ) + + if found is None: + break + + index, column, row, local_x, local_y = found + current = image_points[:, index] + + indices.append(index) + grid.append((column, row)) + + local_x = _fill_row( + image_points, + current, + local_x, + local_y, + row, + indices, + grid, + options, + column=column + ) + + grid = np.asarray( + grid, + dtype=np.float64 + ).T + + order = np.lexsort((grid[0], -grid[1])) + indices = np.asarray( + indices, + dtype=int + )[order] + + object_points = np.zeros((3, indices.size)) + object_points[:2] = grid[:, order] * spacing[:, None] + + return image_points[:, indices].copy(), object_points, indices + + +def _asymmetric( + image_points: np.ndarray, + matched_indices: np.ndarray, + origin: np.ndarray, + basis: np.ndarray, + spacing: np.ndarray, + asymmetric_z: float, + tolerance: float=0.3, + max_missing: int=2 +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + keep = np.ones( + image_points.shape[1], + dtype=bool + ) + keep[matched_indices] = False + + remaining_indices = np.flatnonzero(keep) + remaining = image_points[:, remaining_indices] + + prediction = origin + 0.5 * (basis[:, 1] - basis[:, 0]) + + index = _find_prediction( + remaining, + prediction, + basis, + np.zeros( + remaining_indices.size, + dtype=bool + ), + tolerance + ) + + if index < 0: + msg = 'No second-layer origin found near the half-cell shift' + raise ValueError(msg) + + paired, objects, indices = _symmetric( + remaining, + index, + basis, + spacing, + tolerance, + max_missing + ) + + objects += np.array( + [ + -spacing[0] / 2, + spacing[1] / 2, + asymmetric_z + ] + )[:, None] + + return paired, objects, remaining_indices[indices] + + +@_cal_doc_utils.docfiller +def get_pairs_marching( + image_points: np.ndarray, + origin: np.ndarray | None=None, + origin_x: np.ndarray | None=None, + origin_y: np.ndarray | None=None, + spacing: float=1.0, + asymmetric: bool=False, + asymmetric_z: float=0.0, + tolerance: float=0.25, + max_missing: int=2, + return_indices: bool=False +): + """Match a symmetric or two-layer asymmetric target. + + Pair detected image points with physical grid coordinates by + following rows and columns from a selected or automatically + determined origin. Local image-space step vectors update after + each accepted marker to follow changes in spacing and direction. + + Parameters + ---------- + %(image_points)s + origin : array_like, optional + Approximate image position of the zero grid point, of shape (2,). + The nearest detection is selected. If omitted for a symmetric + target, use the marker nearest to the median. + origin_x, origin_y : array_like, optional + Approximate image positions of the adjacent positive-x and + positive-y markers, each of shape (2,). Missing axes are inferred + from up to eight nearest neighbors for symmetric targets. + spacing : float or sequence of two floats, optional + Positive physical spacing between adjacent markers within one + layer. A scalar applies to both axes; (spacing_x, spacing_y) + specifies independent spacings. Both layers use the same spacing. + Default is 1.0. + asymmetric : bool, optional + Match two interleaved symmetric grids and return both layers. + Supplied origin and axis positions identify the first layer. + The second layer is offset by half a cell in -x and +y. Default + is False. + asymmetric_z : float, optional + Second-layer z coordinate relative to the first layer, in the + same physical units as spacing. Default is 0.0, allowing coplanar + interleaved grids. Used only when asymmetric is True. + tolerance : float, optional + Maximum Euclidean prediction error after expressing the image + displacement in local x/y grid steps. Default is 0.25. + max_missing : int, optional + Number of consecutive missing grid positions allowed before a + line search ends. Default is 2. + return_indices : bool, optional + Also return the column indices of matched detections in the + original image_points array. Default is False. + + Returns + ------- + %(image_points)s + %(object_points)s + indices : 1D np.ndarray, optional + Original input column indices of shape (m,), returned only when + return_indices is True. Their ordering matches both point arrays. + + Raises + ------ + ValueError + If automatic origin selection receives an empty point field, + grid axes cannot be inferred, required asymmetric seed positions + are omitted, or no second-layer origin is found near the + half-cell shift. + numpy.linalg.LinAlgError + If the projected grid basis is singular, for example when + supplied axis positions define parallel or zero-length steps. + + Notes + ----- + For asymmetric targets, first-layer matches are excluded before searching + the second layer. The image-space origin is predicted by a half-cell + shift along the selected axes, and the object coordinates receive the + corresponding x/y/z offsets. + + Automatic labeling can swap or reverse physical axes on rotated symmetric + targets. Supply both axis positions to fix the physical frame. Missing + markers on the y spine can leave entire rows unsearched. Unrelated + detections within the acceptance tolerance can still be matched. Each + accepted detection is used once, and input arrays are not modified. + + Origin-and-axis initialization is inspired by CalVi [1]_. Median-based + seed selection and expansion using locally updated grid steps follow + ideas described by Douskos et al. [2]_. + + References + ---------- + .. [1] G. Paolillo and T. Astarita, "CalVi User Guide", + PaIRS version 0.1.13, revised February 7, 2024, + Sections 9 and 11, pp. 11-13 and 19-20. + https://www.pairs.unina.it/web/CalVi-Guide.pdf + + .. [2] V. Douskos, I. Kalisperakis, G. Karras, and E. Petsa, + "Fully automatic camera calibration using regular planar + patterns", Int. Arch. Photogramm. Remote Sens. Spatial + Inf. Sci., XXXVII-B5, pp. 21-26, 2008. + See Section 2.1.2, pp. 22-23. + https://www.isprs.org/proceedings/XXXVII/congress/5_pdf/04.pdf + + Examples + -------- + >>> image_points, object_points, = match_pairs_marching( + ... image_points, + ... spacing=(5.0, 5.0) + ... ) + + >>> image_points, object_points, indices = match_pairs_marching( + ... image_points, + ... origin=origin, + ... origin_x=origin_x, + ... origin_y=origin_y, + ... spacing=(5.0, 5.0), + ... asymmetric=True, + ... asymmetric_z=2.0, + ... return_indices=True + ... ) + + """ + image_points = np.asarray( + image_points, + dtype=np.float64 + ) + spacing = np.asarray( + spacing, + dtype=np.float64 + ) + + if spacing.ndim == 0: + spacing = np.repeat( + spacing, + 2 + ) + + if asymmetric and (origin is None or origin_x is None or origin_y is None): + msg = 'An asymmetric search requires origin, origin_x and origin_y' + raise ValueError(msg) + + if spacing[0] <= 0.0 or spacing[1] <= 0.0: + raise ValueError('Grid spacing must be greater than 0') + + if origin is None: + origin = find_median_point(image_points) + + origin_index = find_nearest_points( + image_points, + origin, + return_indices=True + )[0] + + origin = image_points[:, origin_index] + + basis = _initial_steps( + image_points, + origin, + origin_x, + origin_y + ) + + paired, objects, indices = _symmetric( + image_points, + origin_index, + basis, + spacing, + tolerance, + max_missing + ) + + if asymmetric: + paired_second, objects_second, indices_second = _asymmetric( + image_points, + indices, + origin, + basis, + spacing, + asymmetric_z, + tolerance, + max_missing + ) + + paired = np.column_stack([paired, paired_second]) + objects = np.column_stack([objects, objects_second]) + indices = np.concatenate([indices, indices_second]) + + result = paired, objects + + return result + (indices,) if return_indices else result + + +@_cal_doc_utils.docfiller +def get_pairs_auto( + image_points: np.ndarray, + board_size: tuple[int, int], + origin: np.ndarray | None=None, + origin_x: np.ndarray | None=None, + origin_y: np.ndarray | None=None, + spacing: float | tuple[float, float]=1.0, + set_x_long: bool=True, + tolerance: float=0.25, + max_missing: int=2, + return_indices: bool=False +): + """Match a symmetric board using its known shape and a centered origin. + + March once with unit spacing to identify the board lattice. Use its + shape and image directions to select an origin and adjacent axis + markers, then march again with the requested physical spacing. + + Parameters + ---------- + %(image_points)s + board_size : sequence of two ints + Full marker counts along the two board directions, including + missing detections. Either order is accepted; set_x_long controls + which direction becomes x. For a checkerboard, these are internal + corner counts. + origin : array_like, optional + Approximate image position of the desired origin, of shape (2,). + If omitted, select board cell (nx // 2, ny // 2), counted from + the upper-left after assigning the axes. + origin_x, origin_y : array_like, optional + Approximate image positions of adjacent positive-x and positive-y + markers, each of shape (2,). Omitted positions use the origin's + rightward and upward grid neighbors, respectively. + spacing : float or sequence of two floats, optional + Positive physical marker spacing. A scalar applies to both axes; + (spacing_x, spacing_y) follows the final x/y assignment. + Default is 1.0. + set_x_long : bool, optional + Assign x to the direction with more markers on rectangular + boards. If False, assign x to the direction with fewer markers. + Square boards use the more horizontal direction for x regardless + of this setting. Default is True. + tolerance : float, optional + Maximum prediction error in local grid cells, passed to both + marching calls. Default is 0.25. + max_missing : int, optional + Consecutive missing positions allowed during marching, passed to + both calls. Default is 2. + return_indices : bool, optional + Also return matched column indices in the original image_points + array. Default is False. + + Returns + ------- + %(image_points)s + %(object_points)s + indices : 1D np.ndarray, optional + Original input column indices of shape (k,), returned only when + return_indices is True. + + Raises + ------ + ValueError + If either march does not recover the requested board shape, an + automatically selected origin or axis marker is missing, or the + marching function cannot establish the grid. + + """ + detected_points = np.asarray(image_points, dtype=np.float64) + expected_shape = np.sort(board_size) + + rough_image_points, rough_object_points = get_pairs_marching( + detected_points, + origin=origin, + origin_x=origin_x, + origin_y=origin_y, + spacing=1.0, + tolerance=tolerance, + max_missing=max_missing + ) + + grid = np.rint(rough_object_points[:2]).astype(int) + shape = np.ptp(grid, axis=1) + 1 + + if not np.array_equal(np.sort(shape), expected_shape): + msg = ( + f'Detected board shape {tuple(shape.tolist())} does not match ' + f'board_size={tuple(board_size)}' + ) + raise ValueError(msg) + + # Estimate image directions from the first-pass correspondences. + coordinates = np.column_stack([ + grid.T, + np.ones(grid.shape[1]) + ]) + directions = np.linalg.lstsq( + coordinates, + rough_image_points.T, + rcond=None + )[0][:2].T + + if shape[0] == shape[1]: + horizontal = abs(directions[0]) / np.linalg.norm(directions, axis=0) + x_axis = int(np.argmax(horizontal)) + elif set_x_long: + x_axis = int(np.argmax(shape)) + else: + x_axis = int(np.argmin(shape)) + + axes = [x_axis, 1 - x_axis] + grid = grid[axes] + directions = directions[:, axes] + shape = shape[axes] + + if directions[0, 0] < 0: + grid[0] *= -1 + + if directions[1, 1] > 0: + grid[1] *= -1 + + # Board columns increase rightward; rows increase downward. + grid[0] -= grid[0].min() + grid[1] = grid[1].max() - grid[1] + + if origin is None: + origin_cell = shape // 2 + else: + index = find_nearest_points( + rough_image_points, + origin, + return_indices=True + )[0] + origin_cell = grid[:, index] + + cells = [ + origin_cell, + origin_cell + [1, 0], + origin_cell + [0, -1] + ] + positions = [origin, origin_x, origin_y] + names = ['origin', 'origin_x', 'origin_y'] + + for i in range(3): + if positions[i] is not None: + continue + + index = np.flatnonzero(np.all(grid == cells[i][:, None], axis=0)) + + if index.size == 0: + msg = ( + f'The required {names[i]} marker is missing at ' + f'board cell {tuple(cells[i].tolist())}' + ) + raise ValueError(msg) + + positions[i] = rough_image_points[:, index[0]] + + result = get_pairs_marching( + detected_points, + origin=positions[0], + origin_x=positions[1], + origin_y=positions[2], + spacing=spacing, + tolerance=tolerance, + max_missing=max_missing, + return_indices=return_indices + ) + + spacing = np.broadcast_to(np.asarray(spacing, dtype=np.float64), (2,)) + grid = np.rint(result[1][:2] / spacing[:, None]).astype(int) + shape = np.ptp(grid, axis=1) + 1 + + if not np.array_equal(np.sort(shape), expected_shape): + msg = ( + f'Final board shape {tuple(shape.tolist())} does not match ' + f'board_size={tuple(board_size)}' + ) + raise ValueError(msg) + + return result diff --git a/openpiv/calibration/marker_match/_match_points.py b/openpiv/calibration/marker_match/_match_points.py new file mode 100644 index 00000000..ec795f3d --- /dev/null +++ b/openpiv/calibration/marker_match/_match_points.py @@ -0,0 +1,255 @@ +import numpy as np + +from .. import _cal_doc_utils + +__all__ = [ + 'find_median_point', + 'find_middle_point', + 'find_nearest_points', + 'show_calibration_image' +] + + +# @author: Theo +# Created on Thu Mar 25 21:03:47 2021 + +# @ErichZimmer - Changes (June 2, 2023): +# Revised function + +# @ErichZimmer - Changes (Decemmber 2, 2023): +# Revised function +def show_calibration_image( + image: np.ndarray, + markers: np.ndarray, + figsize=(8,11), + radius: int=30, + fontsize=20 +): + """Plot markers on image. + + Plot markers on image and their associated index. This allows one to find the + origin, x-axis, and y-axis point indexes for object-image point matching. + + Parameters + ---------- + image : 2D np.ndarray + A 2D array containing grayscale pixel intensities. + markers : 2D np.ndarray + A 2D array containing image marker coordinates in [x, y]` image coordinates. + radius : int, optional + The radius of the circle drawn around the marker point. + + Returns + ------- + None + + Let cal_img be a calibration image. + + >>> marks_pos = calib_utils.detect_markers_template( + cal_img, + window_size = 64, + template_radius=5, + min_peak_height = 0.2, + merge_radius = 10, + merge_iter=5, + min_count=8, + ) + + >>> calib_utils.show_calibration_image( + cal_img, + marks_pos + ) + + """ + from matplotlib import pyplot as plt + from PIL import Image, ImageDraw, ImageFont + + markers = markers.T + + # funtction to show th clalibration iamge with numbers and circles + plt.close('all') + + marker_numbers=np.arange(0,np.size(markers[:,0])) + + image_p = Image.fromarray(np.uint8((image/np.max(image[::]))*255)) + + draw = ImageDraw.Draw(image_p) + font = ImageFont.truetype("arial.ttf", fontsize) + + for i in range(np.size(markers, 0)): + x, y=markers[i,:] + draw.text((x, y), str(marker_numbers[i]), fill=(255), + anchor='mb',font=font) + + plt.figure(1) + fig, ax = plt.subplots(figsize=figsize) + ax.imshow(image_p) + + for marker in markers: + x, y = marker + c = plt.Circle((x, y), radius, color='red', linewidth=2, fill=False) + ax.add_patch(c) + + plt.show() + plt.pause(1) + + +@_cal_doc_utils.docfiller +def find_nearest_points( + image_points: np.ndarray, + point: np.ndarray, + threshold: float | None=None, + flag_nans: bool=False, + return_indices: bool=False +): + """Locate the nearest image point. + + Locate the closest image point to the user selected image point. + This function is implemented by find the minimum distance to the + point of interest, so it will always return a point no matter how + (un)realistic that point is. + + Parameters + ---------- + %(image_points)s + point : 2D np.ndarray + A 2D array of points of interest in [x, y]' image coordinates. + threshold : float, optional + If set, distances that are greater than the threshold are ignored. + flag_nans : bool, optional + If enabled, set flagged points that exceed the threshold to nan. + Otherwise, the flagged points are not returned. Rejected indices + are represented by -1 when return_indices is enabled. + return_indices : bool, optional + Return input column indices instead of matched coordinates. + + Returns + ------- + points : np.ndarray + Matched coordinates of shape (2, m), or a one-dimensional array + of input column indices when return_indices is enabled. + + """ + dist = np.linalg.norm( + [ + image_points[0][:, np.newaxis] - point[0], + image_points[1][:, np.newaxis] - point[1] + ], + axis=0 + ) + + index = np.argmin(dist, axis = 0) + + set_to_nan = np.zeros_like(index).astype("bool") + + if threshold is not None: + min_dist = np.min(dist, axis=0) + + if flag_nans: + set_to_nan[min_dist > threshold] = True + + else: + index = index[min_dist < threshold] + set_to_nan = np.zeros_like(index).astype("bool") + + if return_indices: + index[set_to_nan] = -1 + return index + + candidate_points = image_points[:, index] + + if flag_nans: + candidate_points = candidate_points.astype(np.float64, copy=False) + candidate_points[:, set_to_nan] = np.nan + + return candidate_points + + +@_cal_doc_utils.docfiller +def find_middle_point( + image_points: np.ndarray, + return_index: bool=False +): + """Find the detection nearest the coordinate-wise field mean. + + Calculate the mean x and y coordinates of the detection field, then + select the nearest detected point. + + Parameters + ---------- + %(image_points)s + return_index : bool, optional + Return the original input column index instead of its coordinates. + Default is False. + + Returns + ------- + point : np.ndarray or int + Coordinates of shape (2,), or an integer input column index when + return_index is True. + + Raises + ------ + ValueError + If the input detection field is empty. + + """ + if image_points.shape[1] == 0: + msg = 'The point field is empty' + raise ValueError(msg) + + nearest = find_nearest_points( + image_points, + np.mean(image_points, axis=1), + return_indices=return_index + ) + + if return_index: + return int(nearest[0]) + + return nearest[:, 0] + + +@_cal_doc_utils.docfiller +def find_median_point( + image_points: np.ndarray, + return_index: bool=False +): + """Find the detection nearest the coordinate-wise field median. + + Calculate the median x and y coordinates of the detection field, then + select the nearest detected point. + + Parameters + ---------- + %(image_points)s + return_index : bool, optional + Return the original input column index instead of its coordinates. + Default is False. + + Returns + ------- + point : np.ndarray or int + Coordinates of shape (2,), or an integer input column index when + return_index is True. + + Raises + ------ + ValueError + If the input detection field is empty. + + """ + if image_points.shape[1] == 0: + msg = 'The point field is empty' + raise ValueError(msg) + + nearest = find_nearest_points( + image_points, + np.median(image_points, axis=1), + return_indices=return_index + ) + + if return_index: + return int(nearest[0]) + + return nearest[:, 0] diff --git a/openpiv/calibration/pinhole_model/__init__.py b/openpiv/calibration/pinhole_model/__init__.py new file mode 100644 index 00000000..54ae4057 --- /dev/null +++ b/openpiv/calibration/pinhole_model/__init__.py @@ -0,0 +1,29 @@ +""" +==================== +Pinhole Camera Model +==================== + +This module contains an implementation of the pinhole camera model. This +model is an approximation of how light rays are captured by a camera. Under +ideal circumstances, lab coordinates can be mapped to image sensor +coordinates (also known as pixel coordinates). However, cameras are not +usually ideal and are placed arbitrarily in the lab space. This means that +the lab coordinates have to be transformed into normalized camera +coordinates to remove this arbitrary translation. The normalized camera +coordinates are calculated as explained in readme pinhole camera model file. + + +Functions +========= + calibrate_intrinsics - Calculate the intrinsic parameters using Zang's algorithm + camera - Create an instance of a Pinhole camera model + calibrate_dlt - Calibrate and return DLT coefficients and fitting error + line_intersect - Using two lines, locate where those lines intersect + multi_line_intersect - Using multiple lines, approximate their intersection + +""" +from ._camera import * +from ._epipolar_geom import * +from ._zang import * + +__all__ = [s for s in dir() if not s.startswith('_')] diff --git a/openpiv/calibration/pinhole_model/_camera.py b/openpiv/calibration/pinhole_model/_camera.py new file mode 100644 index 00000000..12a81f62 --- /dev/null +++ b/openpiv/calibration/pinhole_model/_camera.py @@ -0,0 +1,152 @@ + +import numpy as np +from numpy.typing import DTypeLike + +from ._check_params import _check_parameters +from ._distortion import ( + _distort_points_brown, + _distort_points_poly, + _undistort_points_brown, + _undistort_points_poly, +) +from ._minimization import _minimize_params +from ._projection import ( + _get_inverse_vector, + _normalize_image_points, + _normalize_world_points, + _project_points, + _project_to_z, +) +from ._utils import _get_rotation_matrix, _load_parameters, _save_parameters + +__all__ = ['camera'] + + +class camera: + """An instance of a DLT camera model. + + Create an instance of a pinhole camera. The pinhole camera model is + based on a physically constrained model where image points and object + points are mapped through an extrinsic and intrinsic matrix with a + distortion compensation model. + + Attributes + ---------- + name : str + Name of camera. + resolution : tuple[int, int] + Resolution of camera in x and y axes respectively. + translation : 1D np.ndarray-like + Location of camera origin/center in x, y, and z axes respectively. + orientation : 1D np.ndarray-like + Orientation of camera in x, y, z axes respectively. + rotation : 2D np.ndarray + Rotational camera parameter for camera system. + distortion_model : str + The type of distortion model to use. + + ``brown`` + The Brown model follows the distortion model incorporated by OpenCV. + It consists of a radial and tangential model to compensate for + distortion. + + ``polynomial`` + The polynomial model is used for general distortion compensation. It + consists of a 2nd order polynomial in the x and y axes. This + distortion model relies on the theory put forth by MyPTV and copies + the model in whole. + + Both models do not attempt to correct distortions along the z-plane. + + distortion1 : 2D np.ndarray + Radial and tangential distortion compensation matrix for a camera. + distortion2 : 2D np.ndarray + 2nd order polynomial distortion compensation matrix for a camera. + focal : tuple[float, float] + Focal distance/magnification of camera-lens system for x any y axis + respectively. + principal : tuple[float, float] + Principal point offset for x any y axis respectively. + dtype : np.dtype + The dtype used in the projections. All data is copied if the dtype is + different. It is highly unadvisable to change this parameter. + + Methods + ------- + minimize_params + project_points + project_to_z + save_parameters + load_parameters + + References + ---------- + .. [1] Shnapp, R. (2022). MyPTV: A Python Package for 3D Particle + Tracking. J. Open Source Softw., 7, 4398. + + .. [2] Čuljak, I., Abram, D., Pribanić, T., Džapo, H., & Cifrek, M. + (2012). A brief introduction to OpenCV. 2012 Proceedings of the + 35th International Convention MIPRO, 1725-1730. + + """ + def __init__( + self, + name: str, + resolution: tuple[int, int], + translation: np.ndarray=None, + orientation: np.ndarray=np.zeros(3, dtype=np.float64), + distortion_model: str="polynomial", + distortion1: np.ndarray=np.zeros(8, dtype=np.float64), + distortion2: np.ndarray=np.zeros([2, 5], dtype=np.float64), + focal: tuple[float, float]=[1.0, 1.0], + principal: tuple[float, float] | None=None, + dtype: DTypeLike=np.float64 + ): + if translation is None: + translation = [0, 0, 1] + translation = np.array(translation, dtype=dtype) + orientation = np.array(orientation, dtype=dtype) + distortion1 = np.array(distortion1, dtype=dtype) + distortion2 = np.array(distortion2, dtype=dtype) + + self.name = name + self.resolution = resolution + self.translation = np.array(translation) + self.orientation = np.array(orientation) + self.distortion_model = distortion_model + self.distortion1 = distortion1 + self.distortion2 = distortion2 + self.focal = focal + self.dtype = np.dtype(dtype) + + if principal is not None: + self.principal = principal + else: + # temporary place holder + self.principal = [0, 0] + + # method definitions + camera._check_parameters = _check_parameters + camera._get_inverse_vector = _get_inverse_vector + camera._get_rotation_matrix = _get_rotation_matrix + camera._normalize_world_points = _normalize_world_points + camera._normalize_image_points = _normalize_image_points + camera._undistort_points_brown = _undistort_points_brown + camera._distort_points_brown = _distort_points_brown + camera._undistort_points_poly = _undistort_points_poly + camera._distort_points_poly = _distort_points_poly + camera.minimize_params = _minimize_params + camera.project_points = _project_points + camera.project_to_z = _project_to_z + camera.save_parameters = _save_parameters + camera.load_parameters = _load_parameters + + # check parameters + self._check_parameters() + + # fix principal point if necessary (placed here due to error checking) + if principal is None: + self.principal = [self.resolution[0] / 2, self.resolution[1] / 2] + + # get roation matrix + self._get_rotation_matrix() diff --git a/openpiv/calibration/pinhole_model/_check_params.py b/openpiv/calibration/pinhole_model/_check_params.py new file mode 100644 index 00000000..eba804fa --- /dev/null +++ b/openpiv/calibration/pinhole_model/_check_params.py @@ -0,0 +1,51 @@ +import numpy as np + + +def _check_parameters(self): + if not isinstance(self.name, str): + raise ValueError('Camera name must be a string') + + if len(self.resolution) != 2: + raise ValueError('Resolution must be a two element tuple') + + if self.translation.shape != (3,): + msg = 'Translation must be a three element 1D numpy ndarray' + raise ValueError(msg) + + if self.orientation.shape != (3,): + msg = 'Orientation must be a three element 1D numpy ndarray' + raise ValueError(msg) + + if self.distortion_model.lower() not in ['brown', 'polynomial']: + msg = 'Distortion model must be either "brown" or "polynomial", not ' +\ + f'{self.distortion_model}' + raise ValueError(msg) + + if self.distortion1.shape != (8,): + msg = 'Radial and tangential distortion coefficients must be ' +\ + 'an 8 element 1D numpy ndarray' + raise ValueError(msg) + + if not isinstance(self.distortion2, np.ndarray): + msg = 'Polynomial distortion coefficients must be a numpy ndarray' + raise ValueError(msg) + + if self.distortion2.shape != (2, 5): + msg = 'Polynomial distortion coefficients must be a 2x5 numpy ndarray' + raise ValueError(msg) + + if not isinstance(self.focal, (tuple, list, np.ndarray)): + raise ValueError('Focal point must be a tuple or list') + + if len(self.focal) != 2: + raise ValueError('Focal point must be a two element tuple or list') + + if not isinstance(self.principal, (tuple, list, np.ndarray)): + raise ValueError('Principal point must be a tuple or list') + + if len(self.principal) != 2: + msg = 'Principal point must be a two element tuple or list' + raise ValueError(msg) + + if self.dtype not in [np.float32, np.float64]: + raise ValueError('Dtype is not supported for camera calibration') diff --git a/openpiv/calibration/pinhole_model/_distortion.py b/openpiv/calibration/pinhole_model/_distortion.py new file mode 100644 index 00000000..399ee427 --- /dev/null +++ b/openpiv/calibration/pinhole_model/_distortion.py @@ -0,0 +1,233 @@ +import numpy as np + + +def _undistort_points_brown( + self, + xd: np.ndarray, + yd: np.ndarray +): + """Undistort normalized points. + + Undistort normalized camera points using a radial and tangential + distortion model. + + Parameters + ---------- + xd : 1D np.ndarray + Distorted x-coordinates. + yd : 1D np.ndarray + Distorted y-coordinates. + + Returns + ------- + x : 1D np.ndarray + Undistorted x-coordinates. + y : 1D np.ndarray + Undistorted y-coordinates. + + Notes + ----- + Distortion model is based off of OpenCV. The direct link where the + distortion model was accessed is provided below. + https://docs.opencv.org/3.4/d9/d0c/group__calib3d.html + + """ + k = self.distortion1 + dtype = self.dtype + + r2 = xd*xd + yd*yd + r4 = r2 * r2 + r6 = r4 * r2 + + num = 1 + k[0]*r2 + k[1]*r4 + k[4]*r6 + den = 1 + k[5]*r2 + k[6]*r4 + k[7]*r6 + + delta_x = k[2]*2*xd*yd + k[3]*(r2 + 2*xd*xd) + delta_y = k[3]*2*xd*yd + k[2]*(r2 + 2*yd*yd) + + x = xd*(num / den) + delta_x + y = yd*(num / den) + delta_y + + return np.array([x, y], dtype=dtype) + + +def _distort_points_brown( + self, + x: np.ndarray, + y: np.ndarray +): + """Distort normalized points. + + Distort normalized camera points using a radial and tangential + distortion model. + + Parameters + ---------- + x : 1D np.ndarray + Undistorted x-coordinates. + y : 1D np.ndarray + Undistorted y-coordinates. + + Returns + ------- + xd : 1D np.ndarray + Distorted x-coordinates. + yd : 1D np.ndarray + Distorted y-coordinates. + + Notes + ----- + Distortion model is based off of OpenCV. The direct link where the + distortion model was accessed is provided below. + https://docs.opencv.org/3.4/d9/d0c/group__calib3d.html + + """ + k = self.distortion1 + dtype = self.dtype + + r2 = x*x + y*y + r4 = r2 * r2 + r6 = r4 * r2 + + den = 1 + k[0]*r2 + k[1]*r4 + k[4]*r6 + num = 1 + k[5]*r2 + k[6]*r4 + k[7]*r6 + + delta_x = k[2]*2*x*y + k[3]*(r2 + 2*x*x) + delta_y = k[3]*2*x*y + k[2]*(r2 + 2*y*y) + + xd = (x - delta_x) * (num / den) + yd = (y - delta_y) * (num / den) + + return np.array([xd, yd], dtype=dtype) + + +def _undistort_points_poly( + self, + xd: np.ndarray, + yd: np.ndarray +): + """Undistort normalized points. + + Undistort normalized camera points using a polynomial distortion model. + + Parameters + ---------- + xd : 1D np.ndarray + Distorted x-coordinates. + yd : 1D np.ndarray + Distorted y-coordinates. + + Returns + ------- + x : 1D np.ndarray + Undistorted x-coordinates. + y : 1D np.ndarray + Undistorted y-coordinates. + + Notes + ----- + Distortion model based wholly on by MyPTV. The link is provided below. + https://github.com/ronshnapp/MyPTV/tree/master/myptv + + The polynomial is of the 2nd order type, with the coefficients arranged + like such: coeff = [1, x, y, x**2, y**2, x*y]. This effectively allows + any distortion in the x and y axes to be compensated. However, the + polynomial model is not stable when extrapolating, so beware of + artifcacts. + + References + ---------- + .. [1] Shnapp, R. (2022). MyPTV: A Python Package for 3D Particle + Tracking. J. Open Source Softw., 7, 4398. + + """ + dtype = self.dtype + coeffs = self.distortion2 + + poly = np.array([xd, yd, xd**2, yd**2, xd * yd]) + + e_ = np.dot(coeffs, poly) + + # Calculate derivatives of the polynomials + e_0 = e_[0] + a, b, c, d, ee = coeffs[0,:] + e_xd_0 = a + 2*c*xd + ee*yd + e_yd_0 = b + 2*d*yd + ee*xd + + e_1 = e_[1] + a, b, c, d, ee = coeffs[1,:] + e_xd_1 = a + 2*c*xd + ee*yd + e_yd_1 = b + 2*d*yd + ee*xd + + # Calculate the inverse of the polynomials using derivatives + A11 = 1.0 + e_xd_0 + A12 = e_yd_0 + A21 = e_xd_1 + A22 = 1.0 + e_yd_1 + + rhs1 = xd*(1.0 + e_xd_0) + yd*e_yd_0 - e_0 + rhs2 = yd*(1.0 + e_yd_1) + xd*e_xd_1 - e_1 + + Ainv = np.array([[A22, -A12],[-A21, A11]]) / (A11*A22 - A12*A21) + + xn = Ainv[0, 0] * rhs1 + Ainv[0, 1] * rhs2 + yn = Ainv[1, 0] * rhs1 + Ainv[1, 1] * rhs2 + + return np.array([xn, yn], dtype=dtype) + + +def _distort_points_poly( + self, + xn: np.ndarray, + yn: np.ndarray +): + """Distort normalized points. + + Distort normalized camera points using a polynomial distortion model. + + Parameters + ---------- + xn : 1D np.ndarray + Undistorted x-coordinates. + yn : 1D np.ndarray + Undistorted y-coordinates. + + Returns + ------- + xd : 1D np.ndarray + Distorted x-coordinates. + yd : 1D np.ndarray + Distorted y-coordinates. + + Notes + ----- + Distortion model is inspired by MyPTV. The link is provided below. + https://github.com/ronshnapp/MyPTV/tree/master/myptv + + The polynomial is of the 2nd order type, with the coefficients arranged + like such: coeff = [x, y, x**2, y**2, x*y]. This effectively allows + any distortion in the x and y axes to be compensated. However, the + polynomial model is not stable when extrapolating, so beware of + artifcacts. + + To compute the inverse of the distortion model, we use compute the error + term and add it to the normalized camera coordinated. This method does + not require further iterations for refiunement. + + References + ---------- + .. [1] Shnapp, R. (2022). MyPTV: A Python Package for 3D Particle + Tracking. J. Open Source Softw., 7, 4398. + + """ + dtype = self.dtype + coeffs = self.distortion2 + + poly = np.array([xn, yn, xn**2, yn**2, xn * yn]) + + e_x, e_y = np.dot(coeffs, poly) + + xd = xn + e_x + yd = yn + e_y + + return np.array([xd, yd], dtype=dtype) diff --git a/openpiv/calibration/pinhole_model/_epipolar_geom.py b/openpiv/calibration/pinhole_model/_epipolar_geom.py new file mode 100644 index 00000000..1328097d --- /dev/null +++ b/openpiv/calibration/pinhole_model/_epipolar_geom.py @@ -0,0 +1,132 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - import only for type hints + from .. import pinhole_model + +import numpy as np + +from .._epipolar_utils import _line_intersect, _multi_line_intersect + +__all__ = [ + 'line_intersect', + 'multi_line_intersect' +] + + +def line_intersect( + cam_1: "pinhole_model.camera", + cam_2: "pinhole_model.camera", + img_points_1: np.ndarray, + img_points_2: np.ndarray +): + """Calculate where two rays intersect. + + Using two cameras, calculate the world coordinates where two rays + intersect. This is done through an analytical solution based on + direction vectors and camera origins/translations. + + Parameters + ---------- + cam_1, cam_2 : pinhole_model.camera + An instance of a Pinhole camera. + img_points_1, img_points_2 : np.ndarray + Image coordinates stored in a ndarray structured like [x, y]'. + + Returns + ------- + coords : np.ndarray + The world coordinate that is nearest to the two rays intersecting. + dist : float + The minimum dinstance between the two rays. + + """ + cam_1._check_parameters() + cam_2._check_parameters() + + dtype1 = cam_1.dtype + dtype2 = cam_2.dtype + + # all cameras should have the same dtype + if dtype1 != dtype2: + raise ValueError('Dtypes between camera structures must match') + + img_points_1 = np.array(img_points_1, dtype=dtype1) + img_points_2 = np.array(img_points_2, dtype=dtype2) + + r1 = cam_1._get_inverse_vector( + img_points_1 + ) + + r2 = cam_2._get_inverse_vector( + img_points_2 + ) + + t1 = cam_1.translation + t2 = cam_2.translation + + # TODO: move extention of dimensions into _line_intersect + return _line_intersect( + t1[:, np.newaxis], + r1, + t2[:, np.newaxis], + r2 + ) + + +def multi_line_intersect( + cameras: list, + img_points: list, +): + """Calculate where multiple rays intersect. + + Using at least two cameras, calculate the world coordinates where the + rays intersect. This is done through an least squares solution based on + direction vectors and camera origins/translations. + + Parameters + ---------- + cameras : list + A list of instances of Pinhole cameras. + img_points : list + A list of image coordinates for each canera structure. + + Returns + ------- + coords : np.ndarray + The world coordinate that is nearest to the intersection of all rays. + + """ + n_cams = len(cameras) + n_imgs = len(img_points) + + # make sure each camera has a set of images + if n_cams != n_imgs: + msg = f'Camera - image size mismatch. Got {n_cams} cameras and ' +\ + f'{n_imgs} images' + raise ValueError(msg) + + # check each camera structure + for cam in range(n_cams): + cameras[cam]._check_parameters() + + # all cameras should have the same dtype + dtype1 = cameras[0].dtype + for cam in range(1, n_cams): + dtype2 = cameras[cam].dtype + + if dtype1 != dtype2: + raise ValueError('Dtypes between camera structures must match') + + lines = [] + for cam in range(n_cams): + points = np.array(img_points[cam], dtype=dtype1) + + r = cameras[cam]._get_inverse_vector( + points + ) + + t = cameras[cam].translation + + lines.append([t, r]) + + return _multi_line_intersect(lines, dtype1) diff --git a/openpiv/calibration/pinhole_model/_minimization.py b/openpiv/calibration/pinhole_model/_minimization.py new file mode 100644 index 00000000..c6d17897 --- /dev/null +++ b/openpiv/calibration/pinhole_model/_minimization.py @@ -0,0 +1,139 @@ +import numpy as np +from scipy.optimize import least_squares + +from .. import _cal_doc_utils + + +def _refine_func( + parameters: np.ndarray, + camera, + object_points: np.ndarray, + image_points: np.ndarray, + correct_focal: bool, + correct_distortion: bool +): + camera.translation = parameters[0:3] + camera.orientation = parameters[3:6] + camera.principal = parameters[6:8] + + offset = 8 + + if correct_focal: + camera.focal = parameters[offset:offset + 2] + offset += 2 + + if correct_distortion: + if camera.distortion_model.lower() == "brown": + camera.distortion1 = parameters[offset:offset + 8] + else: + camera.distortion2 = parameters[offset:offset + 10].reshape( + (2, 5) + ) + + camera._get_rotation_matrix() + + projected_points = camera.project_points(object_points) + + return (projected_points - image_points).T.ravel() + + +@_cal_doc_utils.docfiller +def _minimize_params( + self, + object_points: list, + image_points: list, + correct_focal: bool = False, + correct_distortion: bool = False, + max_iter: int = 1000, + iterations: int = 3 +): + """Refine pinhole camera parameters using Levenberg-Marquardt. + + Minimize image reprojection residuals using a two-point numerical + Jacobian. Translation, orientation, and principal point are always + refined. Focal lengths and distortion coefficients are optionally + refined. + + Parameters + ---------- + %(object_points)s + %(image_points)s + correct_focal : bool, optional + If True, refine both focal lengths. Default is False. + correct_distortion : bool, optional + If True, refine coefficients of the selected distortion model. + Otherwise, keep the existing coefficients fixed. Default is False. + max_iter : int, optional + Maximum function evaluations per refinement pass. Default is 1000. + iterations : int, optional + Number of successive refinement passes. Default is 3. + + Returns + ------- + None + The camera parameters are updated in place. + """ + self._check_parameters() + + object_points = np.array(object_points, dtype=self.dtype) + image_points = np.array(image_points, dtype=self.dtype) + + if object_points.shape[1] != image_points.shape[1]: + raise ValueError('Object point image point size mismatch') + + # Pack only parameters that will be refined. + parameters = [ + self.translation, + self.orientation, + self.principal + ] + + if correct_focal: + parameters.append(self.focal) + + if correct_distortion: + if self.distortion_model.lower() == "brown": + parameters.append(self.distortion1) + else: + parameters.append(self.distortion2.ravel()) + + params_to_minimize = np.hstack(parameters) + + # Retain the existing nine-point minimum while ensuring that LM + # has at least as many scalar residuals as free parameters. + min_points = max( + 9, + (params_to_minimize.size + 1) // 2 + ) + + if object_points.shape[1] < min_points: + msg = f'Too few points to calibrate. Need at least {min_points} points' + raise ValueError(msg) + + args = ( + self, + object_points, + image_points, + correct_focal, + correct_distortion + ) + + for _ in range(iterations): + res = least_squares( + _refine_func, + x0=params_to_minimize, + args=args, + method="lm", + jac="2-point", + x_scale="jac", + max_nfev=max_iter + ) + + # Update the camera with the latest and most valid result + _refine_func(res.x, *args) + + if not res.success: + raise RuntimeError(f'Pinhole refinement failed: {res.message}') + + # Continue subsequent passes from the returned solution. + params_to_minimize = res.x diff --git a/openpiv/calibration/pinhole_model/_projection.py b/openpiv/calibration/pinhole_model/_projection.py new file mode 100644 index 00000000..67fa3ae2 --- /dev/null +++ b/openpiv/calibration/pinhole_model/_projection.py @@ -0,0 +1,324 @@ +import numpy as np + +from .. import _cal_doc_utils + +_all__ = [ + "project_points", + "project_to_z" +] + + +def _normalize_world_points( + self, + object_points: np.ndarray +): + R = self.rotation + T = self.translation + dtype = self.dtype + + # transformation to camera coordinates + Wc = np.dot( + R.T, + object_points + ) - np.dot(R.T, T[:, np.newaxis]) + + # the camera coordinates + Wc_x = Wc[0, :] + Wc_y = Wc[1, :] + Wc_h = Wc[2, :] + + # normalize coordinates + Wn_x = Wc_x / Wc_h + Wn_y = Wc_y / Wc_h + + return np.array([Wn_x, Wn_y], dtype=dtype) + + +def _normalize_image_points( + self, + image_points: np.ndarray +): + fx, fy = self.focal + cx, cy = self.principal + dtype = self.dtype + + x, y = image_points + + # normalize image coordinates + Wn_x = (x - cx) / fx + Wn_y = (y - cy) / fy + + return np.array([Wn_x, Wn_y], dtype=dtype) + + +@_cal_doc_utils.docfiller +def _project_points( + self, + object_points: np.ndarray, + correct_distortion: bool = True +): + """Project object points to image points. + + Project object, or real world points, to image points. + + Parameters + ---------- + %(object_points)s + correct_distortion : bool + If true, perform distortion correction. + + Returns + ------- + %(x_img_coord)s + %(y_img_coord)s + + Examples + -------- + >>> import numpy as np + >>> from importlib_resources import files + >>> from openpiv.calibration import pinhole_model + + >>> path_to_calib = files('openpiv.data').joinpath('test7/D_Cal.csv') + + >>> obj_x, obj_y, obj_z, img_x, img_y = np.loadtxt( + path_to_calib, + unpack=True, + skiprows=1, + usecols=range(5), # get first 5 columns of data + delimiter=',' + ) + + >>> obj_points = np.array([obj_x[0:3], obj_y[0:3], obj_z[0:3]], dtype=np.float64) + >>> img_points = np.array([img_x[0:3], img_y[0:3]], dtype=np.float64) + + >>> cam = pinhole_model.camera( + 'cam1', + [4512, 800], + translation = [-340, 125, 554], + orientation = [0., 0, np.pi], + focal = [15310, 15310], + ) + + >>> cam.minimize_params( + [obj_x, obj_y, obj_z], + [img_x, img_y], + correct_focal = True, + correct_distortion = False, + iterations=5 + ) + + >>> cam.minimize_params( + [obj_x, obj_y, obj_z], + [img_x, img_y], + correct_focal = True, + correct_distortion = True, + iterations=5 + ) + + >>> cam.project_points( + obj_points + ) + array([[-44.18942498, -33.54150164, -22.85390086], + [ 89.60689939, 211.87910151, 334.5884999 ]]) + + >>> img_points + array([[-44.33764398, -33.67518587, -22.97467733], + [ 89.61102873, 211.8863641 , 334.5980555 ]]) + + """ + self._check_parameters() + + fx, fy = self.focal + cx, cy = self.principal + dtype = self.dtype + + object_points = np.array(object_points, dtype=dtype) + + Wn_x, Wn_y = self._normalize_world_points( + object_points + ) + + if correct_distortion: + if self.distortion_model.lower() == "brown": + Wn_x, Wn_y = self._undistort_points_brown( + Wn_x, + Wn_y + ) + else: + Wn_x, Wn_y = self._undistort_points_poly( + Wn_x, + Wn_y + ) + + # rescale coordinates + x = Wn_x * fx + cx + y = Wn_y * fy + cy + + return np.array([x, y], dtype=dtype) + + +@_cal_doc_utils.docfiller +def _get_inverse_vector( + self, + image_points: np.ndarray +): + """Get pixel to direction vector. + + Calculate a direction vector from a pixel. This vector can be used for + forward projection along a ray using y + ar where y is the origin of + the camera, a is the z plane along the ray, and r is the direction + vector. + + Parameters + ---------- + %(image_points)s + + Returns + ------- + dx : 1D np.ndarray + Direction vector for x-axis. + dy : 1D np.ndarray + Direction vector for y-axis. + dz : 1D np.ndarray + Direction vector for z-axis. + + Notes + ----- + The direction vector is not normalized. + + """ + R = self.rotation + dtype = self.dtype + + image_points = np.array(image_points, dtype=dtype) + + Wn_x, Wn_y = self._normalize_image_points( + image_points + ) + + if self.distortion_model.lower() == "brown": + Wn_x, Wn_y = self._distort_points_brown( + Wn_x, + Wn_y + ) + else: + Wn_x, Wn_y = self._distort_points_poly( + Wn_x, + Wn_y + ) + + # inverse rotation + dx, dy, dz = np.dot( + R, + [Wn_x, Wn_y, np.ones_like(Wn_x)] + ) + + return np.array([dx, dy, dz], dtype=dtype) + + +@_cal_doc_utils.docfiller +def _project_to_z( + self, + image_points: np.ndarray, + z: np.ndarray +): + """Project image points to world points. + + Project image points to world points at specified z-plane using a + closed form solution (when omiting distortion correction). This means + under ideal circumstances with no distortion, the forward project + coordinates would be accurate down to machine precision or numerical + round off errors. + + Parameters + ---------- + %(image_points)s + %(project_z)s + + Returns + ------- + %(x_lab_coord)s + %(y_lab_coord)s + %(z_lab_coord)s + + Examples + -------- + >>> import numpy as np + >>> from importlib_resources import files + >>> from openpiv.calibration import pinhole_model + + >>> path_to_calib = files('openpiv.data').joinpath('test7/D_Cal.csv') + + >>> obj_x, obj_y, obj_z, img_x, img_y = np.loadtxt( + path_to_calib, + unpack=True, + skiprows=1, + usecols=range(5), # get first 5 columns of data + delimiter=',' + ) + + >>> obj_points = np.array([obj_x[0:3], obj_y[0:3], obj_z[0:3]], dtype=np.float64) + >>> img_points = np.array([img_x[0:3], img_y[0:3]], dtype=np.float64) + + >>> cam = pinhole_model.camera( + 'cam1', + [4512, 800], + translation = [-340, 125, 554], + orientation = [0., 0, np.pi], + focal = [15310, 15310], + ) + + >>> cam.minimize_params( + [obj_x, obj_y, obj_z], + [img_x, img_y], + correct_focal = True, + correct_distortion = False, + iterations=5 + ) + + >>> cam.minimize_params( + [obj_x, obj_y, obj_z], + [img_x, img_y], + correct_focal = True, + correct_distortion = True, + iterations=5 + ) + + >>> ij = cam.project_points( + obj_points + ) + + >>> ij + array([[-44.18942498, -33.54150164, -22.85390086], + [ 89.60689939, 211.87910151, 334.5884999 ]]) + + >>> cam.project_to_z( + ij, + z=obj_points[2] + ) + array([[-104.99762952, -104.99828339, -104.99883109], + [ -14.99969016, -9.99966432, -4.99960968], + [ -10. , -10. , -10. ]]) + + >>> obj_points + array([[-105., -105., -105.], + [ -15., -10., -5.], + [ -10., -10., -10.]]) + + """ + self._check_parameters() + + dtype = self.dtype + + dx, dy, dz = self._get_inverse_vector( + image_points + ) + + tx, ty, tz = self.translation + + a = (z - tz) / dz + + X = a*dx + tx + Y = a*dy + ty + + return np.array([X, Y, np.zeros_like(X) + z], dtype=dtype) diff --git a/openpiv/calibration/pinhole_model/_utils.py b/openpiv/calibration/pinhole_model/_utils.py new file mode 100644 index 00000000..f8194ec5 --- /dev/null +++ b/openpiv/calibration/pinhole_model/_utils.py @@ -0,0 +1,228 @@ +from os.path import join + +import numpy as np + +from .. import _cal_doc_utils + + +def _get_rotation_matrix(self): + """Calculate a rotation matrix for a camera. + + Calculate a rotation matrix for a camera. The matrix is a 3x3 numpy ndarray + like such: + + [ r1 r2 r3 ] + [ r4 r5 r6 ] + [ r7 r8 r9 ] + + where + + r1 = cos(tz) * cos(ty) + r2 = -sin(tz) * cos(ty) + r3 = sin(ty) + r4 = cos(tz) * sin(tx) * sin(ty) + sin(tz) * cos(tx) + r5 = cos(tz) * cos(tx) - sin(tz) * sin(tx) * sin(ty) + r6 = -sin(tx) * cos(ty) + r7 = sin(tz) * sin(tx) - cos(tz) * cos(tx) * sin(ty) + r8 = sin(tz) * cos(tx) * sin(ty) + cos(tz) * sin(tx) + r9 = cos(tx) * cos(ty) + + """ + self._check_parameters() + + # Orientation is composed of angles, or theta, for each axes. + # Theta for each dimensions is abbreviated as t. + tx, ty, tz = self.orientation + dtype = self.dtype + + # We compute the camera patrix based off of this website. + # https://support.pix4d.com/hc/en-us/articles/202559089-How-are-the-Internal-and-External-Camera-Parameters-defined + + rot_x = np.array( + [ + [1, 0, 0], + [0, np.cos(tx),-np.sin(tx)], + [0, np.sin(tx), np.cos(tx)] + ], + dtype=dtype + ) + + rot_y = np.array( + [ + [ np.cos(ty), 0, np.sin(ty)], + [ 0, 1, 0], + [-np.sin(ty), 0, np.cos(ty)] + ], + dtype=dtype + ) + + rot_z = np.array( + [ + [np.cos(tz),-np.sin(tz), 0], + [np.sin(tz), np.cos(tz), 0], + [ 0, 0, 1] + ], + dtype=dtype + ) + + rotation_matrix = np.dot( + np.dot(rot_x, rot_y), + rot_z + ) + + self.rotation = rotation_matrix + + +@_cal_doc_utils.docfiller +def _save_parameters( + self, + file_path: str, + file_name: str | None=None +): + """Save pinhole camera parameters. + + Save the pinhole camera parameters to a text file. + + Parameters + ---------- + file_path : str + File path where the camera parameters are saved. + file_name : str, optional + If specified, override the default file name. + + Returns + ------- + None + + """ + self._check_parameters() + + if file_name is None: + file_name = self.name + + full_path = join(file_path, file_name) + + with open(full_path, 'w') as f: + f.write(self.name + '\n') + + _r = '' + for i in range(2): + _r += str(self.resolution[i]) + ' ' + + f.write(_r + '\n') + + _t = '' + for i in range(3): + _t += str(self.translation[i]) + ' ' + + f.write(_t + '\n') + + _o = '' + for i in range(3): + _o += str(self.orientation[i]) + ' ' + + f.write(_o + '\n') + + f.write(self.distortion_model + '\n') + + _d1 = '' + for i in range(8): + _d1 += str(self.distortion1[i]) + ' ' + + f.write(_d1 + '\n') + + for i in range(2): + _d2 = '' + for j in range(5): + _d2 += str(self.distortion2[i, j]) + ' ' + + f.write(_d2 + '\n') + + _f = '' + for i in range(2): + _f += str(self.focal[i]) + ' ' + + f.write(_f + '\n') + + _p = '' + for i in range(2): + _p += str(self.principal[i]) + ' ' + + f.write(_p + '\n') + + f.write(np.dtype(self.dtype).name + '\n') + + +@_cal_doc_utils.docfiller +def _load_parameters( + self, + file_path: str, + file_name: str +): + """Load pinhole camera parameters. + + Load the pinhole camera parameters from a text file. + + Parameters + ---------- + file_path : str + File path where the camera parameters are saved. + file_name : str + Name of the file that contains the camera parameters. + + Returns + ------- + None + + """ + full_path = join(file_path, file_name) + + with open(full_path) as f: + + name = f.readline()[:-1] + + _r = f.readline()[:-2] + resolution = np.array([float(s) for s in _r.split()]) + + _t = f.readline()[:-2] + translation = np.array([float(s) for s in _t.split()]) + + _o = f.readline()[:-2] + orientation = np.array([float(s) for s in _o.split()]) + + distortion_model = f.readline()[:-1] + + _d1 = f.readline()[:-2] + distortion1 = np.array([float(s) for s in _d1.split()]) + + distortion2 = [] + for _i in range(2): + _d2 = f.readline()[:-2] + distortion2.append(np.array([float(s) for s in _d2.split()])) + + distortion2 = np.array(distortion2, dtype = np.float64) + + _f = f.readline()[:-2] + focal = np.array([float(s) for s in _f.split()]) + + _p = f.readline()[:-2] + principal = np.array([float(s) for s in _p.split()]) + + dtype = np.dtype(f.readline().strip()) + + self.name = name + self.resolution = resolution + self.translation = translation + self.orientation = orientation + self.distortion_model = distortion_model + self.distortion1 = distortion1 + self.distortion2 = distortion2 + self.focal = focal + self.principal = principal + self.dtype = np.dtype(dtype) + + # make sure all parameters are valid + self._check_parameters() + + # get the rotation matrix as needed + self._get_rotation_matrix() diff --git a/openpiv/calibration/pinhole_model/_zang.py b/openpiv/calibration/pinhole_model/_zang.py new file mode 100644 index 00000000..e973e71c --- /dev/null +++ b/openpiv/calibration/pinhole_model/_zang.py @@ -0,0 +1,120 @@ +import numpy as np + +from ..dlt_model import calibrate_dlt + +__all__ = [ + 'calibrate_intrinsics' +] + + +def _get_all_homography( + all_object_points: np.ndarray, + all_image_points: np.ndarray +): + if len(all_object_points) < 2: + raise ValueError( + "Too little planes to calibrate" + ) + + all_H = [] + + for i in range(len(all_image_points)): + H, err = calibrate_dlt( + all_object_points, + all_image_points[i], + enforce_coplanar=True + ) + all_H.append(H) + + return np.array(all_H, dtype=np.float64).reshape((len(all_H), 3, 3)) + + +def _get_Vij( + all_h: np.ndarray, + i: int, + j: int +): + v_ij = np.zeros((all_h.shape[0], 6), dtype=np.float64) + + v_ij[:, 0] = all_h[:, 0, i] * all_h[:, 0, j] + v_ij[:, 1] = all_h[:, 0, i] * all_h[:, 1, j] + all_h[:, 1, i] * all_h[:, 0, j] + v_ij[:, 2] = all_h[:, 1, i] * all_h[:, 1, j] + v_ij[:, 3] = all_h[:, 2, i] * all_h[:, 0, j] + all_h[:, 0, i] * all_h[:, 2, j] + v_ij[:, 4] = all_h[:, 2, i] * all_h[:, 1, j] + all_h[:, 1, i] * all_h[:, 2, j] + v_ij[:, 5] = all_h[:, 2, i] * all_h[:, 2, j] + + return v_ij + + +def _get_B( + all_h: np.ndarray +): + v_00 = _get_Vij(all_h, 0, 0) + v_01 = _get_Vij(all_h, 0, 1) + v_11 = _get_Vij(all_h, 1, 1) + + v = np.zeros((all_h.shape[0] * 2, 6), dtype = np.float64) + + v[0::2, :] = v_01 + v[1::2, :] = v_00 - v_11 + + U, E, V = np.linalg.svd(v) + + b = V[-1, :] + + B0, B1, B2, B3, B4, B5 = b + + # Rearrage B to form B = K^-T K^-1 + return np.array([[B0, B1, B3], + [B1, B2, B4], + [B3, B4, B5]]) + + + +def _get_intrinsics( + B: np.ndarray +): + v0 = (B[0, 1] * B[0, 2] - B[0, 0] * B[1, 2]) / (B[0, 0] * B[1, 1] - B[0, 1]**2) + lambda_ = B[2, 2] - (B[0, 2]**2 + v0 * (B[0, 1] * B[0, 2] - B[0, 0] * B[1, 2])) / B[0, 0] + alpha = np.sqrt(lambda_ / B[0, 0]) + beta = np.sqrt((lambda_ * B[0, 0]) / (B[0, 0] * B[1, 1] - B[0, 1]**2)) + gamma = -(B[0, 1] * alpha**2 * beta) / lambda_ + u0 = (gamma * v0 / beta) - (B[0, 2] * alpha**2) / lambda_ + + return alpha, beta, u0, v0, gamma # <-- gamma is skew + + +def calibrate_intrinsics( + all_object_points: np.ndarray, + all_image_points: np.ndarray +): + """Intrinsic calibration using Zhang's method. + + Using multiple views of planar targets, calculate the intrinsic + parameters that best fits all views using a closed-form solution. + + Parameters + ---------- + all_object_points : np.ndarray + Lab coordinates with a structured like [[X, Y, Z]'] * number + of planes. + + all_image_points : np.ndarray + Image coordinates with a structured like [[x, y]'] * number + of planes. + + Returns + ------- + intrinsics : np.ndarray + 5 elements that contain camera intrinsic information and are in the + order as such: fx, fy, cx, cy, and gamma (skew). + + """ + all_h = _get_all_homography( + all_object_points, + all_image_points + ) + + B = _get_B(all_h) + + return _get_intrinsics(B) diff --git a/openpiv/calibration/pinhole_model/readme_pinhole_model.md b/openpiv/calibration/pinhole_model/readme_pinhole_model.md new file mode 100644 index 00000000..7cb3fa43 --- /dev/null +++ b/openpiv/calibration/pinhole_model/readme_pinhole_model.md @@ -0,0 +1,142 @@ +""" +==================== +Pinhole Camera Model +==================== + +This module contains an implementation of the pinhole camera model. This +model is an approximation of how light rays are captured by a camera. Under +ideal circumstances, lab coordinates can be mapped to image sensor +coordinates (also known as pixel coordinates). However, cameras are not +usually ideal and are placed arbitrarily in the lab space. This means that +the lab coordinates have to be transformed into normalized camera +coordinates to remove this arbitrary translation. The normalized camera +coordinates are calculated as such: + +$$ P = +\begin{vmatrix} +X \\ Y \\ Z \\ +\end{vmatrix} +$$ + +$$ +\begin{vmatrix} +x_c \\ y_c \\ z_c +\end{vmatrix} += P * R^{-1} - R^{-1} * T +$$ + +$$ +x_n = \frac{x_c}{z_c} \\ +y_n = \frac{y_c}{z_c} +$$ + +where + +$$ +R = +\begin{vmatrix} +r_{11} & r_{12} & r_{13} \\ +r_{21} & r_{22} & r_{23} \\ +r_{31} & r_{32} & r_{33} \\ +\end{vmatrix} +$$ +and +$$ +T = +\begin{vmatrix} +T_x \\ T_y \\ T_z +\end{vmatrix} +$$ + +where letters denoting R and T define the 3x3 rotation matrix and the +translation vector respectively, which are commonly associated with +the extrinsic matrix. + +Since there are usually additional irregularities such as lens +distortion and intermediate medias, additional steps to mitigate them are +necessary. Without correcting these irregularities, results from triangulation +or tomographic reconstruction are often meaningless. To circumnavigate this +issue, two distortion models are implemented: polynomial and brown +distortion models. The brown distortion model covers nearly all tangential +and radial distortion components and is relatively simple. However, this +distortion model cannot account for scheimpflug lenses or intermediate +medias. Under these circumstances, it is wise to use the polynomial +distortion model. This distortion model utilized second degree polynomials +for distorting and undistorting normalized camera coordinates. Since we are +working with polynomials, most distortions can be minimized including the use +of scheimpflug lenses and distortions caused by intermediate medias. All +distortion correction methods are applied directly to the normalized camera +coordinates. + +Finally, the normalized pixel coordinates are scaled using the intrinsic +matrix. The intrinsic matrix is composed of focal depths (fx, fy), measured +in pixels, and the principal point (cx, cy). One can estimate the focal +depths of fx and fy by dividing the focal number in mm with its associated +pixel size. For instance, if a lens had a focal number of 20mm and the pixels +are 5 um, then the fx/fy would be approximately 20/0.005 = 4000 pixels. The +following transformation applies the intrinsic matrix to normalized camera +coordinates: + +$$ +\begin{vmatrix} +x \\ y \\ 1 +\end{vmatrix} = +\begin{vmatrix} +f_x & 0 & c_x \\ +0 & f_y & c_y \\ +0 & 0 & 1 +\end{vmatrix} * +\begin{vmatrix} +x_n \\ y_n \\ 1 +\end{vmatrix} +$$ + +Once a camera systen is calibrated on an individual basis, it may be +beneficial to calculate 3D triangulation errors. This requires projecting +pixels along a light ray to a specific Z-plane. Since the distortion model +operate with normalized camera coordinates, it is vital that the image +coordinates are properly normalized. + +$$ +\begin{vmatrix} +x_n \\ y_n \\ 1 +\end{vmatrix} = +\begin{vmatrix} +f_x & 0 & c_x \\ +0 & f_y & c_y \\ +0 & 0 & 1 \\ +\end{vmatrix}^{-1} * +\begin{vmatrix} +x \\ +y \\ +1 \\ +\end{vmatrix} +$$ + +After normalization, the camera coordinates are distorted and turned into +direction vectors. + +$$ +\begin{vmatrix} +dx \\ +dy \\ +dz \\ +\end{vmatrix} = R * +\begin{vmatrix} +x_n \\y_n \\ 1 +\end{vmatrix} +$$ + +Finally, the lab coordinates can be calculated as Ax + b where x is the +direction vector, A is the distance from the physical camera to the +projection plane, and b is the translation of the physical camera in +respect to the calibration markers. + +$$ +a = \frac{(Z - T_z)}{d_z} \\ +X = a*d_x + T_x \\ +Y = a*d_y + T_y \\ +Z = Z +$$ + +""" \ No newline at end of file diff --git a/openpiv/calibration/poly_model/__init__.py b/openpiv/calibration/poly_model/__init__.py new file mode 100644 index 00000000..d0f04a9e --- /dev/null +++ b/openpiv/calibration/poly_model/__init__.py @@ -0,0 +1,30 @@ +""" +======================= +Polynomial Camera Model +======================= + +This module contains an implementation of a polynomial camera model. This +model is implemented using 3rd order polynomials in the x and y axis and a +2nd order polynomial along the z-axis. This model can handle a multiplitude +of different distortions and is usually preferred if processing algorithms +later on do not heavily utilize triangulation. Additionally, it is important +that the calibration markers cover as much of the image(s) as possible to +limit artifacts from extrapolation. + +Functions +========= + camera - Create an instance of a Soloff camera model + multi_line_intersect - Using multiple lines, approximate their intersection + +Note +==== +It is important to only import the submodule and not the functions that +are in the submodules. Explicitly importing a function from this submodule +could cause conflicts between other camera models due to similar naming +conventions that are normally protected behind namespaces. + +""" +from ._camera import * +from ._epipolar_geom import * + +__all__ = [s for s in dir() if not s.startswith('_')] diff --git a/openpiv/calibration/poly_model/_camera.py b/openpiv/calibration/poly_model/_camera.py new file mode 100644 index 00000000..936be543 --- /dev/null +++ b/openpiv/calibration/poly_model/_camera.py @@ -0,0 +1,72 @@ + +import numpy as np +from numpy.typing import DTypeLike + +from ._check_params import _check_parameters +from ._minimization import _minimize_params +from ._projection import _get_inverse_vector, _project_points, _project_to_z +from ._utils import _load_parameters, _save_parameters + +__all__ = ['camera'] + + +class camera: + """An instance of a polynomial camera model. + + Create an instance of a polynomial camera. The polynomial camera model + is based on the Soloff camera model where a polymial mapping function + is created with third order polynomials in the x- and y-axis and a second + order polynomial along the z-axis. The polynomial camera models also has + ray-based estimator for 3D point triangulation. + + Attributes + ---------- + cam_name : str + Name of camera. + resolution : tuple[int, int] + Resolution of camera in x and y axes respectively. + poly_wi : np.ndarray + 19 coefficients for world to image polynomial calibration in [x, y]'. + poly_iw_o : np.ndarray + 10 coefficients for ray origins in [X, Y]'. + poly_iw_v : np.ndarray + 10 coefficients for ray slopes in [dX/dZ, dY/dZ]'. + dtype : np.dtype + The dtype used in the projections. + + Methods + ------- + minimize_params + project_points + project_to_z + save_parameters + load_parameters + + """ + def __init__( + self, + name: str, + resolution: tuple[int, int], + poly_wi: np.ndarray=np.ones((2,19), dtype=np.float64).T, + poly_iw_o: np.ndarray=np.ones((2,10), dtype=np.float64).T, + poly_iw_v: np.ndarray=np.ones((2,10), dtype=np.float64).T, + dtype: DTypeLike=np.float64 + ): + self.name = name + self.resolution = resolution + self.poly_wi = poly_wi + self.poly_iw_o = poly_iw_o + self.poly_iw_v = poly_iw_v + self.dtype = np.dtype(dtype) + + # method definitions + camera._check_parameters = _check_parameters + camera.minimize_params = _minimize_params + camera.project_points = _project_points + camera.project_to_z = _project_to_z + camera._get_inverse_vector = _get_inverse_vector + camera.save_parameters = _save_parameters + camera.load_parameters = _load_parameters + + # check camera params at init + self._check_parameters() diff --git a/openpiv/calibration/poly_model/_check_params.py b/openpiv/calibration/poly_model/_check_params.py new file mode 100644 index 00000000..0ddc0160 --- /dev/null +++ b/openpiv/calibration/poly_model/_check_params.py @@ -0,0 +1,49 @@ +import numpy as np + + +def _check_parameters(self): + if not isinstance(self.name, str): + raise ValueError('Camera name must be a string') + + if len(self.resolution) != 2: + raise ValueError('Resolution must be a two element tuple') + + if len(self.poly_wi.shape) != 2: + msg = 'World to image polynomial coefficients must be 2 dimensional.' + raise ValueError(msg) + + if self.poly_wi.shape[0] != 19: + msg = 'World to image polynomial coefficients must be ordered ' +\ + "in [x, y]'" + raise ValueError(msg) + + if self.poly_wi.shape[1] != 2: + msg = 'There must be 19 coefficients in the world to image polynomial' + raise ValueError(msg) + + if len(self.poly_iw_o.shape) != 2: + msg = 'Ray origin polynomial coefficients must be 2 dimensional.' + raise ValueError(msg) + + if self.poly_iw_o.shape[0] != 10: + msg = "Ray origin polynomial coefficients must be ordered in [x, y]'" + raise ValueError(msg) + + if self.poly_iw_o.shape[1] != 2: + msg = 'There must be 10 coefficients in the ray origin polynomial' + raise ValueError(msg) + + if len(self.poly_iw_v.shape) != 2: + msg = 'Ray direction polynomial coefficients must be 2 dimensional.' + raise ValueError(msg) + + if self.poly_iw_v.shape[0] != 10: + msg = "Ray direction polynomial coefficients must be ordered in [x, y]'" + raise ValueError(msg) + + if self.poly_iw_v.shape[1] != 2: + msg = 'There must be 10 coefficients in the ray direction polynomial' + raise ValueError(msg) + + if self.dtype not in [np.float32, np.float64]: + raise ValueError('Dtype is not supported for camera calibration') diff --git a/openpiv/calibration/poly_model/_epipolar_geom.py b/openpiv/calibration/poly_model/_epipolar_geom.py new file mode 100644 index 00000000..77934510 --- /dev/null +++ b/openpiv/calibration/poly_model/_epipolar_geom.py @@ -0,0 +1,415 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - import only for type hints + from .. import poly_model + +import numpy as np + +from .._epipolar_utils import _line_intersect, _multi_line_intersect + +__all__ = [ + 'line_intersect', + 'multi_line_intersect' +] + + +def line_intersect( + cam_1: "poly_model.camera", + cam_2: "poly_model.camera", + img_points_1: np.ndarray, + img_points_2: np.ndarray +): + """Intersect polynomial-computed backward rays. + + Parameters + ---------- + cam_1, cam_2 : poly_model.camera + Calibrated polynomial cameras with matching dtypes. + img_points_1, img_points_2 : np.ndarray + Corresponding image coordinates with shape (2, N). + + Returns + ------- + coords : np.ndarray + Midpoint between the closest points on each pair of backward + rays, with shape (3, N). + dist : np.ndarray + Shortest separation between each pair of backward rays, + with shape (N,), in world-coordinate units. + + """ + cam_1._check_parameters() + cam_2._check_parameters() + + if cam_1.dtype != cam_2.dtype: + raise ValueError( + "Dtypes between camera structures must match" + ) + + img_points_1 = np.array(img_points_1, dtype=cam_1.dtype) + img_points_2 = np.array(img_points_2, dtype=cam_2.dtype) + + t1, r1 = cam_1._get_inverse_vector(img_points_1) + t2, r2 = cam_2._get_inverse_vector(img_points_2) + + return _line_intersect(t1, r1, t2, r2) + + +def _dFdx( + cam: "poly_model.camera", + object_points: np.ndarray +) -> np.ndarray: + dtype = cam.dtype + a = cam.poly_wi + + object_points = np.array(object_points, dtype=dtype) + + X = object_points[0] + Y = object_points[1] + Z = object_points[2] + + polynomial_dx = np.array( + [ + np.ones_like(X), + Y, Z, + 2*X, + 3*X*X, 2*X*Y, 2*X*Z, + Y*Y, + Z*Z, Y*Z + ], + dtype=dtype + ).T + + polynomial_dx_a = np.array( + [ + a[1, :], + a[4, :], a[5, :], + a[7, :], + a[10, :], a[11, :], a[12, :], + a[14, :], + a[16, :], a[18, :] + ], + dtype=dtype + ) + + return np.dot( + polynomial_dx, + polynomial_dx_a + ).T + + +def _dFdy( + cam: "poly_model.camera", + object_points: np.ndarray +) -> np.ndarray: + dtype = cam.dtype + a = cam.poly_wi + + object_points = np.array(object_points, dtype=dtype) + + X = object_points[0] + Y = object_points[1] + Z = object_points[2] + + polynomial_dy = np.array( + [ + np.ones_like(Y), + X, Z, + 2*Y, + X*X, + 3*Y*Y, 2*Y*X, 2*Y*Z, + Z*Z, X*Z + ], + dtype=dtype + ).T + + polynomial_dy_a = np.array( + [ + a[2, :], + a[4, :], a[6, :], + a[8, :], + a[11, :], + a[13, :], a[14, :], a[15, :], + a[17, :], a[18, :] + ], + dtype=dtype + ) + + return np.dot( + polynomial_dy, + polynomial_dy_a + ).T + + +def _dFdz( + cam: "poly_model.camera", + object_points: np.ndarray +) -> np.ndarray: + dtype = cam.dtype + a = cam.poly_wi + + object_points = np.array(object_points, dtype=dtype) + + X = object_points[0] + Y = object_points[1] + Z = object_points[2] + + polynomial_dz = np.array( + [ + np.ones_like(Y), + X, Y, + 2*Z, + X*X, + Y*Y, + 2*X*Z, 2*Y*Z, X*Y + ], + dtype=dtype + ).T + + polynomial_dz_a = np.array( + [ + a[3, :], + a[5, :], a[6, :], + a[9, :], + a[12, :], + a[15, :], + a[16, :], a[17, :], a[18, :] + ], + dtype=dtype + ) + + return np.dot( + polynomial_dz, + polynomial_dz_a + ).T + + +def _refine_jac3D( + cams: list, + object_points: np.ndarray +) -> np.ndarray: + dtype = cams[0].dtype + + object_points = np.array(object_points, dtype=dtype) + + # if a 1D array is given, extend it + if len(object_points.shape) == 1: + object_points = object_points[:, np.newaxis] + + n_points = object_points.shape[1] + n_cams = len(cams) + + jac = np.zeros([n_points, n_cams*2, 3], dtype=dtype) + + for i in range(n_cams): + xdx, ydx = _dFdx(cams[i], object_points) + xdy, ydy = _dFdy(cams[i], object_points) + xdz, ydz = _dFdz(cams[i], object_points) + + jac[:, i*2, :] = np.array([xdx, xdy, xdz], dtype=dtype).T + jac[:, (i*2)+1, :] = np.array([ydx, ydy, ydz], dtype=dtype).T + + return jac + + +def _refine_func3D( + cams: list, + object_points: np.ndarray, + image_points: list +) -> np.ndarray: + dtype = cams[0].dtype + + n_cams = len(cams) + + # make sure each camera has a pair of image points + assert(n_cams == len(image_points)) + + # if a 1D array is given, extend it + if len(object_points.shape) == 1: + object_points = object_points[:, np.newaxis] + + if len(image_points[0].shape) == 1: + for i in range(len(image_points)): + image_points[i] = image_points[i][:, np.newaxis] + + n_points = object_points.shape[1] + + residuals = np.zeros([n_points, n_cams, 2], dtype=dtype) + + for i in range(n_cams): + res = cams[i].project_points( + object_points + ) - image_points[i] + + residuals[:, i, :] = res.T + + return residuals.reshape((n_points, n_cams*2)) + + +# TODO: move this function somewhere else? +def _minimize_gradient( + jac: np.ndarray, + residual: np.ndarray, + object_point: np.ndarray +): + object_point += np.linalg.lstsq(jac, -residual, rcond=None)[0] + + return object_point + + +def _refine_pos3D( + cams: list, + object_points: np.ndarray, + image_points: list, + iterations: int=1 +): + dtype = cams[0].dtype + n_cams = len(cams) + + # if a 1D array is given, extend it + if len(object_points.shape) == 1: + object_points = object_points[:, np.newaxis] + + if len(image_points[0].shape) == 1: + for i in range(len(image_points)): + image_points[i] = image_points[i][:, np.newaxis] + + n_points = object_points.shape[1] + + new_object_points = np.zeros([3, n_points], dtype=dtype) + new_object_points[:, :] = object_points + + # TODO: I bet this loop is hellish slow for a large number of particles + for _ in range(iterations): + jac = _refine_jac3D( + cams, + new_object_points + ) + + residuals = _refine_func3D( + cams, + new_object_points, + image_points, + ) + + for particle in range(n_points): + img_points = [] + for cam in range(n_cams): + img_points.append(image_points[cam][:, particle]) + + obj_point = new_object_points[:, particle] + + new_object_points[:, particle] = _minimize_gradient( + jac[particle], + residuals[particle], + obj_point + ).T + + return new_object_points + + +def _estimate_pos( + cams: list, + image_points: list +): + dtype = cams[0].dtype + n_points = image_points[0].shape[1] + + lines = [] + for cam in range(len(cams)): + t, r = cams[cam]._get_inverse_vector(image_points[cam]) + lines.append([t, r]) + + object_points = np.zeros([3, n_points], dtype=dtype) + for particle in range(n_points): + particle_lines = [] + for cam in range(len(cams)): + particle_lines.append([ + lines[cam][0][:, particle], + lines[cam][1][:, particle:particle+1].copy() + ]) + object_points[:, particle] = _multi_line_intersect(particle_lines, dtype)[:, 0] + + return object_points + + +# Now the good part starts :) +def multi_line_intersect( + cameras: list, + image_points: list, + iterations: int=1 +): + """Estimate 3D positions using a gradient descent algorithm. + + Using an approximated initial position, optimize the particle locations + such that the residuals between the image points and the projected + object points are minimized. This is performed by calculating an + analytical solution for the derivatives of the projection function and + finding a least squares solution by iteratively updating each point until + a specified amount of iterations have been completed. The least squares + solution is performed via SVD, making it robust to noise and artifacts. + This is necessary since the intitial particle positions are approximated + using the calibrated backward rays. + + Parameters + ---------- + cameras : list + A list of instances of polynomial cameras. + img_points : list + A list of image coordinates for each canera structure. + iterations : int, optional + The number of Gauss-Newton iterations each object point recieves. + + Returns + ------- + coords : np.ndarray + The world coordinate that mininmize all projection residuals. + + References + ---------- + .. [Herzog] Herzog, S., Schiepel, D., Guido, I. et al. A Probabilistic + Particle Tracking Framework for Guided and Brownian Motion + Systems with High Particle Densities. SN COMPUT. SCI. 2, 485 + (2021). https://doi.org/10.1007/s42979-021-00879-z + + """ + n_cams = len(cameras) + n_imgs = len(image_points) + + # make sure each camera has a set of images + if n_cams != n_imgs: + msg = f'Camera - image size mismatch. Got {n_cams} cameras and ' +\ + f'{n_imgs} images' + raise ValueError(msg) + + # check each camera structure + for cam in range(n_cams): + cameras[cam]._check_parameters() + + # all cameras should have the same dtype + dtype1 = cameras[0].dtype + for cam in range(1, n_cams): + dtype2 = cameras[cam].dtype + + if dtype1 != dtype2: + raise ValueError('Dtypes between camera structures must match') + + # TODO: we should also check for individual image mismatches + # if a 1D array is given, extend it + if len(image_points[0].shape) == 1: + for i in range(len(image_points)): + image_points[i] = image_points[i][:, np.newaxis] + + object_points = _estimate_pos( + cameras, + image_points + ) + + # Refine position using Soloff polynomial + return _refine_pos3D( + cameras, + object_points, + image_points, + iterations=iterations + ) + diff --git a/openpiv/calibration/poly_model/_minimization.py b/openpiv/calibration/poly_model/_minimization.py new file mode 100644 index 00000000..4d673caa --- /dev/null +++ b/openpiv/calibration/poly_model/_minimization.py @@ -0,0 +1,163 @@ +import numpy as np +from scipy.optimize import least_squares + +from .. import _cal_doc_utils + + +def _refine_poly( + poly_coeffs: np.ndarray, + polynomial: np.ndarray, + expected: np.ndarray +): + # use lambdas since it keeps the function definitions local + def refine_func(coeffs): + projected = np.dot(polynomial, coeffs) + return projected - expected + + return least_squares( + refine_func, + poly_coeffs, + method="trf" # more modern lm algorithm + ).x + + +@_cal_doc_utils.docfiller +def _minimize_params( + self, + object_points: list, + image_points: list, +): + """Minimize polynomials. + + Minimize polynomials using Least Squares minimization. + + Parameters + ---------- + %(object_points)s + %(image_points)s + + Examples + -------- + >>> import numpy as np + >>> from importlib_resources import files + >>> from openpiv.calibration import poly_model, calib_utils + + >>> path_to_calib = files('openpiv.data').joinpath('test7/D_Cal.csv') + + >>> obj_x, obj_y, obj_z, img_x, img_y = np.loadtxt( + path_to_calib, + unpack=True, + skiprows=1, + usecols=range(5), + delimiter=',' + ) + + >>> obj_points = np.array([obj_x[0:3], obj_y[0:3], obj_z[0:3]], dtype=np.float64) + >>> img_points = np.array([img_x[0:3], img_y[0:3]], dtype=np.float64) + + >>> cam = poly_model.camera( + 'cam1', + [4512, 800] + ) + + >>> cam.minimize_params( + [obj_x, obj_y, obj_z], + [img_x, img_y] + ) + + >>> calib_utils.get_reprojection_error( + cam, + [obj_x, obj_y, obj_z], + [img_x, img_y] + ) + 0.16553632335727653 + + """ + self._check_parameters() + + dtype = self.dtype + + object_points = np.array(object_points, dtype=dtype) + image_points = np.array(image_points, dtype=dtype) + + if object_points.shape[1] < 20: + raise ValueError('Too little points to calibrate') + + if object_points.shape[1] != image_points.shape[1]: + raise ValueError('Object point image point size mismatch') + + x = image_points[0] + y = image_points[1] + + X = object_points[0] + Y = object_points[1] + Z = object_points[2] + + polynomial_wi = np.array( + [ + np.ones_like(X), + X, Y, Z, + X*Y, X*Z, Y*Z, + X**2, Y**2, Z**2, + X**3, X*X*Y, X*X*Z, + Y**3, X*Y*Y, Y*Y*Z, + X*Z*Z, Y*Z*Z, X*Y*Z + ], + dtype=dtype + ).T + + # X,Y are cubic in image coordinates and linear in world Z. + polynomial_iw = np.array( + [ + np.ones_like(x), + x, y, + x*x, x*y, y*y, + x*x*x, x*x*y, x*y*y, y*y*y, + Z, + x*Z, y*Z, + x*x*Z, x*y*Z, y*y*Z, + x*x*x*Z, x*x*y*Z, x*y*y*Z, y*y*y*Z + ], + dtype=dtype + ).T + + + # world to image (forward projection) + coeff_wi = np.zeros([19, 2], dtype=dtype) + + for i in range(2): + coeff_wi[:, i] = np.linalg.lstsq( + polynomial_wi, + image_points[i], + rcond=None + )[0] + + coeff_wi[:, i] = _refine_poly( + coeff_wi[:, i], + polynomial_wi, + image_points[i] + ) + + # image to world (ray origins and direction slopes) + coeff_iw = np.zeros([20, 2], dtype=dtype) + + # Scale columns for fitting; stored coefficients use raw image coordinates. + scale = np.linalg.norm(polynomial_iw.astype(np.float64), axis=0) + scale[scale == 0] = 1.0 + polynomial_iw_scaled = polynomial_iw / scale + + for i in range(2): + result = np.linalg.lstsq( + polynomial_iw_scaled, + object_points[i], + rcond=None + ) + if result[2] != 20: + msg = 'Volume ray calibration requires full-rank volumetric points' + raise ValueError(msg) + + coeff_iw[:, i] = result[0] / scale + + self.poly_wi = coeff_wi + self.poly_iw_o = coeff_iw[:10, :] + self.poly_iw_v = coeff_iw[10:, :] diff --git a/openpiv/calibration/poly_model/_projection.py b/openpiv/calibration/poly_model/_projection.py new file mode 100644 index 00000000..a85fc573 --- /dev/null +++ b/openpiv/calibration/poly_model/_projection.py @@ -0,0 +1,221 @@ +import numpy as np + +from .. import _cal_doc_utils + + +@_cal_doc_utils.docfiller +def _project_points( + self, + object_points: np.ndarray +): + """Project object points to image points. + + Project object, or real world points, to image points. + + Parameters + ---------- + %(object_points)s + + Returns + ------- + %(x_img_coord)s + %(y_img_coord)s + + Examples + -------- + >>> import numpy as np + >>> from importlib_resources import files + >>> from openpiv.calibration import poly_model + + >>> path_to_calib = files('openpiv.data').joinpath('test7/D_Cal.csv') + + >>> obj_x, obj_y, obj_z, img_x, img_y = np.loadtxt( + path_to_calib, + unpack=True, + skiprows=1, + usecols=range(5), # get first 5 columns of data + delimiter=',' + ) + + >>> obj_points = np.array([obj_x[0:3], obj_y[0:3], obj_z[0:3]], dtype=np.float64) + >>> img_points = np.array([img_x[0:3], img_y[0:3]], dtype=np.float64) + + >>> cam = poly_model.camera( + 'cam1', + [4512, 800] + ) + + >>> cam.minimize_params( + [obj_x, obj_y, obj_z], + [img_x, img_y] + ) + + >>> cam.project_points( + obj_points + ) + array([[-44.24281474, -33.56231972, -22.84229244], + [ 89.63444964, 211.90372246, 334.60601499]]) + + >>> img_points + array([[-44.33764398, -33.67518587, -22.97467733], + [ 89.61102873, 211.8863641 , 334.5980555 ]]) + + """ + self._check_parameters() + + dtype = self.dtype + + object_points = np.array(object_points, dtype=dtype) + + X = object_points[0] + Y = object_points[1] + Z = object_points[2] + + polynomial_wi = np.array( + [ + np.ones_like(X), + X, Y, Z, + X*Y, X*Z, Y*Z, + X**2, Y**2, Z**2, + X**3, X*X*Y, X*X*Z, + Y**3, X*Y*Y, Y*Y*Z, + X*Z*Z, Y*Z*Z, X*Y*Z + ], + dtype=dtype + ).T + + return np.dot( + polynomial_wi, + self.poly_wi + ).T + + +@_cal_doc_utils.docfiller +def _project_to_z( + self, + image_points: np.ndarray, + z: float +): + """Project image points to world points. + + Project image points to world points at specified z-plane. + + Parameters + ---------- + %(image_points)s + %(project_z)s + + Returns + ------- + %(x_lab_coord)s + %(y_lab_coord)s + %(z_lab_coord)s + + Examples + -------- + >>> import numpy as np + >>> from importlib_resources import files + >>> from openpiv.calibration import poly_model + + >>> path_to_calib = files('openpiv.data').joinpath('test7/D_Cal.csv') + + >>> obj_x, obj_y, obj_z, img_x, img_y = np.loadtxt( + path_to_calib, + unpack=True, + skiprows=1, + usecols=range(5), # get first 5 columns of data + delimiter=',' + ) + + >>> obj_points = np.array([obj_x[0:3], obj_y[0:3], obj_z[0:3]], dtype=np.float64) + >>> img_points = np.array([img_x[0:3], img_y[0:3]], dtype=np.float64) + + >>> cam = poly_model.camera( + 'cam1', + [4512, 800] + ) + + >>> cam.minimize_params( + [obj_x, obj_y, obj_z], + [img_x, img_y] + ) + + >>> ij = cam.project_points( + obj_points + ) + + >>> ij + array([[-44.24281474, -33.56231972, -22.84229244], + [ 89.63444964, 211.90372246, 334.60601499]]) + + >>> cam.project_to_z( + ij, + z=obj_points[2] + ) + array([[-105.0088358 , -105.00967895, -105.01057378], + [ -15.0022918 , -10.00166811, -5.00092353], + [ -10.00000004, -10.00000003, -10.00000002]]) + + >>> obj_points + array([[-105., -105., -105.], + [ -15., -10., -5.], + [ -10., -10., -10.]]) + + """ + self._check_parameters() + + dtype = self.dtype + + image_points = np.array(image_points, dtype=dtype) + + x = image_points[0] + image_points[1] + + if isinstance(z, np.ndarray): + Z = np.array(z, dtype=dtype) + elif isinstance(z, (float, int)): + Z = np.zeros_like(x) + z + else: + msg = 'Unsupported value entered for `z`. `z` must be either a scalar ' +\ + 'or numpy ndarray' + raise ValueError(msg) + + t, r = self._get_inverse_vector(image_points) + a = (Z - t[2]) / r[2] + + X = a*r[0] + t[0] + Y = a*r[1] + t[1] + + return np.array([X, Y, Z], dtype=dtype) + + +def _get_inverse_vector( + self, + image_points: np.ndarray +): + """Return per-pixel origins and direction vectors, both shaped [3, N]. + + Origins lie on Z=0. Directions are not normalized; their Z component + is one. Coefficients use the raw image coordinates, as in project_points. + """ + image_points = np.array(image_points, dtype=self.dtype) + x = image_points[0] + y = image_points[1] + + polynomial_iw = np.array( + [ + np.ones_like(x), + x, y, + x*x, x*y, y*y, + x*x*x, x*x*y, x*y*y, y*y*y + ], + dtype=self.dtype + ).T + + X, Y = np.dot(polynomial_iw, self.poly_iw_o).T + dx, dy = np.dot(polynomial_iw, self.poly_iw_v).T + + t = np.array([X, Y, np.zeros_like(X)], dtype=self.dtype) + r = np.array([dx, dy, np.ones_like(dx)], dtype=self.dtype) + + return t, r diff --git a/openpiv/calibration/poly_model/_utils.py b/openpiv/calibration/poly_model/_utils.py new file mode 100644 index 00000000..837ca896 --- /dev/null +++ b/openpiv/calibration/poly_model/_utils.py @@ -0,0 +1,120 @@ +from os.path import join + +import numpy as np + + +def _save_parameters( + self, + file_path: str, + file_name: str | None=None +): + """Save polynomial camera parameters. + + Save the polynomial camera parameters to a text file. + + Parameters + ---------- + file_path : str + File path where the camera parameters are saved. + file_name : str, optional + If specified, override the default file name. + + Returns + ------- + None + + """ + if file_name is None: + file_name = self.name + + full_path = join(file_path, file_name) + + with open(full_path, 'w') as f: + f.write(self.name + '\n') + + _r = '' + for i in range(2): + _r += str(self.resolution[i]) + ' ' + + f.write(_r + '\n') + + for i in range(19): + _d2 = '' + for j in range(2): + _d2 += str(self.poly_wi[i, j]) + ' ' + + f.write(_d2 + '\n') + + for i in range(10): + _d2 = '' + for j in range(2): + _d2 += str(self.poly_iw_o[i, j]) + ' ' + + f.write(_d2 + '\n') + + for i in range(10): + _d2 = '' + for j in range(2): + _d2 += str(self.poly_iw_v[i, j]) + ' ' + + f.write(_d2 + '\n') + + f.write(np.dtype(self.dtype).name + '\n') + + +def _load_parameters( + self, + file_path: str, + file_name: str +): + """Load polynomial camera parameters. + + Load the polynomial camera parameters from a text file. + + Parameters + ---------- + file_path : str + File path where the camera parameters are saved. + file_name : str + Name of the file that contains the camera parameters. + + """ + full_path = join(file_path, file_name) + + with open(full_path) as f: + + name = f.readline()[:-1] + + _r = f.readline()[:-2] + resolution = np.array([float(s) for s in _r.split()]) + + poly_wi = [] + for _i in range(19): + _d2 = f.readline()[:-2] + poly_wi.append(np.array([float(s) for s in _d2.split()])) + + poly_iw_o = [] + for _i in range(10): + _d2 = f.readline()[:-2] + poly_iw_o.append(np.array([float(s) for s in _d2.split()])) + if any(len(row) != 2 for row in poly_iw_o): + msg = 'Old poly_iw file: recalibrate to obtain volume ray coefficients' + raise ValueError(msg) + + poly_iw_v = [] + for _i in range(10): + _d2 = f.readline()[:-2] + poly_iw_v.append(np.array([float(s) for s in _d2.split()])) + + dtype = np.dtype(f.readline().strip()) + + poly_wi = np.array(poly_wi, dtype=dtype) + poly_iw_o = np.array(poly_iw_o, dtype=dtype) + poly_iw_v = np.array(poly_iw_v, dtype=dtype) + + self.name = name + self.resolution = resolution + self.poly_wi = poly_wi + self.poly_iw_o = poly_iw_o + self.poly_iw_v = poly_iw_v + self.dtype = np.dtype(dtype) diff --git a/openpiv/calibration/stereo.py b/openpiv/calibration/stereo.py new file mode 100644 index 00000000..31927ac1 --- /dev/null +++ b/openpiv/calibration/stereo.py @@ -0,0 +1,510 @@ +"""Stereoscopic calibration for stereo-PIV. + +Model-agnostic helpers on top of ``openpiv.calibration`` camera models +(DLT / pinhole / polynomial/Soloff). Covers the four stereo steps: + +1. stereo pair calibration (via each camera's ``minimize_params``), +2. dewarp to a common world grid at the light-sheet plane, +3. disparity (self-calibration, Wieneke-style) correction, +4. 3C reconstruction from two 2C views. + +All functions use duck-typing: a camera needs ``project_points``, +``project_to_z``, ``resolution`` and ``dtype``. +""" + +from typing import Optional, Union + +import numpy as np +from numpy.typing import DTypeLike + +__all__ = [ + 'correct_maps', + 'dewarp_image', + 'estimate_disparity', + 'get_common_world_grid', + 'get_dewarp_mapping', + 'reconstruct_3c', + 'self_calibration', + 'stereo_reprojection_error', + 'triangulate', +] + + +def _as_z_array(z: float | np.ndarray, n: int, dtype: DTypeLike = np.float64) -> np.ndarray: + if isinstance(z, np.ndarray): + z = np.asarray(z, dtype=dtype).ravel() + if z.size == 1: + return np.full(n, float(z[0]), dtype=dtype) + if z.size != n: + raise ValueError(f"z array size {z.size} != n points {n}") + return z + return np.full(n, float(z), dtype=dtype) + + +def get_common_world_grid( + cams: list, + z: float = 0.0, + nx: int | None = None, + ny: int | None = None, + stride: int = 4, +) -> tuple[np.ndarray, np.ndarray, dict]: + """Common world grid at sheet plane ``z`` (intersection of FOVs). + + Back-projects a strided pixel grid per camera via + ``project_to_z`` and intersects the world bounds so every grid + node is seen by all cameras. + + Returns + ------- + Xw, Yw : 2D ndarrays (ny, nx) world coords + info : dict with bounds/scale/z/nx/ny + """ + if len(cams) < 2: + raise ValueError("Need at least 2 cameras for stereo") + bounds = [] + for cam in cams: + w, h = int(cam.resolution[0]), int(cam.resolution[1]) + xs = np.arange(0, w, stride, dtype=np.float64) + ys = np.arange(0, h, stride, dtype=np.float64) + px, py = np.meshgrid(xs, ys) + x = px.ravel() + y = py.ravel() + zz = _as_z_array(z, x.size, getattr(cam, "dtype", np.float64)) + X, Y, _ = cam.project_to_z([x, y], zz) + X = np.asarray(X, dtype=np.float64) + Y = np.asarray(Y, dtype=np.float64) + finite = np.isfinite(X) & np.isfinite(Y) + if not np.any(finite): + raise ValueError(f"Camera {getattr(cam, 'name', '?')} back-projection all non-finite") + bounds.append((X[finite].min(), X[finite].max(), Y[finite].min(), Y[finite].max())) + bounds = np.array(bounds) + min_x = float(bounds[:, 0].max()) + max_x = float(bounds[:, 1].min()) + min_y = float(bounds[:, 2].max()) + max_y = float(bounds[:, 3].min()) + if not (max_x > min_x and max_y > min_y): + raise ValueError(f"No common FOV overlap at z={z}: bounds {bounds.tolist()}") + if nx is None: + nx = min(int(c.resolution[0]) for c in cams) + if ny is None: + ny = min(int(c.resolution[1]) for c in cams) + nx, ny = max(int(nx), 8), max(int(ny), 8) + scale_x = (max_x - min_x) / nx + scale_y = (max_y - min_y) / ny + scale = float(min(scale_x, scale_y)) + x1d = np.linspace(min_x, max_x, nx) + y1d = np.linspace(min_y, max_y, ny) + Xw, Yw = np.meshgrid(x1d, y1d) + info = { + "min_x": min_x, "max_x": max_x, + "min_y": min_y, "max_y": max_y, + "scale": scale, "z": float(z) if np.ndim(z) == 0 else z, + "nx": nx, "ny": ny, + } + return Xw, Yw, info + + +def get_dewarp_mapping( + cam, + Xw: np.ndarray, + Yw: np.ndarray, + z: float | np.ndarray = 0.0, +) -> tuple[np.ndarray, np.ndarray]: + """Image coords (map_x, map_y) for each world node. + + Forward-projects ``(Xw, Yw, z)`` via ``cam.project_points``. + Output shapes match ``Xw``; use with :func:`dewarp_image` + (``map_y`` = rows, ``map_x`` = cols). + """ + Xf = np.asarray(Xw, dtype=np.float64).ravel() + Yf = np.asarray(Yw, dtype=np.float64).ravel() + Zf = _as_z_array(z, Xf.size, getattr(cam, "dtype", np.float64)) + proj = cam.project_points(np.array([Xf, Yf, Zf])) + map_x = np.asarray(proj[0], dtype=np.float64).reshape(Xw.shape) + map_y = np.asarray(proj[1], dtype=np.float64).reshape(Xw.shape) + return map_x, map_y + + +def dewarp_image( + image: np.ndarray, + map_x: np.ndarray, + map_y: np.ndarray, + order: int = 1, + cval: float = 0.0, +) -> np.ndarray: + """Rectify ``image`` onto world grid via ``scipy.ndimage.map_coordinates``.""" + from scipy.ndimage import map_coordinates + image = np.asarray(image) + coords = np.array([np.asarray(map_y).ravel(), np.asarray(map_x).ravel()]) + rect = map_coordinates(image, coords, order=order, mode="constant", cval=cval) + return rect.reshape(map_x.shape) + + +def estimate_disparity( + imgL_rect: np.ndarray, + imgR_rect: np.ndarray, + window_size: int = 32, + overlap: int = 16, + correlation_method: str = "circular", + subpixel_method: str = "gaussian", + sig2noise_method: str | None = "peak2peak", + **kwargs, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Disparity ``D = R - L`` between dewarped pair (same-t). + + Wraps :func:`openpiv.pyprocess.extended_search_area_piv`. + Returns ``x, y, dx, dy, s2n`` on the PIV grid. + """ + from openpiv.pyprocess import ( + extended_search_area_piv, + get_field_shape, + get_rect_coordinates, + ) + A = np.asarray(imgL_rect, dtype=np.float32) + B = np.asarray(imgR_rect, dtype=np.float64 if imgR_rect.dtype != np.float32 else np.float32) + # scipy fft path needs matching float32 or float64; cast both to float32 + A = np.ascontiguousarray(A, dtype=np.float32) + B = np.ascontiguousarray(B, dtype=np.float32) + u, v, s2n = extended_search_area_piv( + A, B, + window_size=window_size, + overlap=overlap, + search_area_size=kwargs.pop("search_area_size", window_size), + correlation_method=kwargs.pop("correlation_method", correlation_method), + subpixel_method=kwargs.pop("subpixel_method", subpixel_method), + sig2noise_method=kwargs.pop("sig2noise_method", sig2noise_method), + **kwargs, + ) + shape = tuple(int(s) for s in get_field_shape(A.shape, window_size, overlap)) + x, y = get_rect_coordinates(A.shape, window_size, overlap) + return x, y, np.asarray(u).reshape(shape), np.asarray(v).reshape(shape), np.asarray(s2n).reshape(shape) + + +def _residual_median(dx: np.ndarray, dy: np.ndarray) -> float: + dx = np.asarray(dx).ravel() + dy = np.asarray(dy).ravel() + m = np.isfinite(dx) & np.isfinite(dy) + if not np.any(m): + return float("inf") + return float(np.median(np.hypot(dx[m], dy[m]))) + + +def _interp_to_full(xg, yg, dx, dy, Xo, Yo): + """Bilinear interp of PIV-grid disparity onto full rect grid.""" + from scipy.interpolate import RectBivariateSpline + # xg, yg are 2D meshgrids from get_rect_coordinates: x varies along cols, y along rows + x1d = np.asarray(xg[0, :], dtype=float) + y1d = np.asarray(yg[:, 0], dtype=float) + # ensure strictly increasing (PIV grids are) + dx = np.asarray(dx, dtype=float) + dy = np.asarray(dy, dtype=float) + # replace non-finite with 0 for spline stability + dx = np.where(np.isfinite(dx), dx, 0.0) + dy = np.where(np.isfinite(dy), dy, 0.0) + splx = RectBivariateSpline(y1d, x1d, dx, kx=1, ky=1) + sply = RectBivariateSpline(y1d, x1d, dy, kx=1, ky=1) + Xo_c = np.clip(np.asarray(Xo, dtype=float), x1d.min(), x1d.max()) + Yo_c = np.clip(np.asarray(Yo, dtype=float), y1d.min(), y1d.max()) + # ev() evaluates at scattered points (grid=False semantics) + return ( + splx.ev(Yo_c.ravel(), Xo_c.ravel()).reshape(Xo.shape), + sply.ev(Yo_c.ravel(), Xo_c.ravel()).reshape(Xo.shape), + ) + + +def correct_maps(maps, x, y, dx, dy, factor: float = 0.5) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Apply a disparity field to dewarp mappings (Wieneke half-correction). + + The corrected map at world node ``W`` samples the old map at + ``W +/- D/2`` (map *fields* are resampled — see ``self_calibration``). + ``factor=0.5`` is the symmetric update; ``factor=1.0`` applies the + full bulk shift (useful as a coarse first step for large offsets). + + Parameters + ---------- + maps : tuple (mapLx, mapLy, mapRx, mapRy) from :func:`get_dewarp_mapping` + x, y, dx, dy : PIV-grid disparity (output of :func:`estimate_disparity`) + factor : fraction of D applied symmetrically to each side + + Returns + ------- + (mapLx, mapLy, mapRx, mapRy) corrected mappings, same shapes + """ + from scipy.ndimage import map_coordinates as _mc + mapLx, mapLy, mapRx, mapRy = (np.asarray(m, dtype=np.float64) for m in maps) + ny, nx = mapLx.shape + Yo, Xo = np.mgrid[0:ny, 0:nx].astype(float) + Dx_full, Dy_full = _interp_to_full( + np.asarray(x), np.asarray(y), + np.asarray(dx), np.asarray(dy), Xo, Yo, + ) + # the left camera samples the map at W - D/2, the right one at W + D/2, + # so that both see the particle at the same (midpoint) world node + sampleL = np.array([(Yo - factor * Dy_full).ravel(), (Xo - factor * Dx_full).ravel()]) + sampleR = np.array([(Yo + factor * Dy_full).ravel(), (Xo + factor * Dx_full).ravel()]) + return ( + _mc(mapLx, sampleL, order=1, mode="nearest").reshape(ny, nx), + _mc(mapLy, sampleL, order=1, mode="nearest").reshape(ny, nx), + _mc(mapRx, sampleR, order=1, mode="nearest").reshape(ny, nx), + _mc(mapRy, sampleR, order=1, mode="nearest").reshape(ny, nx), + ) + + +def self_calibration( + cams: list, + imgL: np.ndarray, + imgR: np.ndarray, + z: float = 0.0, + window_size: int = 32, + overlap: int = 16, + max_iter: int = 3, + order: int = 1, + **kwargs, +) -> dict: + """Wieneke-style disparity self-calibration. + + Dewarps both raw frames to the common grid (when shapes match + camera resolutions, else treats inputs as already rectified), + estimates disparity, applies symmetric half-correction to the + mappings (or images), iterates. Returns mappings + residuals. + """ + imgL = np.asarray(imgL) + imgR = np.asarray(imgR) + # Dewarp only when raw frames match sensor sizes; otherwise treat + # inputs as already rectified (pre-dewarped data, unit tests). + use_dewarp = ( + len(cams) >= 2 + and tuple(imgL.shape) == (int(cams[0].resolution[1]), int(cams[0].resolution[0])) + and tuple(imgR.shape) == (int(cams[1].resolution[1]), int(cams[1].resolution[0])) + ) + if use_dewarp: + Xw, Yw, info = get_common_world_grid(cams, z=z) + mapLx, mapLy = get_dewarp_mapping(cams[0], Xw, Yw, z=z) + mapRx, mapRy = get_dewarp_mapping(cams[1], Xw, Yw, z=z) + curL = dewarp_image(imgL, mapLx, mapLy, order=order) + curR = dewarp_image(imgR, mapRx, mapRy, order=order) + else: + # already-rectified inputs (e.g. unit tests / pre-dewarped data) + Xw = Yw = None + info = {"z": z, "note": "inputs treated as rectified (shape != sensor)"} + mapLx = mapLy = mapRx = mapRy = None + curL, curR = imgL, imgR + + x, y, dx, dy, s2n = estimate_disparity(curL, curR, window_size, overlap, **kwargs) + residual_before = _residual_median(dx, dy) + for _ in range(max(0, max_iter - 1)): + if use_dewarp: + # Warp the MAP FIELDS (not the map values): the corrected map + # at world node W must sample the old map at W +/- D/2. + # Adding D/2 to map values instead assumes d(map)/dW = I, + # which diverges under rotated/perspective mappings. + mapLx, mapLy, mapRx, mapRy = correct_maps( + (mapLx, mapLy, mapRx, mapRy), x, y, dx, dy, factor=0.5, + ) + curL = dewarp_image(imgL, mapLx, mapLy, order=order) + curR = dewarp_image(imgR, mapRx, mapRy, order=order) + else: + # image-space symmetric shift of right frame toward left + from scipy.ndimage import map_coordinates + ny, nx = curR.shape + Yo, Xo = np.mgrid[0:ny, 0:nx].astype(float) + Dx_full, Dy_full = _interp_to_full(x, y, dx, dy, Xo, Yo) + curR = map_coordinates( + curR, + np.array([(Yo - 0.5 * Dy_full).ravel(), (Xo - 0.5 * Dx_full).ravel()]), + order=order, mode="constant", cval=0.0, + ).reshape(ny, nx) + x, y, dx, dy, s2n = estimate_disparity(curL, curR, window_size, overlap, **kwargs) + residual_after = _residual_median(dx, dy) + return { + "x": x, "y": y, "dx": dx, "dy": dy, "s2n": s2n, + "residual_before": float(residual_before), + "residual_after": float(min(residual_after, residual_before)), + "maps": (mapLx, mapLy, mapRx, mapRy), + "grid": (Xw, Yw, info), + "rectified": (curL, curR), + } + + +def _numeric_jacobian(cams: list, X: np.ndarray, eps: float = 1e-4) -> np.ndarray: + """(N, 2*nCam, 3) Jacobian d(image)/d(world) via central differences.""" + X = np.asarray(X, dtype=np.float64) + if X.ndim == 1: + X = X[:, None] + n = X.shape[1] + nc = len(cams) + J = np.zeros((n, 2 * nc, 3)) + for k in range(3): + d = np.zeros_like(X) + d[k, :] = eps * (1.0 + np.abs(X[k, :])) + Fp = [np.asarray(c.project_points(X + d), dtype=np.float64) for c in cams] + Fm = [np.asarray(c.project_points(X - d), dtype=np.float64) for c in cams] + for i in range(nc): + J[:, 2 * i, k] = (Fp[i][0, :] - Fm[i][0, :]) / (2 * d[k, :]) + J[:, 2 * i + 1, k] = (Fp[i][1, :] - Fm[i][1, :]) / (2 * d[k, :]) + # guard non-finite (extrapolated poly): replace with per-cam median + bad = ~np.isfinite(J) + if np.any(bad): + med = np.nanmedian(np.where(np.isfinite(J), J, np.nan), axis=0) + med = np.where(np.isfinite(med), med, 0.0) + J[bad] = np.take(med, np.where(bad)[2]) + return J + + +def reconstruct_3c( + cams: list, + x_list: list, + y_list: list, + u_list: list, + v_list: list, + world_points: np.ndarray, + dt: float = 1.0, + iterations: int = 5, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """3C world velocity from two (or more) 2C pixel-displacement views. + + Soloff (1997): ``[u1,v1,u2,v2]^T = J * [U,V,W]^T * dt`` solved + per point by least squares with numerical ``J``. The projection is + nonlinear (3rd-order poly, strong Scheimpflug angles), so the + displacement ``Dw = V*dt`` is Gauss-Newton refined: ``J`` is + re-linearized at ``X + Dw`` and steps are backtracked (halved) unless + the forward-projection residual decreases. Converges in 1-2 iters + for PIV-small displacements; never diverges on large steps. + """ + if len(cams) != len(x_list) or len(cams) != len(u_list): + raise ValueError("cams/x/u list length mismatch") + X = np.asarray(world_points, dtype=np.float64) + if X.ndim == 1: + X = X[:, None] + n = X.shape[1] + d = np.zeros((n, 2 * len(cams))) + for i in range(len(cams)): + d[:, 2 * i] = np.asarray(u_list[i], dtype=np.float64).ravel() + d[:, 2 * i + 1] = np.asarray(v_list[i], dtype=np.float64).ravel() + + def _fwd(Dw_j, j): + return np.concatenate([ + (np.asarray(c.project_points(X[:, [j]] + Dw_j[:, None]), dtype=np.float64) + - np.asarray(c.project_points(X[:, [j]]), dtype=np.float64)).ravel() + for c in cams + ]) + + Dw = np.zeros((3, n)) + for j in range(n): + R = _fwd(Dw[:, j], j) - d[j] + res = float(np.dot(R, R)) + for _ in range(max(1, iterations)): + J = _numeric_jacobian(cams, X[:, [j]] + Dw[:, j][:, None])[0] + step, *_ = np.linalg.lstsq(J, -R, rcond=None) + if not np.all(np.isfinite(step)): + break + improved = False + alpha = 1.0 + for _bt in range(4): + trial = Dw[:, j] + alpha * step + Rt = _fwd(trial, j) - d[j] + rt = float(np.dot(Rt, Rt)) + if rt < res: + Dw[:, j] = trial + R, res = Rt, rt + improved = True + break + alpha *= 0.5 + if not improved: + break + return Dw[0] / dt, Dw[1] / dt, Dw[2] / dt + + +def _dlt_init(cams: list, pts: list, dtype: DTypeLike = np.float64): + """DLT-based initial guess (plane-agnostic). Returns None on failure.""" + try: + from .dlt_model import camera as dlt_camera + from .dlt_model import multi_line_intersect as dlt_multi + dlt_cams = [] + for c in cams: + if hasattr(c, "coeffs"): # already DLT + dlt_cams.append(c) + elif hasattr(c, "dlt"): # poly carries a DLT estimator + dlt_cams.append(dlt_camera("dlt-init", resolution=c.resolution, + coeffs=np.asarray(c.dlt), dtype=c.dtype)) + else: + return None + return np.asarray(dlt_multi(dlt_cams, pts), dtype=np.float64) + except Exception: + return None + + +def _reproj_residual(cams: list, X: np.ndarray, pts: list) -> np.ndarray: + n = X.shape[1] + r = np.zeros(n) + for j in range(n): + s = 0.0 + for c, p in zip(cams, pts): + pr = np.asarray(c.project_points(X[:, [j]]), dtype=np.float64) + d = pr - np.asarray(p)[:, [j]] + s += float(np.sum(d ** 2)) + r[j] = s / len(cams) + return r + + +def triangulate( + cams: list, + img_points_list: list, + init_z: float = 0.0, + iterations: int = 10, +) -> np.ndarray: + """Generic Gauss-Newton triangulation for any camera model. + + Init is DLT-based when available (poly/DLT cams) — plane-agnostic, + unlike fixed-z back-projection which traps z=-3 points at z=0 for + dual-plane targets. Falls back to mean back-projection at + ``init_z``. Refinement accepts Gauss steps only on residual + decrease (guards weak-Z stereo geometries). + """ + pts = [np.asarray(p, dtype=np.float64) for p in img_points_list] + if len(cams) != len(pts): + raise ValueError("cams/image-points length mismatch") + n = pts[0].shape[1] + X = _dlt_init(cams, pts) + if X is None: + acc = np.zeros((3, n)) + for c, p in zip(cams, pts): + back = np.asarray(c.project_to_z(p, np.full(n, init_z)), dtype=np.float64) + acc += back + X = acc / len(cams) + X = np.asarray(X, dtype=np.float64) + if X.ndim == 1: + X = X[:, None] + resid = _reproj_residual(cams, X, pts) + for _ in range(iterations): + J = _numeric_jacobian(cams, X) + for j in range(n): + pred = np.concatenate( + [np.asarray(c.project_points(X[:, [j]]), dtype=np.float64).ravel() for c in cams] + ) + obs = np.concatenate([p[:, j] for p in pts]) + r = pred - obs + step, *_ = np.linalg.lstsq(J[j], -r, rcond=None) + if not np.all(np.isfinite(step)): + continue + trial = X[:, [j]].copy() + trial[:, 0] += step + rt = _reproj_residual(cams, trial, [p[:, [j]] for p in pts])[0] + if rt < resid[j]: + X[:, j] = trial[:, 0] + resid[j] = rt + return X + + +def stereo_reprojection_error(cams: list, obj_points: np.ndarray, img_points_list: list) -> dict[str, float]: + """Per-camera RMS reprojection error + triangulation residual.""" + from . import _calib_utils as cu + errs = {} + for i, (c, img) in enumerate(zip(cams, img_points_list)): + errs[f"cam{i}_rms"] = float(cu.get_reprojection_error(c, obj_points, img)) + tri = triangulate(cams, img_points_list) + res = 0.0 + for c, img in zip(cams, img_points_list): + res += float(np.mean((np.asarray(c.project_points(tri)) - np.asarray(img)) ** 2)) + errs["triangulation_rmse"] = float(np.sqrt(res / len(cams))) + return errs diff --git a/openpiv/data/test7/D_Cal.csv b/openpiv/data/test7/D_Cal.csv new file mode 100644 index 00000000..f384461a --- /dev/null +++ b/openpiv/data/test7/D_Cal.csv @@ -0,0 +1,1506 @@ +x,y,z,Xcam0,Ycam0,Xcam1,Ycam1,Xcam2,Ycam2,Xcam3,Ycam3 +-105,-15,-10,-44.33764398,89.61102873,313.0399753,109.2077229,221.9885392,61.49948848,40.21196404,-49.85743431 +-105,-10,-10,-33.67518587,211.8863641,297.9770332,210.8318921,237.2767446,164.6434274,29.35719864,74.62324968 +-105,-5,-10,-22.97467733,334.5980555,282.8694386,312.7573161,252.5194949,267.4806972,18.54130868,198.6581165 +-105,0,-10,-12.23591431,457.7484431,267.7169925,414.9853365,267.7169925,370.0126635,7.764085685,322.2495569 +-105,5,-10,-1.458691318,581.3398835,252.5194949,517.5173028,282.8694386,472.2406839,-2.97467733,445.3999445 +-105,10,-10,9.357198644,705.3747503,237.2767446,620.3545726,297.9770332,574.1661079,-13.67518587,568.1116359 +-105,15,-10,20.21196404,829.8554343,221.9885392,723.4985115,313.0399753,675.7902771,-24.33764398,690.3869713 +-100,-15,-10,75.14593866,90.02714407,396.3208472,108.9176877,305.6467403,59.29077543,160.6636727,-46.67731261 +-100,-10,-10,85.96878297,211.7137158,381.32078,210.9521586,320.8720468,162.8573839,149.6465812,77.19327531 +-100,-5,-10,96.83006353,333.8324452,366.2760665,313.2903249,336.0519013,266.1148165,138.6687554,200.6223813 +-100,0,-10,107.7299855,456.3856386,351.1865073,415.9335445,351.1865073,369.0644555,127.7299855,323.6123614 +-100,5,-10,118.6687554,579.3756187,336.0519013,518.8831835,366.2760665,471.7076751,116.8300635,446.1655548 +-100,10,-10,129.6465812,702.8047247,320.8720468,622.1406161,381.32078,574.0458414,105.968783,568.2842842 +-100,15,-10,140.6636727,826.6753126,305.6467403,725.7072246,396.3208472,676.0803123,95.14593866,689.9708559 +-95,-15,-10,193.4849841,90.43927342,480.2766338,108.625302,389.9890288,57.06400136,279.9492495,-43.52797869 +-95,-10,-10,204.4646566,211.5427242,465.3409586,211.0734011,405.149896,161.0567576,268.7734725,79.7384639 +-95,-5,-10,215.4831348,333.0741946,450.3606483,313.8276661,420.2653191,264.7378002,257.6373345,202.5676969 +-95,0,-10,226.5406249,455.0359578,435.3355023,416.8894714,435.3355023,368.1085286,246.5406249,324.9620422 +-95,5,-10,237.6373345,577.4303031,420.2653191,520.2601998,450.3606483,471.1703339,235.4831348,446.9238054 +-95,10,-10,248.7734725,700.2595361,405.149896,623.9412424,465.3409586,573.9245989,224.4646566,568.4552758 +-95,15,-10,259.9492495,823.5259787,389.9890288,727.9339986,480.2766338,676.372698,213.4849841,689.5587266 +-90,-15,-10,310.6958594,90.84747379,564.9155727,108.3305372,475.0238301,54.81894381,398.0855475,-40.40898761 +-90,-10,-10,321.8288816,211.3733656,550.0458378,211.1956317,490.1186859,159.241369,386.7546431,82.25917375 +-90,-5,-10,333.0010633,332.3231981,535.1314834,314.3693928,505.1681105,263.3495116,375.4637349,204.4943361 +-90,0,-10,344.2126116,453.699212,520.1723085,417.8532118,520.1723085,367.1447882,364.2126116,326.298788 +-90,5,-10,355.4637349,575.5036639,505.1681105,521.6484884,535.1314834,470.6286072,353.0010633,447.6748019 +-90,10,-10,366.7546431,697.7388262,490.1186859,625.756631,550.0458378,573.8023683,341.8288816,568.6246344 +-90,15,-10,378.0855475,820.4069876,475.0238301,730.1790562,564.9155727,676.6674628,330.6958594,689.1505262 +-85,-15,-10,426.7946208,91.2518011,650.2460363,108.033364,560.7597083,52.55537669,515.0890964,-37.31990295 +-85,-10,-10,438.077592,211.2056167,635.4438215,211.3188625,575.7869485,157.411036,503.6065425,84.75575633 +-85,-5,-10,449.4000613,331.5793521,620.5970078,314.9155589,590.768775,261.9498117,492.1643266,206.4025666 +-85,0,-10,460.7622364,452.3752162,605.7053933,418.8248618,605.7053933,366.1731382,480.7622364,327.6227838 +-85,5,-10,472.1643266,573.5954334,590.768775,523.0481883,620.5970078,470.0824411,469.4000613,448.4186479 +-85,10,-10,483.6065425,695.2422437,575.7869485,627.586964,635.4438215,573.6791375,458.077592,568.7923833 +-85,15,-10,495.0890964,817.3179029,560.7597083,732.4426233,650.2460363,676.964636,446.7946208,688.7461989 +-80,-15,-10,541.7970215,91.6523102,736.2765343,107.7337529,647.2053696,50.27307014,630.9761105,-34.26029663 +-80,-10,-10,553.2266166,211.0394547,721.5434516,211.4431058,662.1633566,155.5655732,619.3453071,87.22855638 +-80,-5,-10,564.6960336,330.842555,706.7657957,315.4662192,677.0759526,260.5385593,607.7551682,208.2926511 +-80,0,-10,576.2054808,451.0637888,691.943364,419.8045193,691.943364,365.1934807,596.2054808,328.9342112 +-80,5,-10,587.7551682,571.7053489,677.0759526,524.4594407,706.7657957,469.5317808,584.6960336,449.155445 +-80,10,-10,599.3453071,692.7694436,662.1633566,629.4324268,721.5434516,573.5548942,573.2266166,568.9585453 +-80,15,-10,610.9761105,814.2582966,647.2053696,734.7249299,736.2765343,677.2642471,561.7970215,688.3456898 +-75,-15,-10,655.7185184,92.04905494,823.0157166,107.4316737,734.3696646,47.97179049,745.7624962,-31.2297487 +-75,-10,-10,667.2914862,210.8748571,808.3534113,211.568374,749.2567271,153.7047922,733.9867671,89.67791212 +-75,-5,-10,678.9045851,330.112707,793.6465635,316.0214294,764.0984266,259.1156107,722.2520142,210.1648471 +-75,0,-10,690.5580243,449.7647516,778.8949701,420.7922837,778.8949701,364.2057163,710.5580243,330.2332484 +-75,5,-10,702.2520142,569.8331529,764.0984266,525.8823893,793.6465635,468.9765706,698.9045851,449.885293 +-75,10,-10,713.9867671,690.3200879,749.2567271,631.2932078,808.3534113,573.429626,687.2914862,569.1231429 +-75,15,-10,725.7624962,811.2277487,734.3696646,737.0262095,823.0157166,677.5663263,675.7185184,687.9489451 +-70,-15,-10,768.5742793,92.44208812,910.4723762,107.1270958,822.261592,45.65130015,859.463859,-28.22784715 +-70,-10,-10,780.2874402,210.711802,895.8825272,211.69468,837.0760238,151.8285016,847.5464538,92.10415539 +-70,-5,-10,792.0410277,329.3897103,881.248172,316.5812461,851.8451261,257.6808199,835.6703227,212.0194073 +-70,0,-10,803.8352515,448.47793,866.5691067,421.7882559,866.5691067,363.2097441,823.8352515,331.52007 +-70,5,-10,815.6703227,567.9785927,851.8451261,527.3171801,881.248172,468.4167539,812.0410277,450.6082897 +-70,10,-10,827.5464538,687.8938446,837.0760238,633.1694984,895.8825272,573.30332,800.2874402,569.286198 +-70,15,-10,839.463859,808.2258471,822.261592,739.3466998,910.4723762,677.8709042,788.5742793,687.5559119 +-65,-15,-10,880.3791896,92.83146159,998.6554522,106.8199881,910.8903011,43.31135758,972.095511,-25.2541877 +-65,-10,-10,892.229434,210.5502678,984.1397731,211.8220368,925.6303607,149.9365065,960.0396072,94.5076118 +-65,-5,-10,904.1203871,328.6734689,969.5796296,317.1457269,940.3251299,256.2340385,948.0252614,213.8565799 +-65,0,-10,916.0522591,447.2031524,954.9748171,422.7925388,954.9748171,362.2054612,936.0522591,332.7948476 +-65,5,-10,928.0252614,566.1414201,940.3251299,528.7639615,969.5796296,467.8522731,924.1203871,451.3245311 +-65,10,-10,940.0396072,685.4903882,925.6303607,635.0614935,984.1397731,573.1759632,912.229434,569.4477322 +-65,15,-10,952.095511,805.2521877,910.8903011,741.6866424,998.6554522,678.1780119,900.3791896,687.1665384 +-60,-15,-10,991.1478587,93.21722624,1087.574033,106.5103189,1000.265095,40.95171714,1083.672477,-22.30837368 +-60,-10,-10,1003.132145,210.3902332,1073.134273,211.9504574,1014.929005,148.0286091,1071.481182,96.88860086 +-60,-5,-10,1015.15741,327.9638885,1058.650096,317.7149304,1029.547669,254.7751155,1059.331715,215.6766082 +-60,0,-10,1027.223862,445.9402505,1044.121296,423.8052367,1044.121296,361.1927633,1047.223862,334.0577495 +-60,5,-10,1039.331715,564.3213918,1029.547669,530.2228845,1058.650096,467.2830696,1035.15741,452.0341115 +-60,10,-10,1051.481182,683.1093991,1014.929005,636.9693909,1073.134273,573.0475426,1023.132145,569.6077668 +-60,15,-10,1063.672477,802.3063737,1000.265095,744.0462829,1087.574033,678.4876811,1011.147859,686.7807738 +-55,-15,-10,1100.894626,93.599432,1177.237358,106.198056,1090.395436,38.57212905,1194.209503,-19.3900158 +-55,-10,-10,1113.009981,210.2316776,1162.875302,212.0799552,1104.98138,146.1046079,1181.885855,99.24743613 +-55,-5,-10,1125.166569,327.2608767,1148.468883,318.288916,1119.52213,253.3038975,1169.604293,217.479731 +-55,0,-10,1137.364602,444.6890592,1134.017894,424.826456,1134.017894,360.171544,1157.364602,335.3089408 +-55,5,-10,1149.604293,562.518269,1119.52213,531.6941025,1148.468883,466.709084,1145.166569,452.7371233 +-55,10,-10,1161.885855,680.7505639,1104.98138,638.8933921,1162.875302,572.9180448,1133.009981,569.7663224 +-55,15,-10,1174.209503,799.3880158,1090.395436,746.425871,1177.237358,678.799944,1120.894626,686.398568 +-50,-15,-10,1209.633569,93.97812789,1267.654824,105.8831668,1181.290943,36.17233929,1303.721059,-16.49873196 +-50,-10,-10,1221.877081,210.0745805,1253.372295,212.2105439,1195.797069,144.1642983,1291.26803,101.5844254 +-50,-5,-10,1234.162071,326.5643426,1239.045462,318.8677442,1210.258057,251.8202283,1278.857332,219.2661829 +-50,0,-10,1246.488751,443.4494163,1224.674119,425.8563046,1224.674119,359.1416954,1266.488751,336.5485837 +-50,5,-10,1258.857332,560.7318171,1210.258057,533.1777717,1239.045462,466.1302558,1254.162071,453.4336574 +-50,10,-10,1271.26803,678.4135746,1195.797069,640.8337017,1253.372295,572.7874561,1241.877081,569.9234195 +-50,15,-10,1283.721059,796.496732,1181.290943,748.8256607,1267.654824,679.1148332,1229.633569,686.0198721 +-45,-15,-10,1317.378506,94.35336204,1358.835986,105.5656179,1272.961404,33.75208952,1412.221351,-13.63414717 +-45,-10,-10,1329.747329,209.9189219,1344.634844,212.3422374,1287.385819,142.2074719,1399.641847,103.8998707 +-45,-5,-10,1342.157862,325.8741972,1330.389464,319.4514766,1301.76516,250.3239493,1387.104908,221.0361939 +-45,0,-10,1354.610317,442.2211626,1316.099639,426.8948923,1316.099639,358.1031077,1374.610317,337.7768374 +-45,5,-10,1367.104908,558.9618061,1301.76516,534.6740507,1330.389464,465.5465234,1362.157862,454.1238028 +-45,10,-10,1379.641847,676.0981293,1287.385819,642.7905281,1344.634844,572.6557626,1349.747329,570.0790781 +-45,15,-10,1392.221351,793.6321472,1272.961404,751.2459105,1358.835986,679.4323821,1337.378506,685.644638 +-40,-15,-10,1424.143004,94.7251817,1450.79056,105.2453755,1365.41677,31.31111696,1519.724321,-10.7958933 +-40,-10,-10,1436.634353,209.7646821,1436.672704,212.4750496,1379.757542,140.233917,1507.021186,106.1940686 +-40,-5,-10,1449.167633,325.1903529,1422.510683,320.0401758,1394.053311,248.814899,1494.360836,222.7899899 +-40,0,-10,1461.743057,441.004142,1408.304288,427.9423308,1408.304288,357.0556692,1481.743057,338.993858 +-40,5,-10,1474.360836,557.2080101,1394.053311,536.183101,1422.510683,464.9578242,1469.167633,454.8076471 +-40,10,-10,1487.021186,673.8039314,1379.757542,644.764083,1436.672704,572.5229504,1456.634353,570.2333179 +-40,15,-10,1499.724321,790.7938933,1365.41677,753.686883,1450.79056,679.7526245,1444.143004,685.2728183 +-35,-15,-10,1529.940387,95.09363327,1543.528428,104.9224052,1458.667166,28.8491543,1626.243658,-7.983608956 +-35,-10,-10,1542.551536,209.6118417,1529.495798,212.6089949,1472.922323,138.2434182,1613.419671,108.4673103 +-35,-5,-10,1555.204827,324.5127239,1515.419081,320.6339055,1487.132553,247.2929131,1600.638681,224.5277928 +-35,0,-10,1567.900471,439.7982009,1501.298069,428.9987339,1501.298069,355.9992661,1587.900471,340.1997991 +-35,5,-10,1580.638681,555.4702072,1487.132553,537.7050869,1515.419081,464.3640945,1575.204827,455.4852761 +-35,10,-10,1593.419671,671.5306897,1472.922323,646.7545818,1529.495798,572.3890051,1562.551536,570.3861583 +-35,15,-10,1606.243658,787.981609,1458.667166,756.1488457,1543.528428,680.0755948,1549.940387,684.9043667 +-30,-15,-10,1634.783734,95.4587623,1637.059641,104.596672,1552.722891,26.36592965,1731.792799,-5.196939348 +-30,-10,-10,1647.512018,209.460382,1623.114216,212.7440879,1566.890418,136.2357563,1718.850683,110.7198815 +-30,-5,-10,1660.282643,323.8412257,1609.124791,321.2327304,1581.013102,245.7578246,1705.951762,226.2498204 +-30,0,-10,1673.09582,438.6031889,1595.091156,430.064217,1595.091156,354.933783,1693.09582,341.3948111 +-30,5,-10,1685.951762,553.7481796,1581.013102,539.2401754,1609.124791,463.7652696,1680.282643,456.1567743 +-30,10,-10,1698.850683,669.2781185,1566.890418,648.7622437,1623.114216,572.2539121,1667.512018,570.537618 +-30,15,-10,1711.792799,785.1949393,1552.722891,758.6320704,1637.059641,680.401328,1654.783734,684.5392377 +-25,-15,-10,1738.685893,95.82061353,1731.394425,104.2681402,1647.594423,23.86116636,1836.384941,-2.435536105 +-25,-10,-10,1751.528703,209.3102841,1717.538226,212.8803434,1661.672262,134.2107084,1823.327356,112.9520629 +-25,-5,-10,1764.414042,323.1757756,1703.638121,321.8367164,1675.705349,244.2094637,1810.313155,227.9562865 +-25,0,-10,1777.342122,437.418958,1689.693898,431.1388977,1689.693898,353.8591023,1797.342122,342.579042 +-25,5,-10,1790.313155,552.0417135,1675.705349,540.7885363,1703.638121,463.1612836,1784.414042,456.8222244 +-25,10,-10,1803.327356,667.0459371,1661.672262,650.7872916,1717.538226,572.1176566,1771.528703,570.6877159 +-25,15,-10,1816.384941,782.4335361,1647.594423,761.1368336,1731.394425,680.7298598,1758.685893,684.1773865 +-20,-15,-10,1841.659481,96.17923091,1826.543178,103.9367738,1743.292421,21.334583,1940.033039,0.300942854 +-20,-10,-10,1854.614264,209.1615299,1812.778271,213.0177764,1757.278471,132.1680475,1926.86259,115.1641302 +-20,-5,-10,1867.611754,322.5162922,1798.969556,322.4459304,1771.219868,242.6476573,1913.735703,229.647401 +-20,0,-10,1880.652162,436.2453629,1785.116826,432.2228957,1785.116826,352.7751043,1900.652162,343.7526371 +-20,5,-10,1893.735703,550.350599,1771.219868,542.3503427,1798.969556,462.5520696,1887.611754,457.4817078 +-20,10,-10,1906.86259,664.8338698,1757.278471,652.8299525,1812.778271,571.9802236,1874.614264,570.8364701 +-20,15,-10,1920.033039,779.6970571,1743.292421,763.663417,1826.543178,681.0612262,1861.659481,683.8187691 +-15,-15,-10,1943.716891,96.5346576,1922.516481,103.6025357,1839.827734,18.78589319,2042.749818,3.01283346 +-15,-10,-10,1956.781147,209.0141013,1908.844973,213.1564023,1853.719848,130.1075431,2029.469053,117.3563539 +-15,-5,-10,1969.888279,321.8626957,1895.129767,323.0604408,1867.567416,241.0722297,2016.232019,231.3233702 +-15,0,-10,1983.038499,435.0822611,1881.370651,433.3163326,1881.370651,351.6816674,2003.038499,344.9157389 +-15,5,-10,1996.232019,548.6746298,1867.567416,543.9257703,1895.129767,461.9375592,1989.888279,458.1353043 +-15,10,-10,2009.469053,662.6416461,1853.719848,654.8904569,1908.844973,571.8415977,1976.781147,570.9838987 +-15,15,-10,2022.749818,776.9851665,1839.827734,766.2121068,1922.516481,681.3954643,1963.716891,683.4633424 +-10,-15,-10,2044.870295,96.88693597,2019.325101,103.2653885,1937.211398,16.21480551,2144.547773,5.700465635 +-10,-10,-10,2058.041579,208.8679807,2005.749145,213.2962367,1951.007384,128.0289601,2131.159186,119.529 +-10,-5,-10,2071.255898,321.2149075,1992.129606,323.6803167,1964.758938,239.4830019,2117.814488,232.9843967 +-10,0,-10,2084.513464,433.9295124,1978.466274,434.4193323,1978.466274,350.5786677,2104.513464,346.0684876 +-10,5,-10,2097.814488,547.0136033,1964.758938,545.5149981,1992.129606,461.3176833,2091.255898,458.7830925 +-10,10,-10,2111.159186,660.469,1951.007384,656.9690399,2005.749145,571.7017633,2078.041579,571.1300193 +-10,15,-10,2124.547773,774.2975344,1937.211398,768.7831945,2019.325101,681.7326115,2064.870295,683.111064 +-5,-15,-10,2145.131652,97.23610769,2116.979989,102.9252941,2035.454645,13.62102343,2245.439179,8.364163426 +-5,-10,-10,2158.40757,208.7231509,2103.501783,213.4372954,2049.152263,125.9320597,2231.945211,121.6823294 +-5,-5,-10,2171.726673,320.5728506,2089.98012,324.3056289,2062.805572,237.8797917,2218.49528,234.6306794 +-5,0,-10,2185.089172,432.7869791,2076.414787,435.5320208,2076.414787,349.4659792,2205.089172,347.2110209 +-5,5,-10,2198.49528,545.3673206,2062.805572,547.1182083,2089.98012,460.6923711,2191.726673,459.4251494 +-5,10,-10,2211.945211,658.3156706,2049.152263,659.0659403,2103.501783,571.5607046,2178.40757,571.2748491 +-5,15,-10,2225.439179,771.6338366,2035.454645,771.3769766,2116.979989,682.0727059,2165.131652,682.7618923 +0,-15,-10,2244.51271,97.58221367,2215.49229,102.5822137,2134.568908,11.00424513,2345.436092,11.00424513 +0,-10,-10,2257.890918,208.5795947,2202.114082,213.5795947,2148.16587,123.8165986,2331.83913,123.8165986 +0,-5,-10,2271.312453,319.9364492,2188.692547,324.9364492,2161.718654,236.2624137,2318.286346,236.2624137 +0,0,-10,2284.777525,431.6545262,2175.227475,436.6545262,2175.227475,348.3434738,2304.777525,348.3434738 +0,5,-10,2298.286346,543.7355863,2161.718654,548.7355863,2188.692547,460.0615508,2291.312453,460.0615508 +0,10,-10,2311.83913,656.1814014,2148.16587,661.1814014,2202.114082,571.4184053,2277.890918,571.4184053 +0,15,-10,2325.436092,768.9937549,2134.568908,773.9937549,2215.49229,682.4157863,2264.51271,682.4157863 +5,-15,-10,2343.025011,97.92529411,2314.873348,102.2361077,2234.565821,8.364163426,2444.550355,13.62102343 +5,-10,-10,2356.503217,208.4372954,2301.59743,213.7231509,2248.059789,121.6823294,2430.852737,125.9320597 +5,-5,-10,2370.02488,319.3056289,2288.278327,325.5728506,2261.50972,234.6306794,2417.199428,237.8797917 +5,0,-10,2383.590213,430.5320208,2274.915828,437.7869791,2274.915828,347.2110209,2403.590213,349.4659792 +5,5,-10,2397.199428,542.1182083,2261.50972,550.3673206,2288.278327,459.4251494,2390.02488,460.6923711 +5,10,-10,2410.852737,654.0659403,2248.059789,663.3156706,2301.59743,571.2748491,2376.503217,571.5607046 +5,15,-10,2424.550355,766.3769766,2234.565821,776.6338366,2314.873348,682.7618923,2363.025011,682.0727059 +10,-15,-10,2440.679899,98.2653885,2415.134705,101.886936,2335.457227,5.700465635,2542.793602,16.21480551 +10,-10,-10,2454.255855,208.2962367,2401.963421,213.8679807,2348.845814,119.529,2528.997616,128.0289601 +10,-5,-10,2467.875394,318.6803167,2388.749102,326.2149075,2362.190512,232.9843967,2515.246062,239.4830019 +10,0,-10,2481.538726,429.4193323,2375.491536,438.9295124,2375.491536,346.0684876,2501.538726,350.5786677 +10,5,-10,2495.246062,540.5149981,2362.190512,552.0136033,2388.749102,458.7830925,2487.875394,461.3176833 +10,10,-10,2508.997616,651.9690399,2348.845814,665.469,2401.963421,571.1300193,2474.255855,571.7017633 +10,15,-10,2522.793602,763.7831945,2335.457227,779.2975344,2415.134705,683.111064,2460.679899,681.7326115 +15,-15,-10,2537.488519,98.60253567,2516.288109,101.5346576,2437.255182,3.01283346,2640.177266,18.78589319 +15,-10,-10,2551.160027,208.1564023,2503.223853,214.0141013,2450.535947,117.3563539,2626.285152,130.1075431 +15,-5,-10,2564.875233,318.0604408,2490.116721,326.8626957,2463.772981,231.3233702,2612.437584,241.0722297 +15,0,-10,2578.634349,428.3163326,2476.966501,440.0822611,2476.966501,344.9157389,2598.634349,351.6816674 +15,5,-10,2592.437584,538.9257703,2463.772981,553.6746298,2490.116721,458.1353043,2584.875233,461.9375592 +15,10,-10,2606.285152,649.8904569,2450.535947,667.6416461,2503.223853,570.9838987,2571.160027,571.8415977 +15,15,-10,2620.177266,761.2121068,2437.255182,781.9851665,2516.288109,683.4633424,2557.488519,681.3954643 +20,-15,-10,2633.461822,98.93677375,2618.345519,101.1792309,2539.971961,0.300942854,2736.712579,21.334583 +20,-10,-10,2647.226729,208.0177764,2605.390736,214.1615299,2553.14241,115.1641302,2722.726529,132.1680475 +20,-5,-10,2661.035444,317.4459304,2592.393246,327.5162922,2566.269297,229.647401,2708.785132,242.6476573 +20,0,-10,2674.888174,427.2228957,2579.352838,441.2453629,2579.352838,343.7526371,2694.888174,352.7751043 +20,5,-10,2688.785132,537.3503427,2566.269297,555.350599,2592.393246,457.4817078,2681.035444,462.5520696 +20,10,-10,2702.726529,647.8299525,2553.14241,669.8338698,2605.390736,570.8364701,2667.226729,571.9802236 +20,15,-10,2716.712579,758.663417,2539.971961,784.6970571,2618.345519,683.8187691,2653.461822,681.0612262 +25,-15,-10,2728.610575,99.26814024,2721.319107,100.8206135,2643.620059,-2.435536105,2832.410577,23.86116636 +25,-10,-10,2742.466774,207.8803434,2708.476297,214.3102841,2656.677644,112.9520629,2818.332738,134.2107084 +25,-5,-10,2756.366879,316.8367164,2695.590958,328.1757756,2669.691845,227.9562865,2804.299651,244.2094637 +25,0,-10,2770.311102,426.1388977,2682.662878,442.418958,2682.662878,342.579042,2790.311102,353.8591023 +25,5,-10,2784.299651,535.7885363,2669.691845,557.0417135,2695.590958,456.8222244,2776.366879,463.1612836 +25,10,-10,2798.332738,645.7872916,2656.677644,672.0459371,2708.476297,570.6877159,2762.466774,572.1176566 +25,15,-10,2812.410577,756.1368336,2643.620059,787.4335361,2721.319107,684.1773865,2748.610575,680.7298598 +30,-15,-10,2822.945359,99.59667199,2825.221266,100.4587623,2748.212201,-5.196939348,2927.282109,26.36592965 +30,-10,-10,2836.890784,207.7440879,2812.492982,214.460382,2761.154317,110.7198815,2913.114582,136.2357563 +30,-5,-10,2850.880209,316.2327304,2799.722357,328.8412257,2774.053238,226.2498204,2898.991898,245.7578246 +30,0,-10,2864.913844,425.064217,2786.90918,443.6031889,2786.90918,341.3948111,2884.913844,354.933783 +30,5,-10,2878.991898,534.2401754,2774.053238,558.7481796,2799.722357,456.1567743,2870.880209,463.7652696 +30,10,-10,2893.114582,643.7622437,2761.154317,674.2781185,2812.492982,570.537618,2856.890784,572.2539121 +30,15,-10,2907.282109,753.6320704,2748.212201,790.1949393,2825.221266,684.5392377,2842.945359,680.401328 +35,-15,-10,2916.476572,99.92240521,2930.064613,100.0936333,2853.761342,-7.983608956,3021.337834,28.8491543 +35,-10,-10,2930.509202,207.6089949,2917.453464,214.6118417,2866.585329,108.4673103,3007.082677,138.2434182 +35,-5,-10,2944.585919,315.6339055,2904.800173,329.5127239,2879.366319,224.5277928,2992.872447,247.2929131 +35,0,-10,2958.706931,423.9987339,2892.104529,444.7982009,2892.104529,340.1997991,2978.706931,355.9992661 +35,5,-10,2972.872447,532.7050869,2879.366319,560.4702072,2904.800173,455.4852761,2964.585919,464.3640945 +35,10,-10,2987.082677,641.7545818,2866.585329,676.5306897,2917.453464,570.3861583,2950.509202,572.3890051 +35,15,-10,3001.337834,751.1488457,2853.761342,792.981609,2930.064613,684.9043667,2936.476572,680.0755948 +40,-15,-10,3009.21444,100.2453755,3035.861996,99.7251817,2960.280679,-10.7958933,3114.58823,31.31111696 +40,-10,-10,3023.332296,207.4750496,3023.370647,214.7646821,2972.983814,106.1940686,3100.247458,140.233917 +40,-5,-10,3037.494317,315.0401758,3010.837367,330.1903529,2985.644164,222.7899899,3085.951689,248.814899 +40,0,-10,3051.700712,422.9423308,2998.261943,446.004142,2998.261943,338.993858,3071.700712,357.0556692 +40,5,-10,3065.951689,531.183101,2985.644164,562.2080101,3010.837367,454.8076471,3057.494317,464.9578242 +40,10,-10,3080.247458,639.764083,2972.983814,678.8039314,3023.370647,570.2333179,3043.332296,572.5229504 +40,15,-10,3094.58823,748.686883,2960.280679,795.7938933,3035.861996,685.2728183,3029.21444,679.7526245 +45,-15,-10,3101.169014,100.5656179,3142.626494,99.35336204,3067.783649,-13.63414717,3207.043596,33.75208952 +45,-10,-10,3115.370156,207.3422374,3130.257671,214.9189219,3080.363153,103.8998707,3192.619181,142.2074719 +45,-5,-10,3129.615536,314.4514766,3117.847138,330.8741972,3092.900092,221.0361939,3178.23984,250.3239493 +45,0,-10,3143.905361,421.8948923,3105.394683,447.2211626,3105.394683,337.7768374,3163.905361,358.1031077 +45,5,-10,3158.23984,529.6740507,3092.900092,563.9618061,3117.847138,454.1238028,3149.615536,465.5465234 +45,10,-10,3172.619181,637.7905281,3080.363153,681.0981293,3130.257671,570.0790781,3135.370156,572.6557626 +45,15,-10,3187.043596,746.2459105,3067.783649,798.6321472,3142.626494,685.644638,3121.169014,679.4323821 +50,-15,-10,3192.350176,100.8831668,3250.371431,98.97812789,3176.283941,-16.49873196,3298.714057,36.17233929 +50,-10,-10,3206.632705,207.2105439,3238.127919,215.0745805,3188.73697,101.5844254,3284.207931,144.1642983 +50,-5,-10,3220.959538,313.8677442,3225.842929,331.5643426,3201.147668,219.2661829,3269.746943,251.8202283 +50,0,-10,3235.330881,420.8563046,3213.516249,448.4494163,3213.516249,336.5485837,3255.330881,359.1416954 +50,5,-10,3249.746943,528.1777717,3201.147668,565.7318171,3225.842929,453.4336574,3240.959538,466.1302558 +50,10,-10,3264.207931,635.8337017,3188.73697,683.4135746,3238.127919,569.9234195,3226.632705,572.7874561 +50,15,-10,3278.714057,743.8256607,3176.283941,801.496732,3250.371431,686.0198721,3212.350176,679.1148332 +55,-15,-10,3282.767642,101.198056,3359.110374,98.599432,3285.795497,-19.3900158,3389.609564,38.57212905 +55,-10,-10,3297.129698,207.0799552,3346.995019,215.2316776,3298.119145,99.24743613,3375.02362,146.1046079 +55,-5,-10,3311.536117,313.288916,3334.838431,332.2608767,3310.400707,217.479731,3360.48287,253.3038975 +55,0,-10,3325.987106,419.826456,3322.640398,449.6890592,3322.640398,335.3089408,3345.987106,360.171544 +55,5,-10,3340.48287,526.6941025,3310.400707,567.518269,3334.838431,452.7371233,3331.536117,466.709084 +55,10,-10,3355.02362,633.8933921,3298.119145,685.7505639,3346.995019,569.7663224,3317.129698,572.9180448 +55,15,-10,3369.609564,741.425871,3285.795497,804.3880158,3359.110374,686.398568,3302.767642,678.799944 +60,-15,-10,3372.430967,101.5103189,3468.857141,98.21722624,3396.332523,-22.30837368,3479.739905,40.95171714 +60,-10,-10,3386.870727,206.9504574,3456.872855,215.3902332,3408.523818,96.88860086,3465.075995,148.0286091 +60,-5,-10,3401.354904,312.7149304,3444.84759,332.9638885,3420.673285,215.6766082,3450.457331,254.7751155 +60,0,-10,3415.883704,418.8052367,3432.781138,450.9402505,3432.781138,334.0577495,3435.883704,361.1927633 +60,5,-10,3430.457331,525.2228845,3420.673285,569.3213918,3444.84759,452.0341115,3421.354904,467.2830696 +60,10,-10,3445.075995,631.9693909,3408.523818,688.1093991,3456.872855,569.6077668,3406.870727,573.0475426 +60,15,-10,3459.739905,739.0462829,3396.332523,807.3063737,3468.857141,686.7807738,3392.430967,678.4876811 +65,-15,-10,3461.349548,101.8199881,3579.62581,97.83146159,3507.909489,-25.2541877,3569.114699,43.31135758 +65,-10,-10,3475.865227,206.8220368,3567.775566,215.5502678,3519.965393,94.5076118,3554.374639,149.9365065 +65,-5,-10,3490.42537,312.1457269,3555.884613,333.6734689,3531.979739,213.8565799,3539.67987,256.2340385 +65,0,-10,3505.030183,417.7925388,3543.952741,452.2031524,3543.952741,332.7948476,3525.030183,362.2054612 +65,5,-10,3519.67987,523.7639615,3531.979739,571.1414201,3555.884613,451.3245311,3510.42537,467.8522731 +65,10,-10,3534.374639,630.0614935,3519.965393,690.4903882,3567.775566,569.4477322,3495.865227,573.1759632 +65,15,-10,3549.114699,736.6866424,3507.909489,810.2521877,3579.62581,687.1665384,3481.349548,678.1780119 +70,-15,-10,3549.532624,102.1270958,3691.430721,97.44208812,3620.541141,-28.22784715,3657.743408,45.65130015 +70,-10,-10,3564.122473,206.69468,3679.71756,215.711802,3632.458546,92.10415539,3642.928976,151.8285016 +70,-5,-10,3578.756828,311.5812461,3667.963972,334.3897103,3644.334677,212.0194073,3628.159874,257.6808199 +70,0,-10,3593.435893,416.7882559,3656.169749,453.47793,3656.169749,331.52007,3613.435893,363.2097441 +70,5,-10,3608.159874,522.3171801,3644.334677,572.9785927,3667.963972,450.6082897,3598.756828,468.4167539 +70,10,-10,3622.928976,628.1694984,3632.458546,692.8938446,3679.71756,569.286198,3584.122473,573.30332 +70,15,-10,3637.743408,734.3466998,3620.541141,813.2258471,3691.430721,687.5559119,3569.532624,677.8709042 +75,-15,-10,3636.989283,102.4316737,3804.286482,97.04905494,3734.242504,-31.2297487,3745.635335,47.97179049 +75,-10,-10,3651.651589,206.568374,3792.713514,215.8748571,3746.018233,89.67791212,3730.748273,153.7047922 +75,-5,-10,3666.358436,311.0214294,3781.100415,335.112707,3757.752986,210.1648471,3715.906573,259.1156107 +75,0,-10,3681.11003,415.7922837,3769.446976,454.7647516,3769.446976,330.2332484,3701.11003,364.2057163 +75,5,-10,3695.906573,520.8823893,3757.752986,574.8331529,3781.100415,449.885293,3686.358436,468.9765706 +75,10,-10,3710.748273,626.2932078,3746.018233,695.3200879,3792.713514,569.1231429,3671.651589,573.429626 +75,15,-10,3725.635335,732.0262095,3734.242504,816.2277487,3804.286482,687.9489451,3656.989283,677.5663263 +80,-15,-10,3723.728466,102.7337529,3918.207979,96.6523102,3849.028889,-34.26029663,3832.79963,50.27307014 +80,-10,-10,3738.461548,206.4431058,3906.778383,216.0394547,3860.659693,87.22855638,3817.841643,155.5655732 +80,-5,-10,3753.239204,310.4662192,3895.308966,335.842555,3872.249832,208.2926511,3802.929047,260.5385593 +80,0,-10,3768.061636,414.8045193,3883.799519,456.0637888,3883.799519,328.9342112,3788.061636,365.1934807 +80,5,-10,3782.929047,519.4594407,3872.249832,576.7053489,3895.308966,449.155445,3773.239204,469.5317808 +80,10,-10,3797.841643,624.4324268,3860.659693,697.7694436,3906.778383,568.9585453,3758.461548,573.5548942 +80,15,-10,3812.79963,729.7249299,3849.028889,819.2582966,3918.207979,688.3456898,3743.728466,677.2642471 +85,-15,-10,3809.758964,103.033364,4033.210379,96.2518011,3964.915904,-37.31990295,3919.245292,52.55537669 +85,-10,-10,3824.561179,206.3188625,4021.927408,216.2056167,3976.398457,84.75575633,3904.218051,157.411036 +85,-5,-10,3839.407992,309.9155589,4010.604939,336.5793521,3987.840673,206.4025666,3889.236225,261.9498117 +85,0,-10,3854.299607,413.8248618,3999.242764,457.3752162,3999.242764,327.6227838,3874.299607,366.1731382 +85,5,-10,3869.236225,518.0481883,3987.840673,578.5954334,4010.604939,448.4186479,3859.407992,470.0824411 +85,10,-10,3884.218051,622.586964,3976.398457,700.2422437,4021.927408,568.7923833,3844.561179,573.6791375 +85,15,-10,3899.245292,727.4426233,3964.915904,822.3179029,4033.210379,688.7461989,3829.758964,676.964636 +90,-15,-10,3895.089427,103.3305372,4149.309141,95.84747379,4081.919452,-40.40898761,4004.98117,54.81894381 +90,-10,-10,3909.959162,206.1956317,4138.176118,216.3733656,4093.250357,82.25917375,3989.886314,159.241369 +90,-5,-10,3924.873517,309.3693928,4127.003937,337.3231981,4104.541265,204.4943361,3974.83689,263.3495116 +90,0,-10,3939.832692,412.8532118,4115.792388,458.699212,4115.792388,326.298788,3959.832692,367.1447882 +90,5,-10,3954.83689,516.6484884,4104.541265,580.5036639,4127.003937,447.6748019,3944.873517,470.6286072 +90,10,-10,3969.886314,620.756631,4093.250357,702.7388262,4138.176118,568.6246344,3929.959162,573.8023683 +90,15,-10,3984.98117,725.1790562,4081.919452,825.4069876,4149.309141,689.1505262,3915.089427,676.6674628 +95,-15,-10,3979.728366,103.625302,4266.520016,95.43927342,4200.05575,-43.52797869,4090.015971,57.06400136 +95,-10,-10,3994.664041,206.0734011,4255.540343,216.5427242,4211.231527,79.7384639,4074.855104,161.0567576 +95,-5,-10,4009.644352,308.8276661,4244.521865,338.0741946,4222.367666,202.5676969,4059.739681,264.7378002 +95,0,-10,4024.669498,411.8894714,4233.464375,460.0359578,4233.464375,324.9620422,4044.669498,368.1085286 +95,5,-10,4039.739681,515.2601998,4222.367666,582.4303031,4244.521865,446.9238054,4029.644352,471.1703339 +95,10,-10,4054.855104,618.9412424,4211.231527,705.2595361,4255.540343,568.4552758,4014.664041,573.9245989 +95,15,-10,4070.015971,722.9339986,4200.05575,828.5259787,4266.520016,689.5587266,3999.728366,676.372698 +100,-15,-10,4063.684153,103.9176877,4384.859061,95.02714407,4319.341327,-46.67731261,4174.35826,59.29077543 +100,-10,-10,4078.68422,205.9521586,4374.036217,216.7137158,4330.358419,77.19327531,4159.132953,162.8573839 +100,-5,-10,4093.728933,308.2903249,4363.174936,338.8324452,4341.336245,200.6223813,4143.953099,266.1148165 +100,0,-10,4108.818493,410.9335445,4352.275015,461.3856386,4352.275015,323.6123614,4128.818493,369.0644555 +100,5,-10,4123.953099,513.8831835,4341.336245,584.3756187,4363.174936,446.1655548,4113.728933,471.7076751 +100,10,-10,4139.132953,617.1406161,4330.358419,707.8047247,4374.036217,568.2842842,4098.68422,574.0458414 +100,15,-10,4154.35826,720.7072246,4319.341327,831.6753126,4384.859061,689.9708559,4083.684153,676.0803123 +105,-15,-10,4146.965025,104.2077229,4504.342644,94.61102873,4439.793036,-49.85743431,4258.016461,61.49948848 +105,-10,-10,4162.027967,205.8318921,4493.680186,216.8863641,4450.647801,74.62324968,4242.728255,164.6434274 +105,-5,-10,4177.135561,307.7573161,4482.979677,339.5980555,4461.463691,198.6581165,4227.485505,267.4806972 +105,0,-10,4192.288007,409.9853365,4472.240914,462.7484431,4472.240914,322.2495569,4212.288007,370.0126635 +105,5,-10,4207.485505,512.5173028,4461.463691,586.3398835,4482.979677,445.3999445,4197.135561,472.2406839 +105,10,-10,4222.728255,615.3545726,4450.647801,710.3747503,4493.680186,568.1116359,4182.027967,574.1661079 +105,15,-10,4238.016461,718.4985115,4439.793036,834.8554343,4504.342644,690.3869713,4166.965025,675.7902771 +-105,-15,-5,3.499862592,59.2479239,250.9323411,84.07579579,158.8315274,83.54653435,88.98845213,-23.97901238 +-105,-10,-5,14.31679741,182.2569254,235.6964047,186.2062336,174.296546,187.2125698,77.97515054,101.2630505 +-105,-5,-5,25.17258317,305.7077355,220.4150609,288.6410492,189.7153367,290.5687275,67.00154777,226.0536629 +-105,0,-5,36.06742956,429.6027388,205.0881064,391.3816052,205.0881064,393.6163948,56.06742956,350.3952612 +-105,5,-5,47.00154777,553.9443371,189.7153367,494.4292725,220.4150609,496.3569508,45.17258317,474.2902645 +-105,10,-5,57.97515054,678.7349495,174.296546,597.7854302,235.6964047,598.7917664,34.31679741,597.7410746 +-105,15,-5,68.98845213,803.9770124,158.8315274,701.4514657,250.9323411,700.9222042,23.49986259,720.7500761 +-100,-15,-5,123.5167508,59.81376347,334.407896,83.68223641,242.6839157,81.41612799,209.9808945,-20.90500251 +-100,-10,-5,134.4953024,182.2269323,319.2347601,186.2270824,258.0861264,185.5091335,198.8040214,103.7194929 +-100,-5,-5,145.5130938,305.0776327,304.0162199,289.0787848,273.442109,289.289715,187.6672402,227.8969553 +-100,0,-5,156.5703356,428.3682145,288.7520713,392.2387231,288.7520713,392.7592769,176.5703356,351.6297855 +-100,5,-5,167.6672402,552.1010447,273.442109,495.708285,304.0162199,495.9192152,165.5130938,474.9203673 +-100,10,-5,178.8040214,676.2785071,258.0861264,599.4888665,319.2347601,598.7709176,154.4953024,597.7710677 +-100,15,-5,189.9808945,800.9030025,242.6839157,703.581872,334.407896,701.3157636,143.5167508,720.1842365 +-95,-15,-5,242.3766682,60.3741483,418.5635864,83.28547042,327.2257054,79.26820626,329.7944262,-17.86094479 +-95,-10,-5,253.5132218,182.1972289,403.4547837,186.2481013,342.5635522,183.7917134,318.4576805,106.1520434 +-95,-5,-5,264.6893873,304.4536257,388.3005844,289.5200977,357.8551754,288.0002187,307.1614027,229.7223514 +-95,0,-5,275.9053765,427.1456547,373.1007836,393.1028561,373.1007836,391.8951439,295.9053765,352.8523453 +-95,5,-5,287.1614027,550.2756486,357.8551754,496.9977813,388.3005844,495.4779023,284.6893873,475.5443743 +-95,10,-5,298.4576805,673.8459566,342.5635522,601.2062866,403.4547837,598.7498987,273.5132218,597.8007711 +-95,15,-5,309.7944262,797.8589448,327.2257054,705.7297937,418.5635864,701.7125296,262.3766682,719.6238517 +-90,-15,-5,360.0962643,60.9291569,503.4077587,82.88545847,412.4654335,77.10255227,448.4461941,-14.84640356 +-90,-10,-5,371.3872867,182.1678109,488.3648531,186.2692924,427.7373283,182.0601366,436.9531903,108.5610487 +-90,-5,-5,382.7182771,303.8356265,473.2765636,289.9650319,442.9630088,286.7001091,425.5010141,231.5301105 +-90,0,-5,394.0894482,425.9348864,458.1426845,393.9740906,458.1426845,391.0239094,414.0894482,354.0631136 +-90,5,-5,405.5010141,548.4678895,442.9630088,498.2978909,473.2765636,495.0329681,402.7182771,476.1623735 +-90,10,-5,416.9531903,671.4369513,427.7373283,602.9378634,488.3648531,598.7287076,391.3872867,597.8301891 +-90,15,-5,428.4461941,794.8444036,412.4654335,707.8954477,503.4077587,702.1125415,380.0962643,719.0688431 +-85,-15,-5,476.6918708,61.47886627,588.9488962,82.48216056,498.4117788,74.91894552,565.9530141,-11.8609516 +-85,-10,-5,488.1339082,182.1386744,573.9734842,186.2906578,513.6161004,180.3142272,554.3072849,110.9468492 +-85,-5,-5,499.6162542,303.2235488,558.9527056,290.4136321,528.7742221,285.3892547,542.7027269,233.3204871 +-85,0,-5,511.1391221,424.7357396,543.8863542,394.8525146,543.8863542,390.1454854,531.1391221,355.2622604 +-85,5,-5,522.7027269,546.6775129,528.7742221,499.6087453,558.9527056,494.5843679,519.6162542,476.7744512 +-85,10,-5,534.3072849,669.0511508,513.6161004,604.6837728,573.9734842,598.7073422,508.1339082,597.8593256 +-85,15,-5,545.9530141,791.8589516,498.4117788,710.0790545,588.9488962,702.5158394,496.6918708,718.5191337 +-80,-15,-5,592.1795092,62.02335193,675.1956225,82.07553604,585.0735644,72.71716185,682.3313792,-8.904169876 +-80,-10,-5,603.7691851,182.1098152,660.289333,186.3121997,600.208658,178.5538067,670.5363773,113.3097784 +-80,-5,-5,615.3994951,302.6173078,645.3376997,290.8659438,615.2975713,284.0675216,658.7828749,235.0937306 +-80,0,-5,627.0706531,423.548048,630.3405153,395.7382174,630.3405153,389.2597826,647.0706531,356.449952 +-80,5,-5,638.7828749,544.9042694,615.2975713,500.9304784,645.3376997,494.1320562,635.3994951,477.3806922 +-80,10,-5,650.5363773,666.6882216,600.208658,606.4441933,660.289333,598.6858003,623.7691851,597.8881848 +-80,15,-5,662.3313792,788.9021699,585.0735644,712.2808381,675.1956225,702.922464,612.1795092,717.9746481 +-75,-15,-5,706.5748976,62.562688,762.1567037,81.66554357,672.4597607,70.49697334,797.597467,-5.975647363 +-75,-10,-5,718.308911,182.0812294,747.3211996,186.3339203,687.5239371,176.7786932,785.6565674,115.6501637 +-75,-5,-5,730.0838693,302.0168205,732.4403794,291.3220134,702.5419581,282.734774,773.7574805,236.8500858 +-75,0,-5,741.8999871,422.3716481,717.5140353,396.6312899,717.5140353,388.3667101,761.8999871,357.6263519 +-75,5,-5,753.7574805,543.1479142,702.5419581,502.263226,732.4403794,493.6759866,750.0838693,477.9811795 +-75,10,-5,765.6565674,664.3478363,687.5239371,608.2193068,747.3211996,598.6640797,738.308911,597.9167706 +-75,15,-5,777.597467,785.9736474,672.4597607,714.5010267,762.1567037,703.3324564,726.5748976,717.435312 +-70,-15,-5,819.8934585,63.09694719,849.8410519,81.25214114,760.5794891,68.25814826,911.7671475,-3.074980863 +-70,-10,-5,831.7685817,182.0529131,835.0780303,186.3558219,775.5710238,174.9887022,899.6836494,117.9683262 +-70,-5,-5,843.6849464,301.4220053,820.269726,291.7818878,790.5164333,281.3908735,887.6422628,238.5897928 +-70,0,-5,855.6427679,421.2063797,805.4159303,397.5318244,805.4159303,387.4661756,875.6427679,358.7916203 +-70,5,-5,867.6422628,541.4082072,790.5164333,503.6071265,820.269726,493.2161122,863.6849464,478.5759947 +-70,10,-5,879.6836494,662.0296738,775.5710238,610.0092978,835.0780303,598.6421781,851.7685817,597.9450869 +-70,15,-5,891.7671475,783.0729809,760.5794891,716.7398517,849.8410519,703.7458589,839.8934585,716.9010528 +-65,-15,-5,932.1503252,63.62620084,938.2577281,80.83528602,849.4420245,66.00045096,1024.85599,-0.201774808 +-65,-10,-5,944.1634021,182.0248626,923.5689215,186.3779067,864.3591568,173.1836457,1012.633119,120.264581 +-65,-5,-5,956.2180035,300.8327823,908.834871,292.2456148,879.2301999,280.0356796,1000.452644,240.3130873 +-65,0,-5,968.314345,420.0520856,894.0553674,398.4399149,894.0553674,386.5580851,988.314345,359.9459144 +-65,5,-5,980.4526439,539.6849127,879.2301999,504.9623204,908.834871,492.7523852,976.2180035,479.1652177 +-65,10,-5,992.6331189,659.733419,864.3591568,611.8143543,923.5689215,598.6200933,964.1634021,597.9731374 +-65,15,-5,1004.85599,780.1997748,849.4420245,718.997549,938.2577281,704.162714,952.1503252,716.3717992 +-60,-15,-5,1043.360349,64.15051898,1027.415945,80.41493479,939.056799,63.7236418,1136.879271,2.644358914 +-60,-10,-5,1055.508293,181.9970742,1012.803122,186.4001769,953.8977311,171.3633328,1124.52018,122.539237 +-60,-5,-5,1067.698031,300.249073,998.1450996,292.7132431,968.6926162,278.6690493,1112.203757,242.0202003 +-60,0,-5,1079.92978,418.9086113,983.4416681,399.3556568,983.4416681,385.6423432,1099.92978,361.0893887 +-60,5,-5,1092.203757,537.9777997,968.6926162,506.3289507,998.1450996,492.2847569,1087.698031,479.748927 +-60,10,-5,1104.52018,657.458763,953.8977311,613.6346672,1012.803122,598.5978231,1075.508293,598.0009258 +-60,15,-5,1116.879271,777.3536411,939.056799,721.2743582,1027.415945,704.5830652,1063.360349,715.847481 +-55,-15,-5,1153.538105,64.66997033,1117.325071,79.99104327,1029.433405,61.42747708,1247.851979,5.463801135 +-55,-10,-5,1165.817898,181.9695441,1102.790037,186.422635,1044.196301,169.5275692,1235.359752,124.7925977 +-55,-5,-5,1178.139742,299.6708003,1088.209854,293.1848221,1058.913199,277.2908372,1222.91045,243.7113588 +-55,0,-5,1190.503854,417.7758056,1073.584312,400.2791473,1073.584312,384.7188527,1210.503854,362.2221944 +-55,5,-5,1202.91045,536.2866412,1058.913199,507.7071628,1088.209854,491.8131779,1198.139742,480.3271997 +-55,10,-5,1215.359752,655.2054023,1044.196301,615.4704308,1102.790037,598.575365,1185.817898,598.0284559 +-55,15,-5,1227.851979,774.5341989,1029.433405,723.5705229,1117.325071,705.0069567,1173.538105,715.3280297 +-50,-15,-5,1262.697899,65.18462232,1207.994633,79.56356655,1120.581598,59.11170891,1357.788825,8.25692558 +-50,-10,-5,1275.106589,181.9422688,1193.539231,186.4452834,1135.264585,167.6761575,1345.166476,127.0249606 +-50,-5,-5,1287.557574,299.0978886,1179.038736,293.6604021,1149.901627,275.9008955,1332.587299,245.3867854 +-50,0,-5,1300.051072,416.6535198,1164.492939,401.2104851,1164.492939,383.7875149,1320.051072,363.3444802 +-50,5,-5,1312.587299,534.6112146,1149.901627,509.0971045,1179.038736,491.3375979,1307.557574,480.9001114 +-50,10,-5,1325.166476,652.9730394,1135.264585,617.3218425,1193.539231,598.5527166,1295.106589,598.0557312 +-50,15,-5,1337.788825,771.7410744,1120.581598,725.8862911,1207.994633,705.4344334,1282.697899,714.8133777 +-45,-15,-5,1370.853775,65.69454117,1299.434319,79.13245896,1212.511303,56.77608519,1466.704244,11.02409903 +-45,-10,-5,1383.388474,181.9152448,1285.06043,186.4681244,1227.112466,165.8088966,1453.954723,129.236618 +-45,-5,-5,1395.965701,298.5302637,1270.641513,294.1400342,1241.667745,274.4990738,1441.248606,247.0466985 +-45,0,-5,1408.585672,415.541608,1256.177355,402.1497706,1256.177355,382.8482294,1428.585672,364.456392 +-45,5,-5,1421.248606,532.9513015,1241.667745,510.4989262,1270.641513,490.8579658,1415.965701,481.4677363 +-45,10,-5,1433.954723,650.761382,1227.112466,619.1891034,1285.06043,598.5298756,1403.388474,598.0827552 +-45,15,-5,1446.704244,768.973901,1212.511303,728.2219148,1299.434319,705.865541,1390.853775,714.3034588 +-40,-15,-5,1478.019519,66.19979188,1391.653983,78.69767403,1305.232613,54.42034944,1574.612406,13.76568147 +-40,-10,-5,1490.677402,181.8884686,1377.363529,186.4911606,1319.749997,163.9255821,1561.738596,131.4278565 +-40,-5,-5,1503.378033,297.9678528,1363.028116,294.6237704,1334.221565,273.0852191,1548.908412,248.6913126 +-40,0,-5,1516.12163,414.4399271,1348.647533,403.0971061,1348.647533,381.9008939,1536.12163,365.5580729 +-40,5,-5,1528.908412,531.3066874,1334.221565,511.9127809,1363.028116,490.3742296,1523.378033,482.0301472 +-40,10,-5,1541.738596,648.5701435,1319.749997,621.0724179,1377.363529,598.5068394,1510.677402,598.1095314 +-40,15,-5,1554.612406,766.2323185,1305.232613,730.5776506,1391.653983,706.300326,1498.019519,713.7982081 +-35,-15,-5,1584.208664,66.70043825,1484.663646,78.25916451,1398.755797,52.04424077,1681.52722,16.48202628 +-35,-10,-5,1596.986968,181.8619368,1470.45859,186.5143944,1413.187407,162.0260061,1668.531942,133.5989577 +-35,-5,-5,1609.80823,297.4105844,1456.208651,295.1116636,1427.573275,271.6591758,1655.580498,250.3208383 +-35,0,-5,1622.672667,413.3483364,1441.913617,404.0525955,1441.913617,380.9454045,1642.672667,366.6496636 +-35,5,-5,1635.580498,529.6771617,1427.573275,513.3388242,1456.208651,489.8863364,1629.80823,482.5874156 +-35,10,-5,1648.531942,646.3990423,1413.187407,622.9719939,1470.45859,598.4836056,1616.986968,598.1360632 +-35,15,-5,1661.52722,763.5159737,1398.755797,732.9537592,1484.663646,706.7388355,1604.208664,713.2975618 +-30,-15,-5,1689.434499,67.19654294,1578.473506,77.81688234,1493.091303,49.64749374,1787.462339,19.17348034 +-30,-10,-5,1702.330521,181.8356461,1564.355849,186.5378285,1507.4351,160.1099572,1774.34835,135.7501978 +-30,-5,-5,1715.2697,296.8583882,1550.193395,295.6037676,1521.733235,270.2207857,1761.278395,251.9354823 +-30,0,-5,1728.252251,412.2666981,1535.985928,405.0163445,1535.985928,379.9816555,1748.252251,367.7313019 +-30,5,-5,1741.278395,528.0625177,1521.733235,514.7772143,1550.193395,489.3942324,1735.2697,483.1396118 +-30,10,-5,1754.34835,644.2478022,1507.4351,624.8880428,1564.355849,598.4601715,1722.330521,598.1623539 +-30,15,-5,1767.462339,760.8245197,1493.091303,735.3505063,1578.473506,707.1811177,1709.434499,712.8014571 +-25,-15,-5,1793.710075,67.68816747,1673.093931,77.37077862,1588.249761,47.22983829,1892.431167,21.84038422 +-25,-10,-5,1806.72117,181.8095933,1659.065721,186.5614653,1602.503661,158.1772201,1879.201167,137.8818481 +-25,-5,-5,1819.775608,296.3111953,1644.992804,296.1001373,1616.71199,268.7698876,1866.015388,253.5354474 +-25,0,-5,1832.873609,411.1948766,1630.874965,405.9884607,1630.874965,379.0095393,1852.873609,368.8031234 +-25,5,-5,1846.015388,526.4625526,1616.71199,516.2281124,1644.992804,488.8978627,1839.775608,483.6868047 +-25,10,-5,1859.201167,642.1161519,1602.503661,626.8207799,1659.065721,598.4365347,1826.72117,598.1884067 +-25,15,-5,1872.431167,758.1576158,1588.249761,737.7681617,1673.093931,707.6272214,1813.710075,712.3098325 +-20,-15,-5,1897.048205,68.17537226,1768.535474,76.9208036,1684.241986,44.79099963,1996.446866,24.4830723 +-20,-10,-5,1910.171784,181.783775,1754.5988,186.5853076,1698.403863,156.2275758,1983.103495,139.9941751 +-20,-5,-5,1923.338885,295.7689381,1740.617517,296.6008282,1712.520265,267.3063178,1969.804522,255.1209331 +-20,0,-5,1936.549725,410.1327388,1726.591411,406.9690534,1726.591411,378.0289466,1956.549725,369.8652612 +-20,5,-5,1949.804522,524.8770669,1712.520265,517.6916822,1740.617517,488.3971718,1943.338885,484.2290619 +-20,10,-5,1963.103495,640.0038249,1698.403863,628.7704242,1754.5988,598.4126924,1930.171784,598.214225 +-20,15,-5,1976.446866,755.5149277,1684.241986,740.2070004,1768.535474,708.0771964,1917.048205,711.8226277 +-15,-15,-5,1999.461474,68.65821664,1864.80887,76.46690668,1781.078984,42.33069812,2099.522359,27.1018729 +-15,-10,-5,2012.695005,181.7581882,1850.965866,186.609358,1795.146665,154.2608014,2086.068199,142.0874403 +-15,-5,-5,2025.972226,295.23155,1837.078359,297.1058971,1809.168975,265.8299095,2072.658604,256.6921348 +-15,0,-5,2039.293353,409.0801542,1823.146134,407.9582342,1823.146134,377.0397658,2059.293353,370.9178458 +-15,5,-5,2052.658604,523.3058652,1809.168975,519.1680905,1837.078359,487.8921029,2045.972226,484.76645 +-15,10,-5,2066.068199,637.9105597,1795.146665,630.7371986,1850.965866,598.388642,2032.695005,598.2398118 +-15,15,-5,2079.522359,752.8961271,1781.078984,742.6673019,1864.80887,708.5310933,2019.461474,711.3397834 +-10,-15,-5,2100.962243,69.13675888,1961.925041,76.00903635,1878.771957,39.84864919,2201.670336,29.69710846 +-10,-10,-5,2114.303248,181.7328297,1948.177887,186.6336193,1892.743222,152.2766703,2188.107914,144.1619005 +-10,-5,-5,2127.6881,294.6989657,1934.386343,297.6154016,1906.669229,264.3404929,2174.590215,258.2492449 +-10,0,-5,2141.117017,408.0369944,1920.550196,408.9561162,1920.550196,376.0418838,2161.117017,371.9610056 +-10,5,-5,2154.590215,521.7487551,1906.669229,520.6575071,1934.386343,487.3825984,2147.6881,485.2990343 +-10,10,-5,2168.107914,635.8360995,1892.743222,632.7213297,1948.177887,598.3643807,2134.303248,598.2651703 +-10,15,-5,2181.670336,750.3008915,1878.771957,745.1493508,1961.925041,708.9889637,2120.962243,710.8612411 +-5,-15,-5,2201.562653,69.61105623,2059.895103,75.54714021,1977.332304,37.3445632,2302.90326,32.26909562 +-5,-10,-5,2215.008707,181.7076966,2046.246025,186.6580943,1991.204885,150.2749517,2289.235049,146.217808 +-5,-5,-5,2228.498756,294.1711212,2032.55268,298.1294005,2005.032328,262.8378955,2275.611709,259.792452 +-5,0,-5,2242.033017,407.0031334,2018.814854,409.9628148,2018.814854,375.0351852,2262.033017,372.9948666 +-5,5,-5,2255.611709,520.205548,2005.032328,522.1601045,2032.55268,486.8685995,2248.498756,485.8268788 +-5,10,-5,2269.235049,633.780192,1991.204885,634.7230483,2046.246025,598.3399057,2235.008707,598.2903034 +-5,15,-5,2282.90326,747.7289044,1977.332304,747.6534368,2059.895103,709.4508598,2221.562653,710.3869438 +0,-15,-5,2301.274632,70.08116493,2158.730368,75.08116493,2076.771628,34.81814537,2403.233372,34.81814537 +0,-10,-5,2314.82336,181.6827857,2145.18164,186.6827857,2090.543207,148.2554106,2389.461793,148.2554106 +0,-5,-5,2328.416223,293.6479534,2131.588777,298.6479534,2104.269779,261.3219415,2375.735221,261.3219415 +0,0,-5,2342.053437,405.9784474,2117.951563,410.9784474,2117.951563,374.0195526,2362.053437,374.0195526 +0,5,-5,2355.735221,518.6760585,2104.269779,523.6760585,2131.588777,486.3500466,2348.416223,486.3500466 +0,10,-5,2369.461793,631.7425894,2090.543207,636.7425894,2145.18164,598.3152143,2334.82336,598.3152143 +0,15,-5,2383.233372,745.1798546,2076.771628,750.1798546,2158.730368,709.9168351,2321.274632,709.9168351 +5,-15,-5,2400.109897,70.54714021,2258.442347,74.61105623,2177.10174,32.26909562,2502.672696,37.3445632 +5,-10,-5,2413.758975,181.6580943,2244.996293,186.7076966,2190.769951,146.217808,2488.800115,150.2749517 +5,-5,-5,2427.45232,293.1294005,2231.506244,299.1711212,2204.393291,259.792452,2474.972672,262.8378955 +5,0,-5,2441.190146,404.9628148,2217.971983,412.0031334,2217.971983,372.9948666,2461.190146,375.0351852 +5,5,-5,2454.972672,517.1601045,2204.393291,525.205548,2231.506244,485.8268788,2447.45232,486.8685995 +5,10,-5,2468.800115,629.7230483,2190.769951,638.780192,2244.996293,598.2903034,2433.758975,598.3399057 +5,15,-5,2482.672696,742.6534368,2177.10174,752.7289044,2258.442347,710.3869438,2420.109897,709.4508598 +10,-15,-5,2498.079959,71.00903635,2359.042757,74.13675888,2278.334664,29.69710846,2601.233043,39.84864919 +10,-10,-5,2511.827113,181.6336193,2345.701752,186.7328297,2291.897086,144.1619005,2587.261778,152.2766703 +10,-5,-5,2525.618657,292.6154016,2332.3169,299.6989657,2305.414785,258.2492449,2573.335771,264.3404929 +10,0,-5,2539.454804,403.9561162,2318.887983,413.0369944,2318.887983,371.9610056,2559.454804,376.0418838 +10,5,-5,2553.335771,515.6575071,2305.414785,526.7487551,2332.3169,485.2990343,2545.618657,487.3825984 +10,10,-5,2567.261778,627.7213297,2291.897086,640.8360995,2345.701752,598.2651703,2531.827113,598.3643807 +10,15,-5,2581.233043,740.1493508,2278.334664,755.3008915,2359.042757,710.8612411,2518.079959,708.9889637 +15,-15,-5,2595.19613,71.46690668,2460.543526,73.65821664,2380.482641,27.1018729,2698.926016,42.33069812 +15,-10,-5,2609.039134,181.609358,2447.309995,186.7581882,2393.936801,142.0874403,2684.858335,154.2608014 +15,-5,-5,2622.926641,292.1058971,2434.032774,300.23155,2407.346396,256.6921348,2670.836025,265.8299095 +15,0,-5,2636.858866,402.9582342,2420.711647,414.0801542,2420.711647,370.9178458,2656.858866,377.0397658 +15,5,-5,2650.836025,514.1680905,2407.346396,528.3058652,2434.032774,484.76645,2642.926641,487.8921029 +15,10,-5,2664.858335,625.7371986,2393.936801,642.9105597,2447.309995,598.2398118,2629.039134,598.388642 +15,15,-5,2678.926016,737.6673019,2380.482641,757.8961271,2460.543526,711.3397834,2615.19613,708.5310933 +20,-15,-5,2691.469526,71.9208036,2562.956795,73.17537226,2483.558134,24.4830723,2795.763014,44.79099963 +20,-10,-5,2705.4062,181.5853076,2549.833216,186.783775,2496.901505,139.9941751,2781.601137,156.2275758 +20,-5,-5,2719.387483,291.6008282,2536.666115,300.7689381,2510.200478,255.1209331,2767.484735,267.3063178 +20,0,-5,2733.413589,401.9690534,2523.455275,415.1327388,2523.455275,369.8652612,2753.413589,378.0289466 +20,5,-5,2747.484735,512.6916822,2510.200478,529.8770669,2536.666115,484.2290619,2739.387483,488.3971718 +20,10,-5,2761.601137,623.7704242,2496.901505,645.0038249,2549.833216,598.214225,2725.4062,598.4126924 +20,15,-5,2775.763014,735.2070004,2483.558134,760.5149277,2562.956795,711.8226277,2711.469526,708.0771964 +25,-15,-5,2786.911069,72.37077862,2666.294925,72.68816747,2587.573833,21.84038422,2891.755239,47.22983829 +25,-10,-5,2800.939279,181.5614653,2653.28383,186.8095933,2600.803833,137.8818481,2877.501339,158.1772201 +25,-5,-5,2815.012196,291.1001373,2640.229392,301.3111953,2613.989612,253.5354474,2863.29301,268.7698876 +25,0,-5,2829.130035,400.9884607,2627.131391,416.1948766,2627.131391,368.8031234,2849.130035,379.0095393 +25,5,-5,2843.29301,511.2281124,2613.989612,531.4625526,2640.229392,483.6868047,2835.012196,488.8978627 +25,10,-5,2857.501339,621.8207799,2600.803833,647.1161519,2653.28383,598.1884067,2820.939279,598.4365347 +25,15,-5,2871.755239,732.7681617,2587.573833,763.1576158,2666.294925,712.3098325,2806.911069,707.6272214 +30,-15,-5,2881.531494,72.81688234,2770.570501,72.19654294,2692.542661,19.17348034,2986.913697,49.64749374 +30,-10,-5,2895.649151,181.5378285,2757.674479,186.8356461,2705.65665,135.7501978,2972.5699,160.1099572 +30,-5,-5,2909.811605,290.6037676,2744.7353,301.8583882,2718.726605,251.9354823,2958.271765,270.2207857 +30,0,-5,2924.019072,400.0163445,2731.752749,417.2666981,2731.752749,367.7313019,2944.019072,379.9816555 +30,5,-5,2938.271765,509.7772143,2718.726605,533.0625177,2744.7353,483.1396118,2929.811605,489.3942324 +30,10,-5,2952.5699,619.8880428,2705.65665,649.2478022,2757.674479,598.1623539,2915.649151,598.4601715 +30,15,-5,2966.913697,730.3505063,2692.542661,765.8245197,2770.570501,712.8014571,2901.531494,707.1811177 +35,-15,-5,2975.341354,73.25916451,2875.796336,71.70043825,2798.47778,16.48202628,3081.249203,52.04424077 +35,-10,-5,2989.54641,181.5143944,2863.018032,186.8619368,2811.473058,133.5989577,3066.817593,162.0260061 +35,-5,-5,3003.796349,290.1116636,2850.19677,302.4105844,2824.424502,250.3208383,3052.431725,271.6591758 +35,0,-5,3018.091383,399.0525955,2837.332333,418.3483364,2837.332333,366.6496636,3038.091383,380.9454045 +35,5,-5,3032.431725,508.3388242,2824.424502,534.6771617,2850.19677,482.5874156,3023.796349,489.8863364 +35,10,-5,3046.817593,617.9719939,2811.473058,651.3990423,2863.018032,598.1360632,3009.54641,598.4836056 +35,15,-5,3061.249203,727.9537592,2798.47778,768.5159737,2875.796336,713.2975618,2995.341354,706.7388355 +40,-15,-5,3068.351017,73.69767403,2981.985481,71.19979188,2905.392594,13.76568147,3174.772387,54.42034944 +40,-10,-5,3082.641471,181.4911606,2969.327598,186.8884686,2918.266404,131.4278565,3160.255003,163.9255821 +40,-5,-5,3096.976884,289.6237704,2956.626967,302.9678528,2931.096588,248.6913126,3145.783435,273.0852191 +40,0,-5,3111.357467,398.0971061,2943.88337,419.4399271,2943.88337,365.5580729,3131.357467,381.9008939 +40,5,-5,3125.783435,506.9127809,2931.096588,536.3066874,2956.626967,482.0301472,3116.976884,490.3742296 +40,10,-5,3140.255003,616.0724179,2918.266404,653.5701435,2969.327598,598.1095314,3102.641471,598.5068394 +40,15,-5,3154.772387,725.5776506,2905.392594,771.2323185,2981.985481,713.7982081,3088.351017,706.300326 +45,-15,-5,3160.570681,74.13245896,3089.151225,70.69454117,3013.300756,11.02409903,3267.493697,56.77608519 +45,-10,-5,3174.94457,181.4681244,3076.616526,186.9152448,3026.050277,129.236618,3252.892534,165.8088966 +45,-5,-5,3189.363487,289.1400342,3064.039299,303.5302637,3038.756394,247.0466985,3238.337255,274.4990738 +45,0,-5,3203.827645,397.1497706,3051.419328,420.541608,3051.419328,364.456392,3223.827645,382.8482294 +45,5,-5,3218.337255,505.4989262,3038.756394,537.9513015,3064.039299,481.4677363,3209.363487,490.8579658 +45,10,-5,3232.892534,614.1891034,3026.050277,655.761382,3076.616526,598.0827552,3194.94457,598.5298756 +45,15,-5,3247.493697,723.2219148,3013.300756,773.973901,3089.151225,714.3034588,3180.570681,705.865541 +50,-15,-5,3252.010367,74.56356655,3197.307101,70.18462232,3122.216175,8.25692558,3359.423402,59.11170891 +50,-10,-5,3266.465769,181.4452834,3184.898411,186.9422688,3134.838524,127.0249606,3344.740415,167.6761575 +50,-5,-5,3280.966264,288.6604021,3172.447426,304.0978886,3147.417701,245.3867854,3330.103373,275.9008955 +50,0,-5,3295.512061,396.2104851,3159.953928,421.6535198,3159.953928,363.3444802,3315.512061,383.7875149 +50,5,-5,3310.103373,504.0971045,3147.417701,539.6112146,3172.447426,480.9001114,3300.966264,491.3375979 +50,10,-5,3324.740415,612.3218425,3134.838524,657.9730394,3184.898411,598.0557312,3286.465769,598.5527166 +50,15,-5,3339.423402,720.8862911,3122.216175,776.7410744,3197.307101,714.8133777,3272.010367,705.4344334 +55,-15,-5,3342.679929,74.99104327,3306.466895,69.66997033,3232.153021,5.463801135,3450.571595,61.42747708 +55,-10,-5,3357.214963,181.422635,3294.187102,186.9695441,3244.645248,124.7925977,3435.808699,169.5275692 +55,-5,-5,3371.795146,288.1848221,3281.865258,304.6708003,3257.09455,243.7113588,3421.091801,277.2908372 +55,0,-5,3386.420688,395.2791473,3269.501146,422.7758056,3269.501146,362.2221944,3406.420688,384.7188527 +55,5,-5,3401.091801,502.7071628,3257.09455,541.2866412,3281.865258,480.3271997,3391.795146,491.8131779 +55,10,-5,3415.808699,610.4704308,3244.645248,660.2054023,3294.187102,598.0284559,3377.214963,598.575365 +55,15,-5,3430.571595,718.5705229,3232.153021,779.5341989,3306.466895,715.3280297,3362.679929,705.0069567 +60,-15,-5,3432.589055,75.41493479,3416.644651,69.15051898,3343.125729,2.644358914,3540.948201,63.7236418 +60,-10,-5,3447.201878,181.4001769,3404.496707,186.9970742,3355.48482,122.539237,3526.107269,171.3633328 +60,-5,-5,3461.8599,287.7132431,3392.306969,305.249073,3367.801243,242.0202003,3511.312384,278.6690493 +60,0,-5,3476.563332,394.3556568,3380.07522,423.9086113,3380.07522,361.0893887,3496.563332,385.6423432 +60,5,-5,3491.312384,501.3289507,3367.801243,542.9777997,3392.306969,479.748927,3481.8599,492.2847569 +60,10,-5,3506.107269,608.6346672,3355.48482,662.458763,3404.496707,598.0009258,3467.201878,598.5978231 +60,15,-5,3520.948201,716.2743582,3343.125729,782.3536411,3416.644651,715.847481,3452.589055,704.5830652 +65,-15,-5,3521.747272,75.83528602,3527.854675,68.62620084,3455.14901,-0.201774808,3630.562975,66.00045096 +65,-10,-5,3536.436079,181.3779067,3515.841598,187.0248626,3467.371881,120.264581,3615.645843,173.1836457 +65,-5,-5,3551.170129,287.2456148,3503.786997,305.8327823,3479.552356,240.3130873,3600.7748,280.0356796 +65,0,-5,3565.949633,393.4399149,3491.690655,425.0520856,3491.690655,359.9459144,3585.949633,386.5580851 +65,5,-5,3580.7748,499.9623204,3479.552356,544.6849127,3503.786997,479.1652177,3571.170129,492.7523852 +65,10,-5,3595.645843,606.8143543,3467.371881,664.733419,3515.841598,597.9731374,3556.436079,598.6200933 +65,15,-5,3610.562975,713.997549,3455.14901,785.1997748,3527.854675,716.3717992,3541.747272,704.162714 +70,-15,-5,3610.163948,76.25214114,3640.111541,68.09694719,3568.237853,-3.074980863,3719.425511,68.25814826 +70,-10,-5,3624.92697,181.3558219,3628.236418,187.0529131,3580.321351,117.9683262,3704.433976,174.9887022 +70,-5,-5,3639.735274,286.7818878,3616.320054,306.4220053,3592.362737,238.5897928,3689.488567,281.3908735 +70,0,-5,3654.58907,392.5318244,3604.362232,426.2063797,3604.362232,358.7916203,3674.58907,387.4661756 +70,5,-5,3669.488567,498.6071265,3592.362737,546.4082072,3616.320054,478.5759947,3659.735274,493.2161122 +70,10,-5,3684.433976,605.0092978,3580.321351,667.0296738,3628.236418,597.9450869,3644.92697,598.6421781 +70,15,-5,3699.425511,711.7398517,3568.237853,788.0729809,3640.111541,716.9010528,3630.163948,703.7458589 +75,-15,-5,3697.848296,76.66554357,3753.430102,67.562688,3682.407533,-5.975647363,3807.545239,70.49697334 +75,-10,-5,3712.6838,181.3339203,3741.696089,187.0812294,3694.348433,115.6501637,3792.481063,176.7786932 +75,-5,-5,3727.564621,286.3220134,3729.921131,307.0168205,3706.247519,236.8500858,3777.463042,282.734774 +75,0,-5,3742.490965,391.6312899,3718.105013,427.3716481,3718.105013,357.6263519,3762.490965,388.3667101 +75,5,-5,3757.463042,497.263226,3706.247519,548.1479142,3729.921131,477.9811795,3747.564621,493.6759866 +75,10,-5,3772.481063,603.2193068,3694.348433,669.3478363,3741.696089,597.9167706,3732.6838,598.6640797 +75,15,-5,3787.545239,709.5010267,3682.407533,790.9736474,3753.430102,717.435312,3717.848296,703.3324564 +80,-15,-5,3784.809378,77.07553604,3867.825491,67.02335193,3797.673621,-8.904169876,3894.931436,72.71716185 +80,-10,-5,3799.715667,181.3121997,3856.235815,187.1098152,3809.468623,113.3097784,3879.796342,178.5538067 +80,-5,-5,3814.6673,285.8659438,3844.605505,307.6173078,3821.222125,235.0937306,3864.707429,284.0675216 +80,0,-5,3829.664485,390.7382174,3832.934347,428.548048,3832.934347,356.449952,3849.664485,389.2597826 +80,5,-5,3844.707429,495.9304784,3821.222125,549.9042694,3844.605505,477.3806922,3834.6673,494.1320562 +80,10,-5,3859.796342,601.4441933,3809.468623,671.6882216,3856.235815,597.8881848,3819.715667,598.6858003 +80,15,-5,3874.931436,707.2808381,3797.673621,793.9021699,3867.825491,717.9746481,3804.809378,702.922464 +85,-15,-5,3871.056104,77.48216056,3983.313129,66.47886627,3914.051986,-11.8609516,3981.593221,74.91894552 +85,-10,-5,3886.031516,181.2906578,3971.871092,187.1386744,3925.697715,110.9468492,3966.3889,180.3142272 +85,-5,-5,3901.052294,285.4136321,3960.388746,308.2235488,3937.302273,233.3204871,3951.230778,285.3892547 +85,0,-5,3916.118646,389.8525146,3948.865878,429.7357396,3948.865878,355.2622604,3936.118646,390.1454854 +85,5,-5,3931.230778,494.6087453,3937.302273,551.6775129,3960.388746,476.7744512,3921.052294,494.5843679 +85,10,-5,3946.3889,599.6837728,3925.697715,674.0511508,3971.871092,597.8593256,3906.031516,598.7073422 +85,15,-5,3961.593221,705.0790545,3914.051986,796.8589516,3983.313129,718.5191337,3891.056104,702.5158394 +90,-15,-5,3956.597241,77.88545847,4099.908736,65.9291569,4031.558806,-14.84640356,4067.539566,77.10255227 +90,-10,-5,3971.640147,181.2692924,4088.617713,187.1678109,4043.05181,108.5610487,4052.267672,182.0601366 +90,-5,-5,3986.728436,284.9650319,4077.286723,308.8356265,4054.503986,231.5301105,4037.041991,286.7001091 +90,0,-5,4001.862315,388.9740906,4065.915552,430.9348864,4065.915552,354.0631136,4021.862315,391.0239094 +90,5,-5,4017.041991,493.2978909,4054.503986,553.4678895,4077.286723,476.1623735,4006.728436,495.0329681 +90,10,-5,4032.267672,597.9378634,4043.05181,676.4369513,4088.617713,597.8301891,3991.640147,598.7287076 +90,15,-5,4047.539566,702.8954477,4031.558806,799.8444036,4099.908736,719.0688431,3976.597241,702.1125415 +95,-15,-5,4041.441414,78.28547042,4217.628332,65.3741483,4150.210574,-17.86094479,4152.779295,79.26820626 +95,-10,-5,4056.550216,181.2481013,4206.491778,187.1972289,4161.54732,106.1520434,4137.441448,183.7917134 +95,-5,-5,4071.704416,284.5200977,4195.315613,309.4536257,4172.843597,229.7223514,4122.149825,288.0002187 +95,0,-5,4086.904216,388.1028561,4184.099623,432.1456547,4184.099623,352.8523453,4106.904216,391.8951439 +95,5,-5,4102.149825,491.9977813,4172.843597,555.2756486,4195.315613,475.5443743,4091.704416,495.4779023 +95,10,-5,4117.441448,596.2062866,4161.54732,678.8459566,4206.491778,597.8007711,4076.550216,598.7498987 +95,15,-5,4132.779295,700.7297937,4150.210574,802.8589448,4217.628332,719.6238517,4061.441414,701.7125296 +100,-15,-5,4125.597104,78.68223641,4336.488249,64.81376347,4270.024106,-20.90500251,4237.321084,81.41612799 +100,-10,-5,4140.77024,181.2270824,4325.509698,187.2269323,4281.200979,103.7194929,4221.918874,185.5091335 +100,-5,-5,4155.98878,284.0787848,4314.491906,310.0776327,4292.33776,227.8969553,4206.562891,289.289715 +100,0,-5,4171.252929,387.2387231,4303.434664,433.3682145,4303.434664,351.6297855,4191.252929,392.7592769 +100,5,-5,4186.562891,490.708285,4292.33776,557.1010447,4314.491906,474.9203673,4175.98878,495.9192152 +100,10,-5,4201.918874,594.4888665,4281.200979,681.2785071,4325.509698,597.7710677,4160.77024,598.7709176 +100,15,-5,4217.321084,698.581872,4270.024106,805.9030025,4336.488249,720.1842365,4145.597104,701.3157636 +105,-15,-5,4209.072659,79.07579579,4456.505137,64.2479239,4391.016548,-23.97901238,4321.173473,83.54653435 +105,-10,-5,4224.308595,181.2062336,4445.688203,187.2569254,4402.029849,101.2630505,4305.708454,187.2125698 +105,-5,-5,4239.589939,283.6410492,4434.832417,310.7077355,4413.003452,226.0536629,4290.289663,290.5687275 +105,0,-5,4254.916894,386.3816052,4423.93757,434.6027388,4423.93757,350.3952612,4274.916894,393.6163948 +105,5,-5,4270.289663,489.4292725,4413.003452,558.9443371,4434.832417,474.2902645,4259.589939,496.3569508 +105,10,-5,4285.708454,592.7854302,4402.029849,683.7349495,4445.688203,597.7410746,4244.308595,598.7917664 +105,15,-5,4301.173473,696.4514657,4391.016548,808.9770124,4456.505137,720.7500761,4229.072659,700.9222042 +-105,-15,0,51.95690139,28.4915937,188.1576111,58.67392749,94.99001382,105.8325282,138.4035336,2.23821601 +-105,-10,0,62.93143489,152.2430704,172.7458215,161.3156762,110.6347892,210.0259506,127.2284634,128.2509921 +-105,-5,0,73.94564155,276.4419112,157.287853,264.2649742,126.2325466,313.9062361,116.093938,253.8065769 +-105,0,0,84.99973688,401.0905461,141.7834978,367.5232059,141.7834978,417.4747941,104.9997369,378.9074539 +-105,5,0,96.09393796,526.1914231,126.2325466,471.0917639,157.287853,520.7330258,93.94564155,503.5560888 +-105,10,0,107.2284634,651.7470079,110.6347892,574.9720494,172.7458215,623.6823238,82.93143489,627.7549296 +-105,15,0,118.4035336,777.759784,94.99001382,679.1654718,188.1576111,726.3240725,71.95690139,751.5064063 +-100,-15,0,172.5109483,29.21100389,271.8272274,58.17462633,179.035885,103.7822643,259.9406075,5.203062151 +-100,-10,0,183.6483367,152.3594517,256.4781505,161.2349512,194.6179576,208.4070231,248.600713,130.5906619 +-100,-5,0,194.82579,275.9509055,241.0828944,264.6053431,210.1530086,312.7160582,237.3017593,255.525572 +-100,0,0,206.0435248,399.9877603,225.6412504,368.2872033,225.6412504,416.7107967,226.0435248,380.0102397 +-100,5,0,217.3017593,524.472428,210.1530086,472.2819418,241.0828944,520.3926569,214.82579,504.0470945 +-100,10,0,228.600713,649.4073381,194.6179576,576.5909769,256.4781505,623.7630488,203.6483367,627.6385483 +-100,15,0,239.9406075,774.7949378,179.035885,681.2157357,271.8272274,726.8233737,192.5109483,750.7869961 +-95,-15,0,291.8953938,29.92343446,356.1822515,57.67123498,263.7765238,101.7150518,380.285788,8.138832551 +-95,-10,0,303.1919645,152.4747017,340.8974355,161.153564,279.2943219,206.7747329,368.7848276,132.9074282 +-95,-5,0,314.5289749,275.4646806,325.5664447,264.9485086,294.7650994,311.5160715,357.3251866,257.2277698 +-95,0,0,325.9066426,398.8957311,310.1890696,369.0574877,310.1890696,415.9405123,345.9066426,381.1022689 +-95,5,0,337.3251866,522.7702302,294.7650994,473.4819285,325.5664447,520.0494914,334.5289749,504.5333194 +-95,10,0,348.7848276,647.0905718,279.2943219,578.2232671,340.8974355,623.844436,323.1919645,627.5232983 +-95,15,0,360.285788,771.8591674,263.7765238,683.2829482,356.1822515,727.326765,311.8953938,750.0745655 +-90,-15,0,410.1271765,30.62898647,441.2311402,57.16370297,349.2205809,99.63067976,499.4565226,11.04595284 +-90,-10,0,421.5793404,152.5888371,426.0121652,161.0715062,364.6725001,205.128914,487.7981686,135.2016257 +-90,-5,0,433.0723024,274.9831669,410.7470248,265.2945054,380.0774045,310.3061542,476.1814957,258.9134153 +-90,0,0,444.6062808,397.814302,395.4355085,369.8341369,395.4355085,415.1638631,464.6062808,382.183698 +-90,5,0,456.1814957,521.0845847,380.0774045,474.6918458,410.7470248,519.7034946,453.0723024,505.0148331 +-90,10,0,467.7981686,644.7963743,364.6725001,579.869086,426.0121652,623.9264938,441.5793404,627.4091629 +-90,15,0,479.4565226,768.9520472,349.2205809,685.3673202,441.2311402,727.834297,430.1271765,749.3690135 +-85,-15,0,527.2229099,31.32775907,526.9824898,56.651979,435.3768514,97.52893354,617.46992,13.92484036 +-85,-10,0,538.827159,152.7018738,511.8309689,160.9887697,450.7612537,203.4693975,605.6577607,137.4735826 +-85,-5,0,550.4725487,274.5062964,496.6332966,265.6433687,466.0986521,309.0861826,593.887628,260.5827488 +-85,0,0,562.1592981,396.7433193,481.3892619,370.6172302,481.3892619,414.3807698,582.1592981,383.2546807 +-85,5,0,573.887628,519.4152512,466.0986521,475.9118174,496.6332966,519.3546313,570.4725487,505.4917036 +-85,10,0,585.6577607,642.5244174,450.7612537,581.5286025,511.8309689,624.0092303,558.827159,627.2961262 +-85,15,0,597.46992,766.0731596,435.3768514,687.4690665,526.9824898,728.346021,547.2229099,748.6702409 +-80,-15,0,643.1988896,32.0198495,613.4450396,56.13601092,522.2542774,95.40959503,734.342758,16.77590442 +-80,-10,0,654.9517951,152.8138277,598.3626189,160.905346,537.5694905,201.7960117,722.3803,139.7236207 +-80,-5,0,666.7461682,274.0340021,583.2340662,265.9951342,552.837716,307.8560307,710.4601985,262.2360058 +-80,0,0,678.5822289,395.6826324,568.0591697,371.4068481,568.0591697,413.5911519,698.5822289,384.3153676 +-80,5,0,690.4601985,517.7619942,552.837716,477.1419693,583.2340662,519.0028658,686.7461682,505.9639979 +-80,10,0,702.3803,640.2743793,537.5694905,583.2019883,598.3626189,624.092654,674.9517951,627.1841723 +-80,15,0,714.342758,763.2220956,522.2542774,689.588405,613.4450396,728.8619891,663.1988896,747.9781505 +-75,-15,0,758.0711009,32.70535316,700.627674,55.61574572,609.8619513,93.27244242,850.0914918,19.59954642 +-75,-10,0,769.969311,152.9247142,685.6160341,160.8212264,625.106268,200.1085819,837.9821623,141.9520558 +-75,-5,0,781.9093009,273.5662185,670.5582869,266.3498383,640.3036186,306.6155706,825.9155038,263.8734175 +-75,0,0,793.8912912,394.6320936,655.4542197,372.2030726,655.4542197,412.7949274,813.8912912,385.3659064 +-75,5,0,805.9155038,516.1245825,640.3036186,478.3824294,670.5582869,518.6481617,801.9093009,506.4317815 +-75,10,0,817.9821623,638.0459442,625.106268,584.8894181,685.6160341,624.1767736,789.969311,627.0732858 +-75,15,0,830.0914918,760.3984536,609.8619513,691.7255576,700.627674,729.3822543,778.0711009,747.2926468 +-70,-15,0,871.8552264,33.38436363,788.5394264,55.09112947,698.2091193,91.11725017,964.7322616,22.3961601 +-70,-10,0,883.8954643,153.0345485,773.6002827,160.7364022,713.3807963,198.4069306,952.4794101,144.1591974 +-70,-5,0,895.9777799,273.1028813,758.615062,266.707518,728.5055343,305.3646723,940.2695295,265.4952106 +-70,0,0,908.1023942,393.5915579,743.5835508,373.0059868,743.5835508,411.9920132,928.1023942,386.4064421 +-70,5,0,920.2695295,514.5027894,728.5055343,479.6333277,758.615062,518.290482,915.9777799,506.8951187 +-70,10,0,932.4794101,635.8388026,713.3807963,586.5910694,773.6002827,624.2615978,903.8954643,626.9634515 +-70,15,0,944.7322616,757.6018399,698.2091193,693.8807498,788.5394264,729.9068705,891.8552264,746.6136364 +-65,-15,0,984.566653,34.05697274,877.1894816,54.56210739,787.3051841,88.94378889,1078.2809,25.1661317 +-65,-10,0,996.7457152,153.1433456,862.3245856,160.6508645,802.4024414,196.6908773,1065.887801,146.3453494 +-65,-5,0,1008.967139,272.6439275,847.4136484,267.0682109,817.4527926,304.1032033,1053.537958,267.1016076 +-65,0,0,1021.231145,392.5608831,832.456456,373.8156754,832.456456,411.1823246,1041.231145,387.4371169 +-65,5,0,1033.537958,512.8963924,817.4527926,480.8947967,847.4136484,517.9297891,1028.967139,507.3540725 +-65,10,0,1045.887801,633.6526506,802.4024414,588.3071227,862.3245856,624.3471355,1016.745715,626.8546544 +-65,15,0,1058.2809,754.8318683,787.3051841,696.0542111,877.1894816,730.4358926,1004.566653,745.9410273 +-60,-15,0,1096.220479,34.72327057,966.5871795,54.02862371,877.1597085,86.75182531,1190.752939,27.9098401 +-60,-10,0,1108.535233,153.2511201,951.7983192,160.5646043,892.1807285,194.9602384,1178.222793,148.5108096 +-60,-5,0,1120.892619,272.189295,936.9634596,267.4319551,907.1548812,302.8310292,1165.736175,268.6928266 +-60,0,0,1133.292858,391.5399296,922.0823857,374.6322246,922.0823857,410.3657754,1153.292858,388.4580704 +-60,5,0,1145.736175,511.3051734,907.1548812,482.1669708,936.9634596,517.5660449,1140.892619,507.808705 +-60,10,0,1158.222793,631.4871904,892.1807285,590.0377616,951.7983192,624.4333957,1128.535233,626.7468799 +-60,15,0,1170.752939,752.0881599,877.1597085,698.2461747,966.5871795,730.9693763,1116.220479,745.2747294 +-55,-15,0,1206.83152,35.38334556,1056.742018,53.49062178,967.7824188,84.54112215,1302.163619,30.62765708 +-55,-10,0,1219.278903,153.3578863,1042.031019,160.4776125,982.7253453,193.2148271,1289.499554,150.6558706 +-55,-5,0,1231.769175,271.7389231,1027.274069,267.7987897,997.6214496,301.5480131,1276.879277,270.2690818 +-55,0,0,1244.302559,390.5285607,1012.470951,375.4557219,1012.470951,409.5422781,1264.302559,389.4694393 +-55,5,0,1256.879277,509.7289182,997.6214496,483.4499869,1027.274069,517.1992103,1251.769175,508.2590769 +-55,10,0,1269.499554,629.3421294,982.7253453,591.7831729,1042.031019,624.5203875,1239.278903,626.6401137 +-55,15,0,1282.163619,749.3703429,967.7824188,700.4568778,1056.742018,731.5073782,1226.83152,744.6146544 +-50,-15,0,1316.414317,36.03728447,1147.663657,52.94804394,1059.183208,82.31143807,1412.527891,33.31994741 +-50,-10,0,1328.991334,153.4636582,1133.032384,160.3898795,1074.046146,191.4544534,1399.732968,152.7808191 +-50,-5,0,1341.611485,271.2927523,1118.355213,268.1687542,1088.862313,300.2540157,1386.982077,271.8305831 +-50,0,0,1354.274991,389.526642,1103.631929,376.2862563,1103.631929,408.7117437,1374.274991,390.471358 +-50,5,0,1366.982077,508.1674169,1088.862313,484.7439843,1118.355213,516.8292458,1361.611485,508.7052477 +-50,10,0,1379.732968,627.2171809,1074.046146,593.5435466,1133.032384,624.6081205,1348.991334,626.5343418 +-50,15,0,1392.527891,746.6780526,1059.183208,702.6865619,1147.663657,732.0499561,1336.414317,743.9607155 +-45,-15,0,1424.983142,36.68517246,1239.361922,52.40083156,1151.372141,80.06252757,1521.86043,35.98706902 +-45,-10,0,1437.686863,153.5684498,1224.812277,160.301396,1166.153154,189.6789241,1508.93764,154.8859368 +-45,-5,0,1450.433951,270.850724,1210.216796,268.5418887,1180.887453,298.9488956,1496.059114,273.3775369 +-45,0,0,1463.224626,388.5340416,1195.575261,377.1239185,1195.575261,407.8740815,1483.224626,391.4639584 +-45,5,0,1476.059114,506.6204631,1180.887453,486.0491044,1210.216796,516.4561113,1470.433951,509.147276 +-45,10,0,1488.93764,625.1120632,1166.153154,595.3190759,1224.812277,624.696604,1457.686863,626.4295502 +-45,15,0,1501.86043,744.010931,1151.372141,704.9354724,1239.361922,732.5971684,1444.983142,743.3128275 +-40,-15,0,1532.552002,37.32709314,1331.846804,51.84892502,1244.359456,77.79414088,1630.175636,38.62937322 +-40,-10,0,1545.379563,153.6722746,1317.380733,160.2121523,1259.056566,187.8880427,1617.127903,156.9714999 +-40,-5,0,1558.250711,270.4127808,1302.868891,268.9182342,1273.707028,297.6325086,1604.124656,274.9101454 +-40,0,0,1571.165667,387.5506301,1288.311063,377.9688006,1288.311063,407.0291994,1591.165667,392.4473699 +-40,5,0,1584.124656,505.0878546,1273.707028,487.3654914,1302.868891,516.0797658,1578.250711,509.5852192 +-40,10,0,1597.127903,623.0265001,1259.056566,597.1099573,1317.380733,624.7858477,1565.379563,626.3257254 +-40,15,0,1610.175636,741.3686268,1244.359456,707.2038591,1331.846804,733.149075,1552.552002,742.6709069 +-35,-15,0,1639.134651,37.96312856,1425.128471,51.29226364,1338.155569,75.50602389,1737.487643,41.24720479 +-35,-10,0,1652.08325,153.7751459,1410.747957,160.1221384,1352.766757,186.0816093,1724.317828,159.0377796 +-35,-5,0,1665.075643,269.9788664,1396.321747,269.2978324,1367.331367,296.3047083,1711.192707,276.4286073 +-35,0,0,1678.112054,386.5762806,1381.849624,378.8209963,1381.849624,406.1770037,1698.112054,393.4217194 +-35,5,0,1691.192707,503.5693927,1367.331367,488.6932917,1396.321747,515.7001676,1685.075643,510.0191336 +-35,10,0,1704.317828,620.9602204,1352.766757,598.9163907,1410.747957,624.8758616,1672.08325,626.2228541 +-35,15,0,1717.487643,738.7507952,1338.155569,709.4919761,1425.128471,733.7057364,1659.134651,742.0348714 +-30,-15,0,1744.744589,38.5933593,1519.217262,50.73078571,1432.771078,73.19791807,1843.810323,43.84090215 +-30,-10,0,1757.811483,153.8770768,1504.924333,160.0313445,1447.29428,184.2594202,1830.521223,161.0850422 +-30,-5,0,1770.922371,269.5489253,1490.58579,269.6807255,1461.770985,294.9653455,1817.277016,277.9331176 +-30,0,0,1784.077473,385.6108683,1476.201414,379.6806011,1476.201414,405.3173989,1804.077473,394.3871317 +-30,5,0,1797.277016,502.0648824,1461.770985,490.0326545,1490.58579,515.3172745,1790.922371,510.4490747 +-30,10,0,1810.521223,618.9129578,1447.29428,600.7385798,1504.924333,624.9666555,1777.811483,626.1209232 +-30,15,0,1823.810323,736.1570979,1432.771078,711.8000819,1519.217262,734.2672143,1764.744589,741.4046407 +-25,-15,0,1849.39507,39.21786446,1614.123699,50.16442846,1528.216768,70.86956034,1949.157295,46.41079751 +-25,-10,0,1862.577579,153.9780801,1599.920427,159.9397603,1542.649877,182.4212685,1935.751647,163.1135491 +-25,-5,0,1875.804268,269.1229033,1585.671628,270.0669567,1557.036575,293.6142686,1922.391077,279.4238676 +-25,0,0,1889.07536,384.6542708,1571.377084,380.5477119,1571.377084,404.4502881,1909.07536,395.3437292 +-25,5,0,1902.391077,500.5741324,1557.036575,491.3837314,1585.671628,514.9310433,1895.804268,510.8750967 +-25,10,0,1915.751647,616.8844509,1542.649877,602.5767315,1599.920427,625.0582397,1882.577579,626.0199199 +-25,15,0,1929.157295,733.5872025,1528.216768,714.1284397,1614.123699,734.8335715,1869.39507,740.7801355 +-20,-15,0,1953.099112,39.8367217,1709.858488,49.593128,1624.503613,68.52068297,2053.541928,48.95721703 +-20,-10,0,1966.394612,154.0781684,1695.746986,159.8473755,1638.844476,180.5669435,2040.022408,165.1235567 +-20,-5,0,1979.734469,268.700747,1681.590054,270.4565698,1653.139021,292.2513233,2026.548142,280.9010453 +-20,0,0,1993.118904,383.706368,1667.387473,381.4224275,1667.387473,403.5755725,2013.118904,396.291632 +-20,5,0,2006.548142,499.0969547,1653.139021,492.7466767,1681.590054,514.5414302,1999.734469,511.297253 +-20,10,0,2020.022408,614.8744433,1638.844476,604.4310565,1695.746986,625.1506245,1986.394612,625.9198316 +-20,15,0,2033.541928,731.040783,1624.503613,716.477317,1709.858488,735.404872,1973.099112,740.1612783 +-15,-15,0,2055.869495,40.4500073,1806.43252,49.01681932,1721.642783,66.15101352,2156.977349,51.48048091 +-15,-10,0,2069.275419,154.1773541,1792.414949,159.7541795,1735.889199,178.6962309,2143.346574,167.1153169 +-15,-5,0,2082.725867,268.282404,1778.352053,270.8496094,1750.0894,290.8763523,2129.76122,282.3648351 +-15,0,0,2096.221059,382.7670419,1764.24361,382.3048485,1764.24361,402.6931515,2116.221059,397.2309581 +-15,5,0,2109.76122,497.6331649,1750.0894,494.1216477,1778.352053,514.1483906,2102.725867,511.715596 +-15,10,0,2123.346574,612.8826831,1735.889199,606.3017691,1792.414949,625.2438205,2089.275419,625.8206459 +-15,15,0,2136.977349,728.5175191,1721.642783,718.8469865,1806.43252,735.9811807,2075.869495,739.5479927 +-10,-15,0,2157.718772,41.05779618,1903.856879,48.43543629,1819.645644,63.76027467,2259.476447,53.98090358 +-10,-10,0,2171.232609,154.2756494,1889.935446,159.6601616,1833.795366,176.8089124,2245.736978,169.0890773 +-10,-5,0,2184.791125,267.867823,1875.9688,271.2461209,1847.898983,289.489196,2232.043086,283.8154183 +-10,0,0,2198.394543,381.8361765,1861.95672,383.195077,1861.95672,401.802923,2218.394543,398.1618235 +-10,5,0,2212.043086,496.1825817,1847.898983,495.508804,1875.9688,513.7518791,2204.791125,512.130177 +-10,10,0,2225.736978,610.9089227,1833.795366,608.1890876,1889.935446,625.3378384,2191.232609,625.7223506 +-10,15,0,2239.476447,726.0170964,1819.645644,721.2377253,1903.856879,736.5625637,2177.718772,738.9402038 +-5,-15,0,2258.65927,41.66016188,2002.142846,47.84891158,1918.523767,61.34818417,2361.051877,56.45879377 +-5,-10,0,2272.278562,154.3730661,1988.319806,159.5653108,1932.574498,174.9047659,2347.206221,171.0450805 +-5,-5,0,2285.942681,267.4569533,1974.451674,271.6461506,1946.579243,288.0896915,2333.406287,285.2529728 +-5,0,0,2299.651848,380.9136581,1960.538227,384.0932172,1960.538227,400.9047828,2319.651848,399.0843419 +-5,5,0,2313.406287,494.7450272,1946.579243,496.9083085,1974.451674,513.3518494,2305.942681,512.5410467 +-5,10,0,2327.206221,608.9529195,1932.574498,610.0932341,1988.319806,625.4326892,2292.278562,625.6249339 +-5,15,0,2341.051877,723.5392062,1918.523767,723.6498158,2002.142846,737.1490884,2278.65927,738.3378381 +0,-15,0,2358.703099,42.25717669,2101.301901,47.25717669,2018.28893,58.9144547,2461.71607,58.9144547 +0,-10,0,2372.425442,154.4696161,2087.579558,159.4696161,2032.238323,172.9835653,2447.766677,172.9835653 +0,-5,0,2386.192749,267.0497454,2073.812251,272.0497454,2046.141857,286.6776734,2433.863143,286.6776734 +0,0,0,2400.005242,379.9993749,2059.999758,384.9993749,2059.999758,399.9986251,2420.005242,399.9986251 +0,5,0,2413.863143,493.3203266,2046.141857,498.3203266,2073.812251,512.9482546,2406.192749,512.9482546 +0,10,0,2427.766677,607.0144347,2032.238323,612.0144347,2087.579558,625.5283839,2392.425442,625.5283839 +0,15,0,2441.71607,721.0835453,2018.28893,726.0835453,2101.301901,737.7408233,2378.703099,737.7408233 +5,-15,0,2457.862154,42.84891158,2201.34573,46.66016188,2118.953123,56.45879377,2561.481233,61.34818417 +5,-10,0,2471.685194,154.5653108,2187.726438,159.3730661,2132.798779,171.0450805,2547.430502,174.9047659 +5,-5,0,2485.553326,266.6461506,2174.062319,272.4569533,2146.598713,285.2529728,2533.425757,288.0896915 +5,0,0,2499.466773,379.0932172,2160.353152,385.9136581,2160.353152,399.0843419,2519.466773,400.9047828 +5,5,0,2513.425757,491.9083085,2146.598713,499.7450272,2174.062319,512.5410467,2505.553326,513.3518494 +5,10,0,2527.430502,605.0932341,2132.798779,613.9529195,2187.726438,625.6249339,2491.685194,625.4326892 +5,15,0,2541.481233,718.6498158,2118.953123,728.5392062,2201.34573,738.3378381,2477.862154,737.1490884 +10,-15,0,2556.148121,43.43543629,2302.286228,46.05779618,2220.528553,53.98090358,2660.359356,63.76027467 +10,-10,0,2570.069554,154.6601616,2288.772391,159.2756494,2234.268022,169.0890773,2646.209634,176.8089124 +10,-5,0,2584.0362,266.2461209,2275.213875,272.867823,2247.961914,283.8154183,2632.106017,289.489196 +10,0,0,2598.04828,378.195077,2261.610457,386.8361765,2261.610457,398.1618235,2618.04828,401.802923 +10,5,0,2612.106017,490.508804,2247.961914,501.1825817,2275.213875,512.130177,2604.0362,513.7518791 +10,10,0,2626.209634,603.1890876,2234.268022,615.9089227,2288.772391,625.7223506,2590.069554,625.3378384 +10,15,0,2640.359356,716.2377253,2220.528553,731.0170964,2302.286228,738.9402038,2576.148121,736.5625637 +15,-15,0,2653.57248,44.01681932,2404.135505,45.4500073,2323.027651,51.48048091,2758.362217,66.15101352 +15,-10,0,2667.590051,154.7541795,2390.729581,159.1773541,2336.658426,167.1153169,2744.115801,178.6962309 +15,-5,0,2681.652947,265.8496094,2377.279133,273.282404,2350.24378,282.3648351,2729.9156,290.8763523 +15,0,0,2695.76139,377.3048485,2363.783941,387.7670419,2363.783941,397.2309581,2715.76139,402.6931515 +15,5,0,2709.9156,489.1216477,2350.24378,502.6331649,2377.279133,511.715596,2701.652947,514.1483906 +15,10,0,2724.115801,601.3017691,2336.658426,617.8826831,2390.729581,625.8206459,2687.590051,625.2438205 +15,15,0,2738.362217,713.8469865,2323.027651,733.5175191,2404.135505,739.5479927,2673.57248,735.9811807 +20,-15,0,2750.146512,44.593128,2506.905888,44.8367217,2426.463072,48.95721703,2855.501387,68.52068297 +20,-10,0,2764.258014,154.8473755,2493.610388,159.0781684,2439.982592,165.1235567,2841.160524,180.5669435 +20,-5,0,2778.414946,265.4565698,2480.270531,273.700747,2453.456858,280.9010453,2826.865979,292.2513233 +20,0,0,2792.617527,376.4224275,2466.886096,388.706368,2466.886096,396.291632,2812.617527,403.5755725 +20,5,0,2806.865979,487.7466767,2453.456858,504.0969547,2480.270531,511.297253,2798.414946,514.5414302 +20,10,0,2821.160524,599.4310565,2439.982592,619.8744433,2493.610388,625.9198316,2784.258014,625.1506245 +20,15,0,2835.501387,711.477317,2426.463072,736.040783,2506.905888,740.1612783,2770.146512,735.404872 +25,-15,0,2845.881301,45.16442846,2610.60993,44.21786446,2530.847705,46.41079751,2951.788232,70.86956034 +25,-10,0,2860.084573,154.9397603,2597.427421,158.9780801,2544.253353,163.1135491,2937.355123,182.4212685 +25,-5,0,2874.333372,265.0669567,2584.200732,274.1229033,2557.613923,279.4238676,2922.968425,293.6142686 +25,0,0,2888.627916,375.5477119,2570.92964,389.6542708,2570.92964,395.3437292,2908.627916,404.4502881 +25,5,0,2902.968425,486.3837314,2557.613923,505.5741324,2584.200732,510.8750967,2894.333372,514.9310433 +25,10,0,2917.355123,597.5767315,2544.253353,621.8844509,2597.427421,626.0199199,2880.084573,625.0582397 +25,15,0,2931.788232,709.1284397,2530.847705,738.5872025,2610.60993,740.7801355,2865.881301,734.8335715 +30,-15,0,2940.787738,45.73078571,2715.260411,43.5933593,2636.194677,43.84090215,3047.233922,73.19791807 +30,-10,0,2955.080667,155.0313445,2702.193517,158.8770768,2649.483777,161.0850422,3032.71072,184.2594202 +30,-5,0,2969.41921,264.6807255,2689.082629,274.5489253,2662.727984,277.9331176,3018.234015,294.9653455 +30,0,0,2983.803586,374.6806011,2675.927527,390.6108683,2675.927527,394.3871317,3003.803586,405.3173989 +30,5,0,2998.234015,485.0326545,2662.727984,507.0648824,2689.082629,510.4490747,2989.41921,515.3172745 +30,10,0,3012.71072,595.7385798,2649.483777,623.9129578,2702.193517,626.1209232,2975.080667,624.9666555 +30,15,0,3027.233922,706.8000819,2636.194677,741.1570979,2715.260411,741.4046407,2960.787738,734.2672143 +35,-15,0,3034.876529,46.29226364,2820.870349,42.96312856,2742.517357,41.24720479,3141.849431,75.50602389 +35,-10,0,3049.257043,155.1221384,2807.92175,158.7751459,2755.687172,159.0377796,3127.238243,186.0816093 +35,-5,0,3063.683253,264.2978324,2794.929357,274.9788664,2768.812293,276.4286073,3112.673633,296.3047083 +35,0,0,3078.155376,373.8209963,2781.892946,391.5762806,2781.892946,393.4217194,3098.155376,406.1770037 +35,5,0,3092.673633,483.6932917,2768.812293,508.5693927,2794.929357,510.0191336,3083.683253,515.7001676 +35,10,0,3107.238243,593.9163907,2755.687172,625.9602204,2807.92175,626.2228541,3069.257043,624.8758616 +35,15,0,3121.849431,704.4919761,2742.517357,743.7507952,2820.870349,742.0348714,3054.876529,733.7057364 +40,-15,0,3128.158196,46.84892502,2927.452998,42.32709314,2849.829364,38.62937322,3235.645544,77.79414088 +40,-10,0,3142.624267,155.2121523,2914.625437,158.6722746,2862.877097,156.9714999,3220.948434,187.8880427 +40,-5,0,3157.136109,263.9182342,2901.754289,275.4127808,2875.880344,274.9101454,3206.297972,297.6325086 +40,0,0,3171.693937,372.9688006,2888.839333,392.5506301,2888.839333,392.4473699,3191.693937,407.0291994 +40,5,0,3186.297972,482.3654914,2875.880344,510.0878546,2901.754289,509.5852192,3177.136109,516.0797658 +40,10,0,3200.948434,592.1099573,2862.877097,628.0265001,2914.625437,626.3257254,3162.624267,624.7858477 +40,15,0,3215.645544,702.2038591,2849.829364,746.3686268,2927.452998,742.6709069,3148.158196,733.149075 +45,-15,0,3220.643078,47.40083156,3035.021858,41.68517246,2958.14457,35.98706902,3328.632859,80.06252757 +45,-10,0,3235.192723,155.301396,3022.318137,158.5684498,2971.06736,154.8859368,3313.851846,189.6789241 +45,-5,0,3249.788204,263.5418887,3009.571049,275.850724,2983.945886,273.3775369,3299.117547,298.9488956 +45,0,0,3264.429739,372.1239185,2996.780374,393.5340416,2996.780374,391.4639584,3284.429739,407.8740815 +45,5,0,3279.117547,481.0491044,2983.945886,511.6204631,3009.571049,509.147276,3269.788204,516.4561113 +45,10,0,3293.851846,590.3190759,2971.06736,630.1120632,3022.318137,626.4295502,3255.192723,624.696604 +45,15,0,3308.632859,699.9354724,2958.14457,749.010931,3035.021858,743.3128275,3240.643078,732.5971684 +50,-15,0,3312.341343,47.94804394,3143.590683,41.03728447,3067.477109,33.31994741,3420.821792,82.31143807 +50,-10,0,3326.972616,155.3898795,3131.013666,158.4636582,3080.272032,152.7808191,3405.958854,191.4544534 +50,-5,0,3341.649787,263.1687542,3118.393515,276.2927523,3093.022923,271.8305831,3391.142687,300.2540157 +50,0,0,3356.373071,371.2862563,3105.730009,394.526642,3105.730009,390.471358,3376.373071,408.7117437 +50,5,0,3371.142687,479.7439843,3093.022923,513.1674169,3118.393515,508.7052477,3361.649787,516.8292458 +50,10,0,3385.958854,588.5435466,3080.272032,632.2171809,3131.013666,626.5343418,3346.972616,624.6081205 +50,15,0,3400.821792,697.6865619,3067.477109,751.6780526,3143.590683,743.9607155,3332.341343,732.0499561 +55,-15,0,3403.262982,48.49062178,3253.17348,40.38334556,3177.841381,30.62765708,3512.222581,84.54112215 +55,-10,0,3417.973981,155.4776125,3240.726097,158.3578863,3190.505446,150.6558706,3497.279655,193.2148271 +55,-5,0,3432.730931,262.7987897,3228.235825,276.7389231,3203.125723,270.2690818,3482.38355,301.5480131 +55,0,0,3447.534049,370.4557219,3215.702441,395.5285607,3215.702441,389.4694393,3467.534049,409.5422781 +55,5,0,3462.38355,478.4499869,3203.125723,514.7289182,3228.235825,508.2590769,3452.730931,517.1992103 +55,10,0,3477.279655,586.7831729,3190.505446,634.3421294,3240.726097,626.6401137,3437.973981,624.5203875 +55,15,0,3492.222581,695.4568778,3177.841381,754.3703429,3253.17348,744.6146544,3423.262982,731.5073782 +60,-15,0,3493.417821,49.02862371,3363.784521,39.72327057,3289.252061,27.9098401,3602.845292,86.75182531 +60,-10,0,3508.206681,155.5646043,3351.469767,158.2511201,3301.782207,148.5108096,3587.824272,194.9602384 +60,-5,0,3523.04154,262.4319551,3339.112381,277.189295,3314.268825,268.6928266,3572.850119,302.8310292 +60,0,0,3537.922614,369.6322246,3326.712142,396.5399296,3326.712142,388.4580704,3557.922614,410.3657754 +60,5,0,3552.850119,477.1669708,3314.268825,516.3051734,3339.112381,507.808705,3543.04154,517.5660449 +60,10,0,3567.824272,585.0377616,3301.782207,636.4871904,3351.469767,626.7468799,3528.206681,624.4333957 +60,15,0,3582.845292,693.2461747,3289.252061,757.0881599,3363.784521,745.2747294,3513.417821,730.9693763 +65,-15,0,3582.815518,49.56210739,3475.438347,39.05697274,3401.7241,25.1661317,3692.699816,88.94378889 +65,-10,0,3597.680414,155.6508645,3463.259285,158.1433456,3414.117199,146.3453494,3677.602559,196.6908773 +65,-5,0,3612.591352,262.0682109,3451.037861,277.6439275,3426.467042,267.1016076,3662.552207,304.1032033 +65,0,0,3627.548544,368.8156754,3438.773855,397.5608831,3438.773855,387.4371169,3647.548544,411.1823246 +65,5,0,3642.552207,475.8947967,3426.467042,517.8963924,3451.037861,507.3540725,3632.591352,517.9297891 +65,10,0,3657.602559,583.3071227,3414.117199,638.6526506,3463.259285,626.8546544,3617.680414,624.3471355 +65,15,0,3672.699816,691.0542111,3401.7241,759.8318683,3475.438347,745.9410273,3602.815518,730.4358926 +70,-15,0,3671.465574,50.09112947,3588.149774,38.38436363,3515.272738,22.3961601,3781.795881,91.11725017 +70,-10,0,3686.404717,155.7364022,3576.109536,158.0345485,3527.52559,144.1591974,3766.624204,198.4069306 +70,-5,0,3701.389938,261.707518,3564.02722,278.1028813,3539.73547,265.4952106,3751.499466,305.3646723 +70,0,0,3716.421449,368.0059868,3551.902606,398.5915579,3551.902606,386.4064421,3736.421449,411.9920132 +70,5,0,3731.499466,474.6333277,3539.73547,519.5027894,3564.02722,506.8951187,3721.389938,518.290482 +70,10,0,3746.624204,581.5910694,3527.52559,640.8388026,3576.109536,626.9634515,3706.404717,624.2615978 +70,15,0,3761.795881,688.8807498,3515.272738,762.6018399,3588.149774,746.6136364,3691.465574,729.9068705 +75,-15,0,3759.377326,50.61574572,3701.933899,37.70535316,3629.913508,19.59954642,3870.143049,93.27244242 +75,-10,0,3774.388966,155.8212264,3690.035689,157.9247142,3642.022838,141.9520558,3854.898732,200.1085819 +75,-5,0,3789.446713,261.3498383,3678.095699,278.5662185,3654.089496,263.8734175,3839.701381,306.6155706 +75,0,0,3804.55078,367.2030726,3666.113709,399.6320936,3666.113709,385.3659064,3824.55078,412.7949274 +75,5,0,3819.701381,473.3824294,3654.089496,521.1245825,3678.095699,506.4317815,3809.446713,518.6481617 +75,10,0,3834.898732,579.8894181,3642.022838,643.0459442,3690.035689,627.0732858,3794.388966,624.1767736 +75,15,0,3850.143049,686.7255576,3629.913508,765.3984536,3701.933899,747.2926468,3779.377326,729.3822543 +80,-15,0,3846.55996,51.13601092,3816.80611,37.0198495,3745.662242,16.77590442,3957.750723,95.40959503 +80,-10,0,3861.642381,155.905346,3805.053205,157.8138277,3757.6247,139.7236207,3942.43551,201.7960117 +80,-5,0,3876.770934,260.9951342,3793.258832,279.0340021,3769.544801,262.2360058,3927.167284,307.8560307 +80,0,0,3891.94583,366.4068481,3781.422771,400.6826324,3781.422771,384.3153676,3911.94583,413.5911519 +80,5,0,3907.167284,472.1419693,3769.544801,522.7619942,3793.258832,505.9639979,3896.770934,519.0028658 +80,10,0,3922.43551,578.2019883,3757.6247,645.2743793,3805.053205,627.1841723,3881.642381,624.092654 +80,15,0,3937.750723,684.588405,3745.662242,768.2220956,3816.80611,747.9781505,3866.55996,728.8619891 +85,-15,0,3933.02251,51.651979,3932.78209,36.32775907,3862.53508,13.92484036,4044.628149,97.52893354 +85,-10,0,3948.174031,155.9887697,3921.177841,157.7018738,3874.347239,137.4735826,4029.243746,203.4693975 +85,-5,0,3963.371703,260.6433687,3909.532451,279.5062964,3886.117372,260.5827488,4013.906348,309.0861826 +85,0,0,3978.615738,365.6172302,3897.845702,401.7433193,3897.845702,383.2546807,3998.615738,414.3807698 +85,5,0,3993.906348,470.9118174,3886.117372,524.4152512,3909.532451,505.4917036,3983.371703,519.3546313 +85,10,0,4009.243746,576.5286025,3874.347239,647.5244174,3921.177841,627.2961262,3968.174031,624.0092303 +85,15,0,4024.628149,682.4690665,3862.53508,771.0731596,3932.78209,748.6702409,3953.02251,728.346021 +90,-15,0,4018.77386,52.16370297,4049.877823,35.62898647,3980.548477,11.04595284,4130.784419,99.63067976 +90,-10,0,4033.992835,156.0715062,4038.42566,157.5888371,3992.206831,135.2016257,4115.3325,205.128914 +90,-5,0,4049.257975,260.2945054,4026.932698,279.9831669,4003.823504,258.9134153,4099.927595,310.3061542 +90,0,0,4064.569492,364.8341369,4015.398719,402.814302,4015.398719,382.183698,4084.569492,415.1638631 +90,5,0,4079.927595,469.6918458,4003.823504,526.0845847,4026.932698,505.0148331,4069.257975,519.7034946 +90,10,0,4095.3325,574.869086,3992.206831,649.7963743,4038.42566,627.4091629,4053.992835,623.9264938 +90,15,0,4110.784419,680.3673202,3980.548477,773.9520472,4049.877823,749.3690135,4038.77386,727.834297 +95,-15,0,4103.822749,52.67123498,4168.109606,34.92343446,4099.719212,8.138832551,4216.228476,101.7150518 +95,-10,0,4119.107565,156.153564,4156.813036,157.4747017,4111.220172,132.9074282,4200.710678,206.7747329 +95,-5,0,4134.438555,259.9485086,4145.476025,280.4646806,4122.679813,257.2277698,4185.239901,311.5160715 +95,0,0,4149.81593,364.0574877,4134.098357,403.8957311,4134.098357,381.1022689,4169.81593,415.9405123 +95,5,0,4165.239901,468.4819285,4122.679813,527.7702302,4145.476025,504.5333194,4154.438555,520.0494914 +95,10,0,4180.710678,573.2232671,4111.220172,652.0905718,4156.813036,627.5232983,4139.107565,623.844436 +95,15,0,4196.228476,678.2829482,4099.719212,776.8591674,4168.109606,750.0745655,4123.822749,727.326765 +100,-15,0,4188.177773,53.17462633,4287.494052,34.21100389,4220.064393,5.203062151,4300.969115,103.7822643 +100,-10,0,4203.52685,156.2349512,4276.356663,157.3594517,4231.404287,130.5906619,4285.387042,208.4070231 +100,-5,0,4218.922106,259.6053431,4265.17921,280.9509055,4242.703241,255.525572,4269.851991,312.7160582 +100,0,0,4234.36375,363.2872033,4253.961475,404.9877603,4253.961475,380.0102397,4254.36375,416.7107967 +100,5,0,4249.851991,467.2819418,4242.703241,529.472428,4265.17921,504.0470945,4238.922106,520.3926569 +100,10,0,4265.387042,571.5909769,4231.404287,654.4073381,4276.356663,627.6385483,4223.52685,623.7630488 +100,15,0,4280.969115,676.2157357,4220.064393,779.7949378,4287.494052,750.7869961,4208.177773,726.8233737 +105,-15,0,4271.847389,53.67392749,4408.048099,33.4915937,4341.601466,2.23821601,4385.014986,105.8325282 +105,-10,0,4287.259178,156.3156762,4397.073565,157.2430704,4352.776537,128.2509921,4369.370211,210.0259506 +105,-5,0,4302.717147,259.2649742,4386.059358,281.4419112,4363.911062,253.8065769,4353.772453,313.9062361 +105,0,0,4318.221502,362.5232059,4375.005263,406.0905461,4375.005263,378.9074539,4338.221502,417.4747941 +105,5,0,4333.772453,466.0917639,4363.911062,531.1914231,4386.059358,503.5560888,4322.717147,520.7330258 +105,10,0,4349.370211,569.9720494,4352.776537,656.7470079,4397.073565,627.7549296,4307.259178,623.6823238 +105,15,0,4365.014986,674.1654718,4341.601466,782.759784,4408.048099,751.5064063,4291.847389,726.3240725 +-105,-15,5,101.045586,-2.66565053,124.7049792,32.99774544,30.45280971,128.3613759,188.469832,28.8009483 +-105,-10,5,112.1809233,121.8372693,109.114415,136.1559223,46.28035033,233.0875561,177.129674,155.5939439 +-105,-5,5,123.3567784,246.7932137,93.47688323,239.6248699,62.06006538,337.4972885,165.8309298,281.9238972 +-105,0,5,134.5733729,372.2046598,77.7921713,343.4059947,77.7921713,441.5920053,154.5733729,407.7933402 +-105,5,5,145.8309298,498.0741028,62.06006538,447.5007115,93.47688323,545.3731301,143.3567784,533.2047863 +-105,10,5,157.129674,624.4040561,46.28035033,551.9104439,109.114415,648.8420777,132.1809233,658.1607307 +-105,15,5,168.469832,751.1970517,30.45280971,656.6366241,124.7049792,752.0002546,121.045586,782.6636505 +-100,-15,5,222.1406602,-1.788710736,208.5679809,32.39043087,114.6914016,126.3931365,310.5554493,31.65348557 +-100,-10,5,233.4400979,122.1038533,193.0400276,135.9714149,130.4563594,231.5550863,299.0492071,157.8135549 +-100,-5,5,244.780448,246.4450013,177.4651029,239.8657269,146.1734842,336.3979605,287.5847776,283.5151702 +-100,0,5,256.1619332,371.237174,161.8429934,344.074791,161.8429934,440.923209,276.1619332,408.760826 +-100,5,5,267.5847776,496.4828298,146.1734842,448.6000395,177.4651029,545.1322731,264.780448,533.5529987 +-100,10,5,279.0492071,622.1844451,130.4563594,553.4429137,193.0400276,649.0265851,253.4400979,657.8941467 +-100,15,5,290.5554493,748.3445144,114.6914016,658.6048635,208.5679809,752.6075691,242.1406602,781.7867107 +-95,-15,5,342.0533016,-0.920333821,293.1217145,31.77811419,199.6301799,124.4085372,431.4359824,34.47786599 +-95,-10,5,353.513108,122.3678296,277.657936,135.7853854,215.3309673,230.0098982,419.7674753,160.0112962 +-95,-5,5,365.0142043,246.1002013,262.1471871,240.1085738,230.983919,335.2895228,408.1411619,285.0907928 +-95,0,5,376.5568138,370.2791863,246.5892533,344.7491209,246.5892533,440.2488791,396.5568138,409.7188137 +-95,5,5,388.1411619,494.9072072,230.983919,449.7084772,262.1471871,544.8894262,385.0142043,533.8977987 +-95,10,5,399.7674753,619.9867038,215.3309673,554.9881018,277.657936,649.2126146,373.513108,657.6301704 +-95,15,5,411.4359824,745.520134,199.6301799,660.5894628,293.1217145,753.2198858,362.0533016,780.9183338 +-90,-15,5,460.8007446,-0.060394976,378.3747491,31.16073335,285.2779111,122.4073732,551.1291866,37.2745044 +-90,-10,5,472.4172736,122.6292362,362.9767418,135.597815,300.9129071,228.4518327,539.3021455,162.1874895 +-90,-5,5,484.075453,245.7587637,347.5317699,240.3534352,316.5000697,334.1718617,527.5176621,286.6509949 +-90,0,5,495.7755072,369.3305576,332.039618,345.4290535,332.039618,439.5689465,515.7755072,410.6674424 +-90,5,5,507.5176621,493.3470051,316.5000697,450.8261383,347.5317699,544.6445648,504.075453,534.2392363 +-90,10,5,519.3021455,617.8105105,300.9129071,556.5461673,362.9767418,649.400185,492.4172736,657.3687638 +-90,15,5,531.1291866,742.7234956,285.2779111,662.5906268,378.3747491,753.8372667,480.8007446,780.058395 +-85,-15,5,578.3998908,0.791228194,464.335796,30.53822526,371.6435082,120.3894362,669.6524698,40.04380754 +-85,-10,5,590.1695792,122.8881104,449.0051896,135.4086845,387.2110579,226.8807283,657.6705399,164.3424501 +-85,-5,5,601.9812623,245.4206396,433.6276293,240.6003364,402.7307815,333.0448615,645.7315151,288.1960016 +-85,0,5,613.8351654,368.3911514,418.202899,346.1146587,418.202899,438.8833413,633.8351654,411.6068486 +-85,5,5,625.7315151,491.8019984,402.7307815,451.9531385,433.6276293,544.3976636,621.9812623,534.5773604 +-85,10,5,637.6705399,615.6555499,387.2110579,558.1172717,449.0051896,649.5893155,610.1695792,657.1098896 +-85,15,5,649.6524698,739.9541925,371.6435082,664.6085638,464.335796,754.4597747,598.3998908,779.2067718 +-80,-15,5,694.8673161,1.634655729,551.0137119,29.91052579,458.7360347,118.3545144,787.0229014,42.78617424 +-80,-10,5,706.7866816,123.144489,535.75217,135.2179743,474.2344482,225.2964204,774.8896443,166.4764872 +-80,-5,5,718.7483705,245.085781,520.4436902,240.8493029,489.6850482,331.9084048,762.7996237,289.7260339 +-80,0,5,730.7526087,367.4608339,505.0880557,346.8060079,505.0880557,438.1919921,750.7526087,412.5371661 +-80,5,5,742.7996237,490.2719661,489.6850482,453.0895952,520.4436902,544.1486971,738.7483705,534.912219 +-80,10,5,754.8896443,613.5215128,474.2344482,559.7015796,535.75217,649.7800257,726.7866816,656.853511 +-80,15,5,767.0229014,737.2118258,458.7360347,666.6434856,551.0137119,755.0874742,714.8673161,778.3633443 +-75,-15,5,810.219279,2.470005368,638.4175018,29.27756973,546.5647071,116.3023926,903.2572204,45.50199561 +-75,-10,5,822.2849182,123.3984078,623.2267229,135.0256646,561.9922588,223.698742,890.9761163,168.5899042 +-75,-5,5,834.3931943,244.7541409,607.9890275,241.1003608,577.3720151,330.762372,878.7385645,291.2413085 +-75,0,5,846.5443337,366.5394737,592.7041979,347.5031737,592.7041979,437.4948263,866.5443337,413.4585263 +-75,5,5,858.7385645,488.7566915,577.3720151,454.235628,607.9890275,543.8976392,854.3931943,535.2438591 +-75,10,5,870.9761163,611.4080958,561.9922588,561.299258,623.2267229,649.9723354,842.2849182,656.5995922 +-75,15,5,883.2572204,734.4960044,546.5647071,668.6956074,638.4175018,755.7204303,830.219279,777.5279946 +-70,-15,5,924.4717281,3.297392607,726.5563222,28.63929076,635.1388982,114.2328516,1018.371843,48.1916552 +-70,-10,5,936.6803142,123.649902,711.4380403,134.8317352,650.4938261,222.0875232,1005.946293,170.6829984 +-70,-5,5,948.9318361,244.425673,696.2728689,241.3535366,665.8009823,329.6066416,993.5645962,292.7420377 +-70,0,5,961.2265206,365.6269422,681.0605895,348.2062296,681.0605895,436.7917704,981.2265206,414.3710578 +-70,5,5,973.5645962,487.2559623,665.8009823,455.3913584,696.2728689,543.6444634,968.9318361,535.572327 +-70,10,5,985.9462931,609.3150016,650.4938261,562.9104768,711.4380403,650.1662648,956.6803142,656.348098 +-70,15,5,998.3718431,731.8063448,635.1388982,670.7651484,726.5563222,756.3587092,944.4717281,776.7006074 +-65,-15,5,1037.640309,4.116930751,815.4394841,27.99562146,724.4681409,112.1456689,1132.382871,50.85552925 +-65,-10,5,1049.98859,123.8990062,800.3954699,134.6361654,739.7486455,220.4625909,1119.816199,172.7560616 +-65,-5,5,1062.380092,244.100332,785.3045988,241.6088571,754.981408,328.4410899,1107.293667,294.2284302 +-65,0,5,1074.815041,364.723113,770.1666517,348.9152507,770.1666517,436.0827493,1094.815041,415.274887 +-65,5,5,1087.293667,485.7695698,754.981408,456.5569101,785.3045988,543.3891429,1082.380092,535.897668 +-65,10,5,1099.816199,607.2419384,739.7486455,564.5354091,800.3954699,650.3618346,1069.98859,656.0989938 +-65,15,5,1112.382871,729.1424707,724.4681409,672.8523311,815.4394841,757.0023785,1057.640309,775.8810692 +-60,-15,5,1149.740373,4.928730964,905.0764567,27.34649326,814.5621312,110.0406178,1245.306099,53.49398679 +-60,-10,5,1162.22517,124.1457544,890.108518,134.4389345,829.7663747,218.8237695,1232.601554,174.8093799 +-60,-5,5,1174.753458,243.7780736,875.0937612,241.8663497,844.9229116,327.2655912,1219.94142,295.7006903 +-60,0,5,1187.325465,363.8278624,860.0319661,349.6303132,860.0319661,435.3676868,1207.325465,416.1701376 +-60,5,5,1199.94142,484.2973097,844.9229116,457.7324088,875.0937612,543.1316503,1194.753458,536.2199264 +-60,10,5,1212.601554,605.1886201,829.7663747,566.1742305,890.108518,650.5590655,1182.22517,655.8522456 +-60,15,5,1225.306099,726.5040132,814.5621312,674.9573822,905.0764567,757.6515067,1169.740373,775.069269 +-55,-15,5,1260.786982,5.73290232,995.4768702,26.69183641,905.4307318,107.9174678,1357.157021,56.10738984 +-55,-10,5,1273.405187,124.3901797,980.5868533,134.2400211,920.5568371,217.1708801,1344.317778,176.8432342 +-55,-5,5,1286.06714,243.4588541,965.6500629,242.1260424,935.6352776,326.0800177,1331.523205,297.1590186 +-55,0,5,1298.77307,362.9410686,950.6662784,350.3514946,950.6662784,434.6465054,1318.77307,417.0569314 +-55,5,5,1311.523205,482.8389814,935.6352776,458.9179823,965.6500629,542.8719576,1306.06714,536.5391459 +-55,10,5,1324.317778,603.1547658,920.5568371,567.8271199,980.5868533,650.7579789,1293.405187,655.6078203 +-55,15,5,1337.157021,723.8906102,905.4307318,677.0805322,995.4768702,758.3061636,1280.786982,774.2650977 +-50,-15,5,1370.794916,6.529551856,1086.65052,26.03157997,997.0839758,105.7759847,1467.950839,58.69609359 +-50,-10,5,1383.543489,124.6323149,1071.84031,134.0394036,1012.130026,215.5037407,1454.980003,178.8578998 +-50,-5,5,1396.336055,243.1426307,1056.983378,242.3879632,1027.128459,324.8842392,1442.054082,298.6036119 +-50,0,5,1409.172843,362.0626124,1042.079502,351.0788739,1042.079502,433.9191261,1429.172843,417.9353876 +-50,5,5,1422.054082,481.3943881,1027.128459,460.1137608,1056.983378,542.6100368,1416.336055,536.8553693 +-50,10,5,1434.980003,601.1401002,1012.130026,569.4942593,1071.84031,650.9585964,1403.543489,655.3656851 +-50,15,5,1447.950839,721.3019064,997.0839758,679.2220153,1086.65052,758.96642,1390.794916,773.4684481 +-45,-15,5,1479.77868,7.318784613,1178.607369,25.36565181,1089.53207,103.6159298,1577.702468,61.26044654 +-45,-10,5,1492.65465,124.8721919,1163.878891,133.83706,1104.496106,213.8221663,1564.603074,180.853647 +-45,-5,5,1505.574845,242.8293614,1149.103749,242.6521412,1119.412581,323.6781235,1551.548827,300.0346635 +-45,0,5,1518.539494,361.1923769,1134.28172,351.8125313,1134.28172,433.1854687,1538.539494,418.8056231 +-45,5,5,1531.548827,479.9633365,1119.412581,461.3198765,1149.103749,542.3458588,1525.574845,537.1686386 +-45,10,5,1544.603074,599.144353,1104.496106,571.1758337,1163.878891,651.16094,1512.65465,655.1258081 +-45,15,5,1557.702468,718.7375535,1089.53207,681.3820702,1178.607369,759.6323482,1499.77868,772.6792154 +-40,-15,5,1587.752509,8.100703686,1271.357553,24.69397852,1182.7854,101.4370605,1686.426545,63.80079068 +-40,-10,5,1600.752971,125.1098422,1256.712774,133.632968,1197.665422,212.1259685,1673.201559,182.8307412 +-40,-5,5,1613.797877,242.5190051,1242.021395,242.9186055,1212.497944,322.4615359,1660.021941,301.4523627 +-40,0,5,1626.887457,360.3302471,1227.283193,352.5525483,1227.283193,432.4454517,1646.887457,419.6677529 +-40,5,5,1640.021941,478.5456373,1212.497944,462.5364641,1242.021395,542.0793945,1633.797877,537.4789949 +-40,10,5,1653.201559,597.1672588,1197.665422,572.8720315,1256.712774,651.365032,1620.752971,654.8881578 +-40,15,5,1666.426545,716.1972093,1182.7854,683.5609395,1271.357553,760.3040215,1607.752509,771.8972963 +-35,-15,5,1694.730379,8.875410268,1364.911382,24.01648543,1276.854531,99.23912998,1794.137433,66.31746161 +-35,-10,5,1707.852489,125.3452967,1350.352311,133.4271048,1291.648495,210.4149559,1780.789757,184.7894424 +-35,-5,5,1721.019253,242.2115212,1335.746711,243.1873861,1306.395028,321.2343394,1767.487656,302.8568957 +-35,0,5,1734.230899,359.4761103,1321.094358,353.2990081,1321.094358,431.6989919,1754.230899,420.5218897 +-35,5,5,1747.487656,477.1411043,1306.395028,463.7636606,1335.746711,541.8106139,1741.019253,537.7864788 +-35,10,5,1760.789757,595.2085576,1291.648495,574.5830441,1350.352311,651.5708952,1727.852489,654.6527033 +-35,15,5,1774.137433,713.6805384,1276.854531,685.75887,1364.911382,760.9815146,1714.730379,771.1225897 +-30,-15,5,1800.726006,9.643003694,1459.279347,23.33309658,1371.750217,97.02188698,1900.849229,68.81078869 +-30,-10,5,1813.966985,125.5785857,1444.808035,133.2194472,1386.456035,208.6889334,1887.381699,186.7300064 +-30,-5,5,1827.252815,241.9068702,1430.290272,243.4585131,1401.114499,319.9963946,1873.959942,304.2484449 +-30,0,5,1840.583724,358.6298559,1415.725835,354.0519952,1415.725835,430.9460048,1860.583724,421.3681441 +-30,5,5,1853.959942,475.7495551,1401.114499,465.0016054,1430.290272,541.5394869,1847.252815,538.0911298 +-30,10,5,1867.381699,593.2679936,1386.456035,576.3090666,1444.808035,651.7785528,1833.966985,654.4194143 +-30,15,5,1880.849229,711.1872113,1371.750217,687.976113,1459.279347,761.6649034,1820.726006,770.3549963 +-25,-15,5,1905.752855,10.40358148,1554.472123,22.64373466,1467.483399,94.78507583,2006.57577,71.28109524 +-25,-10,5,1919.109986,125.8097388,1540.090665,133.0099718,1482.098938,206.9477026,1992.991161,188.6526837 +-25,-5,5,1932.512152,241.605013,1525.662843,243.7320176,1496.667206,318.7475598,1979.45251,305.6271896 +-25,0,5,1945.959584,357.7913753,1511.188432,354.8115955,1511.188432,430.1864045,1965.959584,422.2066247 +-25,5,5,1959.45251,474.3708104,1496.667206,466.2504402,1525.662843,541.2659824,1952.512152,538.392987 +-25,10,5,1972.991161,591.3453163,1482.098938,578.0502974,1540.090665,651.9880282,1939.109986,654.1882612 +-25,15,5,1986.57577,708.7169048,1467.483399,690.2129242,1554.472123,762.3542653,1925.752855,769.5944185 +-20,-15,5,2009.824149,11.15723938,1650.500569,21.94832102,1564.065213,92.52843634,2111.330639,73.72869859 +-20,-10,5,2023.294771,126.0387854,1636.211107,132.7986544,1578.588294,205.1910618,2097.631663,190.5577206 +-20,-5,5,2036.810604,241.3059114,1621.875374,244.0079308,1593.064195,317.4876906,2083.97882,306.9933058 +-20,0,5,2050.371877,356.9605618,1607.493145,355.5778966,1607.493145,429.4201034,2070.371877,423.0374382 +-20,5,5,2063.97882,473.0046942,1593.064195,467.5103094,1621.875374,540.9900692,2056.810604,538.6920886 +-20,10,5,2077.631663,589.4402794,1578.588294,579.8069382,1636.211107,652.1993456,2043.294771,653.9592146 +-20,15,5,2091.330639,706.2693014,1564.065213,692.4695637,1650.500569,763.049679,2029.824149,768.8407606 +-15,-15,5,2112.952869,11.90407139,1747.375739,21.24677563,1661.506994,90.25170368,2215.127168,76.15391031 +-15,-10,5,2126.53438,126.265754,1733.180459,132.5854707,1675.935389,203.4188053,2201.316479,192.4453587 +-15,-5,5,2140.161268,241.0095278,1718.939011,244.2862848,1690.316703,316.2166401,2187.552088,308.346966 +-15,0,5,2153.833761,356.1373108,1704.651168,356.3509875,1704.651168,428.6470125,2173.833761,423.8606892 +-15,5,5,2167.552088,471.651034,1690.316703,468.7813599,1718.939011,540.7117152,2160.161268,538.9884722 +-15,10,5,2181.316479,587.5526413,1675.935389,581.5791947,1733.180459,652.4125293,2146.53438,653.732246 +-15,15,5,2195.127168,703.8440897,1661.506994,694.7462963,1747.375739,763.7512244,2132.952869,768.0939286 +-10,-15,5,2215.151762,12.64416984,1845.108881,20.53901702,1759.820278,87.95460829,2317.97845,78.55703626 +-10,-10,5,2228.841618,126.4906729,1831.010018,132.3703959,1774.151711,201.630724,2304.058644,194.3158354 +-10,-5,5,2242.577005,240.7158253,1816.865097,244.5671121,1788.436172,314.9342587,2290.185289,309.68834 +-10,0,5,2256.358153,355.3215194,1802.673891,357.1309589,1802.673891,427.8670411,2276.358153,424.6764806 +-10,5,5,2270.185289,470.30966,1788.436172,470.0637413,1816.865097,540.4308879,2262.577005,539.2821747 +-10,10,5,2284.058644,585.6821646,1774.151711,583.367276,1831.010018,652.6276041,2248.841618,653.5073271 +-10,15,5,2297.97845,701.4409637,1759.820278,697.0433917,1845.108881,764.458983,2235.151762,767.3538302 +-5,-15,5,2316.433347,13.37762538,1943.711443,19.82496229,1859.016811,85.63687576,2419.897337,80.93837676 +-5,-10,5,2330.229058,126.7135696,1929.711282,132.1534046,1873.248956,199.8266051,2405.870952,196.1693835 +-5,-5,5,2344.070445,240.4247678,1915.665179,244.8504458,1887.434245,313.6403944,2391.891163,311.0175942 +-5,0,5,2357.957737,354.5130868,1901.57291,357.917903,1901.57291,427.080097,2377.957737,425.4849132 +-5,5,5,2371.891163,468.9804058,1887.434245,471.3576056,1915.665179,540.1475542,2364.070445,539.5732322 +-5,10,5,2385.870952,583.8286165,1873.248956,585.1713949,1929.711282,652.8445954,2350.229058,653.2844304 +-5,15,5,2399.897337,699.0596232,1859.016811,699.3611242,1943.711443,765.1730377,2336.433347,766.6203746 +0,-15,5,2416.80992,14.10452704,2043.19508,19.10452704,1959.108548,83.29822672,2520.896452,83.29822672 +0,-10,5,2430.709048,126.9344713,2029.295952,131.9344713,1973.239028,198.0062318,2506.765972,198.0062318 +0,-5,5,2444.65399,240.1363196,2015.35101,245.1363196,1987.322777,312.3348921,2492.682223,312.3348921 +0,0,5,2458.644972,353.7119138,2001.360028,358.7119138,2001.360028,426.2860862,2478.644972,426.2860862 +0,5,5,2472.682223,467.6631079,1987.322777,472.6631079,2015.35101,539.8616804,2464.65399,539.8616804 +0,10,5,2486.765972,581.9917682,1973.239028,586.9917682,2029.295952,653.0635287,2450.709048,653.0635287 +0,15,5,2500.896452,696.6997733,1959.108548,701.6997733,2043.19508,765.893473,2436.80992,765.893473 +5,-15,5,2516.293557,14.82496229,2143.571653,18.37762538,2060.107663,80.93837676,2620.988189,85.63687576 +5,-10,5,2530.293718,127.1534046,2129.775942,131.7135696,2074.134048,196.1693835,2606.756044,199.8266051 +5,-5,5,2544.339821,239.8504458,2115.934555,245.4247678,2088.113837,311.0175942,2592.570755,313.6403944 +5,0,5,2558.43209,352.917903,2102.047263,359.5130868,2102.047263,425.4849132,2578.43209,427.080097 +5,5,5,2572.570755,466.3576056,2088.113837,473.9804058,2115.934555,539.5732322,2564.339821,540.1475542 +5,10,5,2586.756044,580.1713949,2074.134048,588.8286165,2129.775942,653.2844304,2550.293718,652.8445954 +5,15,5,2600.988189,694.3611242,2060.107663,704.0596232,2143.571653,766.6203746,2536.293557,765.1730377 +10,-15,5,2614.896119,15.53901702,2244.853238,17.64416984,2162.02655,78.55703626,2720.184722,87.95460829 +10,-10,5,2628.994982,127.3703959,2231.163382,131.4906729,2175.946356,194.3158354,2705.853289,201.630724 +10,-5,5,2643.139903,239.5671121,2217.427995,245.7158253,2189.819711,309.68834,2691.568828,314.9342587 +10,0,5,2657.331109,352.1309589,2203.646847,360.3215194,2203.646847,424.6764806,2677.331109,427.8670411 +10,5,5,2671.568828,465.0637413,2189.819711,475.30966,2217.427995,539.2821747,2663.139903,540.4308879 +10,10,5,2685.853289,578.367276,2175.946356,590.6821646,2231.163382,653.5073271,2648.994982,652.6276041 +10,15,5,2700.184722,692.0433917,2162.02655,706.4409637,2244.853238,767.3538302,2634.896119,764.458983 +15,-15,5,2712.629261,16.24677563,2347.052131,16.90407139,2264.877832,76.15391031,2818.498006,90.25170368 +15,-10,5,2726.824541,127.5854707,2333.47062,131.265754,2278.688521,192.4453587,2804.069611,203.4188053 +15,-5,5,2741.065989,239.2862848,2319.843732,246.0095278,2292.452912,308.346966,2789.688297,316.2166401 +15,0,5,2755.353832,351.3509875,2306.171239,361.1373108,2306.171239,423.8606892,2775.353832,428.6470125 +15,5,5,2769.688297,463.7813599,2292.452912,476.651034,2319.843732,538.9884722,2761.065989,540.7117152 +15,10,5,2784.069611,576.5791947,2278.688521,592.5526413,2333.47062,653.732246,2746.824541,652.4125293 +15,15,5,2798.498006,689.7462963,2264.877832,708.8440897,2347.052131,768.0939286,2732.629261,763.7512244 +20,-15,5,2809.504431,16.94832102,2450.180851,16.15723938,2368.674361,73.72869859,2915.939787,92.52843634 +20,-10,5,2823.793893,127.7986544,2436.710229,131.0387854,2382.373337,190.5577206,2901.416706,205.1910618 +20,-5,5,2838.129626,239.0079308,2423.194396,246.3059114,2396.02618,306.9933058,2886.940805,317.4876906 +20,0,5,2852.511855,350.5778966,2409.633123,361.9605618,2409.633123,423.0374382,2872.511855,429.4201034 +20,5,5,2866.940805,462.5103094,2396.02618,478.0046942,2423.194396,538.6920886,2858.129626,540.9900692 +20,10,5,2881.416706,574.8069382,2382.373337,594.4402794,2436.710229,653.9592146,2843.793893,652.1993456 +20,15,5,2895.939787,687.4695637,2368.674361,711.2693014,2450.180851,768.8407606,2829.504431,763.049679 +25,-15,5,2905.532877,17.64373466,2554.252145,15.40358148,2473.42923,71.28109524,3012.521601,94.78507583 +25,-10,5,2919.914335,128.0099718,2540.895014,130.8097388,2487.013839,188.6526837,2997.906062,206.9477026 +25,-5,5,2934.342157,238.7320176,2527.492848,246.605013,2500.55249,305.6271896,2983.337794,318.7475598 +25,0,5,2948.816568,349.8115955,2514.045416,362.7913753,2514.045416,422.2066247,2968.816568,430.1864045 +25,5,5,2963.337794,461.2504402,2500.55249,479.3708104,2527.492848,538.392987,2954.342157,541.2659824 +25,10,5,2977.906062,573.0502974,2487.013839,596.3453163,2540.895014,654.1882612,2939.914335,651.9880282 +25,15,5,2992.521601,685.2129242,2473.42923,713.7169048,2554.252145,769.5944185,2925.532877,762.3542653 +30,-15,5,3000.725653,18.33309658,2659.278994,14.64300369,2579.155771,68.81078869,3108.254783,97.02188698 +30,-10,5,3015.196965,128.2194472,2646.038015,130.5785857,2592.623301,186.7300064,3093.548965,208.6889334 +30,-5,5,3029.714728,238.4585131,2632.752185,246.9068702,2606.045058,304.2484449,3078.890501,319.9963946 +30,0,5,3044.279165,349.0519952,2619.421276,363.6298559,2619.421276,421.3681441,3064.279165,430.9460048 +30,5,5,3058.890501,460.0016054,2606.045058,480.7495551,2632.752185,538.0911298,3049.714728,541.5394869 +30,10,5,3073.548965,571.3090666,2592.623301,598.2679936,2646.038015,654.4194143,3035.196965,651.7785528 +30,15,5,3088.254783,682.976113,2579.155771,716.1872113,2659.278994,770.3549963,3020.725653,761.6649034 +35,-15,5,3095.093618,19.01648543,2765.274621,13.87541027,2685.867567,66.31746161,3203.150469,99.23912998 +35,-10,5,3109.652689,128.4271048,2752.152511,130.3452967,2699.215243,184.7894424,3188.356505,210.4149559 +35,-5,5,3124.258289,238.1873861,2738.985747,247.2115212,2712.517344,302.8568957,3173.609972,321.2343394 +35,0,5,3138.910642,348.2990081,2725.774101,364.4761103,2725.774101,420.5218897,3158.910642,431.6989919 +35,5,5,3153.609972,458.7636606,2712.517344,482.1411043,2738.985747,537.7864788,3144.258289,541.8106139 +35,10,5,3168.356505,569.5830441,2699.215243,600.2085576,2752.152511,654.6527033,3129.652689,651.5708952 +35,15,5,3183.150469,680.75887,2685.867567,718.6805384,2765.274621,771.1225897,3115.093618,760.9815146 +40,-15,5,3188.647447,19.69397852,2872.252491,13.10070369,2793.578455,63.80079068,3297.2196,101.4370605 +40,-10,5,3203.292226,128.632968,2859.252029,130.1098422,2806.803441,182.8307412,3282.339578,212.1259685 +40,-5,5,3217.983605,237.9186055,2846.207123,247.5190051,2819.983059,301.4523627,3267.507056,322.4615359 +40,0,5,3232.721807,347.5525483,2833.117543,365.3302471,2833.117543,419.6677529,3252.721807,432.4454517 +40,5,5,3247.507056,457.5364641,2819.983059,483.5456373,2846.207123,537.4789949,3237.983605,542.0793945 +40,10,5,3262.339578,567.8720315,2806.803441,602.1672588,2859.252029,654.8881578,3223.292226,651.365032 +40,15,5,3277.2196,678.5609395,2793.578455,721.1972093,2872.252491,771.8972963,3208.647447,760.3040215 +45,-15,5,3281.397631,20.36565181,2980.22632,12.31878461,2902.302532,61.26044654,3390.47293,103.6159298 +45,-10,5,3296.126109,128.83706,2967.35035,129.8721919,2915.401926,180.853647,3375.508894,213.8221663 +45,-5,5,3310.901251,237.6521412,2954.430155,247.8293614,2928.456173,300.0346635,3360.592419,323.6781235 +45,0,5,3325.72328,346.8125313,2941.465506,366.1923769,2941.465506,418.8056231,3345.72328,433.1854687 +45,5,5,3340.592419,456.3198765,2928.456173,484.9633365,2954.430155,537.1686386,3330.901251,542.3458588 +45,10,5,3355.508894,566.1758337,2915.401926,604.144353,2967.35035,655.1258081,3316.126109,651.16094 +45,15,5,3370.47293,676.3820702,2902.302532,723.7375535,2980.22632,772.6792154,3301.397631,759.6323482 +50,-15,5,3373.35448,21.03157997,3089.210084,11.52955186,3012.054161,58.69609359,3482.921024,105.7759847 +50,-10,5,3388.16469,129.0394036,3076.461511,129.6323149,3025.024997,178.8578998,3467.874974,215.5037407 +50,-5,5,3403.021622,237.3879632,3063.668945,248.1426307,3037.950918,298.6036119,3452.876541,324.8842392 +50,0,5,3417.925498,346.0788739,3050.832157,367.0626124,3050.832157,417.9353876,3437.925498,433.9191261 +50,5,5,3432.876541,455.1137608,3037.950918,486.3943881,3063.668945,536.8553693,3423.021622,542.6100368 +50,10,5,3447.874974,564.4942593,3025.024997,606.1401002,3076.461511,655.3656851,3408.16469,650.9585964 +50,15,5,3462.921024,674.2220153,3012.054161,726.3019064,3089.210084,773.4684481,3393.35448,758.96642 +55,-15,5,3464.52813,21.69183641,3199.218018,10.73290232,3122.847979,56.10738984,3574.574268,107.9174678 +55,-10,5,3479.418147,129.2400211,3186.599813,129.3901797,3135.687222,176.8432342,3559.448163,217.1708801 +55,-5,5,3494.354937,237.1260424,3173.93786,248.4588541,3148.481795,297.1590186,3544.369722,326.0800177 +55,0,5,3509.338722,345.3514946,3161.23193,367.9410686,3161.23193,417.0569314,3529.338722,434.6465054 +55,5,5,3524.369722,453.9179823,3148.481795,487.8389814,3173.93786,536.5391459,3514.354937,542.8719576 +55,10,5,3539.448163,562.8271199,3135.687222,608.1547658,3186.599813,655.6078203,3499.418147,650.7579789 +55,15,5,3554.574268,672.0805322,3122.847979,728.8906102,3199.218018,774.2650977,3484.52813,758.3061636 +60,-15,5,3554.928543,22.34649326,3310.264627,9.928730964,3234.698901,53.49398679,3665.442869,110.0406178 +60,-10,5,3569.896482,129.4389345,3297.77983,129.1457544,3247.403446,174.8093799,3650.238625,218.8237695 +60,-5,5,3584.911239,236.8663497,3285.251542,248.7780736,3260.06358,295.7006903,3635.082088,327.2655912 +60,0,5,3599.973034,344.6303132,3272.679535,368.8278624,3272.679535,416.1701376,3619.973034,435.3676868 +60,5,5,3615.082088,452.7324088,3260.06358,489.2973097,3285.251542,536.2199264,3604.911239,543.1316503 +60,10,5,3630.238625,561.1742305,3247.403446,610.1886201,3297.77983,655.8522456,3589.896482,650.5590655 +60,15,5,3645.442869,669.9573822,3234.698901,731.5040132,3310.264627,775.069269,3574.928543,757.6515067 +65,-15,5,3644.565516,22.99562146,3422.364691,9.116930751,3347.622129,50.85552925,3755.536859,112.1456689 +65,-10,5,3659.60953,129.6361654,3410.01641,128.8990062,3360.188801,172.7560616,3740.256355,220.4625909 +65,-5,5,3674.700401,236.6088571,3397.624908,249.100332,3372.711333,294.2284302,3725.023592,328.4410899 +65,0,5,3689.838348,343.9152507,3385.189959,369.723113,3385.189959,415.274887,3709.838348,436.0827493 +65,5,5,3705.023592,451.5569101,3372.711333,490.7695698,3397.624908,535.897668,3694.700401,543.3891429 +65,10,5,3720.256355,559.5354091,3360.188801,612.2419384,3410.01641,656.0989938,3679.60953,650.3618346 +65,15,5,3735.536859,667.8523311,3347.622129,734.1424707,3422.364691,775.8810692,3664.565516,757.0023785 +70,-15,5,3733.448678,23.63929076,3535.533272,8.297392607,3461.633157,48.1916552,3844.866102,114.2328516 +70,-10,5,3748.56696,129.8317352,3523.324686,128.649902,3474.058707,170.6829984,3829.511174,222.0875232 +70,-5,5,3763.732131,236.3535366,3511.073164,249.425673,3486.440404,292.7420377,3814.204018,329.6066416 +70,0,5,3778.94441,343.2062296,3498.778479,370.6269422,3498.778479,414.3710578,3798.94441,436.7917704 +70,5,5,3794.204018,450.3913584,3486.440404,492.2559623,3511.073164,535.572327,3783.732131,543.6444634 +70,10,5,3809.511174,557.9104768,3474.058707,614.3150016,3523.324686,656.348098,3768.56696,650.1662648 +70,15,5,3824.866102,665.7651484,3461.633157,736.8063448,3535.533272,776.7006074,3753.448678,756.3587092 +75,-15,5,3821.587498,24.27756973,3649.785721,7.470005368,3576.74778,45.50199561,3933.440293,116.3023926 +75,-10,5,3836.778277,130.0256646,3637.720082,128.3984078,3589.028884,168.5899042,3918.012741,223.698742 +75,-5,5,3852.015973,236.1003608,3625.611806,249.7541409,3601.266435,291.2413085,3902.632985,330.762372 +75,0,5,3867.300802,342.5031737,3613.460666,371.5394737,3613.460666,413.4585263,3887.300802,437.4948263 +75,5,5,3882.632985,449.235628,3601.266435,493.7566915,3625.611806,535.2438591,3872.015973,543.8976392 +75,10,5,3898.012741,556.299258,3589.028884,616.4080958,3637.720082,656.5995922,3856.778277,649.9723354 +75,15,5,3913.440293,663.6956074,3576.74778,739.4960044,3649.785721,777.5279946,3841.587498,755.7204303 +80,-15,5,3908.991288,24.91052579,3765.137684,6.634655729,3692.982099,42.78617424,4021.268965,118.3545144 +80,-10,5,3924.25283,130.2179743,3753.218318,128.144489,3705.115356,166.4764872,4005.770552,225.2964204 +80,-5,5,3939.56131,235.8493029,3741.256629,250.085781,3717.205376,289.7260339,3990.319952,331.9084048 +80,0,5,3954.916944,341.8060079,3729.252391,372.4608339,3729.252391,412.5371661,3974.916944,438.1919921 +80,5,5,3970.319952,448.0895952,3717.205376,495.2719661,3741.256629,534.912219,3959.56131,544.1486971 +80,10,5,3985.770552,554.7015796,3705.115356,618.5215128,3753.218318,656.853511,3944.25283,649.7800257 +80,15,5,4001.268965,661.6434856,3692.982099,742.2118258,3765.137684,778.3633443,3928.991288,755.0874742 +85,-15,5,3995.669204,25.53822526,3881.605109,5.791228194,3810.35253,40.04380754,4108.361492,120.3894362 +85,-10,5,4010.99981,130.4086845,3869.835421,127.8881104,3822.33446,164.3424501,4092.793942,226.8807283 +85,-5,5,4026.377371,235.6003364,3858.023738,250.4206396,3834.273485,288.1960016,4077.274219,333.0448615 +85,0,5,4041.802101,341.1146587,3846.169835,373.3911514,3846.169835,411.6068486,4061.802101,438.8833413 +85,5,5,4057.274219,446.9531385,3834.273485,496.8019984,3858.023738,534.5773604,4046.377371,544.3976636 +85,10,5,4072.793942,553.1172717,3822.33446,620.6555499,3869.835421,657.1098896,4030.99981,649.5893155 +85,15,5,4088.361492,659.6085638,3810.35253,744.9541925,3881.605109,779.2067718,4015.669204,754.4597747 +90,-15,5,4081.630251,26.16073335,3999.204255,4.939605024,3928.875813,37.2745044,4194.727089,122.4073732 +90,-10,5,4097.028258,130.597815,3987.587726,127.6292362,3940.702855,162.1874895,4179.092093,228.4518327 +90,-5,5,4112.47323,235.3534352,3975.929547,250.7587637,3952.487338,286.6509949,4163.50493,334.1718617 +90,0,5,4127.965382,340.4290535,3964.229493,374.3305576,3964.229493,410.6674424,4147.965382,439.5689465 +90,5,5,4143.50493,445.8261383,3952.487338,498.3470051,3975.929547,534.2392363,4132.47323,544.6445648 +90,10,5,4159.092093,551.5461673,3940.702855,622.8105105,3987.587726,657.3687638,4117.028258,649.400185 +90,15,5,4174.727089,657.5906268,3928.875813,747.7234956,3999.204255,780.058395,4101.630251,753.8372667 +95,-15,5,4166.883286,26.77811419,4117.951698,4.079666179,4048.569018,34.47786599,4280.37482,124.4085372 +95,-10,5,4182.347064,130.7853854,4106.491892,127.3678296,4060.237525,160.0112962,4264.674033,230.0098982 +95,-5,5,4197.857813,235.1085738,4094.990796,251.1002013,4071.863838,285.0907928,4249.021081,335.2895228 +95,0,5,4213.415747,339.7491209,4083.448186,375.2791863,4083.448186,409.7188137,4233.415747,440.2488791 +95,5,5,4229.021081,444.7084772,4071.863838,499.9072072,4094.990796,533.8977987,4217.857813,544.8894262 +95,10,5,4244.674033,549.9881018,4060.237525,624.9867038,4106.491892,657.6301704,4202.347064,649.2126146 +95,15,5,4260.37482,655.5894628,4048.569018,750.520134,4117.951698,780.9183338,4186.883286,753.2198858 +100,-15,5,4251.437019,27.39043087,4237.86434,3.211289264,4169.449551,31.65348557,4365.313598,126.3931365 +100,-10,5,4266.964972,130.9714149,4226.564902,127.1038533,4180.955793,157.8135549,4349.548641,231.5550863 +100,-5,5,4282.539897,234.8657269,4215.224552,251.4450013,4192.420222,283.5151702,4333.831516,336.3979605 +100,0,5,4298.162007,339.074791,4203.843067,376.237174,4203.843067,408.760826,4318.162007,440.923209 +100,5,5,4313.831516,443.6000395,4192.420222,501.4828298,4215.224552,533.5529987,4302.539897,545.1322731 +100,10,5,4329.548641,548.4429137,4180.955793,627.1844451,4226.564902,657.8941467,4286.964972,649.0265851 +100,15,5,4345.313598,653.6048635,4169.449551,753.3445144,4237.86434,781.7867107,4271.437019,752.6075691 +105,-15,5,4335.300021,27.99774544,4358.959414,2.33434947,4291.535168,28.8009483,4449.55219,128.3613759 +105,-10,5,4350.890585,131.1559223,4347.824077,126.8372693,4302.875326,155.5939439,4433.72465,233.0875561 +105,-5,5,4366.528117,234.6248699,4336.648222,251.7932137,4314.17407,281.9238972,4417.944935,337.4972885 +105,0,5,4382.212829,338.4059947,4325.431627,377.2046598,4325.431627,407.7933402,4402.212829,441.5920053 +105,5,5,4397.944935,442.5007115,4314.17407,503.0741028,4336.648222,533.2047863,4386.528117,545.3731301 +105,10,5,4413.72465,546.9104439,4302.875326,629.4040561,4347.824077,658.1607307,4370.890585,648.8420777 +105,15,5,4429.55219,651.6366241,4291.535168,756.1970517,4358.959414,782.6636505,4355.300021,752.0002546 +-105,-15,10,150.7783478,-34.23169918,60.56340501,7.04278205,-34.79151876,151.1370688,239.2003059,55.71605962 +-105,-10,10,162.07778,91.03179396,44.79108036,110.7225812,-18.77813756,256.4014596,227.691651,183.298957 +-105,-5,10,173.4185978,216.7540802,28.97098182,214.7164233,-2.813407763,361.3460392,216.225303,310.4128484 +-105,0,10,184.801029,342.9376849,13.10289202,319.0257372,13.10289202,465.9722628,204.801029,437.0603151 +-105,5,10,196.225303,469.5851516,-2.813407763,423.6519608,28.97098182,570.2815767,193.4185978,563.2439198 +-105,10,10,207.691651,596.699043,-18.77813756,528.5965404,44.79108036,674.2754188,182.07778,688.966206 +-105,15,10,219.2003059,724.2819404,-34.79151876,633.8609312,60.56340501,777.955218,170.7783478,814.2296992 +-100,-15,10,272.4183323,-33.19315433,144.6190594,6.325126853,49.63897151,149.2527836,361.8383911,58.4530463 +-100,-10,10,283.8831174,91.45252263,128.9092294,110.4320281,65.5899052,254.9574458,350.1623854,185.3951231 +-100,-5,10,295.3896856,216.5524672,113.1516179,214.8555703,81.49217615,360.3396268,338.5290883,311.8728709 +-100,0,10,306.9382657,342.1091673,97.34600672,319.5972001,97.34600672,465.4007999,326.9382657,437.8888327 +-100,5,10,318.5290883,468.1251291,81.49217615,424.6583732,113.1516179,570.1424297,315.3896856,563.4455328 +-100,10,10,330.1623854,594.6028769,65.5899052,530.0405542,128.9092294,674.5659719,303.8831174,688.5454774 +-100,15,10,341.8383911,721.5449537,49.63897151,635.7452164,144.6190594,778.6728731,292.4183323,813.1911543 +-95,-15,10,392.8628477,-32.16481622,229.3708221,5.601528384,134.7751201,147.3527498,483.2579893,61.16283928 +-95,-10,10,404.4891939,91.86910892,213.7250664,110.1390652,150.6620026,253.5013817,471.418514,187.4705005 +-95,-5,10,416.157703,216.3528428,198.0315265,214.9958732,166.5002159,359.3248286,459.6221308,313.31844 +-95,0,10,427.8686048,341.2888368,182.2899832,320.1734173,182.2899832,464.8245827,447.8686048,438.7091632 +-95,5,10,439.6221308,466.67956,166.5002159,425.6731714,198.0315265,570.0021268,436.157703,563.6451572 +-95,10,10,451.418514,592.5274995,150.6620026,531.4966183,213.7250664,674.8589348,424.4891939,688.1288911 +-95,15,10,463.2579893,718.8351607,134.7751201,637.6452502,229.3708221,779.3964716,412.8628477,812.1628162 +-90,-15,10,512.1294312,-31.14653513,314.8273763,4.871912505,220.6258108,145.4367692,603.4771704,63.84584183 +-90,-10,10,523.913634,92.28161371,299.2473076,109.8436622,236.4470046,252.0331157,591.4780164,189.5253968 +-90,-5,10,535.7403625,216.1551778,283.619457,215.1373464,252.2195277,358.3015396,579.5223205,314.7497692 +-90,0,10,547.6098472,340.4765725,267.9436041,320.7544483,267.9436041,464.2435517,567.6098472,439.5214275 +-90,5,10,559.5223205,465.2482308,252.2195277,426.6964604,283.619457,569.8606536,555.7403625,563.8428222 +-90,10,10,571.4780164,590.4726032,236.4470046,532.9648843,299.2473076,675.1543378,543.913634,687.7163863 +-90,15,10,583.4771704,716.1521582,220.6258108,639.5612308,314.8273763,780.1260875,532.1294312,811.1445351 +-85,-15,10,630.2352788,-30.13816424,400.9975501,4.136203842,307.2000773,143.5046401,722.5136486,66.50244931 +-85,-10,10,642.1737187,92.6900967,385.4848151,109.5457886,322.9539102,250.5524939,710.358519,191.5601137 +-85,-5,10,654.1550306,215.9594434,369.9243054,215.2800046,338.659076,357.2696526,698.2471969,316.1670679 +-85,0,10,666.1794456,339.6722561,354.3157998,321.3403538,354.3157998,463.6576462,686.1794456,440.3257439 +-85,5,10,678.2471969,463.8309321,338.659076,427.7283474,369.9243054,569.7179954,674.1550306,564.0385566 +-85,10,10,690.358519,588.4378863,322.9539102,534.4455061,385.4848151,675.4522114,662.1737187,687.3079033 +-85,15,10,702.5136486,713.4955507,307.2000773,641.4933599,400.9975501,780.8617962,650.2352788,810.1361642 +-80,-15,10,747.1972532,-29.13955957,487.89032,3.394325758,394.5071059,141.5561575,840.3847915,69.13304931 +-80,-10,10,759.2863937,93.09461641,472.4465998,109.2454133,410.1918704,249.0593596,828.0773041,193.5749471 +-80,-5,10,771.4187361,215.7656115,456.9551173,215.4238628,425.8279766,356.2290588,815.8139568,317.5705413 +-80,0,10,783.5945126,338.8757714,441.4156507,321.9311953,441.4156507,463.0668047,803.5945126,441.1222286 +-80,5,10,795.8139568,462.4274587,425.8279766,428.7689412,456.9551173,569.5741372,791.4187361,564.2323885 +-80,10,10,808.0773041,586.4230529,410.1918704,535.9386404,472.4465998,675.7525867,779.2863937,686.9033836 +-80,15,10,820.3847915,710.8649507,394.5071059,643.4418425,487.89032,781.6036742,767.1972532,809.1375596 +-75,-15,10,863.0318924,-28.15057993,575.5148134,2.646200323,482.5562394,139.5911129,957.1076282,71.73802189 +-75,-10,10,875.2682771,93.49523024,560.1418245,108.9425046,498.1701914,247.5535535,944.6513174,195.570187 +-75,-5,10,887.5481786,215.5736545,544.7210914,215.5689362,513.7354997,355.1796476,932.2394637,318.9603909 +-75,0,10,899.8718294,338.0870046,529.2523911,322.5270356,529.2523911,462.4709644,919.8718294,441.9109954 +-75,5,10,912.2394637,461.0376091,513.7354997,429.8183524,544.7210914,569.4290638,907.5481786,564.4243455 +-75,10,10,924.6513174,584.427813,498.1701914,537.4444465,560.1418245,676.0554954,895.2682771,686.5027698 +-75,15,10,937.1076282,708.2599781,482.5562394,645.4068871,575.5148134,782.3517997,883.0318924,808.1485799 +-70,-15,10,977.7554169,-27.17108683,663.880312,1.891748291,571.3569799,137.6092943,1072.698858,74.31773971 +-70,-10,10,990.1356682,93.8919945,648.5798074,108.6370304,586.8983383,246.0349137,1060.097177,197.5461178 +-70,-5,10,1002.559736,215.3835453,633.231582,215.7152403,602.3910732,354.1213064,1047.540255,320.336814 +-70,0,10,1015.027853,337.3058441,617.8354123,323.1279382,617.8354123,461.8700618,1035.027853,442.6921559 +-70,5,10,1027.540255,459.661186,602.3910732,430.8766936,633.231582,569.2827597,1022.559736,564.6144547 +-70,10,10,1040.097177,582.4518822,586.8983383,538.9630863,648.5798074,676.3609696,1010.135668,686.1060055 +-70,15,10,1052.698858,705.6802603,571.3569799,647.3887057,663.880312,783.1062517,997.7554169,807.1690868 +-65,-15,10,1091.383737,-26.20094443,752.9962554,1.130889068,660.9189924,135.6104859,1187.174857,76.87256828 +-65,-10,10,1103.904554,94.2849644,737.770025,108.3289578,676.3859381,244.5032753,1174.431181,199.5030185 +-65,-5,10,1116.469473,215.1952573,722.4961031,215.8627908,691.8042863,353.0539207,1161.73255,321.7000042 +-65,0,10,1129.078726,336.5321804,707.1742655,323.733968,707.1742655,461.264032,1149.078726,443.4658196 +-65,5,10,1141.73255,458.2979958,691.8042863,431.9440793,722.4961031,569.1352092,1136.469473,564.8027427 +-65,10,10,1154.431181,580.4949815,676.3859381,540.4947247,737.770025,676.6690422,1123.904554,685.7130356 +-65,15,10,1167.174857,703.1254317,660.9189924,649.3875141,752.9962554,783.8671109,1111.383737,806.1989444 +-60,-15,10,1203.932462,-25.24001946,842.8722441,0.363540687,751.2521086,133.5944683,1300.551687,79.40286606 +-60,-10,10,1216.590618,94.67419412,827.7221158,108.0182537,766.6427833,242.9584709,1287.669314,201.4411626 +-60,-5,10,1229.293146,215.0087646,812.5243315,216.0116036,781.9848926,351.9773742,1274.832258,323.0501514 +-60,0,10,1242.040281,335.7659061,797.278666,324.3451908,797.278666,460.6528092,1262.040281,444.2320939 +-60,5,10,1254.832258,456.9478486,781.9848926,433.0206258,812.5243315,568.9863964,1249.293146,564.9892354 +-60,10,10,1267.669314,578.5568374,766.6427833,542.0395291,827.7221158,676.9797463,1236.590618,685.3238059 +-60,15,10,1280.551687,700.5951339,751.2521086,651.4035317,842.8722441,784.6344593,1223.932462,805.2380195 +-55,-15,10,1315.416905,-24.28818117,933.5180431,-0.410380225,842.3663299,131.5610183,1412.845105,81.90898467 +-55,-10,10,1328.209245,95.0597368,918.4458839,107.7048841,857.6788355,241.4003297,1399.827256,203.3608186 +-55,-5,10,1341.046215,214.8240415,903.3261102,216.1616951,872.9428141,350.8915483,1386.854984,324.387442 +-55,0,10,1353.92805,335.0069158,888.1584961,324.9616738,888.1584961,460.0363262,1373.92805,444.9910842 +-55,5,10,1366.854984,455.610558,872.9428141,434.1064517,903.3261102,568.8363049,1361.046215,565.1739585 +-55,10,10,1379.827256,576.6371814,857.6788355,543.5976703,918.4458839,677.2931159,1348.209245,684.9382632 +-55,15,10,1392.845105,698.0890153,842.3663299,653.4369817,933.5180431,785.4083802,1335.416905,804.2861812 +-50,-15,10,1425.852091,-23.3453013,1024.943585,-1.190958473,934.2718315,129.5099089,1524.070565,84.39126903 +-50,-10,10,1438.775531,95.44164458,1009.951302,107.3888146,949.504229,239.8286785,1510.920392,205.2622497 +-50,-5,10,1451.743847,214.641063,994.911452,216.3130817,964.6881445,349.7963226,1497.81604,325.7120588 +-50,0,10,1464.757271,334.2551062,979.8238089,325.5834852,979.8238089,459.4145148,1484.757271,445.7428938 +-50,5,10,1477.81604,454.2859412,964.6881445,435.2016774,994.911452,568.6849183,1471.743847,565.356937 +-50,10,10,1490.920392,574.7357503,949.504229,545.1693215,1009.951302,677.6091854,1458.775531,684.5563554 +-50,15,10,1504.070565,695.606731,934.2718315,655.4880911,1024.943585,786.1889585,1445.852091,803.3433013 +-45,-15,10,1535.252763,-22.41125395,1117.158975,-1.978280329,1026.978966,127.440909,1634.243231,86.85005757 +-45,-10,10,1548.304289,95.81996863,1102.248515,107.0700101,1042.129275,238.2433408,1620.963812,207.1457143 +-45,-5,10,1561.400922,214.4598046,1087.290543,216.4657804,1057.231153,348.6915745,1607.730447,327.024181 +-45,0,10,1574.542896,333.5103758,1072.284832,326.2106942,1072.284832,458.7873058,1594.542896,446.4876242 +-45,5,10,1587.730447,452.973819,1057.231153,436.3064255,1087.290543,568.5322196,1581.400922,565.5381954 +-45,10,10,1600.963812,572.8522857,1042.129275,546.7546592,1102.248515,677.9279899,1568.304289,684.1780314 +-45,15,10,1614.243231,693.1479424,1026.978966,657.557091,1117.158975,786.9762803,1555.252763,802.409254 +-40,-15,10,1643.63339,-21.48591561,1210.174493,-2.772433562,1120.498268,125.3537834,1743.377979,89.28568228 +-40,-10,10,1656.810055,96.19475915,1195.347846,106.7484351,1135.564464,236.6441369,1729.972325,209.0114657 +-40,-5,10,1670.032045,214.280242,1180.473748,216.6198083,1150.582288,347.5771792,1716.612943,328.3239845 +-40,0,10,1683.299596,332.7726251,1165.551972,326.8433715,1165.551972,458.1546285,1703.299596,447.2253749 +-40,5,10,1696.612943,451.6740155,1150.582288,437.4208208,1180.473748,568.3781917,1690.032045,565.717758 +-40,10,10,1709.972325,570.9865343,1135.564464,548.3538631,1195.347846,678.2495649,1676.810055,683.8032408 +-40,15,10,1723.377979,690.7123177,1120.498268,659.6442166,1210.174493,787.7704336,1663.63339,801.4839156 +-35,-15,10,1751.008173,-20.56916502,1304.000598,-3.57350747,1214.840457,123.248293,1851.489408,91.69846899 +-35,-10,10,1764.307093,96.56606542,1289.259795,106.4240531,1229.820472,235.0308841,1837.960461,210.8597527 +-35,-5,10,1777.651546,214.1023515,1274.471611,216.7751828,1244.752181,346.4530099,1824.477994,329.6116422 +-35,0,10,1791.041768,332.0417565,1259.635816,327.4815889,1259.635816,457.5164111,1811.041768,447.9562435 +-35,5,10,1804.477994,450.3863578,1244.752181,438.5449901,1274.471611,568.2228172,1797.651546,565.8956485 +-35,10,10,1817.960461,569.1382473,1229.820472,549.9671159,1289.259795,678.5739469,1784.307093,683.4319346 +-35,15,10,1831.489408,688.299531,1214.840457,661.749707,1304.000598,788.5715075,1771.008173,800.567165 +-30,-15,10,1857.391048,-19.6608832,1398.647931,-4.381592915,1310.016442,121.1241942,1958.591845,94.0887374 +-30,-10,10,1870.809405,96.9339358,1383.99505,106.0968274,1324.908163,233.4033966,1944.942481,212.6908194 +-30,-5,10,1884.273492,213.92611,1369.294863,216.9319216,1339.751651,345.3189373,1931.339793,330.8873233 +-30,0,10,1897.783543,331.317674,1354.54714,328.1254196,1354.54714,456.8725804,1917.783543,448.680326 +-30,5,10,1911.339793,449.1106767,1339.751651,439.6790627,1369.294863,568.0660784,1904.273492,566.07189 +-30,10,10,1924.942481,567.3071806,1324.908163,551.5946034,1383.99505,678.9011726,1890.809405,683.0640642 +-30,15,10,1938.591845,685.9092626,1310.016442,663.8738058,1398.647931,789.3795929,1877.391048,799.6588832 +-25,-15,10,1962.795697,-18.76095332,1494.127324,-5.196782355,1406.037325,118.9812394,2064.699348,96.45680129 +-25,-10,10,1976.330735,97.29841777,1479.564484,105.7667204,1420.838593,231.761485,2050.93238,214.5049053 +-25,-5,10,1989.911687,213.7514945,1464.954423,217.0900428,1435.591709,344.1748301,2037.212274,332.1511943 +-25,0,10,2003.538788,330.6002837,1450.296908,328.7749378,1450.296908,456.2230622,2023.538788,449.3977163 +-25,5,10,2017.212274,447.8468057,1435.591709,440.8231699,1464.954423,567.9079572,2009.911687,566.2465055 +-25,10,10,2030.93238,565.4930947,1420.838593,553.236515,1479.564484,679.2312796,1996.330735,682.6995822 +-25,15,10,2044.699348,683.5411987,1406.037325,666.0167606,1494.127324,790.1947824,1982.795697,798.7589533 +-20,-15,10,2067.235551,-17.86926073,1590.449795,-6.019169884,1502.914407,116.8191763,2169.825716,98.80296863 +-20,-10,10,2080.884574,97.65955791,1575.979165,105.4336938,1517.623016,230.1049568,2155.943896,216.3022456 +-20,-5,10,2094.579685,213.5784828,1561.461403,217.2495647,1532.283562,343.0205545,2142.10911,333.4034184 +-20,0,10,2108.321118,329.8894931,1546.89628,329.4302193,1546.89628,455.5677807,2128.321118,450.1085069 +-20,5,10,2122.10911,446.5945816,1532.283562,441.9774455,1561.461403,567.7484353,2114.579685,566.4195172 +-20,10,10,2135.943896,563.6957544,1517.623016,554.8930432,1575.979165,679.5643062,2100.884574,682.3384421 +-20,15,10,2149.825716,681.1950314,1502.914407,668.1788237,1590.449795,791.0171699,2087.235551,797.8672607 +-15,-15,10,2170.723797,-16.98569284,1687.626561,-6.848851261,1600.659192,114.6377481,2273.984497,101.1275417 +-15,-10,10,2184.484167,98.01740199,1673.250354,105.0977087,1615.272887,228.4336159,2259.990514,218.083071 +-15,-5,10,2198.290789,213.4070527,1658.827116,217.4105061,1629.838615,341.8559742,2246.043727,334.6441558 +-15,0,10,2212.143898,329.1852117,1644.356614,330.0913411,1644.356614,454.9066589,2232.143898,450.8127883 +-15,5,10,2226.043727,445.3538442,1629.838615,443.1420258,1658.827116,567.5874939,2218.290789,566.5909473 +-15,10,10,2239.990514,561.914929,1615.272887,556.5643841,1673.250354,679.9002913,2204.484167,681.980598 +-15,15,10,2253.984497,678.8704583,1600.659192,670.3602519,1687.626561,791.8468513,2190.723797,796.9836928 +-10,-15,10,2273.27338,-16.11013912,1785.669038,-7.685923955,1699.283389,112.4366936,2377.188987,103.4308174 +-10,-10,10,2287.142519,98.37199492,1771.389517,104.7587255,1713.799865,226.7472627,2363.085471,219.8476081 +-10,-5,10,2301.058063,213.2371828,1757.063073,217.5728859,1728.26848,340.6809507,2349.029304,335.8735639 +-10,0,10,2315.020246,328.4873503,1742.689471,330.7583817,1742.689471,454.2396183,2335.020246,451.5106497 +-10,5,10,2329.029304,444.1244361,1728.26848,444.3170493,1757.063073,567.4251141,2321.058063,566.7608172 +-10,10,10,2343.085471,560.1503919,1713.799865,558.2507373,1771.389517,680.2392745,2307.142519,681.6260051 +-10,15,10,2357.188987,676.5671826,1699.283389,672.5613064,1785.669038,792.683924,2293.27338,796.1081391 +-5,-15,10,2374.897015,-15.242491,1884.588846,-8.530487176,1798.79892,110.2157466,2479.452241,105.7130869 +-5,-10,10,2388.872399,98.72338081,1870.408324,104.4167039,1813.215822,225.0456941,2465.241765,221.5960793 +-5,-5,10,2402.894332,213.0688518,1856.180994,217.7367236,1827.584977,339.4953429,2451.07878,337.0917971 +-5,0,10,2416.963047,327.7958217,1841.906622,331.4314209,1841.906622,453.5665791,2436.963047,452.2021783 +-5,5,10,2431.07878,442.9062029,1827.584977,445.5026571,1856.180994,567.2612764,2422.894332,566.9291482 +-5,10,10,2445.241765,558.4019207,1813.215822,559.9523059,1870.408324,680.5812961,2408.872399,681.2746192 +-5,15,10,2459.452241,674.2849131,1798.79892,674.7822534,1884.588846,793.5284872,2394.897015,795.240491 +0,-15,10,2475.607186,-14.38264192,1984.397814,-9.382641919,1899.217923,107.9746363,2580.787077,107.9746363 +0,-10,10,2489.686347,99.07160297,1970.318653,104.071603,1913.532843,223.3287032,2566.472157,223.3287032 +0,-5,10,2503.81219,212.9020389,1956.19281,217.9020389,1927.800139,338.2990071,2552.204861,338.2990071 +0,0,10,2517.98495,327.11054,1942.02005,332.11054,1942.02005,452.88746,2537.98495,452.88746 +0,5,10,2532.204861,441.6989929,1927.800139,446.6989929,1956.19281,567.0959611,2523.81219,567.0959611 +0,10,10,2546.472157,556.6692968,1913.532843,561.6692968,1970.318653,680.926397,2509.686347,680.926397 +0,15,10,2560.787077,672.0233637,1899.217923,677.0233637,1984.397814,794.3806419,2495.607186,794.3806419 +5,-15,10,2575.416154,-13.53048718,2085.107985,-10.242491,2000.552759,105.7130869,2681.20608,110.2157466 +5,-10,10,2589.596676,99.41670394,2071.132601,103.7233808,2014.763235,221.5960793,2666.789178,225.0456941 +5,-5,10,2603.824006,212.7367236,2057.110668,218.0688518,2028.92622,337.0917971,2652.420023,339.4953429 +5,0,10,2618.098378,326.4314209,2043.041953,332.7958217,2043.041953,452.2021783,2638.098378,453.5665791 +5,5,10,2632.420023,440.5026571,2028.92622,447.9062029,2057.110668,566.9291482,2623.824006,567.2612764 +5,10,10,2646.789178,554.9523059,2014.763235,563.4019207,2071.132601,681.2746192,2609.596676,680.5812961 +5,15,10,2661.20608,669.7822534,2000.552759,679.2849131,2085.107985,795.240491,2595.416154,793.5284872 +10,-15,10,2674.335962,-12.68592395,2186.73162,-11.11013912,2102.816013,103.4308174,2780.721611,112.4366936 +10,-10,10,2688.615483,99.75872549,2172.862481,103.3719949,2116.919529,219.8476081,2766.205135,226.7472627 +10,-5,10,2702.941927,212.5728859,2158.946937,218.2371828,2130.975696,335.8735639,2751.73652,340.6809507 +10,0,10,2717.315529,325.7583817,2144.984754,333.4873503,2144.984754,451.5106497,2737.315529,454.2396183 +10,5,10,2731.73652,439.3170493,2130.975696,449.1244361,2158.946937,566.7608172,2722.941927,567.4251141 +10,10,10,2746.205135,553.2507373,2116.919529,565.1503919,2172.862481,681.6260051,2708.615483,680.2392745 +10,15,10,2760.721611,667.5613064,2102.816013,681.5671826,2186.73162,796.1081391,2694.335962,792.683924 +15,-15,10,2772.378439,-11.84885126,2289.281203,-11.98569284,2206.020503,101.1275417,2879.345808,114.6377481 +15,-10,10,2786.754646,100.0977087,2275.520833,103.017402,2220.014486,218.083071,2864.732113,228.4336159 +15,-5,10,2801.177884,212.4105061,2261.714211,218.4070527,2233.961273,334.6441558,2850.166385,341.8559742 +15,0,10,2815.648386,325.0913411,2247.861102,334.1852117,2247.861102,450.8127883,2835.648386,454.9066589 +15,5,10,2830.166385,438.1420258,2233.961273,450.3538442,2261.714211,566.5909473,2821.177884,567.5874939 +15,10,10,2844.732113,551.5643841,2220.014486,566.914929,2275.520833,681.980598,2806.754646,679.9002913 +15,15,10,2859.345808,665.3602519,2206.020503,683.8704583,2289.281203,796.9836928,2792.378439,791.8468513 +20,-15,10,2869.555205,-11.01916988,2392.769449,-12.86926073,2310.179284,98.80296863,2977.090593,116.8191763 +20,-10,10,2884.025835,100.4336938,2379.120426,102.6595579,2324.061104,216.3022456,2962.381984,230.1049568 +20,-5,10,2898.543597,212.2495647,2365.425315,218.5784828,2337.89589,333.4034184,2947.721438,343.0205545 +20,0,10,2913.10872,324.4302193,2351.683882,334.8894931,2351.683882,450.1085069,2933.10872,455.5677807 +20,5,10,2927.721438,436.9774455,2337.89589,451.5945816,2365.425315,566.4195172,2918.543597,567.7484353 +20,10,10,2942.381984,549.8930432,2324.061104,568.6957544,2379.120426,682.3384421,2904.025835,679.5643062 +20,15,10,2957.090593,663.1788237,2310.179284,686.1950314,2392.769449,797.8672607,2889.555205,791.0171699 +25,-15,10,2965.877676,-10.19678236,2497.209303,-13.76095332,2415.305652,96.45680129,3073.967675,118.9812394 +25,-10,10,2980.440516,100.7667204,2483.674265,102.2984178,2429.07262,214.5049053,3059.166407,231.761485 +25,-5,10,2995.050577,212.0900428,2470.093313,218.7514945,2442.792726,332.1511943,3044.413291,344.1748301 +25,0,10,3009.708092,323.7749378,2456.466212,335.6002837,2456.466212,449.3977163,3029.708092,456.2230622 +25,5,10,3024.413291,435.8231699,2442.792726,452.8468057,2470.093313,566.2465055,3015.050577,567.9079572 +25,10,10,3039.166407,548.236515,2429.07262,570.4930947,2483.674265,682.6995822,3000.440516,679.2312796 +25,15,10,3053.967675,661.0167606,2415.305652,688.5411987,2497.209303,798.7589533,2985.877676,790.1947824 +30,-15,10,3061.357069,-9.381592915,2602.613952,-14.6608832,2521.413155,94.0887374,3169.988558,121.1241942 +30,-10,10,3076.00995,101.0968274,2589.195595,101.9339358,2535.062519,212.6908194,3155.096837,233.4033966 +30,-5,10,3090.710137,211.9319216,2575.731508,218.92611,2548.665207,330.8873233,3140.253349,345.3189373 +30,0,10,3105.45786,323.1254196,2562.221457,336.317674,2562.221457,448.680326,3125.45786,456.8725804 +30,5,10,3120.253349,434.6790627,2548.665207,454.1106767,2575.731508,566.07189,3110.710137,568.0660784 +30,10,10,3135.096837,546.5946034,2535.062519,572.3071806,2589.195595,683.0640642,3096.00995,678.9011726 +30,15,10,3149.988558,658.8738058,2521.413155,690.9092626,2602.613952,799.6588832,3081.357069,789.3795929 +35,-15,10,3156.004402,-8.57350747,2708.996827,-15.56916502,2628.515592,91.69846899,3265.164543,123.248293 +35,-10,10,3170.745205,101.4240531,2695.697907,101.5660654,2642.044539,210.8597527,3250.184528,235.0308841 +35,-5,10,3185.533389,211.7751828,2682.353454,219.1023515,2655.527006,329.6116422,3235.252819,346.4530099 +35,0,10,3200.369184,322.4815889,2668.963232,337.0417565,2668.963232,447.9562435,3220.369184,457.5164111 +35,5,10,3215.252819,433.5449901,2655.527006,455.3863578,2682.353454,565.8956485,3205.533389,568.2228172 +35,10,10,3230.184528,544.9671159,2642.044539,574.1382473,2695.697907,683.4319346,3190.745205,678.5739469 +35,15,10,3245.164543,656.749707,2628.515592,693.299531,2708.996827,800.567165,3176.004402,788.5715075 +40,-15,10,3249.830507,-7.772433562,2816.37161,-16.48591561,2736.627021,89.28568228,3359.506732,125.3537834 +40,-10,10,3264.657154,101.7484351,2803.194945,101.1947592,2750.032675,209.0114657,3344.440536,236.6441369 +40,-5,10,3279.531252,211.6198083,2789.972955,219.280242,2763.392057,328.3239845,3329.422712,347.5771792 +40,0,10,3294.453028,321.8433715,2776.705404,337.7726251,2776.705404,447.2253749,3314.453028,458.1546285 +40,5,10,3309.422712,432.4208208,2763.392057,456.6740155,2789.972955,565.717758,3299.531252,568.3781917 +40,10,10,3324.440536,543.3538631,2750.032675,575.9865343,2803.194945,683.8032408,3284.657154,678.2495649 +40,15,10,3339.506732,654.6442166,2736.627021,695.7123177,2816.37161,801.4839156,3269.830507,787.7704336 +45,-15,10,3342.846025,-6.978280329,2924.752237,-17.41125395,2845.761769,86.85005757,3453.026034,127.440909 +45,-10,10,3357.756485,102.0700101,2911.700711,100.8199686,2859.041188,207.1457143,3437.875725,238.2433408 +45,-5,10,3372.714457,211.4657804,2898.604078,219.4598046,2872.274553,327.024181,3422.773847,348.6915745 +45,0,10,3387.720168,321.2106942,2885.462104,338.5103758,2885.462104,446.4876242,3407.720168,458.7873058 +45,5,10,3402.773847,431.3064255,2872.274553,457.973819,2898.604078,565.5381954,3392.714457,568.5322196 +45,10,10,3417.875725,541.7546592,2859.041188,577.8522857,2911.700711,684.1780314,3377.756485,677.9279899 +45,15,10,3433.026034,652.557091,2845.761769,698.1479424,2924.752237,802.409254,3362.846025,786.9762803 +50,-15,10,3435.061415,-6.190958473,3034.152909,-18.3453013,2955.934435,84.39126903,3545.733168,129.5099089 +50,-10,10,3450.053698,102.3888146,3021.229469,100.4416446,2969.084608,205.2622497,3530.500771,239.8286785 +50,-5,10,3465.093548,211.3130817,3008.261153,219.641063,2982.18896,325.7120588,3515.316856,349.7963226 +50,0,10,3480.181191,320.5834852,2995.247729,339.2551062,2995.247729,445.7428938,3500.181191,459.4145148 +50,5,10,3495.316856,430.2016774,2982.18896,459.2859412,3008.261153,565.356937,3485.093548,568.6849183 +50,10,10,3510.500771,540.1693215,2969.084608,579.7357503,3021.229469,684.5563554,3470.053698,677.6091854 +50,15,10,3525.733168,650.4880911,2955.934435,700.606731,3034.152909,803.3433013,3455.061415,786.1889585 +55,-15,10,3526.486957,-5.410380225,3144.588095,-19.28818117,3067.159895,81.90898467,3637.63867,131.5610183 +55,-10,10,3541.559116,102.7048841,3131.795755,100.0597368,3080.177744,203.3608186,3622.326164,241.4003297 +55,-5,10,3556.67889,211.1616951,3118.958785,219.8240415,3093.150016,324.387442,3607.062186,350.8915483 +55,0,10,3571.846504,319.9616738,3106.07695,340.0069158,3106.07695,444.9910842,3591.846504,460.0363262 +55,5,10,3587.062186,429.1064517,3093.150016,460.610558,3118.958785,565.1739585,3576.67889,568.8363049 +55,10,10,3602.326164,538.5976703,3080.177744,581.6371814,3131.795755,684.9382632,3561.559116,677.2931159 +55,15,10,3617.63867,648.4369817,3067.159895,703.0890153,3144.588095,804.2861812,3546.486957,785.4083802 +60,-15,10,3617.132756,-4.636459313,3256.072538,-20.24001946,3179.453313,79.40286606,3728.752891,133.5944683 +60,-10,10,3632.282884,103.0182537,3243.414382,99.67419412,3192.335686,201.4411626,3713.362217,242.9584709 +60,-5,10,3647.480668,211.0116036,3230.711854,220.0087646,3205.172742,323.0501514,3698.020107,351.9773742 +60,0,10,3662.726334,319.3451908,3217.964719,340.7659061,3217.964719,444.2320939,3682.726334,460.6528092 +60,5,10,3678.020107,428.0206258,3205.172742,461.9478486,3230.711854,564.9892354,3667.480668,568.9863964 +60,10,10,3693.362217,537.0395291,3192.335686,583.5568374,3243.414382,685.3238059,3652.282884,676.9797463 +60,15,10,3708.752891,646.4035317,3179.453313,705.5951339,3256.072538,805.2380195,3637.132756,784.6344593 +65,-15,10,3707.008745,-3.869110932,3368.621263,-21.20094443,3292.830143,76.87256828,3819.086008,135.6104859 +65,-10,10,3722.234975,103.3289578,3356.100446,99.2849644,3305.573819,199.5030185,3803.619062,244.5032753 +65,-5,10,3737.508897,210.8627908,3343.535527,220.1952573,3318.27245,321.7000042,3788.200714,353.0539207 +65,0,10,3752.830734,318.733968,3330.926274,341.5321804,3330.926274,443.4658196,3772.830734,461.264032 +65,5,10,3768.200714,426.9440793,3318.27245,463.2979958,3343.535527,564.8027427,3757.508897,569.1352092 +65,10,10,3783.619062,535.4947247,3305.573819,585.4949815,3356.100446,685.7130356,3742.234975,676.6690422 +65,15,10,3799.086008,644.3875141,3292.830143,708.1254317,3368.621263,806.1989444,3727.008745,783.8671109 +70,-15,10,3796.124688,-3.108251709,3482.249583,-22.17108683,3407.306142,74.31773971,3908.64802,137.6092943 +70,-10,10,3811.425193,103.6370304,3469.869332,98.8919945,3419.907823,197.5461178,3893.106662,246.0349137 +70,-5,10,3826.773418,210.7152403,3457.445264,220.3835453,3432.464745,320.336814,3877.613927,354.1213064 +70,0,10,3842.169588,318.1279382,3444.977147,342.3058441,3444.977147,442.6921559,3862.169588,461.8700618 +70,5,10,3857.613927,425.8766936,3432.464745,464.661186,3457.445264,564.6144547,3846.773418,569.2827597 +70,10,10,3873.106662,533.9630863,3419.907823,587.4518822,3469.869332,686.1060055,3831.425193,676.3609696 +70,15,10,3888.64802,642.3887057,3407.306142,710.6802603,3482.249583,807.1690868,3816.124688,783.1062517 +75,-15,10,3884.490187,-2.353799677,3596.973108,-23.15057993,3522.897372,71.73802189,3997.448761,139.5911129 +75,-10,10,3899.863175,103.9425046,3584.736723,98.49523024,3535.353683,195.570187,3981.834809,247.5535535 +75,-5,10,3915.283909,210.5689362,3572.456821,220.5736545,3547.765536,318.9603909,3966.2695,355.1796476 +75,0,10,3930.752609,317.5270356,3560.133171,343.0870046,3560.133171,441.9109954,3950.752609,462.4709644 +75,5,10,3946.2695,424.8183524,3547.765536,466.0376091,3572.456821,564.4243455,3935.283909,569.4290638 +75,10,10,3961.834809,532.4444465,3535.353683,589.427813,3584.736723,686.5027698,3919.863175,676.0554954 +75,15,10,3977.448761,640.4068871,3522.897372,713.2599781,3596.973108,808.1485799,3904.490187,782.3517997 +80,-15,10,3972.11468,-1.605674242,3712.807747,-24.13955957,3639.620209,69.13304931,4085.497894,141.5561575 +80,-10,10,3987.5584,104.2454133,3700.718606,98.09461641,3651.927696,193.5749471,4069.81313,249.0593596 +80,-5,10,4003.049883,210.4238628,3688.586264,220.7656115,3664.191043,317.5705413,4054.177023,356.2290588 +80,0,10,4018.589349,316.9311953,3676.410487,343.8757714,3676.410487,441.1222286,4038.589349,463.0668047 +80,5,10,4034.177023,423.7689412,3664.191043,467.4274587,3688.586264,564.2323885,4023.049883,569.5741372 +80,10,10,4049.81313,530.9386404,3651.927696,591.4230529,3700.718606,686.9033836,4007.5584,675.7525867 +80,15,10,4065.497894,638.4418425,3639.620209,715.8649507,3712.807747,809.1375596,3992.11468,781.6036742 +85,-15,10,4059.00745,-0.863796158,3829.769721,-25.13816424,3757.491351,66.50244931,4172.804923,143.5046401 +85,-10,10,4074.520185,104.5457886,3817.831281,97.6900967,3769.646481,191.5601137,4157.05109,250.5524939 +85,-5,10,4090.080695,210.2800046,3805.849969,220.9594434,3781.757803,316.1670679,4141.345924,357.2696526 +85,0,10,4105.6892,316.3403538,3793.825554,344.6722561,3793.825554,440.3257439,4125.6892,463.6576462 +85,5,10,4121.345924,422.7283474,3781.757803,468.8309321,3805.849969,564.0385566,4110.080695,569.7179954 +85,10,10,4137.05109,529.4455061,3769.646481,593.4378863,3817.831281,687.3079033,4094.520185,675.4522114 +85,15,10,4152.804923,636.4933599,3757.491351,718.4955507,3829.769721,810.1361642,4079.00745,780.8617962 +90,-15,10,4145.177624,-0.128087495,3947.875569,-26.14653513,3876.52783,63.84584183,4259.379189,145.4367692 +90,-10,10,4160.757692,104.8436622,3936.091366,97.28161371,3888.526984,189.5253968,4243.557995,252.0331157 +90,-5,10,4176.385543,210.1373464,3924.264638,221.1551778,3900.482679,314.7497692,4227.785472,358.3015396 +90,0,10,4192.061396,315.7544483,3912.395153,345.4765725,3912.395153,439.5214275,4212.061396,464.2435517 +90,5,10,4207.785472,421.6964604,3900.482679,470.2482308,3924.264638,563.8428222,4196.385543,569.8606536 +90,10,10,4223.557995,527.9648843,3888.526984,595.4726032,3936.091366,687.7163863,4180.757692,675.1543378 +90,15,10,4239.379189,634.5612308,3876.52783,721.1521582,3947.875569,811.1445351,4165.177624,780.1260875 +95,-15,10,4230.634178,0.601528384,4067.142152,-27.16481622,3996.747011,61.16283928,4345.22988,147.3527498 +95,-10,10,4246.279934,105.1390652,4055.515806,96.86910892,4008.586486,187.4705005,4329.342997,253.5013817 +95,-5,10,4261.973474,209.9958732,4043.847297,221.3528428,4020.382869,313.31844,4313.504784,359.3248286 +95,0,10,4277.715017,315.1734173,4032.136395,346.2888368,4032.136395,438.7091632,4297.715017,464.8245827 +95,5,10,4293.504784,420.6731714,4020.382869,471.67956,4043.847297,563.6451572,4281.973474,570.0021268 +95,10,10,4309.342997,526.4966183,4008.586486,597.5274995,4055.515806,688.1288911,4266.279934,674.8589348 +95,15,10,4325.22988,632.6452502,3996.747011,723.8351607,4067.142152,812.1628162,4250.634178,779.3964716 +100,-15,10,4315.385941,1.325126853,4187.586668,-28.19315433,4118.166609,58.4530463,4430.366028,149.2527836 +100,-10,10,4331.095771,105.4320281,4176.121883,96.45252263,4129.842615,185.3951231,4414.415095,254.9574458 +100,-5,10,4346.853382,209.8555703,4164.615314,221.5524672,4141.475912,311.8728709,4398.512824,360.3396268 +100,0,10,4362.658993,314.5972001,4153.066734,347.1091673,4153.066734,437.8888327,4382.658993,465.4007999 +100,5,10,4378.512824,419.6583732,4141.475912,473.1251291,4164.615314,563.4455328,4366.853382,570.1424297 +100,10,10,4394.415095,525.0405542,4129.842615,599.6028769,4176.121883,688.5454774,4351.095771,674.5659719 +100,15,10,4410.366028,630.7452164,4118.166609,726.5449537,4187.586668,813.1911543,4335.385941,778.6728731 +105,-15,10,4399.441595,2.04278205,4309.226652,-29.23169918,4240.804694,55.71605962,4514.796519,151.1370688 +105,-10,10,4415.21392,105.7225812,4297.92722,96.03179396,4252.313349,183.298957,4498.783138,256.4014596 +105,-5,10,4431.034018,209.7164233,4286.586402,221.7540802,4263.779697,310.4128484,4482.818408,361.3460392 +105,0,10,4446.902108,314.0257372,4275.203971,347.9376849,4275.203971,437.0603151,4466.902108,465.9722628 +105,5,10,4462.818408,418.6519608,4263.779697,474.5851516,4286.586402,563.2439198,4451.034018,570.2815767 +105,10,10,4478.783138,523.5965404,4252.313349,601.699043,4297.92722,688.966206,4435.21392,674.2754188 +105,15,10,4494.796519,628.8609312,4240.804694,729.2819404,4309.226652,814.2296992,4419.441595,777.955218 diff --git a/openpiv/data/test7/D_cal_cam0_0001.tif b/openpiv/data/test7/D_cal_cam0_0001.tif new file mode 100644 index 00000000..b63d1e41 Binary files /dev/null and b/openpiv/data/test7/D_cal_cam0_0001.tif differ diff --git a/openpiv/data/test7/D_cam0_0000.tif b/openpiv/data/test7/D_cam0_0000.tif new file mode 100644 index 00000000..47efe466 Binary files /dev/null and b/openpiv/data/test7/D_cam0_0000.tif differ diff --git a/openpiv/data/test7/D_cam0_0001.tif b/openpiv/data/test7/D_cam0_0001.tif new file mode 100644 index 00000000..798dd48e Binary files /dev/null and b/openpiv/data/test7/D_cam0_0001.tif differ diff --git a/openpiv/data/test7/D_cam1_0000.tif b/openpiv/data/test7/D_cam1_0000.tif new file mode 100644 index 00000000..b14a07ae Binary files /dev/null and b/openpiv/data/test7/D_cam1_0000.tif differ diff --git a/openpiv/data/test7/D_cam1_0001.tif b/openpiv/data/test7/D_cam1_0001.tif new file mode 100644 index 00000000..055f3008 Binary files /dev/null and b/openpiv/data/test7/D_cam1_0001.tif differ diff --git a/openpiv/data/test7/D_cam2_0000.tif b/openpiv/data/test7/D_cam2_0000.tif new file mode 100644 index 00000000..9dc5a0d7 Binary files /dev/null and b/openpiv/data/test7/D_cam2_0000.tif differ diff --git a/openpiv/data/test7/D_cam2_0001.tif b/openpiv/data/test7/D_cam2_0001.tif new file mode 100644 index 00000000..83cfc50c Binary files /dev/null and b/openpiv/data/test7/D_cam2_0001.tif differ diff --git a/openpiv/data/test7/D_cam3_0000.tif b/openpiv/data/test7/D_cam3_0000.tif new file mode 100644 index 00000000..6e29183d Binary files /dev/null and b/openpiv/data/test7/D_cam3_0000.tif differ diff --git a/openpiv/data/test7/D_cam3_0001.tif b/openpiv/data/test7/D_cam3_0001.tif new file mode 100644 index 00000000..86257c2b Binary files /dev/null and b/openpiv/data/test7/D_cam3_0001.tif differ diff --git a/openpiv/data/test7/READEME.md b/openpiv/data/test7/READEME.md new file mode 100644 index 00000000..2a907fae --- /dev/null +++ b/openpiv/data/test7/READEME.md @@ -0,0 +1 @@ +The following dataset is taken from PIV Challenge for use in OpenPIV. PIV Challenge is an initiative to provide PIV datasets in order to assess the current state-of-the-art PIV algorithms. The test case selected for this dataset is the 4th International PIV Challenge Case D since it coverers multiple-camera imaging systems and tomographic PIV. The complete dataset along with further information can be found at https://www.pivchallenge.org/pivchallenge4.html#case_d. \ No newline at end of file diff --git a/openpiv/docs/conf.py b/openpiv/docs/conf.py index 9955c94b..9a4cda0c 100644 --- a/openpiv/docs/conf.py +++ b/openpiv/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # OpenPIV documentation build configuration file, created by # sphinx-quickstart on Tue Feb 4 13:18:21 2014. @@ -11,8 +10,8 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys import os +import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -43,7 +42,7 @@ 'sphinx.ext.mathjax', 'sphinx.ext.viewcode', 'sphinx.ext.napoleon', - 'recommonmark', + 'recommonmark', 'nbsphinx' ] @@ -89,7 +88,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ['_build', '**.ipynb_checkpoints'] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None @@ -209,8 +208,8 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'OpenPIV.tex', u'OpenPIV Documentation', - u'OpenPIV group', 'manual'), + ('index', 'OpenPIV.tex', 'OpenPIV Documentation', + 'OpenPIV group', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -253,8 +252,8 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'OpenPIV', u'OpenPIV Documentation', - u'OpenPIV group', 'OpenPIV', 'One line description of project.', + ('index', 'OpenPIV', 'OpenPIV Documentation', + 'OpenPIV group', 'OpenPIV', 'One line description of project.', 'Miscellaneous'), ] diff --git a/openpiv/docs/generate_figures.py b/openpiv/docs/generate_figures.py index ff8defc1..b038c52e 100644 --- a/openpiv/docs/generate_figures.py +++ b/openpiv/docs/generate_figures.py @@ -1,12 +1,16 @@ import os from pathlib import Path + import matplotlib + matplotlib.use("Agg") -import matplotlib.pyplot as plt +from importlib.resources import files + import matplotlib.patches as patches +import matplotlib.pyplot as plt import numpy as np -from importlib.resources import files -from openpiv import tools, pyprocess, validation + +from openpiv import pyprocess, tools, validation DOCS_IMG_DIR = Path(__file__).resolve().parent / "images" DOCS_IMG_DIR.mkdir(parents=True, exist_ok=True) @@ -36,7 +40,7 @@ def generate_benchmark_figure(): "End-to-End Search Area PIV\n(512x512 image)", "Multi-Pass Windef\n(3 passes, 256x256)", ] - + speedups = [581.6, 18.9, 16.8, 7.6, 6.2, 3.4, 3.2, 3.7, 1.4] scipy_times = ["890.3 ms", "111.2 ms", "9.1 ms", "30.4 ms", "63.5 ms", "74.8 ms", "10.6 ms", "111.6 ms", "158.8 ms"] rust_times = ["1.53 ms", "5.89 ms", "0.54 ms", "3.99 ms", "10.18 ms", "21.98 ms", "3.31 ms", "30.00 ms", "113.39 ms"] @@ -67,7 +71,7 @@ def generate_benchmark_figure(): ax.set_axisbelow(True) # Annotate bars - for i, (bar, spd, st, rt) in enumerate(zip(bars, speedups, scipy_times, rust_times)): + for bar, spd, st, rt in zip(bars, speedups, scipy_times, rust_times): width = bar.get_width() label_text = f" {spd:.1f}x ({st} \u2192 {rt})" ax.text(width * 1.08, bar.get_y() + bar.get_height() / 2, label_text, @@ -121,7 +125,7 @@ def generate_architecture_diagram(): # Arrows from user to dispatch ax.annotate("", xy=(50, 72), xytext=(50, 78), - arrowprops=dict(arrowstyle="->", lw=1.8, color="#5f6368")) + arrowprops={"arrowstyle": "->", "lw": 1.8, "color": "#5f6368"}) # Backend Columns # Left: Rust Engine @@ -164,10 +168,10 @@ def generate_architecture_diagram(): # Dispatch arrows ax.annotate("backend in ('auto', 'rust')", xy=(26, 56), xytext=(40, 62), - arrowprops=dict(arrowstyle="->", lw=1.8, color="#d93025"), + arrowprops={"arrowstyle": "->", "lw": 1.8, "color": "#d93025"}, ha="right", va="bottom", fontsize=9, fontweight="bold", color="#d93025") ax.annotate("backend in ('scipy', 'fallback')", xy=(74, 56), xytext=(60, 62), - arrowprops=dict(arrowstyle="->", lw=1.8, color="#1e8e3e"), + arrowprops={"arrowstyle": "->", "lw": 1.8, "color": "#1e8e3e"}, ha="left", va="bottom", fontsize=9, fontweight="bold", color="#1e8e3e") # Parity Banner at Bottom @@ -244,7 +248,7 @@ def generate_pipeline_demo_figure(): im3 = axes[2].imshow(s2n, cmap="viridis", origin="upper") cbar2 = plt.colorbar(im3, ax=axes[2], fraction=0.046, pad=0.04) cbar2.set_label("Signal-to-Noise Ratio (Peak-to-Peak)") - + # Highlight flagged outliers from local_norm_median_val outlier_y, outlier_x = np.where(mask) if len(outlier_x) > 0: diff --git a/openpiv/docs/index.rst b/openpiv/docs/index.rst index f4f8b847..57e3d95d 100644 --- a/openpiv/docs/index.rst +++ b/openpiv/docs/index.rst @@ -36,8 +36,9 @@ Contents: src/piv_basics src/installation_instruction src/tutorial1 - src/windef - src/masking + src/windef + src/stereo_calibration + src/masking src/developers src/fft_correlation_backends src/api_reference diff --git a/openpiv/docs/src/stereo_calibration.rst b/openpiv/docs/src/stereo_calibration.rst new file mode 100644 index 00000000..ac6e7c33 --- /dev/null +++ b/openpiv/docs/src/stereo_calibration.rst @@ -0,0 +1,148 @@ +.. _stereo_calibration: + +Stereo calibration and stereo-PIV +================================= + +This page explains how two (u,v) displacement fields become one (U,V,W) +velocity field, how the calibration enters, and what disparity correction +fixes. The implementation lives in ``openpiv.calibration.stereo``. + +What each camera measures +------------------------- + +Per camera, ordinary 2C PIV on its **dewarped** frames gives one pixel +displacement per grid point: left :math:`(u_L, v_L)`, right :math:`(u_R, v_R)`. +That is **4 numbers** per point for **3** velocity components :math:`(U,V,W)` — +the redundancy is what makes stereo possible (and what exposes bad calibration). + +Calibration = the projection function +-------------------------------------- + +Calibration gives, per camera, a world → image map :math:`F` +(:meth:`camera.project_points `, +here a Soloff 3rd-order polynomial): + +.. math:: + F_{cam}(X, Y, Z) = (x, y) + +A world displacement :math:`(U,V,W)\,dt` appears in the images through the +**Jacobian** :math:`J` of :math:`F` at that grid point: + +.. math:: + \begin{pmatrix} u_L \\ v_L \\ u_R \\ v_R \end{pmatrix} + = + \underbrace{ + \begin{pmatrix} + \partial F_L/\partial X & \partial F_L/\partial Y & \partial F_L/\partial Z \\ + \partial F_R/\partial X & \partial F_R/\partial Y & \partial F_R/\partial Z + \end{pmatrix} + }_{J\;(4\times3)} + \begin{pmatrix} U \\ V \\ W \end{pmatrix} dt + +Per grid point we solve the tiny least-squares system :math:`J\,V = d` +(:func:`openpiv.calibration.stereo.reconstruct_3c`). In-plane :math:`(U,V)` +comes mostly from the **common-mode** motion both cameras see; :math:`W` +comes from the **difference** (left sees it shift one way, right the other) — +so :math:`W` needs two views and is the most calibration-sensitive component. + +What disparity correction fixes (two effects) +-------------------------------------------- + +The calibration was made on the **plate** — a different plane and time than the +laser sheet — so dewarped images carry a systematic left-vs-right offset :math:`D`: + +- **Measurement frame.** :math:`(u_L,v_L)` and :math:`(u_R,v_R)` must refer to + the *same* physical point. Correcting the maps (resample each by :math:`\mp D/2` + with :func:`openpiv.calibration.stereo.correct_maps`) re-aligns the frames + *before* PIV. +- **Linearization point.** :math:`J` is evaluated at the grid point, and under + oblique (Scheimpflug) angles it varies fast — :math:`\partial/\partial Z` + reaches thousands of px/mm. Uncorrected maps evaluate :math:`J` at the wrong place. + +Median :math:`|D|` after correction is the single-number quality gate for +everything downstream: if it does not drop, do not trust the 3C field. + +Workflow +-------- + +1. Calibrate both cameras from a dual-plane target + (``poly_model.camera(...).minimize_params(obj_xyz, img_xy)``). +2. Build the common world grid at the sheet plane and dewarp + (``get_common_world_grid`` → ``get_dewarp_mapping`` → ``dewarp_image``). +3. Per frame pair: estimate disparity :math:`D = R - L` on the dewarped + partner images — separately for pulse A and pulse B, averaged — + and correct the maps (``estimate_disparity`` → ``correct_maps``). + The laser sheet drifts, so **every pair gets its own correction**. +4. 2C PIV per camera, validate, then ``reconstruct_3c`` → :math:`(U,V,W)`. +5. Batch: repeat 3–4 over the pair range (maps are ~150 MB each — save + vectors and disparity statistics, not maps). + +.. _stereo_frames: + +Which frame the displacements are in +------------------------------------ + +The 2C PIV has to run in the frame the Jacobian :math:`J` was derived for, +and that is the **camera** frame: + +- PIV on the **dewarped** images measures the displacement in the *rectified* + (world-grid) frame, in grid pixels. That is the right frame for the + **disparity** (both cameras are on the same grid there, so the particles + match), but it is *not* the frame of :math:`J = \partial F/\partial X`: + feeding rectified displacements into ``reconstruct_3c`` silently mixes + frames and biases :math:`(U,V,W)`. +- PIV on the **raw** images measures the displacement in the camera frame, + which is exactly what ``reconstruct_3c`` expects. The price is that the two + cameras no longer share a grid, so the partner field has to be sampled at + the projected position of each particle + (``openpiv.examples.stereo.pipeline.reconstruct_3c`` does this: back-project + onto the sheet plane, project into the partner camera, interpolate). + +The sign convention of the disparity correction follows the same reasoning: +the left camera samples its map at :math:`W - D/2` and the right one at +:math:`W + D/2`, so that the particle lands on the same (midpoint) world node +in both cameras. With the opposite signs the correction *doubles* the +measured disparity instead of cancelling it. + +Conditioning +------------ + +With two cameras the system :math:`J\,V = d` is :math:`4 \times 3` and +rank-deficient in the least observable direction. For a typical DaVis +stereo pair the singular values of :math:`J` are of the order +:math:`3.4\cdot10^3`, :math:`15`, :math:`13`, and the weak direction is +**in-plane** (the two views are nearly aligned in :math:`X`), not the +out-of-plane one. The in-plane component along that weak direction is +therefore the least trustworthy number of the 3C field — check it against a +second, independent estimate before drawing conclusions from it. + +Example package +--------------- + +``openpiv.examples.stereo`` drives the whole workflow from a DaVis +acquisition directory: it parses the DaVis XML exports, fits the pair, +rectifies, self-calibrates the disparity and reconstructs the 3C field. + +.. code-block:: bash + + python -m openpiv.examples.stereo --data-root run \ + --mark-table run/calibration/MarkPositionTable.xml \ + --cameras 1,2 --sensor-size 2432,2048 all + +API +--- + +The ``openpiv.calibration.stereo`` module +----------------------------------------- + +.. automodule:: openpiv.calibration.stereo + :members: + +The ``openpiv.examples.stereo`` package +--------------------------------------- + +.. automodule:: openpiv.examples.stereo.pipeline + :members: + +.. automodule:: openpiv.examples.stereo.davis + :members: diff --git a/openpiv/examples/__init__.py b/openpiv/examples/__init__.py new file mode 100644 index 00000000..2a3a33c1 --- /dev/null +++ b/openpiv/examples/__init__.py @@ -0,0 +1,10 @@ +"""Runnable examples shipped with OpenPIV. + +The sub-packages here are thin, documented drivers around the library +functionality. They are importable (so they can be reused and tested) and +also runnable as scripts, e.g.:: + + python -m openpiv.examples.stereo --data-root /data/run calibrate +""" + +__all__ = ["stereo"] diff --git a/openpiv/examples/stereo/README.md b/openpiv/examples/stereo/README.md new file mode 100644 index 00000000..c022afd0 --- /dev/null +++ b/openpiv/examples/stereo/README.md @@ -0,0 +1,175 @@ +# Stereo-PIV example + +A worked example that drives `openpiv.calibration.stereo` over a DaVis +stereo data set: it calibrates the pair, rectifies both cameras onto a +common world grid, corrects the residual disparity (self-calibration), +runs the 2C PIV per camera and reconstructs the 3C velocity field. + +``` +openpiv.examples.stereo +├── config.py StereoConfig — every path and parameter in one place +├── davis.py readers for the DaVis XML exports +├── frames.py .ims / .im7 access (optional readim7 dependency) +└── pipeline.py the steps, as plain-array functions +``` + +## Data set + +The example expects a DaVis acquisition directory: + +``` +run/ +├── Camera1-1-001.ims raw 12-bit packed frame pairs +├── Camera2-1-002.ims +└── calibration/ + ├── MarkPositionTable.xml calibration marks (world + raw pixel) + └── Calibration.xml DaVis' own fit, for reference +``` + +`.ims` streams are read with +[readim7](https://github.com/alexlib/readim7) (optional dependency): + +```bash +pip install "readim7 @ git+https://github.com/alexlib/readim7.git" +``` + +The stream geometry normally comes from the small index file DaVis writes +next to the stream (`Camera-0.ims`). When that index is not a sibling +of the stream, pass the sensor size explicitly with `--sensor-size`. + +## Usage + +```bash +python -m openpiv.examples.stereo --data-root run \ + --mark-table run/calibration/MarkPositionTable.xml \ + --davis-calibration run/calibration/Calibration.xml \ + --cameras 1,2 --sensor-size 2432,2048 --sheet-z 0 all +``` + +Single steps (each writes into the output directory): + +| Step | Does | +|---|---| +| `info` | print the stream layout and the resolved configuration | +| `calibrate` | fit the stereo pair, report the RMS against the DaVis fit | +| `dewarp` | build the common world grid and the dewarp mappings | +| `disparity` | measure the particle disparity, self-calibrate the mappings | +| `piv` | 2C PIV per camera + 3C reconstruction | + +Every option can also be set through the environment, e.g. +`OPENPIV_STEREO_DATA_ROOT`, `OPENPIV_STEREO_CAMERAS`, +`OPENPIV_STEREO_SHEET_Z`, `OPENPIV_STEREO_MARK_TABLE`. + +## From Python + +```python +from openpiv.examples.stereo import ( + StereoConfig, parse_mark_position_table, fit_cameras, + build_world_grid, build_dewarp_maps, run_2c_piv, reconstruct_3c, +) + +config = StereoConfig.from_env() +marks = parse_mark_position_table(config.mark_table) +cams = fit_cameras(config.model, config.sensor_size, marks, config.cameras) +Xw, Yw, info = build_world_grid(cams, z=config.sheet_z) +maps = build_dewarp_maps(cams, Xw, Yw, z=config.sheet_z) +``` + +## Units + +* world coordinates, calibration plate geometry and velocities are in **mm**; +* the 3C output is in **mm per frame** — multiply by `1e3 / dt[s]` for m/s; +* pixel displacements of the 2C PIV are in **pixels**; +* `sheet_z` is the light-sheet plane in calibration world coordinates; the + dual-level plate used for the reference data has marks at `z = 0` and + `z = -3` mm. + +## Which frame the displacements are in + +`reconstruct_3c()` expects **camera pixel** displacements, because that is +the frame the calibration Jacobian `d(image)/d(world)` lives in. Two +consequences: + +* the **disparity** is measured on the *dewarped* images — there both + cameras share the common world grid, so the particles match; +* the **2C PIV** for the 3C reconstruction runs on the *raw* images, and the + partner field is sampled at the projected position of each particle + (`backproject_to_plane` → `project_points` → `sample_velocity_field`). + +Mixing the two frames (PIV on the rectified images, reconstruction in the +camera frame) biases the 3C field. + +## Reference numbers + +On the DaVis pitching-wing data set (upper stereo pair, cameras 1 and 2, +2432×2048, 300 pairs, dual-level LaVision 204-15 plate): + +| Step | Result | +|---|---| +| Calibration, camera 1 | RMS **0.408 px** (DaVis: 0.396 px) over 213 marks | +| Calibration, camera 2 | RMS **0.369 px** (DaVis: 0.361 px) over 254 marks | +| Common world grid @ z=0 | 2432×2048 nodes, **0.0850 mm/px**, X[-104.6, 102.1], Y[-139.6, 45.1] | +| Disparity, before | median \|D\| = **25.3 px** (bulk shift −4.2, −24.0 px) | +| Disparity, after | median \|D\| = **2.7 px** | + +The bulk shift is the offset between the calibration plane and the laser +sheet — exactly what the planar self-calibration corrects. + +## Comparing with a DaVis vector field + +`compare` takes the ``.vc7`` written by the DaVis StereoPIV module and +compares it with the reconstruction, on the grid of the reference: + +```bash +python -m openpiv.examples.stereo --data-root run \ + --mark-table run/calibration/MarkPositionTable.xml \ + --pair-index 298 --frame-dt 1.45e-3 \ + --davis-vector-field run/StereoPIV/B00299.vc7 compare +``` + +Two conventions have to be respected: + +* the DaVis components live in the **corrected image** frame, where the + image row grows downwards while the world Y axis points upwards — the + vertical component therefore has to be **flipped** to get the world + velocity (`VectorField.v_world` does that); +* the velocities are in m/s, ours in mm per frame — `frame_dt` (the + frame interval in seconds) does the conversion. + +With those in place, the reference and the reconstruction agree on the +reference data set: + +| Component | ours | DaVis | bias | RMSE | correlation | +|---|---|---|---|---|---| +| U | −0.0006 m/s | −0.0009 m/s | −0.008 | 0.121 | +0.35 | +| V | −0.0612 m/s | −0.0670 m/s | +0.006 | 0.082 | +0.56 | + +## The cycle survey + +`survey` measures the particle displacement of **every** frame pair, so +that you know how much signal a pair carries before comparing it with a +reference: + +```bash +python -m openpiv.examples.stereo --data-root run --survey-crop 600,400,1200,1200 survey +``` + +On the reference data set (300 pairs, 1.45 ms per frame, so 0.44 s of +recording): + +* the **vertical** motion is steady: median 1.3–1.6 px per frame, and its + sign never changes along the record; +* the **horizontal** motion is concentrated in ~10 % of the field (near + the wing): the median is 0.3–0.7 px, but the 90th percentile reaches + 1.9–2.8 px; +* the record spans 0.44 s, i.e. **22 % of one 0.5 Hz wing cycle** — there + is no "fast phase" to be found in it, the horizontal signal varies by + less than a factor of two. + +That is why a comparison at a single pair can look meaningless: in the +quiet regions the horizontal velocity is at the noise level, so its sign +is random and it anti-correlates with any reference. Judge the +horizontal component in the active region (the 90th percentile of the +displacement), not over the whole field. + + diff --git a/openpiv/examples/stereo/__init__.py b/openpiv/examples/stereo/__init__.py new file mode 100644 index 00000000..346a1311 --- /dev/null +++ b/openpiv/examples/stereo/__init__.py @@ -0,0 +1,72 @@ +"""Stereo-PIV example: calibration, rectification, disparity and 3C. + +The example drives :mod:`openpiv.calibration.stereo` over a DaVis stereo +data set: + +.. code-block:: bash + + python -m openpiv.examples.stereo --data-root /data/run \ + --mark-table /data/run/MarkPositionTable.xml calibrate + +See the module README for the full workflow. +""" + +from __future__ import annotations + +from .config import ENV_PREFIX, MODEL_CHOICES, StereoConfig +from .davis import ( + CameraMarks, + PlateInfo, + VectorField, + parse_calibration_rms, + parse_mark_position_table, + parse_plate_info, + read_vector_field, + vector_field_grid, +) +from .frames import ims_info, read_image, read_pair +from .pipeline import ( + MAP_NAMES, + build_dewarp_maps, + build_world_grid, + common_marks, + compare_fields, + correct_disparity, + fit_cameras, + make_camera, + reconstruct_3c, + rectify, + reprojection_report, + run_2c_piv, + triangulation_report, +) + +__all__ = [ + "ENV_PREFIX", + "MAP_NAMES", + "MODEL_CHOICES", + "CameraMarks", + "PlateInfo", + "StereoConfig", + "VectorField", + "build_dewarp_maps", + "build_world_grid", + "common_marks", + "compare_fields", + "correct_disparity", + "fit_cameras", + "ims_info", + "make_camera", + "parse_calibration_rms", + "parse_mark_position_table", + "parse_plate_info", + "read_image", + "read_pair", + "read_vector_field", + "reconstruct_3c", + "rectify", + "reprojection_report", + "run_2c_piv", + "triangulation_report", + "vector_field_grid", +] diff --git a/openpiv/examples/stereo/__main__.py b/openpiv/examples/stereo/__main__.py new file mode 100644 index 00000000..2664ef7d --- /dev/null +++ b/openpiv/examples/stereo/__main__.py @@ -0,0 +1,370 @@ +"""Command line driver of the stereo-PIV example. + +.. code-block:: bash + + python -m openpiv.examples.stereo --data-root /data/run \ + --mark-table /data/run/MarkPositionTable.xml \ + --davis-calibration /data/run/Calibration.xml all + +Every step writes its results into the output directory and can be run on +its own; ``all`` simply chains them in order. +""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Sequence +from pathlib import Path + +import numpy as np + +from . import davis, frames, pipeline +from .config import StereoConfig + +__all__ = ["STEPS", "main"] + +#: The pipeline steps, in execution order. +STEP_ORDER = ("calibrate", "dewarp", "disparity", "piv") + + +def _log(message: str) -> None: + print(message, flush=True) + + +def _require_marks(config: StereoConfig) -> dict[int, davis.CameraMarks]: + if config.mark_table is None: + raise SystemExit( + "no calibration marks given: pass --mark-table MarkPositionTable.xml" + ) + return davis.parse_mark_position_table(config.mark_table) + + +def _load_cameras(config: StereoConfig, marks: dict[int, davis.CameraMarks]): + cams = pipeline.fit_cameras(config.model, config.sensor_size, marks, config.cameras) + report = pipeline.reprojection_report(cams, marks, config.cameras) + reference: dict[int, float] = {} + if config.davis_calibration is not None and Path(config.davis_calibration).exists(): + reference = davis.parse_calibration_rms(config.davis_calibration) + for entry in report: + number = entry["camera"] + davis_rms = reference.get(number) + suffix = f" (DaVis {davis_rms:.4f} px)" if davis_rms is not None else "" + _log( + f"camera {number}: RMS {entry['rms']:.4f} px{suffix} | " + f"median {entry['median']:.3f} p95 {entry['p95']:.3f} " + f"max {entry['max']:.3f} over {entry['marks']} marks" + ) + return cams, report + + +def _read_pairs(config: StereoConfig) -> list[tuple[np.ndarray, np.ndarray]]: + """Read the (pulse A, pulse B) pair of every camera of the stereo pair.""" + result = [] + for number in config.cameras: + path = config.stream_path(number) + info = frames.ims_info(path, nx=config.sensor_size[0], ny=config.sensor_size[1]) + _log( + f"camera {number}: {path.name} -> {info['n_pairs']} pairs " + f"{info['nx']}x{info['ny']} (pair {config.pair_index})" + ) + pulse_a, pulse_b = frames.read_pair( + path, config.pair_index, nx=config.sensor_size[0], ny=config.sensor_size[1] + ) + result.append((pulse_a, pulse_b)) + return result + + +def step_info(config: StereoConfig) -> int: + """Print the stream layout and the resolved configuration.""" + _log(f"data root : {config.data_root}") + _log(f"cameras : {config.cameras}") + _log(f"sensor : {config.sensor_size[0]}x{config.sensor_size[1]}") + _log(f"sheet z : {config.sheet_z} mm") + _log(f"output : {config.out_dir}") + for number in config.cameras: + path = config.stream_path(number) + info = frames.ims_info(path, nx=config.sensor_size[0], ny=config.sensor_size[1]) + _log( + f"camera {number}: {path.name} | {info['n_pairs']} pairs | " + f"{info['nx']}x{info['ny']} | {info['pair_bytes']:,} bytes/pair" + ) + return 0 + + +def step_calibrate(config: StereoConfig) -> int: + """Fit the stereo pair to the DaVis marks.""" + marks = _require_marks(config) + cams, report = _load_cameras(config, marks) + out = config.ensure_out() + for cam in cams: + cam.save_parameters(str(out), f"{cam.name}_{config.model}") + (out / "reprojection.json").write_text(json.dumps(report, indent=2)) + _log(f"camera parameters -> {out}") + return 0 + + +def step_dewarp(config: StereoConfig) -> int: + """Rectify the stereo pair onto a common world grid.""" + marks = _require_marks(config) + cams, _ = _load_cameras(config, marks) + Xw, Yw, info = pipeline.build_world_grid(cams, z=config.sheet_z, stride=config.grid_stride) + _log( + f"grid {Xw.shape[1]}x{Xw.shape[0]} | " + f"X[{info['min_x']:.1f},{info['max_x']:.1f}] " + f"Y[{info['min_y']:.1f},{info['max_y']:.1f}] | " + f"{info['scale']:.4f} mm/px at z={info['z']}" + ) + maps = pipeline.build_dewarp_maps(cams, Xw, Yw, z=config.sheet_z) + out = config.ensure_out() + scalar = {key: value for key, value in info.items() if isinstance(value, (int, float))} + np.savez(out / "common_grid.npz", Xw=Xw, Yw=Yw, **scalar) + np.savez(out / "maps.npz", **maps) + _log(f"grid + dewarp maps -> {out}") + return 0 + + +def step_disparity(config: StereoConfig) -> int: + """Measure the particle disparity and self-calibrate the mappings.""" + marks = _require_marks(config) + cams, _ = _load_cameras(config, marks) + Xw, Yw, _ = pipeline.build_world_grid(cams, z=config.sheet_z, stride=config.grid_stride) + maps = pipeline.build_dewarp_maps(cams, Xw, Yw, z=config.sheet_z) + raw = _read_pairs(config) + corrected, report = pipeline.correct_disparity( + [pair[0] for pair in raw], maps, config.disparity_passes, config.self_cal_iterations + ) + _log( + f"disparity: median |D| {report['residual_before']:.3f} px -> " + f"{report['residual_after']:.3f} px | bulk shift " + f"({report['bulk_shift'][0]:.2f}, {report['bulk_shift'][1]:.2f}) px" + ) + out = config.ensure_out() + np.savez(out / "maps_corrected.npz", **corrected) + np.savez( + out / "disparity.npz", + x=report["x"], + y=report["y"], + dx=report["dx"], + dy=report["dy"], + s2n=report["s2n"], + ) + _log(f"corrected maps -> {out}") + return 0 + + +def step_piv(config: StereoConfig) -> int: + """Run the 2C PIV per camera and reconstruct the 3C velocity field. + + The 2C PIV runs on the *raw* camera images, so the displacements are + pixel displacements in the camera frame -- which is what the stereo + reconstruction needs. + """ + from openpiv import filters, validation + + marks = _require_marks(config) + cams, _ = _load_cameras(config, marks) + out = config.ensure_out() + raw = _read_pairs(config) + + results = [] + for number, (pulse_a, pulse_b) in zip(config.cameras, raw): + x, y, u, v, s2n = pipeline.run_2c_piv( + pulse_a.astype(np.float32), pulse_b.astype(np.float32), + config.piv_window, config.piv_overlap, + ) + results.append((x, y, u, v, s2n)) + _log(f"camera {number}: 2C grid {u.shape} | median s2n {np.nanmedian(s2n):.2f}") + + mask = np.zeros(results[0][2].shape, dtype=bool) + filled = [] + for _, _, u, v, _ in results: + flags = np.asarray( + validation.global_val( + np.ma.masked_invalid(u), np.ma.masked_invalid(v), (-10, 10), (-10, 10) + ) + ) + flags = flags | ~np.isfinite(u) | ~np.isfinite(v) + mask |= flags + u_filled, v_filled = filters.replace_outliers( + np.ma.array(u, mask=flags), + np.ma.array(v, mask=flags), + flags, + method="localmean", + ) + filled.append((np.asarray(u_filled), np.asarray(v_filled))) + _log(f"flagged {100 * mask.mean():.1f}% of the vectors") + + U, V, W = pipeline.reconstruct_3c( + cams, + [results[0][0], results[1][0]], + [results[0][1], results[1][1]], + [filled[0][0], filled[1][0]], + [filled[0][1], filled[1][1]], + z=config.sheet_z, + ) + U, V, W = (np.where(mask.ravel(), np.nan, value) for value in (U, V, W)) + world_x, world_y = pipeline.backproject_to_plane(cams[0], results[0][0], results[0][1], z=config.sheet_z) + _log( + f"3C: |U| {np.nanmedian(np.abs(U)):.3f} |V| {np.nanmedian(np.abs(V)):.3f} " + f"|W| {np.nanmedian(np.abs(W)):.3f} mm/frame" + ) + np.savez( + out / "stereo_3c.npz", + x=world_x, + y=world_y, + U=U, + V=V, + W=W, + mask=mask.ravel(), + ) + _log(f"3C field -> {out / 'stereo_3c.npz'}") + return 0 + + +def step_survey(config: StereoConfig) -> int: + """Measure the particle displacement of every frame pair. + + The result tells how much signal each pair carries -- a pair whose + displacement sits at the noise level cannot be compared with a + reference, because the sign of a noise dominated component is + random. + """ + from .survey import summarize_survey, survey_dispersion, survey_stream + + crop = config.survey_crop + records = survey_stream( + config, camera=config.cameras[0], crop=crop, progress=True + ) + summary = summarize_survey(records) + dispersion = survey_dispersion(records, noise_level=config.noise_level) + _log("") + _log(f"pairs surveyed : {summary['n_pairs']}") + _log(f"median displacement : {summary['median_speed']:.3f} px") + _log(f"max / min : {summary['max_speed']:.3f} / {summary['min_speed']:.3f} px") + _log(f"above noise ({dispersion['noise_level']} px) : " + f"{100 * dispersion['fraction_above_noise']:.0f}% of the pairs, " + f"{dispersion['sign_changes']} sign changes") + _log("strongest pairs:") + for record in summary["best"]: + _log( + f" pair {record['pair']:4d}: median |u| {record['median_speed']:.3f} px " + f"(u {record['median_u']:+.3f}, v {record['median_v']:+.3f})" + ) + out = config.ensure_out() + with (out / "survey.csv").open("w") as handle: + handle.write("pair,camera,median_u,median_v,median_speed,n_vectors\n") + for record in records: + handle.write( + f"{record['pair']},{record['camera']},{record['median_u']:.6f}," + f"{record['median_v']:.6f},{record['median_speed']:.6f}," + f"{record['n_vectors']}\n" + ) + _log(f"survey -> {out / 'survey.csv'}") + return 0 + + +def step_compare(config: StereoConfig) -> int: + """Compare the reconstruction with a DaVis vector field. + + The reference is a ``.vc7`` written by the DaVis StereoPIV module. + Its components live in the *corrected image* frame, so the vertical + component has to be flipped to get the world-frame velocity. + """ + if config.davis_vector_field is None: + raise SystemExit("no reference given: pass --davis-vector-field FILE.vc7") + + marks = _require_marks(config) + cams, _ = _load_cameras(config, marks) + raw = _read_pairs(config) + fields = [] + for pulse_a, pulse_b in raw: + fields.append( + pipeline.run_2c_piv( + pulse_a.astype(np.float32), + pulse_b.astype(np.float32), + config.piv_window, + config.piv_overlap, + ) + ) + U, V, W = pipeline.reconstruct_3c( + cams, + [fields[0][0], fields[1][0]], + [fields[0][1], fields[1][1]], + [fields[0][2], fields[1][2]], + [fields[0][3], fields[1][3]], + z=config.sheet_z, + ) + # mm per frame -> m/s (the frame rate has to be given) + to_ms = 1e-3 / config.frame_dt if config.frame_dt else 1.0 + world_x, world_y = pipeline.backproject_to_plane( + cams[0], fields[0][0], fields[0][1], z=config.sheet_z + ) + reference = davis.read_vector_field(config.davis_vector_field) + _log(f"reference: {config.davis_vector_field.name} " + f"({reference.u.shape[1]}x{reference.u.shape[0]} vectors, " + f"{100 * reference.valid.mean():.0f}% valid)") + for name, ours, theirs in ( + ("U", U * to_ms, reference.u_world), + ("V", V * to_ms, reference.v_world), + ("W", W * to_ms, reference.w), + ): + stats = pipeline.compare_fields( + ours, theirs, reference.x, reference.y, world_x, world_y, reference.valid + ) + _log( + f" {name}: ours {stats['median_ours']:+.4f} | " + f"reference {stats['median_theirs']:+.4f} m/s | " + f"bias {stats['bias']:+.4f} | RMSE {stats['rmse']:.4f} | " + f"corr {stats['correlation']:+.3f} | n {stats['n']}" + ) + return 0 + + +STEPS = { + "info": step_info, + "calibrate": step_calibrate, + "dewarp": step_dewarp, + "disparity": step_disparity, + "piv": step_piv, + "survey": step_survey, + "compare": step_compare, +} + + +def _split_step(argv: Sequence[str]) -> tuple[str | None, list[str]]: + """Separate the step keyword from the configuration arguments.""" + step: str | None = None + rest: list[str] = [] + for token in argv: + if step is None and not token.startswith("-") and token in (*STEPS, "all"): + step = token + else: + rest.append(token) + return step, rest + + +def main(argv: Sequence[str] | None = None) -> int: + """Entry point of ``python -m openpiv.examples.stereo``.""" + raw_argv = list(sys.argv[1:] if argv is None else argv) + step, rest = _split_step(raw_argv) + if step is None: + print("usage: python -m openpiv.examples.stereo STEP [options]", file=sys.stderr) + print(f"steps: {', '.join(sorted(STEPS))}, all", file=sys.stderr) + return 2 + config = StereoConfig.from_args(rest) + selected = list(STEP_ORDER) if step == "all" else [step] + try: + for name in selected: + _log(f"--- {name} ---") + STEPS[name](config) + except SystemExit: + raise + except (FileNotFoundError, KeyError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/openpiv/examples/stereo/config.py b/openpiv/examples/stereo/config.py new file mode 100644 index 00000000..e13b3c56 --- /dev/null +++ b/openpiv/examples/stereo/config.py @@ -0,0 +1,314 @@ +"""Configuration for the stereo-PIV example pipeline. + +The example is driven by a single :class:`StereoConfig` object. It can be +built in three ways: + +* :meth:`StereoConfig.from_env` -- read defaults from ``OPENPIV_STEREO_*`` + environment variables (handy for shell/CI usage); +* :meth:`StereoConfig.from_args` -- parse a command line; +* directly in Python -- keyword arguments of the dataclass. + +No path is hard-coded: every file the pipeline touches is either given +explicitly or derived from :attr:`StereoConfig.data_root`. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field, replace +from pathlib import Path + +__all__ = ["ENV_PREFIX", "StereoConfig"] + +ENV_PREFIX = "OPENPIV_STEREO_" + +#: Camera model used for the stereo pair. ``poly`` is the Soloff model that +#: reproduces the DaVis calibration of the reference data set; ``dlt`` and +#: ``pinhole`` are supported for comparison. +MODEL_CHOICES = ("poly", "dlt", "pinhole") + + +def _env_path(name: str, default: Path | None = None) -> Path | None: + value = os.environ.get(ENV_PREFIX + name) + if value: + return Path(value).expanduser() + return default + + +def _env_int(name: str, default: int) -> int: + value = os.environ.get(ENV_PREFIX + name) + return int(value) if value else default + + +def _env_float(name: str, default: float) -> float: + value = os.environ.get(ENV_PREFIX + name) + return float(value) if value else default + + +def _env_cameras(name: str, default: Sequence[int]) -> tuple[int, ...]: + value = os.environ.get(ENV_PREFIX + name) + if not value: + return tuple(default) + cams = tuple(int(part) for part in value.replace(",", " ").split()) + if len(cams) < 2: + raise ValueError(f"{ENV_PREFIX}{name} needs at least two cameras, got {cams}") + return cams + + +@dataclass(frozen=True) +class StereoConfig: + """Everything the stereo-PIV example needs to run. + + Attributes + ---------- + data_root: + Directory holding the raw camera streams. + cameras: + Camera numbers forming the stereo pair, e.g. ``(1, 2)``. + ims_files: + Mapping ``camera number -> .ims stream path``. When empty the + streams are looked up as ``Camera-.ims`` inside + :attr:`data_root`. + mark_table: + DaVis ``MarkPositionTable.xml`` export (calibration marks). + davis_calibration: + DaVis ``Calibration.xml`` export (used only to report the + reference reprojection RMS). + sensor_size: + ``(nx, ny)`` of the camera sensor in pixels. + sheet_z: + Light-sheet plane in calibration world coordinates (mm). + pair_index: + Index of the ``.ims`` frame pair to process. + output_dir: + Directory that receives the intermediate arrays and figures. + disparity_passes: + ``(window, overlap)`` pairs for the disparity estimation, coarse + to fine. + self_cal_iterations: + Number of disparity self-calibration iterations on the finest pass. + piv_window, piv_overlap: + Interrogation window and overlap of the 2C PIV pass. + model: + Camera model used for the stereo pair, see :data:`MODEL_CHOICES`. + grid_stride: + Pixel stride used when back-projecting the common world grid. + davis_vector_field: + A DaVis ``.vc7`` vector field used by the ``compare`` step. + frame_dt: + Frame interval in seconds, needed to convert mm/frame into m/s. + survey_crop: + ``(x0, y0, width, height)`` box the survey measures in. + noise_level: + Displacement (pixels) above which a pair counts as carrying a + signal, used by the survey. + """ + + data_root: Path + cameras: tuple[int, ...] = (1, 2) + ims_files: Mapping[int, Path] = field(default_factory=dict) + mark_table: Path | None = None + davis_calibration: Path | None = None + sensor_size: tuple[int, int] = (2432, 2048) + sheet_z: float = 0.0 + pair_index: int = 0 + output_dir: Path | None = None + disparity_passes: tuple[tuple[int, int], ...] = ((128, 64), (64, 32)) + self_cal_iterations: int = 3 + piv_window: int = 64 + piv_overlap: int = 32 + model: str = "poly" + grid_stride: int = 4 + davis_vector_field: Path | None = None + frame_dt: float | None = None + survey_crop: tuple[int, int, int, int] | None = None + noise_level: float = 0.1 + + def __post_init__(self) -> None: + if len(self.cameras) < 2: + raise ValueError(f"stereo needs at least two cameras, got {self.cameras}") + if self.model not in MODEL_CHOICES: + raise ValueError(f"model must be one of {MODEL_CHOICES}, got {self.model!r}") + if not self.disparity_passes: + raise ValueError("at least one disparity pass is required") + # accept strings for the paths + object.__setattr__(self, "data_root", Path(self.data_root).expanduser()) + object.__setattr__(self, "cameras", tuple(int(camera) for camera in self.cameras)) + object.__setattr__(self, "ims_files", { + int(camera): Path(path) for camera, path in dict(self.ims_files).items() + }) + for name in ("mark_table", "davis_calibration", "output_dir"): + value = getattr(self, name) + if value is not None: + object.__setattr__(self, name, Path(value).expanduser()) + object.__setattr__( + self, "sensor_size", (int(self.sensor_size[0]), int(self.sensor_size[1])) + ) + + # ------------------------------------------------------------------ + # derived paths + # ------------------------------------------------------------------ + @property + def out_dir(self) -> Path: + """Directory that receives outputs (created on demand).""" + return self.output_dir if self.output_dir is not None else self.data_root / "stereo_out" + + def ensure_out(self, sub: str = "") -> Path: + """Return (and create) the output directory, optionally a sub-folder.""" + target = self.out_dir / sub if sub else self.out_dir + target.mkdir(parents=True, exist_ok=True) + return target + + def stream_path(self, camera: int) -> Path: + """Path of the ``.ims`` stream of ``camera``.""" + if camera in self.ims_files: + return Path(self.ims_files[camera]) + matches = sorted(self.data_root.glob(f"Camera{camera}-*.ims")) + if not matches: + raise FileNotFoundError( + f"no .ims stream for camera {camera} in {self.data_root}" + ) + # the stream dwarfs the small index file, keep the largest + return max(matches, key=lambda p: p.stat().st_size) + + def with_data_root(self, root: str | os.PathLike[str]) -> StereoConfig: + """Return a copy that reads from another data root.""" + return replace(self, data_root=Path(root)) + + # ------------------------------------------------------------------ + # constructors + # ------------------------------------------------------------------ + @classmethod + def from_env(cls, data_root: str | os.PathLike[str] | None = None) -> StereoConfig: + """Build a configuration from ``OPENPIV_STEREO_*`` environment variables. + + Recognised variables: ``DATA_ROOT``, ``CAMERAS``, ``IMS_FILES`` (a + ``1=path,2=path`` mapping), ``MARK_TABLE``, ``DAVIS_CALIBRATION``, + ``SENSOR_SIZE`` (``"nx,ny"``), ``SHEET_Z``, ``PAIR_INDEX``, + ``OUTPUT_DIR``, ``SELF_CAL_ITERATIONS``, ``PIV_WINDOW``, + ``PIV_OVERLAP`` and ``MODEL``. + """ + root = Path( + data_root + if data_root is not None + else _env_path("DATA_ROOT", Path.cwd()) + ).expanduser() + + ims_files: dict[int, Path] = {} + raw = os.environ.get(ENV_PREFIX + "IMS_FILES", "") + for item in raw.split(","): + if not item.strip(): + continue + key, _, value = item.partition("=") + ims_files[int(key.strip())] = Path(value.strip()).expanduser() + + sensor = os.environ.get(ENV_PREFIX + "SENSOR_SIZE", "") + if sensor: + parts = [int(p) for p in sensor.replace(",", " ").split()] + if len(parts) != 2: + raise ValueError(f"{ENV_PREFIX}SENSOR_SIZE must be 'nx,ny', got {sensor!r}") + sensor_size = (parts[0], parts[1]) + else: + sensor_size = (2432, 2048) + + return cls( + data_root=root, + cameras=_env_cameras("CAMERAS", (1, 2)), + ims_files=ims_files, + mark_table=_env_path("MARK_TABLE"), + davis_calibration=_env_path("DAVIS_CALIBRATION"), + sensor_size=sensor_size, + sheet_z=_env_float("SHEET_Z", 0.0), + pair_index=_env_int("PAIR_INDEX", 0), + output_dir=_env_path("OUTPUT_DIR"), + self_cal_iterations=_env_int("SELF_CAL_ITERATIONS", 3), + piv_window=_env_int("PIV_WINDOW", 64), + piv_overlap=_env_int("PIV_OVERLAP", 32), + model=os.environ.get(ENV_PREFIX + "MODEL", "poly"), + davis_vector_field=_env_path("DAVIS_VECTOR_FIELD"), + frame_dt=_env_float("FRAME_DT", 0.0) or None, + noise_level=_env_float("NOISE_LEVEL", 0.1), + ) + + @classmethod + def from_args( + cls, argv: Sequence[str] | None = None, env: Mapping[str, str] | None = None + ) -> StereoConfig: + """Build a configuration from a command line (see ``__main__``).""" + import argparse + + parser = argparse.ArgumentParser( + prog="openpiv.examples.stereo", + description="Stereo-PIV example: calibrate, dewarp, disparity, 3C.", + ) + parser.add_argument("--data-root", type=Path, default=None, help="directory with the .ims streams") + parser.add_argument("--cameras", type=str, default=None, help="stereo pair, e.g. '1,2'") + parser.add_argument("--ims-files", type=str, default=None, help="mapping '1=path,2=path'") + parser.add_argument("--mark-table", type=Path, default=None, help="DaVis MarkPositionTable.xml") + parser.add_argument("--davis-calibration", type=Path, default=None, help="DaVis Calibration.xml") + parser.add_argument("--sensor-size", type=str, default=None, help="sensor size 'nx,ny'") + parser.add_argument("--sheet-z", type=float, default=None, help="light sheet plane in mm") + parser.add_argument("--pair-index", type=int, default=None, help="frame pair to process") + parser.add_argument("--output-dir", type=Path, default=None, help="output directory") + parser.add_argument("--model", choices=MODEL_CHOICES, default=None, help="camera model") + parser.add_argument("--piv-window", type=int, default=None) + parser.add_argument("--piv-overlap", type=int, default=None) + parser.add_argument("--self-cal-iterations", type=int, default=None) + parser.add_argument("--davis-vector-field", type=Path, default=None, + help="DaVis .vc7 vector field to compare with") + parser.add_argument("--frame-dt", type=float, default=None, + help="frame interval in seconds (mm/frame -> m/s)") + parser.add_argument("--survey-crop", type=str, default=None, + help="crop box 'x0,y0,width,height' for the survey") + parser.add_argument("--noise-level", type=float, default=None, + help="noise level in pixels used by the survey") + args = parser.parse_args(list(argv) if argv is not None else None) + + if env is not None: + for key, value in env.items(): + os.environ[ENV_PREFIX + key] = value + + config = cls.from_env(args.data_root) + updates: dict[str, object] = {} + if args.cameras: + updates["cameras"] = tuple(int(c) for c in args.cameras.replace(",", " ").split()) + if args.ims_files: + mapping: dict[int, Path] = {} + for item in args.ims_files.split(","): + if not item.strip(): + continue + key, _, value = item.partition("=") + mapping[int(key.strip())] = Path(value.strip()).expanduser() + updates["ims_files"] = mapping + if args.mark_table is not None: + updates["mark_table"] = args.mark_table + if args.davis_calibration is not None: + updates["davis_calibration"] = args.davis_calibration + if args.sensor_size: + parts = [int(p) for p in args.sensor_size.replace(",", " ").split()] + updates["sensor_size"] = (parts[0], parts[1]) + if args.sheet_z is not None: + updates["sheet_z"] = args.sheet_z + if args.pair_index is not None: + updates["pair_index"] = args.pair_index + if args.output_dir is not None: + updates["output_dir"] = args.output_dir + if args.model is not None: + updates["model"] = args.model + if args.piv_window is not None: + updates["piv_window"] = args.piv_window + if args.piv_overlap is not None: + updates["piv_overlap"] = args.piv_overlap + if args.self_cal_iterations is not None: + updates["self_cal_iterations"] = args.self_cal_iterations + if args.davis_vector_field is not None: + updates["davis_vector_field"] = args.davis_vector_field + if args.frame_dt is not None: + updates["frame_dt"] = args.frame_dt + if args.survey_crop: + parts = [int(p) for p in args.survey_crop.replace(",", " ").split()] + updates["survey_crop"] = tuple(parts) + if args.noise_level is not None: + updates["noise_level"] = args.noise_level + return replace(config, **updates) if updates else config diff --git a/openpiv/examples/stereo/davis.py b/openpiv/examples/stereo/davis.py new file mode 100644 index 00000000..34e7f077 --- /dev/null +++ b/openpiv/examples/stereo/davis.py @@ -0,0 +1,335 @@ +"""Readers for the DaVis XML exports used by the stereo-PIV example. + +Two export files are understood: + +``MarkPositionTable.xml`` + The calibration marks of every camera: for each mark the *world* + position on the calibration plate (mm) and the *raw* (distorted) + pixel position in the image. + +``Calibration.xml`` + The camera models DaVis fitted itself. Only the reported + reprojection RMS per camera is read here, so that the fit done by + OpenPIV can be compared against the DaVis reference. + +Both readers work on plain XML text, so they can be unit tested with +small synthetic fixtures and do not require the DaVis software. +""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + +__all__ = [ + "CameraMarks", + "PlateInfo", + "VectorField", + "parse_calibration_rms", + "parse_mark_position_table", + "parse_plate_info", + "parse_scales", + "read_vector_field", + "vector_field_grid", +] + + +@dataclass(frozen=True) +class CameraMarks: + """Calibration marks of a single camera. + + Attributes + ---------- + camera: + DaVis camera number. + object_points: + ``(3, n)`` world positions of the marks in mm. + image_points: + ``(2, n)`` raw (distorted) pixel positions of the marks. + """ + + camera: int + object_points: np.ndarray + image_points: np.ndarray + + def __post_init__(self) -> None: + if self.object_points.shape[0] != 3: + raise ValueError("object_points must have shape (3, n)") + if self.image_points.shape[0] != 2: + raise ValueError("image_points must have shape (2, n)") + if self.object_points.shape[1] != self.image_points.shape[1]: + raise ValueError("object_points and image_points must have the same length") + + @property + def count(self) -> int: + """Number of marks.""" + return int(self.object_points.shape[1]) + + def planes(self) -> dict[float, np.ndarray]: + """Indices of the marks grouped by their world ``z`` plane.""" + z = self.object_points[2] + return {float(value): np.flatnonzero(np.isclose(z, value)) for value in np.unique(z)} + + +@dataclass(frozen=True) +class PlateInfo: + """Geometry of the calibration plate as exported by DaVis.""" + + identifier: str + mark_spacing_mm: float + mark_size_mm: float + plate_thickness_mm: float + plane_to_plane_distance_mm: float + + +def _parse_float(value: str | None) -> float: + if value is None: + raise ValueError("missing numeric attribute") + return float(value) + + +def parse_mark_position_table(path: str | Path) -> dict[int, CameraMarks]: + """Parse a DaVis ``MarkPositionTable.xml`` export. + + Parameters + ---------- + path: + Path of the XML file. + + Returns + ------- + dict + ``camera number -> CameraMarks``. + """ + root = ET.parse(Path(path)).getroot() + marks: dict[int, CameraMarks] = {} + for camera_el in root.findall("Camera"): + number = int(camera_el.attrib["CameraNumber"]) + world: list[list[float]] = [[], [], []] + raw: list[list[float]] = [[], []] + for view in camera_el.findall("View"): + for mark in view.findall("Mark"): + world_el = mark.find("WorldPos") + raw_el = mark.find("RawPos") + if world_el is None or raw_el is None: + continue + world[0].append(_parse_float(world_el.attrib.get("x"))) + world[1].append(_parse_float(world_el.attrib.get("y"))) + world[2].append(_parse_float(world_el.attrib.get("z"))) + raw[0].append(_parse_float(raw_el.attrib.get("x"))) + raw[1].append(_parse_float(raw_el.attrib.get("y"))) + if not world[0]: + continue + marks[number] = CameraMarks( + camera=number, + object_points=np.array(world, dtype=np.float64), + image_points=np.array(raw, dtype=np.float64), + ) + if not marks: + raise ValueError(f"no camera with marks found in {path}") + return marks + + +def parse_calibration_rms(path: str | Path) -> dict[int, float]: + """Parse the per-camera reprojection RMS from a DaVis ``Calibration.xml``. + + Returns + ------- + dict + ``camera number -> RMS in pixels``. Cameras without a reported fit + error are omitted. + """ + root = ET.parse(Path(path)).getroot() + result: dict[int, float] = {} + for mapper in root.findall(".//CoordinateMapper"): + identifier = mapper.attrib.get("CameraIdentifier") + if identifier is None: + continue + fit_error = mapper.find(".//CommonParameters/FitError") + if fit_error is None: + continue + rms = fit_error.attrib.get("RMS") + if rms is None: + continue + result[int(identifier)] = float(rms) + return result + + +def parse_plate_info(path: str | Path) -> PlateInfo: + """Parse the calibration plate geometry from ``MarkPositionTable.xml``.""" + root = ET.parse(Path(path)).getroot() + return PlateInfo( + identifier=root.attrib.get("CalibrationPlateIdentifier", ""), + mark_spacing_mm=_parse_float(root.attrib.get("DistanceMarkToMarkInMm", "nan")), + mark_size_mm=_parse_float(root.attrib.get("MarkSizeMm", "nan")), + plate_thickness_mm=_parse_float(root.attrib.get("PlateThicknessMm", "nan")), + plane_to_plane_distance_mm=_parse_float(root.attrib.get("PlaneToPlaneDistanceMm", "nan")), + ) + + +@dataclass(frozen=True) +class VectorField: + """A DaVis vector field (``.vc7``) with its world grid. + + The components are stored in the *corrected image* frame of DaVis: + ``u`` points to the right, ``v`` points **down** the image and ``w`` + is the out-of-plane component. DaVis' corrected image has + ``y = offset_y - step * row`` (the ``_SCALE_Y`` factor is negative), + i.e. the world Y axis points *up* while the image row grows + downwards. The world-frame vertical velocity is therefore + ``V_world = -v``. + + Attributes + ---------- + u, v, w: + The velocity components in m/s, shaped ``(ny, nx)``. + x, y: + The world coordinates (mm) of the grid columns and rows. + valid: + Mask of the vectors DaVis actually computed (the invalid ones are + stored as zero). + """ + + u: np.ndarray + v: np.ndarray + w: np.ndarray + x: np.ndarray + y: np.ndarray + valid: np.ndarray + + @property + def u_world(self) -> np.ndarray: + """Horizontal velocity in the world frame (m/s).""" + return self.u + + @property + def v_world(self) -> np.ndarray: + """Vertical velocity in the world frame (m/s). + + The sign is flipped with respect to :attr:`v`: the DaVis + corrected image counts its rows downwards, the world Y axis + points upwards. + """ + return -self.v + + +def vector_field_grid( + scale_x: float, offset_x: float, scale_y: float, offset_y: float, + shape: tuple[int, int], spacing_px: float = 16.0, +) -> tuple[np.ndarray, np.ndarray]: + """World grid of a DaVis vector field. + + Parameters + ---------- + scale_x, offset_x: + The ``_SCALE_X`` factor (mm per pixel) and offset (mm). + scale_y, offset_y: + The ``_SCALE_Y`` factor and offset. + shape: + ``(ny, nx)`` of the field. + spacing_px: + Grid spacing in pixels (16 for the 4x64/75% DaVis recipe). + + Returns + ------- + (x, y) + The world coordinates (mm) of the columns and of the rows. The + rows follow the image: the last row is the smallest ``y`` when + ``scale_y`` is negative. + """ + ny, nx = shape + step = abs(scale_x) * spacing_px + x = offset_x + step * np.arange(nx) + y = offset_y + np.sign(scale_y) * step * np.arange(ny) + return x, y + + +def parse_scales(attributes: Mapping[str, str]) -> dict: + """Extract the scale factors of a DaVis vector field. + + Parameters + ---------- + attributes: + The attribute dictionary of a ``.vc7`` file; every entry is a + multi line string whose first line holds the value and the + second one the offset. + + Returns + ------- + dict + ``scale`` (velocity, m/s per unit), ``scale_x``, ``offset_x``, + ``scale_y`` and ``offset_y``. + """ + + def _numbers(*names: str) -> tuple[float, float]: + for name in names: + if name in attributes: + break + else: + raise KeyError(f"none of {names} in the vector field attributes") + lines = str(attributes[name]).split("\n") + tokens = lines[0].split() + value = float(tokens[0]) + if len(tokens) > 1: + # "_SCALE_X: ' '" + return value, float(tokens[1]) + # "FrameScaleX0: '' / '' on the next line" + offset = float(lines[1].split()[0]) if len(lines) > 1 else 0.0 + return value, offset + + scale, _ = _numbers("_SCALE_I", "FrameScaleI0") + scale_x, offset_x = _numbers("_SCALE_X", "FrameScaleX0") + scale_y, offset_y = _numbers("_SCALE_Y", "FrameScaleY0") + return { + "scale": scale, + "scale_x": scale_x, + "offset_x": offset_x, + "scale_y": scale_y, + "offset_y": offset_y, + } + + +def read_vector_field(path: str | Path, spacing_px: float = 16.0) -> VectorField: + """Read a DaVis vector field (``.vc7``) with its world grid. + + The velocities are converted to m/s with the ``_SCALE_I`` factor of + the file; the world grid is rebuilt from the ``_SCALE_X`` / + ``_SCALE_Y`` factors and offsets. + + Parameters + ---------- + path: + The ``.vc7`` file. + spacing_px: + Grid spacing in pixels, 16 for the ``4x64x64_75`` DaVis recipe. + + Returns + ------- + VectorField + """ + try: + import readim7 + except ImportError as exc: # pragma: no cover - depends on the environment + raise ImportError( + "reading DaVis vector fields needs the readim7 package: " + 'pip install "readim7 @ git+https://github.com/alexlib/readim7.git"' + ) from exc + + buffer_, attributes = readim7.get_Buffer_andAttributeList(str(path)) + array, _ = readim7.buffer_as_array(buffer_) + array = np.asarray(array, dtype=np.float64) + if array.ndim != 3 or array.shape[0] < 3: + raise ValueError(f"{path} does not hold a 3-component vector field") + + scales = parse_scales(attributes) + scale = scales["scale"] + scale_x, offset_x = scales["scale_x"], scales["offset_x"] + scale_y, offset_y = scales["scale_y"], scales["offset_y"] + + u, v, w = array[0] * scale, array[1] * scale, array[2] * scale + x, y = vector_field_grid(scale_x, offset_x, scale_y, offset_y, u.shape, spacing_px) + return VectorField(u=u, v=v, w=w, x=x, y=y, valid=u != 0) diff --git a/openpiv/examples/stereo/frames.py b/openpiv/examples/stereo/frames.py new file mode 100644 index 00000000..5f2c130b --- /dev/null +++ b/openpiv/examples/stereo/frames.py @@ -0,0 +1,94 @@ +"""Frame access for the stereo-PIV example. + +DaVis camera streams (``.ims``) and images (``.im7``) are read with +`readim7 `_, which is an *optional* +dependency: it is only imported when a frame is actually requested. + +``.ims`` streams are raw, header-less 12-bit packed frame pairs. Their +geometry normally comes from the small index file that DaVis writes next +to the stream (``Camera-0.ims``). When that index is not a sibling of +the stream, the sensor size has to be supplied by the caller. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np + +__all__ = ["ims_info", "read_image", "read_pair"] + + +def _readim7() -> Any: + try: + import readim7 + except ImportError as exc: # pragma: no cover - depends on the environment + raise ImportError( + "reading DaVis files needs the readim7 package: " + 'pip install "readim7 @ git+https://github.com/alexlib/readim7.git"' + ) from exc + return readim7 + + +def ims_info(path: str | Path, nx: int | None = None, ny: int | None = None) -> dict: + """Describe a DaVis camera stream. + + Parameters + ---------- + path: + The ``.ims`` stream or the run folder holding it. + nx, ny: + Sensor size in pixels. Only needed when no index file sits next + to the stream; otherwise the geometry is read from the index. + + Returns + ------- + dict + The ``readim7.ims_info`` record. + """ + readim7 = _readim7() + if nx is None or ny is None: + return readim7.ims_info(str(path)) + return readim7.ims_info(str(path), nx=int(nx), ny=int(ny)) + + +def read_pair( + path: str | Path, + pair_index: int = 0, + nx: int | None = None, + ny: int | None = None, +) -> tuple[np.ndarray, np.ndarray]: + """Read one dual-frame pair from a DaVis camera stream. + + Parameters + ---------- + path: + The ``.ims`` stream. + pair_index: + Zero based index of the frame pair. + nx, ny: + Sensor size in pixels, see :func:`ims_info`. + + Returns + ------- + (pulse_a, pulse_b) + Two ``uint16`` arrays of shape ``(ny, nx)``. + """ + readim7 = _readim7() + info = ims_info(path, nx=nx, ny=ny) + return readim7.read_ims_pair(info, int(pair_index)) + + +def read_image(path: str | Path) -> np.ndarray: + """Read a DaVis ``.im7`` image (or image sequence) as an array. + + Returns + ------- + numpy.ndarray + ``(ny, nx)`` for a single frame, ``(nf, ny, nx)`` for a sequence. + """ + readim7 = _readim7() + buffer_, _ = readim7.get_Buffer_andAttributeList(str(path)) + array, _ = readim7.buffer_as_array(buffer_) + return np.asarray(array) diff --git a/openpiv/examples/stereo/pipeline.py b/openpiv/examples/stereo/pipeline.py new file mode 100644 index 00000000..68e3bd1b --- /dev/null +++ b/openpiv/examples/stereo/pipeline.py @@ -0,0 +1,597 @@ +"""The stereo-PIV pipeline. + +The pipeline is a thin, well documented layer on top of +:mod:`openpiv.calibration.stereo` and :mod:`openpiv.pyprocess`. It is +deliberately split into small functions that only depend on plain arrays, +so that every step can be tested without the raw camera data: + +1. :func:`fit_cameras` -- fit the stereo pair to the DaVis marks; +2. :func:`build_world_grid` + :func:`build_dewarp_maps` -- rectify both + cameras onto a common world grid at the light-sheet plane; +3. :func:`correct_disparity` -- estimate the particle disparity and + apply the (Wieneke style) self-calibration correction; +4. :func:`run_2c_piv` + :func:`reconstruct_3c` -- 2C PIV per camera and + the 3C reconstruction of the out-of-plane velocity component. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +import numpy as np + +from openpiv.calibration import stereo + +from .davis import CameraMarks + +__all__ = [ + "backproject_to_plane", + "build_dewarp_maps", + "build_world_grid", + "common_marks", + "correct_disparity", + "fit_cameras", + "make_camera", + "projection_jacobian", + "reconstruct_3c", + "rectify", + "reprojection_report", + "run_2c_piv", + "sample_velocity_field", + "triangulation_report", +] + +#: Names of the four dewarp maps of a stereo pair. +MAP_NAMES = ("mLx", "mLy", "mRx", "mRy") + + +def make_camera(model: str, name: str, resolution: tuple[int, int]) -> Any: + """Create an empty camera of the requested model. + + Parameters + ---------- + model: + ``"poly"`` (Soloff), ``"dlt"`` or ``"pinhole"``. + name: + Name of the camera instance. + resolution: + ``(nx, ny)`` sensor size in pixels. + """ + if model == "poly": + from openpiv.calibration import poly_model + + return poly_model.camera(name, resolution=resolution) + if model == "dlt": + from openpiv.calibration import dlt_model + + return dlt_model.camera(name, resolution=resolution) + if model == "pinhole": + from openpiv.calibration import pinhole_model + + return pinhole_model.camera(name, resolution=resolution) + raise ValueError(f"unknown camera model {model!r}") + + +def fit_cameras( + model: str, + resolution: tuple[int, int], + marks: Mapping[int, CameraMarks], + cameras: Sequence[int], +) -> list[Any]: + """Fit one camera per stereo-pair member to its calibration marks. + + Parameters + ---------- + model: + Camera model, see :func:`make_camera`. + resolution: + ``(nx, ny)`` sensor size in pixels. + marks: + ``camera number -> CameraMarks``. + cameras: + The stereo pair, e.g. ``(1, 2)``. + + Returns + ------- + list + The fitted cameras, in the order of ``cameras``. + """ + fitted = [] + for number in cameras: + if number not in marks: + raise KeyError(f"no calibration marks for camera {number}") + camera_marks = marks[number] + cam = make_camera(model, f"cam{number}", resolution) + cam.minimize_params(camera_marks.object_points, camera_marks.image_points) + fitted.append(cam) + return fitted + + +def reprojection_report( + cams: Sequence[Any], marks: Mapping[int, CameraMarks], cameras: Sequence[int] +) -> list[dict]: + """Reprojection statistics of the fitted cameras. + + Each entry holds the RMS error, the median/p95/max residual and the + number of marks, so that an OpenPIV fit can be compared with the + DaVis reference. + """ + from openpiv.calibration import calib_utils + + report = [] + for number, cam in zip(cameras, cams): + camera_marks = marks[number] + rms = float(calib_utils.get_reprojection_error(cam, camera_marks.object_points, camera_marks.image_points)) + projected = cam.project_points(camera_marks.object_points) + residual = np.hypot( + np.asarray(projected[0]) - camera_marks.image_points[0], + np.asarray(projected[1]) - camera_marks.image_points[1], + ) + report.append( + { + "camera": number, + "rms": rms, + "median": float(np.median(residual)), + "p95": float(np.percentile(residual, 95)), + "max": float(residual.max()), + "marks": camera_marks.count, + } + ) + return report + + +def build_world_grid( + cams: Sequence[Any], z: float = 0.0, stride: int = 4 +) -> tuple[np.ndarray, np.ndarray, dict]: + """Common world grid of the stereo pair at the light-sheet plane. + + Returns + ------- + (Xw, Yw, info) + World coordinates of the grid nodes (mm) and a dictionary with the + grid bounds, the mm-per-pixel scale and the plane. + """ + return stereo.get_common_world_grid(list(cams), z=float(z), stride=int(stride)) + + +def build_dewarp_maps( + cams: Sequence[Any], Xw: np.ndarray, Yw: np.ndarray, z: float = 0.0 +) -> dict[str, np.ndarray]: + """Dewarp mappings of the stereo pair for every node of the world grid. + + Returns + ------- + dict + ``{"mLx": ..., "mLy": ..., "mRx": ..., "mRy": ...}`` -- the image + coordinates of every world node, per camera. + """ + maps: dict[str, np.ndarray] = {} + for prefix, cam in zip(("mL", "mR"), cams): + map_x, map_y = stereo.get_dewarp_mapping(cam, Xw, Yw, z=float(z)) + maps[f"{prefix}x"] = map_x + maps[f"{prefix}y"] = map_y + return maps + + +def rectify(frame: np.ndarray, map_x: np.ndarray, map_y: np.ndarray) -> np.ndarray: + """Rectify one camera frame onto the world grid.""" + return stereo.dewarp_image(np.asarray(frame, dtype=np.float32), map_x, map_y) + + +def _rectified_pair( + frames: Sequence[np.ndarray], maps: Mapping[str, np.ndarray] +) -> tuple[np.ndarray, np.ndarray]: + left = rectify(frames[0], maps["mLx"], maps["mLy"]) + right = rectify(frames[1], maps["mRx"], maps["mRy"]) + return left, right + + +def _median_residual(dx: np.ndarray, dy: np.ndarray) -> float: + mask = np.isfinite(dx) & np.isfinite(dy) + if not np.any(mask): + return float("inf") + return float(np.median(np.hypot(dx[mask], dy[mask]))) + + +def correct_disparity( + frames: Sequence[np.ndarray], + maps: Mapping[str, np.ndarray], + disparity_passes: Sequence[tuple[int, int]] = ((128, 64), (64, 32)), + iterations: int = 3, +) -> tuple[dict[str, np.ndarray], dict]: + """Estimate the particle disparity and self-calibrate the mappings. + + The disparity of the dewarped pair is measured with the finest pass + (this is the reference residual) and with a coarse pass, whose median + gives a robust bulk misregistration. The bulk part is corrected + first, then ``iterations`` symmetric half-corrections are applied on + the finest pass. The map *fields* are resampled, never shifted by a + constant. + + Parameters + ---------- + frames: + The two raw camera frames of the same instant. + maps: + The dewarp mappings, see :func:`build_dewarp_maps`. + disparity_passes: + ``(window, overlap)`` pairs, coarse to fine. + iterations: + Number of refinement iterations on the finest pass. + + Returns + ------- + (maps, report) + The corrected mappings and a report with the residuals before and + after the correction. + """ + current = {key: np.asarray(value, dtype=np.float64) for key, value in maps.items()} + fine_window, fine_overlap = disparity_passes[-1] + + # reference residual: measured with the finest pass, before any correction + left, right = _rectified_pair(frames, current) + x, y, dx, dy, s2n = stereo.estimate_disparity( + left, right, window_size=fine_window, overlap=fine_overlap + ) + residual_before = _median_residual(dx, dy) + + # a coarse pass gives a robust bulk shift (larger window, more particles) + coarse_window, coarse_overlap = disparity_passes[0] + left, right = _rectified_pair(frames, current) + x_coarse, y_coarse, dx_coarse, dy_coarse, _ = stereo.estimate_disparity( + left, right, window_size=coarse_window, overlap=coarse_overlap + ) + bulk_x = float(np.nanmedian(dx_coarse)) + bulk_y = float(np.nanmedian(dy_coarse)) + current = dict( + zip( + MAP_NAMES, + stereo.correct_maps( + (current["mLx"], current["mLy"], current["mRx"], current["mRy"]), + x_coarse, + y_coarse, + np.full_like(dx_coarse, bulk_x), + np.full_like(dy_coarse, bulk_y), + factor=0.5, + ), + ) + ) + + history = [] + for iteration in range(max(0, iterations)): + left, right = _rectified_pair(frames, current) + x, y, dx, dy, s2n = stereo.estimate_disparity( + left, right, window_size=fine_window, overlap=fine_overlap + ) + history.append({"iteration": iteration, "residual": _median_residual(dx, dy)}) + current = dict( + zip( + MAP_NAMES, + stereo.correct_maps( + (current["mLx"], current["mLy"], current["mRx"], current["mRy"]), + x, + y, + dx, + dy, + factor=0.5, + ), + ) + ) + + left, right = _rectified_pair(frames, current) + x, y, dx, dy, s2n = stereo.estimate_disparity( + left, right, window_size=fine_window, overlap=fine_overlap + ) + report = { + "residual_before": residual_before, + "residual_after": _median_residual(dx, dy), + "bulk_shift": (bulk_x, bulk_y), + "history": history, + "x": x, + "y": y, + "dx": dx, + "dy": dy, + "s2n": s2n, + } + return current, report + + +def run_2c_piv( + frame_a: np.ndarray, frame_b: np.ndarray, window_size: int = 64, overlap: int = 32 +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """2C PIV of a rectified frame pair. + + Returns + ------- + (x, y, u, v, s2n) + The interrogation grid and the measured displacements (pixels) and + signal-to-noise ratios. + """ + from openpiv import pyprocess + + image_a = np.ascontiguousarray(frame_a, dtype=np.float32) + image_b = np.ascontiguousarray(frame_b, dtype=np.float32) + u, v, s2n = pyprocess.extended_search_area_piv( + image_a, + image_b, + window_size=int(window_size), + overlap=int(overlap), + search_area_size=int(window_size), + ) + shape = tuple(int(s) for s in pyprocess.get_field_shape(image_a.shape, int(window_size), int(overlap))) + x, y = pyprocess.get_rect_coordinates(image_a.shape, int(window_size), int(overlap)) + return ( + x, + y, + np.asarray(u).reshape(shape), + np.asarray(v).reshape(shape), + np.asarray(s2n).reshape(shape), + ) + + +def backproject_to_plane( + cam: Any, x: np.ndarray, y: np.ndarray, z: float = 0.0 +) -> tuple[np.ndarray, np.ndarray]: + """World position of the image pixels ``(x, y)`` on the plane ``z``. + + Returns + ------- + (X, Y) + The world coordinates (mm) of the back-projected pixels. + """ + pixels = np.asarray(x, dtype=np.float64).ravel() + rows = np.asarray(y, dtype=np.float64).ravel() + world_x, world_y, _ = cam.project_to_z( + [pixels, rows], np.full(pixels.size, float(z), dtype=np.float64) + ) + return np.asarray(world_x, dtype=np.float64), np.asarray(world_y, dtype=np.float64) + + +def projection_jacobian(cam: Any, world: np.ndarray, eps: float = 1e-4) -> np.ndarray: + """Derivative ``d(image)/d(world)`` of a camera, shape ``(3, 2)``. + + Computed with central differences; the result is the linearisation + used to turn a world velocity into a pixel displacement. + """ + world = np.asarray(world, dtype=np.float64).ravel() + jacobian = np.zeros((3, 2)) + for axis in range(3): + step = np.zeros(3) + step[axis] = eps * (1.0 + abs(world[axis])) + forward = np.asarray(cam.project_points((world + step)[:, None]), dtype=np.float64) + backward = np.asarray(cam.project_points((world - step)[:, None]), dtype=np.float64) + jacobian[axis] = ((forward[0, 0] - backward[0, 0]) / (2 * step[axis]), + (forward[1, 0] - backward[1, 0]) / (2 * step[axis])) + return jacobian + + +def sample_velocity_field( + x: np.ndarray, + y: np.ndarray, + u: np.ndarray, + v: np.ndarray, + points: np.ndarray, +) -> tuple[np.ndarray, np.ndarray]: + """Interpolate a PIV velocity field at arbitrary image positions. + + Parameters + ---------- + x, y: + The (2D) interrogation grid of the field. + u, v: + The velocity components on that grid. + points: + ``(n, 2)`` array of ``(x, y)`` image positions. + + Returns + ------- + (u_at_points, v_at_points) + The interpolated velocity components. + """ + from scipy.interpolate import RegularGridInterpolator + + x_axis = np.asarray(x)[0, :] + y_axis = np.asarray(y)[:, 0] + interpolator_u = RegularGridInterpolator( + (y_axis, x_axis), np.asarray(u, dtype=np.float64), bounds_error=False, fill_value=np.nan + ) + interpolator_v = RegularGridInterpolator( + (y_axis, x_axis), np.asarray(v, dtype=np.float64), bounds_error=False, fill_value=np.nan + ) + sample = np.column_stack([np.asarray(points)[:, 1], np.asarray(points)[:, 0]]) + return interpolator_u(sample), interpolator_v(sample) + + +def reconstruct_3c( + cams: Sequence[Any], + x_list: Sequence[np.ndarray], + y_list: Sequence[np.ndarray], + u_list: Sequence[np.ndarray], + v_list: Sequence[np.ndarray], + z: float = 0.0, + dt: float = 1.0, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Combine the 2C views of a stereo pair into the 3C velocity field. + + The 2C PIV is run on the *raw* camera images, so ``u`` and ``v`` are + pixel displacements in the camera frame. The particles of the second + camera are found by projecting the world position of every particle of + the first camera (back-projected onto the plane ``z``) into the second + camera and sampling its velocity field there. + + Parameters + ---------- + cams: + The stereo pair. + x_list, y_list: + Interrogation grids of the two cameras (raw image pixels). + u_list, v_list: + The measured pixel displacements of the two cameras. + z: + Light-sheet plane in world coordinates (mm). + dt: + Time between the two frames. + + Returns + ------- + (U, V, W) + The world velocity components (mm per time unit) on the grid of + the first camera. Nodes whose counterpart in another camera falls + outside the interrogation grid are returned as ``nan``. + """ + if not (len(cams) == len(x_list) == len(y_list) == len(u_list) == len(v_list)): + raise ValueError("cams, x, y, u and v must have the same length") + world_x, world_y = backproject_to_plane(cams[0], x_list[0], y_list[0], z=z) + world = np.array([world_x, world_y, np.full(world_x.size, float(z))]) + + # the same particle, seen by the other cameras + matched_u: list[np.ndarray] = [np.asarray(u_list[0], dtype=np.float64).ravel()] + matched_v: list[np.ndarray] = [np.asarray(v_list[0], dtype=np.float64).ravel()] + valid = np.isfinite(matched_u[0]) & np.isfinite(matched_v[0]) + for index in range(1, len(cams)): + projected = cams[index].project_points(world) + points = np.column_stack( + [np.asarray(projected[0], dtype=np.float64), np.asarray(projected[1], dtype=np.float64)] + ) + sampled_u, sampled_v = sample_velocity_field( + x_list[index], y_list[index], u_list[index], v_list[index], points + ) + matched_u.append(sampled_u) + matched_v.append(sampled_v) + valid &= np.isfinite(sampled_u) & np.isfinite(sampled_v) + + u, v, w = stereo.reconstruct_3c( + list(cams), + list(x_list), + list(y_list), + matched_u, + matched_v, + world, + dt=float(dt), + ) + return ( + np.where(valid, u, np.nan), + np.where(valid, v, np.nan), + np.where(valid, w, np.nan), + ) + + +def common_marks( + first: CameraMarks, second: CameraMarks +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Marks seen by both cameras, matched by their world position. + + Returns + ------- + (world, image_first, image_second) + ``(3, n)`` world positions and the two ``(2, n)`` image positions. + """ + lookup = { + (round(float(x), 6), round(float(y), 6), round(float(z), 6)): index + for index, (x, y, z) in enumerate(first.object_points.T) + } + shared: list[tuple[int, int]] = [] + for index, (x, y, z) in enumerate(second.object_points.T): + key = (round(float(x), 6), round(float(y), 6), round(float(z), 6)) + if key in lookup: + shared.append((lookup[key], index)) + if not shared: + raise ValueError("the two cameras have no marks in common") + first_idx = np.array([i for i, _ in shared], dtype=int) + second_idx = np.array([j for _, j in shared], dtype=int) + world = first.object_points[:, first_idx] + return world, first.image_points[:, first_idx], second.image_points[:, second_idx] + + +def triangulation_report( + cams: Sequence[Any], first: CameraMarks, second: CameraMarks +) -> dict: + """Triangulate the common marks and report the 3D error. + + This is the decisive stereo check: the marks are known in world + coordinates, so the deviation of the triangulated positions from them + quantifies the stereo geometry. + + Returns + ------- + dict + ``median``, ``p95`` and ``max`` error in mm plus the per-plane + breakdown and the number of common marks. + """ + world, image_first, image_second = common_marks(first, second) + triangulated = stereo.triangulate(list(cams), [image_first, image_second]) + error = np.linalg.norm(triangulated - world, axis=0) + planes = {} + for z_value in np.unique(world[2]): + mask = np.isclose(world[2], z_value) + planes[f"z={float(z_value):g}"] = { + "count": int(mask.sum()), + "median": float(np.median(error[mask])), + } + return { + "count": int(error.size), + "median": float(np.median(error)), + "p95": float(np.percentile(error, 95)), + "max": float(error.max()), + "planes": planes, + } + + +def compare_fields( + ours: np.ndarray, + theirs: np.ndarray, + their_x: np.ndarray, + their_y: np.ndarray, + our_x: np.ndarray, + our_y: np.ndarray, + valid: np.ndarray | None = None, +) -> dict: + """Compare a reconstructed field with a reference on the reference grid. + + The reconstructed field is interpolated onto the grid of the + reference, then the two are compared where the reference is valid. + + Parameters + ---------- + ours: + The reconstructed velocity component (1D, one value per node). + theirs: + The reference component, shaped ``(ny, nx)``. + their_x, their_y: + The world coordinates (mm) of the reference grid. + our_x, our_y: + The world coordinates (mm) of the reconstructed nodes. + valid: + Mask of the valid reference vectors; ``None`` uses ``theirs != 0``. + + Returns + ------- + dict + ``median_ours``, ``median_theirs``, ``bias``, ``rmse`` and + ``correlation``. + """ + from scipy.interpolate import LinearNDInterpolator + + grid_x, grid_y = np.meshgrid(np.asarray(their_x), np.asarray(their_y)) + interpolated = LinearNDInterpolator( + np.column_stack([np.asarray(our_x), np.asarray(our_y)]), + np.asarray(ours, dtype=np.float64), + fill_value=np.nan, + )(grid_x, grid_y) + reference = np.asarray(theirs, dtype=np.float64) + if valid is None: + valid = reference != 0 + mask = np.asarray(valid, dtype=bool) & np.isfinite(interpolated) + if not np.any(mask): + raise ValueError("no overlapping valid vectors to compare") + a = interpolated[mask] + b = reference[mask] + return { + "median_ours": float(np.median(a)), + "median_theirs": float(np.median(b)), + "bias": float(np.mean(a - b)), + "rmse": float(np.sqrt(np.mean((a - b) ** 2))), + "correlation": float(np.corrcoef(a, b)[0, 1]) if a.size > 2 else float("nan"), + "n": int(mask.sum()), + } diff --git a/openpiv/examples/stereo/survey.py b/openpiv/examples/stereo/survey.py new file mode 100644 index 00000000..f03120fc --- /dev/null +++ b/openpiv/examples/stereo/survey.py @@ -0,0 +1,183 @@ +"""Cycle survey: how fast is the flow in every frame pair? + +Before comparing a stereo-PIV result with a reference it pays to know +*where in the cycle* the measurement was taken. Near a flow reversal the +in-plane velocity passes through zero, and a component that is dominated +by noise there carries no information -- it can even appear to +anti-correlate with a reference computed at the same instant. + +The survey runs the ordinary 2C PIV on every frame pair of a stream and +reports the median particle displacement, which is a cheap proxy for the +signal strength of that pair. + +The 2C PIV runs on the **raw** frames, the same as +:func:`openpiv.examples.stereo.pipeline.run_2c_piv` used by the 3C +reconstruction, so the numbers are directly comparable with it. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np + +from .config import StereoConfig +from .frames import read_pair +from .pipeline import run_2c_piv + +__all__ = ["summarize_survey", "survey_pair", "survey_stream"] + + +def _crop(frame: np.ndarray, crop: Sequence[int] | None) -> np.ndarray: + """Cut a ``(x0, y0, width, height)`` box out of the frame.""" + if crop is None: + return frame + x0, y0, width, height = (int(value) for value in crop) + return frame[y0:y0 + height, x0:x0 + width] + + +def survey_pair( + config: StereoConfig, + pair_index: int, + camera: int = 1, + crop: Sequence[int] | None = None, + window: int = 64, + overlap: int = 32, +) -> dict: + """Measure the particle displacement of a single frame pair. + + Parameters + ---------- + config: + The stereo configuration; the stream of ``camera`` is read. + pair_index: + Index of the frame pair in the stream. + camera: + Which camera of the stereo pair to measure. + crop: + Optional ``(x0, y0, width, height)`` box, to concentrate the + measurement on the region of interest (and to go faster). + window, overlap: + Interrogation window and overlap of the 2C PIV. + + Returns + ------- + dict + ``pair``, ``median_u``, ``median_v``, ``median_speed`` (pixels), + ``n_vectors`` and the pair index. + """ + path = config.stream_path(camera) + pulse_a, pulse_b = read_pair( + path, pair_index, nx=config.sensor_size[0], ny=config.sensor_size[1] + ) + frame_a = _crop(pulse_a, crop).astype(np.float32) + frame_b = _crop(pulse_b, crop).astype(np.float32) + _, _, u, v, _ = run_2c_piv(frame_a, frame_b, window, overlap) + valid = np.isfinite(u) & np.isfinite(v) + if not np.any(valid): + return { + "pair": pair_index, + "camera": camera, + "median_u": float("nan"), + "median_v": float("nan"), + "median_speed": float("nan"), + "n_vectors": 0, + } + speed = np.hypot(u[valid], v[valid]) + return { + "pair": pair_index, + "camera": camera, + "median_u": float(np.median(u[valid])), + "median_v": float(np.median(v[valid])), + "median_speed": float(np.median(speed)), + "n_vectors": int(valid.sum()), + } + + +def survey_stream( + config: StereoConfig, + camera: int = 1, + pairs: Sequence[int] | None = None, + crop: Sequence[int] | None = None, + window: int = 64, + overlap: int = 32, + progress: bool = True, +) -> list[dict]: + """Measure the displacement of every frame pair of a stream. + + Returns + ------- + list of dict + One record per pair, see :func:`survey_pair`. + """ + if pairs is None: + from .frames import ims_info + + info = ims_info( + config.stream_path(camera), + nx=config.sensor_size[0], + ny=config.sensor_size[1], + ) + pairs = range(int(info["n_pairs"])) + records = [] + for index in pairs: + record = survey_pair(config, int(index), camera, crop, window, overlap) + records.append(record) + if progress: + print( + f"pair {record['pair']:4d}: median |u| {record['median_speed']:.3f} px " + f"(u {record['median_u']:+.3f}, v {record['median_v']:+.3f}) " + f"n {record['n_vectors']}", + flush=True, + ) + return records + + +def summarize_survey(records: Sequence[dict]) -> dict: + """Summarize a survey: the strongest pairs and the cycle statistics. + + Returns + ------- + dict + ``best`` (the records with the largest displacement, strongest + first), ``weakest``, ``median_speed`` and the pair range. + """ + ordered = sorted(records, key=lambda record: record["median_speed"], reverse=True) + speeds = np.array([record["median_speed"] for record in records], dtype=float) + finite = speeds[np.isfinite(speeds)] + return { + "best": ordered[:5], + "worst": ordered[-5:], + "median_speed": float(np.median(finite)) if finite.size else float("nan"), + "max_speed": float(finite.max()) if finite.size else float("nan"), + "min_speed": float(finite.min()) if finite.size else float("nan"), + "n_pairs": len(records), + } + + +def survey_dispersion( + records: Sequence[dict], noise_level: float = 0.05 +) -> dict: + """How many pairs carry a signal above the noise level? + + The in-plane velocity of a noise-dominated pair changes sign from one + pair to the next; a pair with a real signal keeps its sign over a + stretch of the cycle. The fraction of pairs whose signed + displacement is larger than ``noise_level`` pixels is a simple + measure of how much of the record is usable. + + Returns + ------- + dict + ``fraction_above_noise``, ``noise_level`` and the sign changes. + """ + signed = np.array([record["median_u"] for record in records], dtype=float) + finite = signed[np.isfinite(signed)] + above = finite[np.abs(finite) > noise_level] + signs = np.sign(above) + changes = int(np.sum(signs[1:] != signs[:-1])) if signs.size > 1 else 0 + return { + "fraction_above_noise": float(above.size / finite.size) if finite.size else 0.0, + "noise_level": noise_level, + "sign_changes": changes, + } diff --git a/openpiv/filters.py b/openpiv/filters.py index e4b6eb81..6b2f088b 100644 --- a/openpiv/filters.py +++ b/openpiv/filters.py @@ -1,8 +1,8 @@ """The openpiv.filters module contains some filtering/smoothing routines.""" -from typing import Tuple, Optional + import numpy as np -import numpy.typing as npt from scipy.signal import convolve + from openpiv.lib import replace_nans __licence_ = """ @@ -47,10 +47,9 @@ def _gaussian_kernel(half_width: int=1)-> np.ndarray: if half_width == 0: return 1 - else: - x, y = np.mgrid[-half_width:half_width + 1, -half_width:half_width + 1] - g = np.exp(-(x ** 2 / float(half_width) + y ** 2 / float(half_width))) - + x, y = np.mgrid[-half_width:half_width + 1, -half_width:half_width + 1] + g = np.exp(-(x ** 2 / float(half_width) + y ** 2 / float(half_width))) + return g / g.sum() @@ -65,16 +64,15 @@ def gaussian_kernel(sigma:float, truncate:float=4.0)->np.ndarray: sigma = sigma ** 2 k = 2 * np.exp(-0.5 * (x ** 2 + y ** 2) / sigma) - k = k / np.sum(k) + return k / np.sum(k) - return k def gaussian( u: np.ndarray, v: np.ndarray, half_width: int=1 - )->Tuple[np.ndarray, np.ndarray]: + )->tuple[np.ndarray, np.ndarray]: """Smooths the velocity field with a Gaussian kernel. Parameters @@ -108,12 +106,12 @@ def replace_outliers( u: np.ndarray, v: np.ndarray, flags: np.ndarray, - w: Optional[np.ndarray]=None, + w: np.ndarray | None=None, method: str="localmean", max_iter: int=5, tol: float=1e-3, kernel_size: int=1, - )-> Tuple[np.ndarray, ...]: + )-> tuple[np.ndarray, ...]: """Replace invalid vectors in an velocity field using an iterative image inpainting algorithm. @@ -173,7 +171,7 @@ def replace_outliers( # we shall now replace NaNs only at flags positions, # regardless the grid_mask (which is a user-provided masked region) - + has_mask = isinstance(u, np.ma.MaskedArray) grid_mask = np.ma.getmaskarray(u).copy() if has_mask else np.ma.nomask @@ -182,7 +180,7 @@ def replace_outliers( u_data[flags] = np.nan v_data[flags] = np.nan - + uf = replace_nans( u_data, method=method, max_iter=max_iter, tol=tol, kernel_size=kernel_size @@ -192,7 +190,7 @@ def replace_outliers( kernel_size=kernel_size ) - + uf = np.ma.masked_array(uf, mask=grid_mask) vf = np.ma.masked_array(vf, mask=grid_mask) @@ -205,5 +203,5 @@ def replace_outliers( ) wf = np.ma.masked_array(wf, mask=grid_mask) return uf, vf, wf - + return uf, vf diff --git a/openpiv/lib.py b/openpiv/lib.py index 3674995a..85096f2c 100644 --- a/openpiv/lib.py +++ b/openpiv/lib.py @@ -133,8 +133,7 @@ def replace_nans(array, max_iter, tol, kernel_size=2, method="disk"): # check if replaced elements are below a certain tolerance if np.mean((replaced_new - replaced_old) ** 2) < tol: break - else: - replaced_old = replaced_new + replaced_old = replaced_new return filled diff --git a/openpiv/phase_separation.py b/openpiv/phase_separation.py index 76fbfcc0..dda5ea87 100644 --- a/openpiv/phase_separation.py +++ b/openpiv/phase_separation.py @@ -19,8 +19,8 @@ import numpy as np import scipy.ndimage -from skimage.morphology import disk, erosion, dilation, opening from skimage.measure import label +from skimage.morphology import dilation, disk, erosion, opening def opening_method(original_image, kernel_size, iterations=1, thresh_factor=1.1): @@ -30,7 +30,7 @@ def opening_method(original_image, kernel_size, iterations=1, thresh_factor=1.1) small particle traces, leaving only bigger particles in the image. The image of small particles is also generated by using the big particles - image as a mask on the original image. A threshold process is used to + image as a mask on the original image. A threshold process is used to intensify the edges of particles in the mask. Parameters @@ -65,9 +65,9 @@ def opening_method(original_image, kernel_size, iterations=1, thresh_factor=1.1) # Apply opening filter, resulting big particles image kernel = disk((kernel_size - 1) / 2) temp_image = original_image - for _ in range(0, iterations): + for _ in range(iterations): temp_image = erosion(temp_image, kernel) - for _ in range(0, iterations): + for _ in range(iterations): temp_image = dilation(temp_image, kernel) big_particles_img = temp_image @@ -103,7 +103,7 @@ def opening_method(original_image, kernel_size, iterations=1, thresh_factor=1.1) def median_filter_method(original_image, kernel_size): """Extract separated images using a median filter - + Proposed by Kiger & Pan. Original paper: Kiger, K. T., & Pan, C. (2000). PIV technique for the simultaneous measurement of dilute two-phase flows. @@ -114,7 +114,7 @@ def median_filter_method(original_image, kernel_size): ---------- original_image : np.ndarray Original two-phase input image - + kernel_size : int Filter stencil width (must be an odd number). Denoted by *Nf* in Kiger & Pan. @@ -147,7 +147,7 @@ def khalitov_longmire( opening_ksize=3, ): """Extract separated images using the method proposed by Khalitov & Longmire, 2002. - + For detailed information see: Khalitov, D., Longmire, E. Simultaneous two-phase PIV by two-parameter phase discrimination. @@ -158,7 +158,7 @@ def khalitov_longmire( ---------- original_image : np.ndarray Original two-phase input image - + big_particles_criteria : { 'min_size' : int, ['max_size' : int,] @@ -248,13 +248,13 @@ def khalitov_longmire( # Generate big particles mask big_particles_mask = np.zeros(original_image.shape, dtype=np.uint8) - for i in range(0, N): + for i in range(N): if is_region_a_big_particle[i]: big_particles_mask[labels_image == i] = 1 # Generate unidentifed particles mask (for efficiency reasons) unindentified_particles_mask = np.zeros(original_image.shape, dtype=np.uint8) - for i in range(0, N): + for i in range(N): if (not is_region_a_big_particle[i]) and (not is_region_a_small_particle[i]): unindentified_particles_mask[labels_image == i] = 1 @@ -374,13 +374,13 @@ def get_size_brightness_map( rounded_brightness = np.uint8(np.round(brightness_array)) # Count size-brightness - for i in range(0, N): + for i in range(N): if size_array[i] >= MAX_PARTICLE_SIZE: continue n[size_array[i], rounded_brightness[i]] += 1 # Calculate total signal density map: - SIZE, BRIGHTNESS = np.meshgrid(range(0, 256), range(0, MAX_PARTICLE_SIZE + 1)) + SIZE, BRIGHTNESS = np.meshgrid(range(256), range(MAX_PARTICLE_SIZE + 1)) with np.errstate(divide="ignore"): density_map = np.log10(SIZE * BRIGHTNESS * n) density_map[np.isneginf(density_map)] = 0 @@ -393,12 +393,12 @@ def khalitov_longmire_analyse_particle_segments(original_image, object_pixels): label_image, N = label(object_pixels, connectivity=1, return_num=True) # Integrate 1 over regions - size_array = scipy.ndimage.sum(1, label_image, index=range(0, N + 1)) + size_array = scipy.ndimage.sum(1, label_image, index=range(N + 1)) size_array = np.uint16(size_array) # Integrate intensity over regions total_light_array = scipy.ndimage.sum( - original_image, label_image, index=range(0, N + 1) + original_image, label_image, index=range(N + 1) ) total_light_array = np.float64(total_light_array) @@ -445,7 +445,7 @@ def khalitov_longmire_get_object_pixels( # Apply conditions for object pixels object_pixels = ( (X_deriv2 < 0) & (Y_deriv2 < 0) & (D45_deriv2 < 0) & (D135_deriv2 < 0) - ) | (I > I_sat) + ) | (I_sat < I) object_pixels_uint8 = np.uint8(object_pixels) # Apply opening filter to remove noisy dots diff --git a/openpiv/piv.py b/openpiv/piv.py index a393852d..5c98435e 100644 --- a/openpiv/piv.py +++ b/openpiv/piv.py @@ -1,10 +1,10 @@ -import numpy as np -import matplotlib.pyplot as plt - -from openpiv import pyprocess, tools, validation, filters from importlib.resources import files import matplotlib.animation as animation +import matplotlib.pyplot as plt +import numpy as np + +from openpiv import filters, pyprocess, tools, validation """This module contains high-level PIV processing functions that combine various steps of the PIV analysis into convenient workflows.""" @@ -24,7 +24,7 @@ """ -def simple_piv(im1, im2, window_size=32, overlap=16, search_area_size=32, +def simple_piv(im1, im2, window_size=32, overlap=16, search_area_size=32, dt=1.0, plot=True, validation_method=None, s2n_thresh=1.3, backend="auto"): """ @@ -73,17 +73,17 @@ def simple_piv(im1, im2, window_size=32, overlap=16, search_area_size=32, # Perform PIV analysis u, v, s2n = pyprocess.extended_search_area_piv( - im1.astype(np.int32), im2.astype(np.int32), + im1.astype(np.int32), im2.astype(np.int32), window_size=window_size, - overlap=overlap, + overlap=overlap, search_area_size=search_area_size, backend=backend, ) - + # Get coordinates x, y = pyprocess.get_coordinates( image_size=im1.shape, - search_area_size=search_area_size, + search_area_size=search_area_size, overlap=overlap ) @@ -95,7 +95,7 @@ def simple_piv(im1, im2, window_size=32, overlap=16, search_area_size=32, else: # Default validation using bottom 5% of s2n values valid = s2n > np.percentile(s2n, 5) - + # Replace outliers if np.any(~valid): u, v = filters.replace_outliers(u, v, ~valid) @@ -106,9 +106,9 @@ def simple_piv(im1, im2, window_size=32, overlap=16, search_area_size=32, ax.imshow(im1, cmap=plt.get_cmap("gray"), alpha=0.5, origin="upper") ax.quiver(x[valid], y[valid], u[valid], -v[valid], scale=70, color='r', width=.005) - plt.title('Velocity field (dt={:.3f})'.format(dt)) + plt.title(f'Velocity field (dt={dt:.3f})') plt.show() - + # Transform coordinates to match windef and tools.display_vector_field x, y, u, v = tools.transform_coordinates(x, y, u, v) @@ -118,17 +118,17 @@ def simple_piv(im1, im2, window_size=32, overlap=16, search_area_size=32, def piv_example(plot_animation=True, plot_quiver=True): """ Demonstrate PIV analysis using example vortex data. - + This function loads example images from the package data, performs PIV analysis, and displays the results. - + Parameters ---------- plot_animation : bool, optional Whether to display an animation of the image pair, default is True plot_quiver : bool, optional Whether to display quiver plots of the results, default is True - + Returns ------- x : 2d np.ndarray @@ -142,7 +142,7 @@ def piv_example(plot_animation=True, plot_quiver=True): """ # Load example images im1 = files('openpiv.data').joinpath('test1/exp1_001_a.bmp') - im2 = files('openpiv.data').joinpath('test1/exp1_001_b.bmp') + im2 = files('openpiv.data').joinpath('test1/exp1_001_b.bmp') frame_a = tools.imread(im1) frame_b = tools.imread(im2) @@ -151,14 +151,15 @@ def piv_example(plot_animation=True, plot_quiver=True): if plot_animation: images = [frame_a, frame_b] fig, ax = plt.subplots() - + ims = [] for i in range(2): im = ax.imshow(images[i % 2], animated=True, cmap="gray") ims.append([im]) - ani = animation.ArtistAnimation(fig, ims, interval=500, blit=False, - repeat_delay=0) + # the animation object must stay referenced while the window is open + _ani = animation.ArtistAnimation(fig, ims, interval=500, blit=False, + repeat_delay=0) plt.title('Image pair animation') plt.show() @@ -166,17 +167,17 @@ def piv_example(plot_animation=True, plot_quiver=True): window_size = 32 search_area_size = 64 overlap = 8 - + u, v, s2n = pyprocess.extended_search_area_piv( - frame_a.astype(np.int32), frame_b.astype(np.int32), + frame_a.astype(np.int32), frame_b.astype(np.int32), window_size=window_size, search_area_size=search_area_size, overlap=overlap ) - + x, y = pyprocess.get_coordinates( image_size=frame_a.shape, - search_area_size=search_area_size, + search_area_size=search_area_size, overlap=overlap ) @@ -186,7 +187,7 @@ def piv_example(plot_animation=True, plot_quiver=True): ax[0].imshow(frame_a, cmap=plt.get_cmap("gray"), alpha=0.8) ax[0].quiver(x, y, u, -v, scale=50, color="r") ax[0].set_title('Original orientation') - + ax[1].quiver(x, y[::-1, :], u, -1*v, scale=50, color="b") ax[1].set_title('Flipped y-axis') ax[1].set_aspect(1) @@ -195,19 +196,19 @@ def piv_example(plot_animation=True, plot_quiver=True): # Transform coordinates for consistency with other functions x, y, u, v = tools.transform_coordinates(x, y, u, v) - + return x, y, u, v -def process_pair(frame_a, frame_b, window_size=32, overlap=16, +def process_pair(frame_a, frame_b, window_size=32, overlap=16, search_area_size=32, dt=1.0, validation_method='sig2noise', - s2n_threshold=1.3, filter_method='localmean', + s2n_threshold=1.3, filter_method='localmean', filter_kernel_size=1, plot=False, backend="auto"): """ Complete PIV processing workflow for a single image pair. - + This function performs PIV analysis with validation and filtering. - + Parameters ---------- frame_a : numpy.ndarray @@ -234,7 +235,7 @@ def process_pair(frame_a, frame_b, window_size=32, overlap=16, Whether to display a quiver plot of the results, default is False backend : str, optional Backend to use: 'auto', 'rust', or 'scipy'. Default is 'auto'. - + Returns ------- x : 2d np.ndarray @@ -250,17 +251,17 @@ def process_pair(frame_a, frame_b, window_size=32, overlap=16, """ # Perform PIV analysis u, v, s2n = pyprocess.extended_search_area_piv( - frame_a.astype(np.int32), frame_b.astype(np.int32), + frame_a.astype(np.int32), frame_b.astype(np.int32), window_size=window_size, - overlap=overlap, + overlap=overlap, search_area_size=search_area_size, backend=backend, ) - + # Get coordinates x, y = pyprocess.get_coordinates( image_size=frame_a.shape, - search_area_size=search_area_size, + search_area_size=search_area_size, overlap=overlap ) @@ -271,15 +272,15 @@ def process_pair(frame_a, frame_b, window_size=32, overlap=16, mask = ~validation.global_std(u, v) else: mask = np.zeros_like(u, dtype=bool) - + # Replace outliers if np.any(mask): u, v = filters.replace_outliers( - u, v, mask, + u, v, mask, method=filter_method, kernel_size=filter_kernel_size ) - + # Plot if requested if plot: fig, ax = plt.subplots(figsize=(8, 8)) @@ -288,8 +289,8 @@ def process_pair(frame_a, frame_b, window_size=32, overlap=16, ax.set_title(f'Velocity field (dt={dt:.3f})') plt.tight_layout() plt.show() - + # Transform coordinates x, y, u, v = tools.transform_coordinates(x, y, u, v) - + return x, y, u, v, mask diff --git a/openpiv/preprocess.py b/openpiv/preprocess.py index 70badd73..c55e5b0d 100644 --- a/openpiv/preprocess.py +++ b/openpiv/preprocess.py @@ -1,15 +1,14 @@ """This module contains image processing routines that improve images prior to PIV processing.""" +import matplotlib.pyplot as plt import numpy as np -from scipy.ndimage import median_filter, gaussian_filter, binary_fill_holes,\ - map_coordinates -from skimage.util import img_as_float, img_as_ubyte -from skimage import exposure -from skimage import filters -from skimage.measure import find_contours, approximate_polygon, points_in_poly +from scipy.ndimage import binary_fill_holes, gaussian_filter, map_coordinates, median_filter +from skimage import exposure, filters +from skimage.measure import approximate_polygon, find_contours, points_in_poly from skimage.transform import rescale -import matplotlib.pyplot as plt +from skimage.util import img_as_float, img_as_ubyte + from openpiv.tools import imread __licence_ = """ @@ -191,18 +190,17 @@ def normalize_array(array, axis = None): if denominator == 0 or np.isnan(denominator): return np.zeros_like(array) return (array - min_val) / denominator - else: - min_val = np.nanmin(array, axis=axis, keepdims=True) - max_val = np.nanmax(array, axis=axis, keepdims=True) - denominator = max_val - min_val - # Handle the case where all values are the same (max = min) - # Use np.divide with 'where' to avoid division by zero - return np.divide( - array - min_val, - denominator, - out=np.zeros_like(array), - where=(denominator != 0) - ) + min_val = np.nanmin(array, axis=axis, keepdims=True) + max_val = np.nanmax(array, axis=axis, keepdims=True) + denominator = max_val - min_val + # Handle the case where all values are the same (max = min) + # Use np.divide with 'where' to avoid division by zero + return np.divide( + array - min_val, + denominator, + out=np.zeros_like(array), + where=(denominator != 0) + ) def standardize_array(array, axis = None): @@ -226,9 +224,8 @@ def standardize_array(array, axis = None): array = array.astype(np.float32) if axis is None: return((array - np.nanmean(array)) / np.nanstd(array)) - else: - return((array - np.nanmean(array, axis = axis, keepdims=True)) / - np.nanstd(array, axis = axis, keepdims=True)) + return((array - np.nanmean(array, axis = axis, keepdims=True)) / + np.nanstd(array, axis = axis, keepdims=True)) def instensity_cap(img, std_mult = 2): @@ -355,8 +352,7 @@ def local_variance_normalization(img, sigma_1 = 2, sigma_2 = 1, clip = True): ) if clip: img[img < 0] = 0 - img = (img - img.min()) / (img.max() - img.min()) - return img + return (img - img.min()) / (img.max() - img.min()) @@ -389,8 +385,7 @@ def contrast_stretch(img, lower_limit = 2, upper_limit = 98): lower = np.percentile(img, lower_limit) upper = np.percentile(img, upper_limit) - img = exposure.rescale_intensity(img, in_range=(lower, upper)) #type:ignore - return img + return exposure.rescale_intensity(img, in_range=(lower, upper)) #type:ignore def threshold_binarize(img, threshold, max_val = 255): """ diff --git a/openpiv/pyprocess.py b/openpiv/pyprocess.py index f3eb3a5e..6623d766 100644 --- a/openpiv/pyprocess.py +++ b/openpiv/pyprocess.py @@ -1,13 +1,14 @@ """This module contains a pure python implementation of the basic cross-correlation algorithm for PIV image processing.""" -from typing import Optional, Tuple, Callable, Union -import numpy.lib.stride_tricks +from collections.abc import Callable + import numpy as np -from numpy import log -from numpy import ma -from scipy.fft import rfft2 as rfft2_, irfft2 as irfft2_ +import numpy.lib.stride_tricks +from numpy import log, ma from numpy.fft import fftshift as fftshift_ +from scipy.fft import irfft2 as irfft2_ +from scipy.fft import rfft2 as rfft2_ from scipy.signal import convolve2d as conv_ try: @@ -37,10 +38,10 @@ def get_field_shape( - image_size: Tuple[int,int], - search_area_size: Tuple[int,int], - overlap: Tuple[int,int], - )->Tuple[int,int]: + image_size: tuple[int,int], + search_area_size: tuple[int,int], + overlap: tuple[int,int], + )->tuple[int,int]: """Compute the shape of the resulting flow field. Given the image size, the interrogation window size and @@ -68,19 +69,18 @@ def get_field_shape( field_shape : 2-element tuple the shape of the resulting flow field """ - field_shape = (np.array(image_size) - np.array(search_area_size)) // ( # type: ignore + return (np.array(image_size) - np.array(search_area_size)) // ( # type: ignore np.array(search_area_size) - np.array(overlap) ) + 1 - - return field_shape + def get_coordinates( - image_size: Tuple[int,int], + image_size: tuple[int,int], search_area_size: int, overlap: int, center_on_field: bool=True - )->Tuple[np.ndarray, np.ndarray]: + )->tuple[np.ndarray, np.ndarray]: """Compute the x, y coordinates of the centers of the interrogation windows. for the SQUARE windows only, see also get_rect_coordinates @@ -121,7 +121,7 @@ def get_coordinates( """ - # get shape of the resulting flow field as a 2 component array + # get shape of the resulting flow field as a 2 component array field_shape = get_field_shape(image_size, (search_area_size, search_area_size), (overlap, overlap) @@ -165,9 +165,9 @@ def get_coordinates( def get_rect_coordinates( - image_size: Tuple[int,int], - window_size: Union[int, Tuple[int,int]], - overlap: Union[int, Tuple[int,int]], + image_size: tuple[int,int], + window_size: int | tuple[int, int], + overlap: int | tuple[int, int], center_on_field: bool=False, ): ''' @@ -178,20 +178,20 @@ def get_rect_coordinates( if isinstance(overlap, int): overlap = (overlap, overlap) - # @alexlib why the center_on_field is False? - # todo: test True as well + # @alexlib why the center_on_field is False? + # todo: test True as well _, y = get_coordinates(image_size, window_size[0], overlap[0], center_on_field=center_on_field) x, _ = get_coordinates(image_size, window_size[1], overlap[1], center_on_field=center_on_field) X,Y = np.meshgrid(x[0,:], y[:,0]) - + return (X, Y) def sliding_window_array( - image: np.ndarray, - window_size: Tuple[int,int]=(64,64), - overlap: Tuple[int,int]=(32,32), + image: np.ndarray, + window_size: tuple[int,int]=(64,64), + overlap: tuple[int,int]=(32,32), backend: str="auto", )-> np.ndarray: ''' @@ -200,7 +200,7 @@ def sliding_window_array( over the interrogation windows. An approach could be to loop over the array but loops are expensive in python. So we create from the array a new array with three dimension, of size (n_windows, window_size, window_size), in - which each slice, (along the first axis) is an interrogation window. + which each slice, (along the first axis) is an interrogation window. ''' if isinstance(window_size, int): window_size = (window_size, window_size) @@ -233,9 +233,8 @@ def sliding_window_array( win_x, win_y = np.meshgrid(np.arange(0, window_size[1]), np.arange(0, window_size[0])) win_x = win_x[np.newaxis,:,:] + x win_y = win_y[np.newaxis,:,:] + y - windows = image[win_y, win_x] - - return windows + return image[win_y, win_x] + def moving_window_array(array, window_size, overlap): @@ -378,11 +377,11 @@ def find_all_second_peaks(corr, width = 2): corr : np.ndarray the correlation map fof the strided images (N,K,M) where N is the number of windows, KxM is the interrogation window size - + width : int the half size of the region around the first correlation peak to ignore for finding the second peak - + Returns ------- index_list : integers, index of the peak position in (N,i,j) @@ -396,13 +395,13 @@ def find_all_second_peaks(corr, width = 2): ifin = np.minimum(x + width + 1, corr.shape[1]) jini = np.maximum(y - width, 0) jfin = np.minimum(y + width + 1, corr.shape[2]) - + # Create a masked view of the corr - vectorized masking tmp = corr.copy() # Need copy to avoid modifying input # Create mask for each window efficiently for i in ind: tmp[i, iini[i]:ifin[i], jini[i]:jfin[i]] = np.nan - + # Convert to masked array where nans are masked tmp = np.ma.masked_invalid(tmp) indexes, peaks = find_all_first_peaks(tmp) @@ -479,48 +478,47 @@ def find_subpixel_peak_position(corr, subpixel_method="gaussian", backend="auto" if ((peak1_i == 0) | (peak1_i == corr.shape[0]-1) | (peak1_j == 0) | (peak1_j == corr.shape[1]-1)): return subp_peak_position - else: - corr += eps # prevents log(0) = nan if "gaussian" is used (notebook) - c = corr[peak1_i, peak1_j] - cl = corr[peak1_i - 1, peak1_j] - cr = corr[peak1_i + 1, peak1_j] - cd = corr[peak1_i, peak1_j - 1] - cu = corr[peak1_i, peak1_j + 1] - - # gaussian fit - if np.logical_and(np.any(np.array([c, cl, cr, cd, cu]) < 0), - subpixel_method == "gaussian"): - subpixel_method = "parabolic" - - # try: - if subpixel_method == "centroid": - subp_peak_position = ( - ((peak1_i - 1) * cl + peak1_i * c + (peak1_i + 1) * cr) / - (cl + c + cr), - ((peak1_j - 1) * cd + peak1_j * c + (peak1_j + 1) * cu) / - (cd + c + cu), - ) + corr += eps # prevents log(0) = nan if "gaussian" is used (notebook) + c = corr[peak1_i, peak1_j] + cl = corr[peak1_i - 1, peak1_j] + cr = corr[peak1_i + 1, peak1_j] + cd = corr[peak1_i, peak1_j - 1] + cu = corr[peak1_i, peak1_j + 1] + + # gaussian fit + if np.logical_and(np.any(np.array([c, cl, cr, cd, cu]) < 0), + subpixel_method == "gaussian"): + subpixel_method = "parabolic" + + # try: + if subpixel_method == "centroid": + subp_peak_position = ( + ((peak1_i - 1) * cl + peak1_i * c + (peak1_i + 1) * cr) / + (cl + c + cr), + ((peak1_j - 1) * cd + peak1_j * c + (peak1_j + 1) * cu) / + (cd + c + cu), + ) - elif subpixel_method == "gaussian": - nom1 = log(cl) - log(cr) - den1 = 2 * log(cl) - 4 * log(c) + 2 * log(cr) - nom2 = log(cd) - log(cu) - den2 = 2 * log(cd) - 4 * log(c) + 2 * log(cu) - - subp_peak_position = ( - peak1_i + np.divide(nom1, den1, out=np.zeros(1), - where=(den1 != 0.0))[0], - peak1_j + np.divide(nom2, den2, out=np.zeros(1), - where=(den2 != 0.0))[0], - ) + elif subpixel_method == "gaussian": + nom1 = log(cl) - log(cr) + den1 = 2 * log(cl) - 4 * log(c) + 2 * log(cr) + nom2 = log(cd) - log(cu) + den2 = 2 * log(cd) - 4 * log(c) + 2 * log(cu) - elif subpixel_method == "parabolic": - subp_peak_position = ( - peak1_i + (cl - cr) / (2 * cl - 4 * c + 2 * cr), - peak1_j + (cd - cu) / (2 * cd - 4 * c + 2 * cu), - ) + subp_peak_position = ( + peak1_i + np.divide(nom1, den1, out=np.zeros(1), + where=(den1 != 0.0))[0], + peak1_j + np.divide(nom2, den2, out=np.zeros(1), + where=(den2 != 0.0))[0], + ) - return subp_peak_position + elif subpixel_method == "parabolic": + subp_peak_position = ( + peak1_i + (cl - cr) / (2 * cl - 4 * c + 2 * cr), + peak1_j + (cd - cu) / (2 * cd - 4 * c + 2 * cu), + ) + + return subp_peak_position def sig2noise_ratio( @@ -654,7 +652,7 @@ def sig2noise_ratio( return sig2noise -def vectorized_sig2noise_ratio(correlation, +def vectorized_sig2noise_ratio(correlation, sig2noise_method = 'peak2peak', width = 2): ''' @@ -710,13 +708,13 @@ def vectorized_sig2noise_ratio(correlation, ) peak2peak[flag] = 0 # replace invalid values return peak2peak - - elif sig2noise_method == "peak2mean": + + if sig2noise_method == "peak2mean": peaks, peaks1max = find_all_first_peaks(correlation) peaks = np.array(peaks) peaks1_i, peaks1_j = peaks[:,1], peaks[:, 2] peaks2mean = np.abs(np.nanmean(correlation, axis = (-2, -1))) - # peak checking + # peak checking flag = np.zeros(peaks1max.shape).astype(bool) flag[peaks1max < 1e-3] = True flag[peaks1_i == 0] = True @@ -731,10 +729,9 @@ def vectorized_sig2noise_ratio(correlation, ) peak2mean[flag] = 0 # replace invalid values return peak2mean - else: - raise ValueError(f"sig2noise_method not supported: {sig2noise_method}") - - + raise ValueError(f"sig2noise_method not supported: {sig2noise_method}") + + def fft_correlate_images( image_a: np.ndarray, image_b: np.ndarray, @@ -745,7 +742,7 @@ def fft_correlate_images( rfft2 = rfft2_, irfft2 = irfft2_, fftshift = fftshift_, - workers: Optional[int] = None, + workers: int | None = None, )->np.ndarray: """ FFT based cross correlation of two images with multiple views of np.stride_tricks() @@ -769,7 +766,7 @@ def fft_correlate_images( backend : string 'scipy' (default) or 'rust' (multithreaded 2D RealFFT via openpiv_rust) - + workers : int, optional number of worker threads for scipy.fft (default: None for single thread, pass -1 for all CPU cores) @@ -835,16 +832,16 @@ def fft_correlate_images( if normalized_correlation: corr = corr/(corr.shape[-2]*corr.shape[-1]) # for extended search area - + return corr def normalize_intensity(window): """Normalize interrogation window or strided image of many windows, - by removing the mean intensity value per window and dividing by + by removing the mean intensity value per window and dividing by the standard deviation. Note: for small signals the standdeviation might not be full converged. Also numpy docs recommend float64 for - better accuracy: + better accuracy: https://numpy.org/doc/stable/reference/generated/numpy.std.html Parameters @@ -862,13 +859,12 @@ def normalize_intensity(window): window = window.astype(np.float64) else: window = window.copy() # Still need a copy to avoid modifying input - + window -= window.mean(axis=(-2, -1), keepdims=True, dtype=np.float64) tmp = window.std(axis=(-2, -1), keepdims=True) - window = np.divide(window, tmp, out=np.zeros_like(window), + return np.divide(window, tmp, out=np.zeros_like(window), where=(tmp != 0)) - return window def correlate_windows(window_a, window_b, correlation_method="fft", @@ -880,10 +876,10 @@ def correlate_windows(window_a, window_b, correlation_method="fft", ---------- window_a : 2d np.ndarray a two dimensions array for the first interrogation window - + window_b : 2d np.ndarray a two dimensions array for the second interrogation window - + correlation_method : string, methods currently implemented: 'circular' - FFT based without zero-padding 'linear' - FFT based with zero-padding @@ -892,18 +888,18 @@ def correlate_windows(window_a, window_b, correlation_method="fft", convolve2d : function function used for 2d convolutions - + rfft2 : function function used for rfft2 - + irfft2 : function function used for irfft2 - + Returns ------- corr : 2d np.ndarray a two dimensions array for the correlation function. - + Note that due to the wish to use 2^N windows for faster FFT we use a slightly different convention for the size of the correlation map. The theory says it is M+N-1, and the @@ -946,21 +942,21 @@ def fft_correlate_windows(window_a, window_b, since we increase the size of the FFT to reduce the edge effects. This should also work out of the box for rectangular windows. - + Parameters ---------- window_a : 2d np.ndarray a two dimensions array for the first interrogation window - + window_b : 2d np.ndarray a two dimensions array for the second interrogation window - + rfft2 : function function used for rfft2 - + irfft2 : function function used for irfft2 - + # from Stackoverflow: from scipy import linalg import numpy as np @@ -988,20 +984,19 @@ def fft_correlate_windows(window_a, window_b, fslice = tuple([slice(0, int(sz)) for sz in size]) f2a = rfft2(window_a, fsize) f2b = rfft2(window_b[::-1, ::-1], fsize) - corr = irfft2(f2a * f2b).real[fslice] - return corr + return irfft2(f2a * f2b).real[fslice] def extended_search_area_piv( frame_a: np.ndarray, frame_b: np.ndarray, - window_size: Union[int, Tuple[int,int]], - overlap: Union[int, Tuple[int,int]]=(0,0), + window_size: int | tuple[int, int], + overlap: int | tuple[int, int]=(0,0), dt: float=1.0, - search_area_size: Optional[Union[int, Tuple[int,int]]]=None, + search_area_size: int | tuple[int, int] | None=None, correlation_method: str="circular", subpixel_method: str="gaussian", - sig2noise_method: Union[str, None]='peak2mean', + sig2noise_method: str | None='peak2mean', width: int=2, normalized_correlation: bool=False, use_vectorized: bool=False, @@ -1109,14 +1104,14 @@ def extended_search_area_piv( # same for overlap if isinstance(overlap, int): overlap = (overlap, overlap) - + # if no search_size, copy window_size if search_area_size is None: search_area_size = window_size elif isinstance(search_area_size, int): search_area_size = (search_area_size, search_area_size) - # verify that things are logically possible: + # verify that things are logically possible: if overlap[0] >= search_area_size[0] or overlap[1] >= search_area_size[1]: raise ValueError("Overlap has to be smaller than the search_area_size") @@ -1249,9 +1244,9 @@ def correlation_to_displacement(corr, n_rows, n_cols, return (u, v) -def vectorized_correlation_to_displacements(corr: np.ndarray, - n_rows: Optional[int]=None, - n_cols: Optional[int]=None, +def vectorized_correlation_to_displacements(corr: np.ndarray, + n_rows: int | None=None, + n_cols: int | None=None, subpixel_method: str='gaussian', eps: float=1e-7 ): @@ -1260,17 +1255,17 @@ def vectorized_correlation_to_displacements(corr: np.ndarray, window using the convention that the size of the correlation map is 2N -1 where N is the size of the largest interrogation window (in frame B) that is called search_area_size - + Parameters ---------- corr : 3D nd.array contains output of the fft_correlate_images - - n_rows, n_cols : + + n_rows, n_cols : number of interrogation windows, output of the get_field_shape - + mask_width: int - distance, in pixels, from the interrogation window in which + distance, in pixels, from the interrogation window in which correlation peaks would be flagged as invalid Returns ------- @@ -1279,12 +1274,12 @@ def vectorized_correlation_to_displacements(corr: np.ndarray, """ if subpixel_method not in ("gaussian", "centroid", "parabolic"): raise ValueError(f"Method not implemented {subpixel_method}") - + corr = corr.astype(np.float32) + eps # avoids division by zero peaks = find_all_first_peaks(corr)[0] ind, peaks_x, peaks_y = peaks[:,0], peaks[:,1], peaks[:,2] peaks1_i, peaks1_j = peaks_x, peaks_y - + # peak checking if subpixel_method in ("gaussian", "centroid", "parabolic"): mask_width = 1 @@ -1294,33 +1289,33 @@ def vectorized_correlation_to_displacements(corr: np.ndarray, invalid += list(np.where(peaks1_j > corr.shape[2] - mask_width - 1)[0]) peaks1_i[invalid] = corr.shape[1] // 2 # temp. so no errors would be produced peaks1_j[invalid] = corr.shape[2] // 2 - + print(f"Found {len(invalid)} bad peak(s)") - if len(invalid) == corr.shape[0]: # in case something goes horribly wrong + if len(invalid) == corr.shape[0]: # in case something goes horribly wrong return np.zeros((np.size(corr, 0), 2))*np.nan - + #points c = corr[ind, peaks1_i, peaks1_j] cl = corr[ind, peaks1_i - 1, peaks1_j] cr = corr[ind, peaks1_i + 1, peaks1_j] cd = corr[ind, peaks1_i, peaks1_j - 1] cu = corr[ind, peaks1_i, peaks1_j + 1] - + if subpixel_method == "centroid": shift_i = ((peaks1_i - 1) * cl + peaks1_i * c + (peaks1_i + 1) * cr) / (cl + c + cr) shift_j = ((peaks1_j - 1) * cd + peaks1_j * c + (peaks1_j + 1) * cu) / (cd + c + cu) - + elif subpixel_method == "gaussian": inv = list(np.where(c <= 0)[0]) # get rid of any pesky NaNs inv += list(np.where(cl <= 0)[0]) inv += list(np.where(cr <= 0)[0]) inv += list(np.where(cu <= 0)[0]) inv += list(np.where(cd <= 0)[0]) - + #cl_, cr_ = np.delete(cl, inv), np.delete(cr, inv) #c_ = np.delete(c, inv) #cu_, cd_ = np.delete(cu, inv), np.delete(cd, inv) - + nom1 = log(cl) - log(cr) den1 = 2 * log(cl) - 4 * log(c) + 2 * log(cr) nom2 = log(cd) - log(cu) @@ -1335,34 +1330,33 @@ def vectorized_correlation_to_displacements(corr: np.ndarray, out=np.zeros_like(nom2), where=(den2 != 0.0) ) - - if len(inv) >= 1: + + if len(inv) >= 1: print(f'Found {len(inv)} negative correlation indices resulting in NaNs\n'+ 'Fallback for negative indices is a 3 point parabolic curve method') shift_i[inv] = (cl[inv] - cr[inv]) / (2 * cl[inv] - 4 * c[inv] + 2 * cr[inv]) shift_j[inv] = (cd[inv] - cu[inv]) / (2 * cd[inv] - 4 * c[inv] + 2 * cu[inv]) - + elif subpixel_method == "parabolic": shift_i = (cl - cr) / (2 * cl - 4 * c + 2 * cr) shift_j = (cd - cu) / (2 * cd - 4 * c + 2 * cu) - + if subpixel_method != "centroid": disp_vy = (peaks1_i.astype(np.float64) + shift_i) - np.floor(np.array(corr.shape[1])/2) disp_vx = (peaks1_j.astype(np.float64) + shift_j) - np.floor(np.array(corr.shape[2])/2) else: disp_vy = shift_i - np.floor(np.array(corr.shape[1])/2) disp_vx = shift_j - np.floor(np.array(corr.shape[2])/2) - + disp_vx[invalid] = peaks_x[invalid]*np.nan disp_vy[invalid] = peaks_y[invalid]*np.nan #disp[ind, :] = np.vstack((disp_vx, disp_vy)).T #return disp[:,0].reshape((n_rows, n_cols)), disp[:,1].reshape((n_rows, n_cols)) - if n_rows == None or n_cols == None: + if n_rows is None or n_cols is None: return disp_vx, disp_vy - else: - return disp_vx.reshape((n_rows, n_cols)), disp_vy.reshape((n_rows, n_cols)) - - + return disp_vx.reshape((n_rows, n_cols)), disp_vy.reshape((n_rows, n_cols)) + + def nextpower2(i): """ Find 2^n that is equal to or greater than. """ n = 1 diff --git a/openpiv/pyprocess3D.py b/openpiv/pyprocess3D.py index 7f9d99b4..c2c1092f 100644 --- a/openpiv/pyprocess3D.py +++ b/openpiv/pyprocess3D.py @@ -1,9 +1,10 @@ -import numpy.lib.stride_tricks import numpy as np -from scipy.fft import rfftn, irfftn +import numpy.lib.stride_tricks from numpy import ma +from scipy.fft import irfftn, rfftn from tqdm import tqdm -from openpiv.pyprocess import get_field_shape, find_first_peak + +from openpiv.pyprocess import find_first_peak, get_field_shape """This module contains a pure python implementation of the basic cross-correlation algorithm for PIV image processing.""" @@ -255,7 +256,7 @@ def find_subpixel_peak_position(corr, subpixel_method="gaussian"): peak1_j + (cd - cu) / (2 * cd - 4 * c + 2 * cu), peak1_z + (cf - cb) / (2 * cf - 4 * c + 2 * cb), ) - except: + except Exception: subp_peak_position = default_peak_position except IndexError: @@ -308,10 +309,8 @@ def sig2noise_ratio(corr, sig2noise_method="peak2peak", width=2): # if the image is lacking particles, totally black it will correlate to very low value, but not zero # if the first peak is on the borders, the correlation map is also wrong if corr_max1 < 1e-3 or any( - [ - x == 0 or x == corr.shape[i] + x == 0 or x == corr.shape[i] for i, x in enumerate([peak1_i, peak1_j, peak1_z]) - ] ): return 0.0 @@ -389,17 +388,15 @@ def correlate_windows( f2a = rfftn(normalize_intensity(window_a), s=(nfftx, nffty, nfftz)) f2b = rfftn(normalize_intensity(window_b), s=(nfftx, nffty, nfftz)) corr = irfftn(f2a * f2b, s=(nfftx, nffty, nfftz)).real - corr = corr[ + return corr[ : window_a.shape[0] + window_b.shape[0], : window_b.shape[1] + window_a.shape[1], : window_b.shape[2] + window_a.shape[2], ] - return corr # elif correlation_method == 'direct': # return convolve2d(normalize_intensity(window_a), # normalize_intensity(window_b[::-1, ::-1, ::-1]), 'full') - else: - raise ValueError("method is not implemented") + raise ValueError("method is not implemented") def normalize_intensity(window): @@ -444,10 +441,10 @@ def check_input(window_size, overlap, search_area_size, frame_a, frame_b): if any((np.array(search_area_size) - np.array(window_size)) < 0): raise ValueError("Search size cannot be smaller than the window_size") - if any([ws > ims for ws, ims in zip(window_size, frame_a.shape)]): + if any(ws > ims for ws, ims in zip(window_size, frame_a.shape)): raise ValueError("window size cannot be larger than the image") - if any([ims_a != ims_b for ims_a, ims_b in zip(frame_a.shape, frame_b.shape)]): + if any(ims_a != ims_b for ims_a, ims_b in zip(frame_a.shape, frame_b.shape)): raise ValueError("frame a and frame b have different sizes.") return window_size, overlap, search_area_size @@ -672,5 +669,4 @@ def extended_search_area_piv3D( # return output if user wanted sig2noise information if sig2noise_method is not None: return u / dt[0], v / dt[1], w / dt[2], sig2noise - else: - return u / dt[0], v / dt[1], w / dt[2] + return u / dt[0], v / dt[1], w / dt[2] diff --git a/openpiv/settings.py b/openpiv/settings.py index 99713bb2..7998da25 100644 --- a/openpiv/settings.py +++ b/openpiv/settings.py @@ -2,9 +2,10 @@ import pathlib from dataclasses import dataclass from importlib.resources import files -from typing import Optional, Tuple, Union + import numpy as np + @dataclass class PIVSettings: """ All the PIV settings for the batch analysis with multi-processing and @@ -12,7 +13,7 @@ class PIVSettings: """ # "Data related settings" # Folder with the images to process - filepath_images: Union[pathlib.Path, str] = files('openpiv') / "data" / "test1" # type: ignore + filepath_images: pathlib.Path | str = files('openpiv') / "data" / "test1" # type: ignore # Folder for the outputs save_path: pathlib.Path = filepath_images.parent # Root name of the output Folder for Result Files @@ -24,21 +25,21 @@ class PIVSettings: # "Region of interest" # (50,300,50,300) #Region of interest: (xmin,xmax,ymin,ymax) or 'full' # for full image - roi: Union[Tuple[int, int, int, int], str] = "full" + roi: tuple[int, int, int, int] | str = "full" # "Image preprocessing" # Every image would be processed separately and the # average mask is applied to both A, B, but it's varying # for the frames sequence #: None for no masking - #: 'edges' for edges masking, + #: 'edges' for edges masking, #: 'intensity' for intensity masking - dynamic_masking_method: Optional[str] = None # ['edge','intensity'] + dynamic_masking_method: str | None = None # ['edge','intensity'] dynamic_masking_threshold: float = 0.005 dynamic_masking_filter_size: int = 7 # Static masking applied to all images, A,B - static_mask: Optional[np.ndarray] = None # or a boolean matrix of image shape + static_mask: np.ndarray | None = None # or a boolean matrix of image shape # "Processing Parameters" backend: str = "auto" # Backend for PIV processing: 'auto', 'scipy', or 'rust' @@ -47,10 +48,10 @@ class PIVSettings: # add the interroagtion window size for each pass. # For the moment, it should be a power of 2 - windowsizes: Tuple[int, ...]=(64,32,16) - + windowsizes: tuple[int, ...]=(64,32,16) + # The overlap of the interroagtion window for each pass. - overlap: Tuple[int, ...] = (32, 16, 8) # This is 50% overlap + overlap: tuple[int, ...] = (32, 16, 8) # This is 50% overlap # Has to be a value with base two. In general window size/2 is a good # choice. @@ -76,18 +77,18 @@ class PIVSettings: # Signal to noise ratio: # we can decide to estimate it or not at every vector position - # we can decided if we use it for validation or only store it for + # we can decided if we use it for validation or only store it for # later post-processing - # plus we need some parameters for threshold validation and for the + # plus we need some parameters for threshold validation and for the # calculations: - sig2noise_method: Optional[str]="peak2mean" # or "peak2peak" or "None" + sig2noise_method: str | None="peak2mean" # or "peak2peak" or "None" # select the width of the masked to masked out pixels next to the main # peak sig2noise_mask: int=2 # If extract_sig2noise::False the values in the signal to noise ratio # output column are set to NaN - + # "Validation based on the signal to noise ratio" # Note: only available when extract_sig2noise::True and only for the # last pass of the interrogation @@ -96,8 +97,8 @@ class PIVSettings: # minmum signal to noise ratio that is need for a valid vector sig2noise_threshold: float=1.0 sig2noise_validate: bool=True # when it's False we can save time by not - #estimating sig2noise ratio at all, so we can set both sig2noise_method to None - + #estimating sig2noise ratio at all, so we can set both sig2noise_method to None + # "vector validation options" # choose if you want to do validation of the first pass: True or False @@ -111,8 +112,8 @@ class PIVSettings: # The first filter is based on the min/max ranges. Observe that these # values are defined in # terms of minimum and maximum displacement in pixel/frames. - min_max_u_disp: Tuple=(-30, 30) - min_max_v_disp: Tuple=(-30, 30) + min_max_u_disp: tuple=(-30, 30) + min_max_v_disp: tuple=(-30, 30) # The second filter is based on the global STD threshold std_threshold: int=10 # threshold of the std validation # The third filter is the median test: pick between normalized and regular @@ -137,7 +138,7 @@ class PIVSettings: # maximum iterations performed to replace the outliers max_filter_iteration: int=4 filter_kernel_size: int=2 # kernel size for the localmean method - + # "Output options" # Select if you want to save the plotted vectorfield: True or False save_plot: bool=False @@ -164,4 +165,4 @@ def __post_init__(self): raise ValueError( f"overlap[{i}]={overlap} must be smaller than " f"windowsizes[{i}]={window}." - ) \ No newline at end of file + ) diff --git a/openpiv/test/OpenPIV_results_16_test1/field_A0000.png b/openpiv/test/OpenPIV_results_16_test1/field_A0000.png index 7288cde4..52428930 100644 Binary files a/openpiv/test/OpenPIV_results_16_test1/field_A0000.png and b/openpiv/test/OpenPIV_results_16_test1/field_A0000.png differ diff --git a/openpiv/test/conftest.py b/openpiv/test/conftest.py index 979307b1..4867e321 100644 --- a/openpiv/test/conftest.py +++ b/openpiv/test/conftest.py @@ -1,8 +1,9 @@ -import pytest +import traceback +from unittest.mock import patch + import matplotlib import matplotlib.pyplot as plt -from unittest.mock import patch -import traceback +import pytest # Set non-interactive backend by default matplotlib.use('Agg') @@ -18,14 +19,14 @@ def debug_show(*args, **kwargs): print("plt.show() called from:") traceback.print_stack() # Don't actually call the original show function - return None + return # Debug wrapper for plt.draw def debug_draw(*args, **kwargs): print("plt.draw() called from:") traceback.print_stack() # Don't actually call the original draw function - return None + return # Store and replace the original functions plt.original_show = plt.show @@ -37,7 +38,7 @@ def debug_draw(*args, **kwargs): def configure_plots(request): """Fixture to configure plot behavior based on markers""" show_plots = request.node.get_closest_marker("show_plots") is not None - + if show_plots: # If test is marked with show_plots, restore original functions print(f"Enabling plots for test: {request.node.name}") @@ -47,11 +48,13 @@ def configure_plots(request): yield else: # Otherwise, disable all plots - with patch('matplotlib.pyplot.show', return_value=None): - with patch('matplotlib.pyplot.draw', return_value=None): - with patch('matplotlib.backend_bases.FigureManagerBase.show', return_value=None): - with patch('matplotlib.figure.Figure.show', return_value=None): - yield - + with ( + patch('matplotlib.pyplot.show', return_value=None), + patch('matplotlib.pyplot.draw', return_value=None), + patch('matplotlib.backend_bases.FigureManagerBase.show', return_value=None), + patch('matplotlib.figure.Figure.show', return_value=None), + ): + yield + # Close all figures at the end plt.close('all') diff --git a/openpiv/test/test_PIV_3D_plotting.py b/openpiv/test/test_PIV_3D_plotting.py index 3571a431..f5a50ffe 100644 --- a/openpiv/test/test_PIV_3D_plotting.py +++ b/openpiv/test/test_PIV_3D_plotting.py @@ -1,23 +1,18 @@ """Test module for PIV_3D_plotting.py""" import os + +import matplotlib import numpy as np import pytest -import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt -from mpl_toolkits.mplot3d import Axes3D from matplotlib.testing.compare import compare_images +from mpl_toolkits.mplot3d import Axes3D -from openpiv.PIV_3D_plotting import ( - set_axes_equal, - scatter_3D, - explode, - plot_3D_alpha, - quiver_3D -) +from openpiv.PIV_3D_plotting import explode, plot_3D_alpha, quiver_3D, scatter_3D, set_axes_equal SKIP_PLOT_TESTS = False @@ -43,9 +38,9 @@ def test_set_axes_equal(): ax.set_zlim(0, 3) # Get the original limits - x_limits_before = ax.get_xlim3d() - y_limits_before = ax.get_ylim3d() - z_limits_before = ax.get_zlim3d() + ax.get_xlim3d() + ax.get_ylim3d() + ax.get_zlim3d() # Apply the function set_axes_equal(ax) diff --git a/openpiv/test/test_calib_dlt.py b/openpiv/test/test_calib_dlt.py new file mode 100644 index 00000000..674c69e0 --- /dev/null +++ b/openpiv/test/test_calib_dlt.py @@ -0,0 +1,424 @@ +import os + +import numpy as np +import pytest +from numpy.testing import ( + assert_, + assert_allclose, + assert_almost_equal, + assert_array_almost_equal, + assert_array_equal, + assert_equal, +) + +from openpiv.calibration import dlt_model +from openpiv.calibration.calib_utils import get_los_error, get_reprojection_error + +file = os.path.join(os.path.dirname(__file__),"test_calibration_points.npz") + +def test_parameters_input(): + with pytest.raises(TypeError): + # missing camera name + dlt_model.camera() + + # missing resolution + dlt_model.camera( + "name" + ) + + with pytest.raises(ValueError): + # name is not a string + dlt_model.camera( + 0, + resolution=[0, 0] + ) + + # not two element tuple + dlt_model.camera( + "name", + resolution=[0] + ) + + # not 2D or 3D + dlt_model.camera( + "name", + resolution=[0, 0], + ndim = 4 + ) + + # coefficients not correct dimension + dlt_model.camera( + "name", + resolution=[0, 0], + coeffs = np.zeros((10, 10, 2)) + ) + + # coefficients not correct shape + dlt_model.camera( + "name", + resolution=[0, 0], + coeffs = np.zeros((10, 10)) + ) + + +def test_parameters_initialization(): + cam = dlt_model.camera( + "name", + resolution=[0, 0] + ) + + assert_(hasattr(cam, "name")) + assert_(hasattr(cam, "resolution")) + assert_(hasattr(cam, "coeffs")) + assert_(hasattr(cam, "dtype")) + + assert_(len(cam.resolution) == 2) + + assert_(cam.coeffs.shape in [(3, 3), (3, 4)]) + + assert_(cam.dtype in ["float32", "float64"]) + + +@pytest.mark.parametrize("case", (1, 2, 3)) +def test_minimization_01( + case: int +): + cal_data = np.load(file) + + cal_obj_points = cal_data[f"obj_points_{case}"] + cal_img_points = cal_data[f"img_points_{case}"] + + cam = dlt_model.camera( + "dummy", + resolution = [512, 512], + ) + + cam.minimize_params( + cal_obj_points, + cal_img_points + ) + + RMSE = get_reprojection_error( + cam, + cal_obj_points, + cal_img_points + ) + + assert_(RMSE < 1e-2) + + +@pytest.mark.parametrize("case", (1, 2, 3)) +def test_projection_01( + case: int +): + cal_data = np.load(file) + + cal_obj_points = cal_data[f"obj_points_{case}"] + cal_img_points = cal_data[f"img_points_{case}"] + + cam = dlt_model.camera( + "dummy", + resolution = [512, 512], + ) + + cam.minimize_params( + cal_obj_points, + cal_img_points + ) + + obj_points = np.random.rand(3, 32) + obj_points[0, :] = np.int32(obj_points[0, :] * 50) + obj_points[1, :] = np.int32(obj_points[1, :] * 50) + obj_points[2, :] = np.int32(obj_points[2, :] * 10) + + obj_points = obj_points.astype("float64", copy=False) + + img_points = cam.project_points( + obj_points + ) + + recon_obj_points = cam.project_to_z( + img_points, + obj_points[2] + ) + + assert_array_almost_equal( + obj_points, + recon_obj_points, + decimal=2 + ) + + +@pytest.mark.parametrize("case", (1, 2, 3)) +def test_projection_02( + case: int +): + cal_data = np.load(file) + + cal_obj_points = cal_data[f"obj_points_{case}"] + cal_img_points = cal_data[f"img_points_{case}"] + + cam = dlt_model.camera( + "dummy", + resolution = [512, 512], + ) + + cam.minimize_params( + cal_obj_points, + cal_img_points + ) + + x, y = cam.project_points( + cal_obj_points + ) + + assert_array_almost_equal( + x, cal_img_points[0], + decimal=2 + ) + + assert_array_almost_equal( + y, cal_img_points[1], + decimal=2 + ) + + +@pytest.mark.parametrize("case", (1, 2, 3)) +def test_projection_03( + case: int +): + cal_data = np.load(file) + + cal_obj_points = cal_data[f"obj_points_{case}"] + cal_img_points = cal_data[f"img_points_{case}"] + + cam = dlt_model.camera( + "dummy", + resolution = [512, 512], + ) + + cam.minimize_params( + cal_obj_points, + cal_img_points + ) + + RMSE_0 = get_los_error( + cam, + z = -10 + ) + + RMSE_1 = get_los_error( + cam, + z = 0 + ) + + RMSE_2 = get_los_error( + cam, + z = 10 + ) + + assert_(RMSE_0 < 1e-2) + assert_(RMSE_1 < 1e-2) + assert_(RMSE_2 < 1e-2) + + +@pytest.mark.parametrize("case", ((1, 2), (1, 3), (2, 3))) +def test_line_intersect_01( + case: tuple +): + cal_data = np.load(file) + + cal_obj_points_1 = cal_data[f"obj_points_{case[0]}"] + cal_img_points_1 = cal_data[f"img_points_{case[0]}"] + + cal_obj_points_2 = cal_data[f"obj_points_{case[1]}"] + cal_img_points_2 = cal_data[f"img_points_{case[1]}"] + + cam_1 = dlt_model.camera( + "dummy", + resolution = [512, 512] + ) + + cam_2 = dlt_model.camera( + "dummy", + resolution = [512, 512] + ) + + cam_1.minimize_params( + cal_obj_points_1, + cal_img_points_1 + ) + + cam_2.minimize_params( + cal_obj_points_2, + cal_img_points_2 + ) + + test_object_points = np.array( + [ + [0, 0, 0], + [1, 2, 3], + [3, 2, 1], + [15, 15, -15], + [15, 15, 0], + [15, 15, 15] + ], + dtype=cam_1.dtype + ).T + + test_image_points_1 = cam_1.project_points( + test_object_points + ) + + test_image_points_2 = cam_2.project_points( + test_object_points + ) + + recon_obj_points, _ = dlt_model.line_intersect( + cam_1, + cam_2, + test_image_points_1, + test_image_points_2 + ) + + assert_array_almost_equal( + test_object_points, + recon_obj_points, + decimal=2 + ) + + +@pytest.mark.parametrize("case", ((1, 2), (1, 3), (2, 3), (1, 2, 3))) +def test_line_intersect_02( + case: tuple +): + cal_data = np.load(file) + n_cams = len(case) + + cal_obj_points = [] + cal_img_points = [] + for cam in case: + cal_obj_points.append(cal_data[f"obj_points_{cam}"]) + cal_img_points.append(cal_data[f"img_points_{cam}"]) + + cams = [] + for cam in range(n_cams): + cam_1 = dlt_model.camera( + "dummy", + resolution = [512, 512] + ) + + cam_1.minimize_params( + cal_obj_points[cam], + cal_img_points[cam] + ) + + cams.append(cam_1) + + test_object_points = np.array( + [ + [0, 0, 0], + [1, 2, 3], + [3, 2, 1], + [15, 15, -15], + [15, 15, 0], + [15, 15, 15] + ], + dtype=cam_1.dtype + ).T + + test_image_points = [] + for cam in range(n_cams): + test_image_points.append( + cams[cam].project_points( + test_object_points + ) + ) + + recon_obj_points = dlt_model.multi_line_intersect( + cams, + test_image_points + ) + + assert_array_almost_equal( + test_object_points, + recon_obj_points, + decimal=0 + ) + + +def test_save_parameters_1(): + cam = dlt_model.camera( + "dummy", + resolution = [512, 512] + ) + + cam.save_parameters( + "." + ) + + +def test_save_parameters_2(): + cam = dlt_model.camera( + "dummy", + resolution = [512, 512] + ) + + cam.save_parameters( + ".", "saved_params" + ) + + +def test_load_parameters_1(): + cam = dlt_model.camera( + "dummy", + resolution = [512, 512] + ) + + with pytest.raises(FileNotFoundError): + cam.load_parameters( + ".", + "does not exist (hopefully)" + ) + + +def test_load_parameters_2(): + cam_orig = dlt_model.camera( + "dummy", + resolution = [512, 512] + ) + + cam_new = dlt_model.camera( + "dummy", + resolution = [512, 512] + ) + + cam_orig.save_parameters( + ".", + "dummy" + ) + + cam_new.load_parameters( + ".", + "dummy" + ) + + assert_array_equal( + cam_orig.name, + cam_new.name + ) + + assert_array_equal( + cam_orig.resolution, + cam_new.resolution + ) + + assert_array_equal( + cam_orig.coeffs, + cam_new.coeffs + ) + + assert_array_equal( + cam_orig.dtype, + cam_new.dtype + ) diff --git a/openpiv/test/test_calib_pinhole.py b/openpiv/test/test_calib_pinhole.py new file mode 100644 index 00000000..a49e96db --- /dev/null +++ b/openpiv/test/test_calib_pinhole.py @@ -0,0 +1,682 @@ +import os + +import numpy as np +import pytest +from numpy.testing import ( + assert_, + assert_allclose, + assert_almost_equal, + assert_array_almost_equal, + assert_array_equal, + assert_equal, +) + +from openpiv.calibration import pinhole_model +from openpiv.calibration.calib_utils import get_los_error, get_reprojection_error + +file = os.path.join(os.path.dirname(__file__),"test_calibration_points.npz") + + +def get_test_camera_params( + case: int=1 +): + pinhole_cam = os.path.join(os.path.dirname(__file__), f"test_calibration_pinhole_{case}.txt") + + + with open(pinhole_cam) as f: + name = f.readline()[:-1] + + line = f.readline()[:] + resolution = [float(num) for num in line.split()] + + line = f.readline()[:] + translation = [float(num) for num in line.split()] + + line = f.readline()[:] + orientation = [float(num) for num in line.split()] + + line = f.readline()[:] + distortion = [float(num) for num in line.split()] + + line = f.readline()[:] + focal = [float(num) for num in line.split()] + + line = f.readline()[:] + principal = [float(num) for num in line.split()] + + return pinhole_model.camera( + name = name, + resolution = resolution, + translation = translation, + orientation = orientation, + distortion_model = "brown", + distortion1 = distortion, + focal = focal, + principal = principal + ) + + + +def test_parameters_input(): + with pytest.raises(TypeError): + # missing camera name + pinhole_model.camera() + + # missing resolution + pinhole_model.camera( + "name" + ) + + with pytest.raises(ValueError): + # name is not a string + pinhole_model.camera( + 0, + resolution=[0, 0] + ) + + # not two element tuple + pinhole_model.camera( + "name", + resolution=[0] + ) + + # not three element vector + pinhole_model.camera( + "name", + resolution=[0, 0], + translation=[0, 0] + ) + + # not three element vector + pinhole_model.camera( + "name", + resolution=[0, 0], + orientation=[0, 0] + ) + + # wrong distortion model + pinhole_model.camera( + "name", + resolution=[0, 0], + distortion_model="non-existent ", + ) + + # not 8 element vector for brown model + pinhole_model.camera( + "name", + resolution=[0, 0], + distortion_model="brown", + distortion1=np.zeros(7) + ) + + # not 2 x 5 matrix for polynomial model + pinhole_model.camera( + "name", + resolution=[0, 0], + distortion_model="polynomial", + distortion2=np.zeros([4, 3]) + ) + + # not 2 element list-like + pinhole_model.camera( + "name", + resolution=[0, 0], + focal="[2, 2]" + ) + + # not 2 element vector + pinhole_model.camera( + "name", + resolution=[0, 0], + focal=[1] + ) + + # not 2 element list-like + pinhole_model.camera( + "name", + resolution=[0, 0], + principal="[2, 2]" + ) + + # not 2 element vector + pinhole_model.camera( + "name", + resolution=[0, 0], + principal=[1] + ) + + # not a support dtype (supported dtypes are float32 and float64) + pinhole_model.camera( + "name", + resolution=[0, 0], + dtype=int + ) + + +def test_parameters_initialization(): + cam = pinhole_model.camera( + "name", + resolution=[0, 0] + ) + + assert_(hasattr(cam, "name")) + assert_(hasattr(cam, "resolution")) + assert_(hasattr(cam, "translation")) + assert_(hasattr(cam, "orientation")) + assert_(hasattr(cam, "rotation")) + assert_(hasattr(cam, "distortion_model")) + assert_(hasattr(cam, "distortion1")) + assert_(hasattr(cam, "distortion2")) + assert_(hasattr(cam, "focal")) + assert_(hasattr(cam, "principal")) + assert_(hasattr(cam, "dtype")) + + assert_(len(cam.resolution) == 2) + + assert_equal( + cam.translation.shape, + [3, ] + ) + + assert_equal( + cam.orientation.shape, + [3, ] + ) + + assert_equal( + cam.rotation.shape, + [3, 3] + ) + + assert_equal( + cam.distortion1.shape, + [8, ] + ) + + assert_equal( + cam.distortion2.shape, + [2, 5] + ) + + assert_(len(cam.focal) == 2) + + assert_(len(cam.principal) == 2) + + # float32 does not work well with the pinhole model, should it be deprecated? + assert_(cam.dtype in ["float32", "float64"]) + + +def test_rotation_matrix_01(): + cam = pinhole_model.camera( + "name", + resolution = [1024, 1024] + ) + + assert_allclose( + cam.rotation, + np.eye(3,3) + ) + + +def test_rotation_matrix_02(): + cam = pinhole_model.camera( + "name", + resolution = [1024, 1024], + translation = [0, 0, 1], + orientation = [0, 0, 0] + ) + + assert_allclose( + cam.rotation, + np.eye(3,3) + ) + + +def test_projection_01(): + cam = pinhole_model.camera( + "name", + resolution = [1024, 1024] + ) + + X, Y, Z = np.random.rand(3, 32) * 100.0 + + x, y = cam.project_points( + [X, Y, Z] + ) + + X_new, Y_new, Z_new = cam.project_to_z( + [x, y], + Z + ) + + assert_array_almost_equal( + X, X_new, + decimal=4 + ) + + assert_array_almost_equal( + Y, Y_new, + decimal=4 + ) + + assert_array_almost_equal( + Z, Z_new, + decimal=4 + ) + + +@pytest.mark.parametrize("case", (1, 2, 3)) +def test_projection_02( + case: int +): + cam = get_test_camera_params(case) + + cal_data = np.load(file) + + cal_obj_points = cal_data[f"obj_points_{case}"] + cal_img_points = cal_data[f"img_points_{case}"] + + x, y = cam.project_points( + cal_obj_points + ) + + assert_array_almost_equal( + x, cal_img_points[0], + decimal=2 + ) + + assert_array_almost_equal( + y, cal_img_points[1], + decimal=2 + ) + + +@pytest.mark.parametrize("case", (1, 2, 3)) +def test_projection_03( + case: int +): + cam = get_test_camera_params(case) + + cal_data = np.load(file) + + cal_obj_points = cal_data[f"obj_points_{case}"] + cal_img_points = cal_data[f"img_points_{case}"] + + RMSE = get_reprojection_error( + cam, + cal_obj_points, + cal_img_points + ) + + assert_(RMSE < 1e-2) + + +@pytest.mark.parametrize("case", (1, 2, 3)) +def test_projection_04( + case: int +): + cam = get_test_camera_params(case) + + cal_data = np.load(file) + + cal_data[f"obj_points_{case}"] + cal_data[f"img_points_{case}"] + + RMSE_0 = get_los_error( + cam, + z = -10 + ) + + RMSE_1 = get_los_error( + cam, + z = 0 + ) + + RMSE_2 = get_los_error( + cam, + z = 10 + ) + + assert_(RMSE_0 < 1e-2) + assert_(RMSE_1 < 1e-2) + assert_(RMSE_2 < 1e-2) + + +@pytest.mark.parametrize("case", (1, 2, 3)) +def test_projection_05( + case: int +): + cam = get_test_camera_params(case) + + cal_data = np.load(file) + + cal_obj_points = cal_data[f"obj_points_{case}"] + cal_img_points = cal_data[f"img_points_{case}"] + + X, Y, Z = cam.project_to_z( + cal_img_points, + cal_obj_points[2] + ) + + assert_array_almost_equal( + X, cal_obj_points[0], + decimal=2 + ) + + assert_array_almost_equal( + Y, cal_obj_points[1], + decimal=2 + ) + + assert_array_almost_equal( + Z, cal_obj_points[2], + decimal=2 + ) + + +@pytest.mark.parametrize("model", ("brown", "polynomial")) +def test_minimization_01( + model: str +): + cal_data = np.load(file) + + cal_obj_points = cal_data["obj_points_1"] + cal_img_points = cal_data["img_points_1"] + + cam = pinhole_model.camera( + "minimized", + resolution = [512, 512], + translation = [1, 1, 520], # initial guess + orientation = [0, np.pi, np.pi], # initial guess + focal = [1000, 1000], + distortion_model = model + ) + + cam.minimize_params( + cal_obj_points, + cal_img_points, + correct_focal = False, + correct_distortion = False, + iterations = 3 + ) + + RMSE = get_reprojection_error( + cam, + cal_obj_points, + cal_img_points + ) + + assert_(RMSE < 1e-2) + + +@pytest.mark.parametrize("model", ("brown", "polynomial")) +def test_minimization_02( + model: str +): + case = 1 + cam_orig = get_test_camera_params(case) + + cal_data = np.load(file) + + cal_obj_points = cal_data[f"obj_points_{case}"] + cal_img_points = cal_data[f"img_points_{case}"] + + cam_new = pinhole_model.camera( + "minimized", + resolution = [512, 512], + translation = [-20, 20, 520], # initial guess + orientation = [0, np.pi, np.pi], # initial guess + focal = [1000, 1000], + distortion_model = model + ) + + cam_new.minimize_params( + cal_obj_points, + cal_img_points, + correct_focal = False, + correct_distortion = False, + iterations = 3 + ) + + assert_array_almost_equal( + cam_orig.translation, + cam_new.translation, + decimal = 1 + ) + + assert_array_almost_equal( + cam_orig.orientation, + cam_new.orientation, + decimal = 1 + ) + + assert_array_almost_equal( + cam_orig.rotation, + cam_new.rotation, + decimal = 1 + ) + + assert_array_almost_equal( + cam_orig.focal, + cam_new.focal, + decimal = 1 + ) + + # set decimal to 0 because it keeps minimizing wrong, this needs to be fixed + assert_array_almost_equal( + cam_orig.principal, + cam_new.principal, + decimal = 0 + ) + + +@pytest.mark.parametrize("case", ((1, 2), (1, 3), (2, 3))) +def test_line_intersect_01( + case: tuple +): + cal_data = np.load(file) + + cal_data[f"obj_points_{case[0]}"] + cal_data[f"img_points_{case[1]}"] + + cal_data[f"obj_points_{case[0]}"] + cal_data[f"img_points_{case[1]}"] + + cam_1 = get_test_camera_params(case[0]) + + cam_2 = get_test_camera_params(case[1]) + + test_object_points = np.array( + [ + [0, 0, 0], + [1, 2, 3], + [3, 2, 1], + [15, 15, -15], + [15, 15, 0], + [15, 15, 15] + ], + dtype=cam_1.dtype + ).T + + test_image_points_1 = cam_1.project_points( + test_object_points + ) + + test_image_points_2 = cam_2.project_points( + test_object_points + ) + + recon_obj_points, _ = pinhole_model.line_intersect( + cam_1, + cam_2, + test_image_points_1, + test_image_points_2 + ) + + assert_array_almost_equal( + test_object_points, + recon_obj_points, + decimal=2 + ) + + +@pytest.mark.parametrize("case", ((1, 2), (1, 3), (2, 3), (1, 2, 3))) +def test_line_intersect_02( + case: tuple +): + cal_data = np.load(file) + n_cams = len(case) + + cal_obj_points = [] + cal_img_points = [] + for cam in case: + cal_obj_points.append(cal_data[f"obj_points_{cam}"]) + cal_img_points.append(cal_data[f"img_points_{cam}"]) + + cams = [] + for cam in case: + cams.append(get_test_camera_params(cam)) + + test_object_points = np.array( + [ + [0, 0, 0], + [1, 2, 3], + [3, 2, 1], + [15, 15, -15], + [15, 15, 0], + [15, 15, 15] + ], + dtype=cams[0].dtype + ).T + + test_image_points = [] + for cam in range(n_cams): + test_image_points.append( + cams[cam].project_points( + test_object_points + ) + ) + + recon_obj_points = pinhole_model.multi_line_intersect( + cams, + test_image_points + ) + + assert_array_almost_equal( + test_object_points, + recon_obj_points, + decimal=2 + ) + + +def test_save_parameters_1(): + cam = get_test_camera_params() + + cam.save_parameters( + "." + ) + + +@pytest.mark.parametrize("default", (True, False)) +def test_save_parameters_2( + default: bool +): + + if default: + cam = pinhole_model.camera( + "dummy", + resolution = [512, 512] + ) + else: + cam = get_test_camera_params() + + cam.save_parameters( + ".", "saved_params" + ) + + +def test_load_parameters_1(): + cam = pinhole_model.camera( + "dummy", + resolution = [512, 512] + ) + + with pytest.raises(FileNotFoundError): + cam.load_parameters( + ".", + "does not exist (hopefully)" + ) + + +def test_load_parameters_2(): + cam_orig = get_test_camera_params() + + + cam_orig.save_parameters( + ".", + "dummy" + ) + + cam_new = pinhole_model.camera( + "dummy", + resolution = [512, 512] + ) + + cam_new.load_parameters( + ".", + "dummy" + ) + assert_array_equal( + cam_orig.name, + cam_new.name + ) + + assert_array_equal( + cam_orig.resolution, + cam_new.resolution + ) + + assert_array_equal( + cam_orig.translation, + cam_new.translation + ) + + assert_array_equal( + cam_orig.orientation, + cam_new.orientation + ) + + assert_array_equal( + cam_orig.rotation, + cam_new.rotation + ) + + assert_array_equal( + cam_orig.distortion_model, + cam_new.distortion_model + ) + + assert_array_equal( + cam_orig.distortion1, + cam_new.distortion1 + ) + + assert_array_equal( + cam_orig.distortion2, + cam_new.distortion2 + ) + + assert_array_equal( + cam_orig.focal, + cam_new.focal + ) + + assert_array_equal( + cam_orig.principal, + cam_new.principal + ) + + assert_array_equal( + cam_orig.dtype, + cam_new.dtype + ) diff --git a/openpiv/test/test_calib_point_matching.py b/openpiv/test/test_calib_point_matching.py new file mode 100644 index 00000000..dfa9afff --- /dev/null +++ b/openpiv/test/test_calib_point_matching.py @@ -0,0 +1,131 @@ +import os + +import numpy as np +import pytest +from numpy.testing import ( + assert_, + assert_allclose, + assert_almost_equal, + assert_array_almost_equal, + assert_array_equal, + assert_equal, +) + +from openpiv.calibration import calib_utils, marker_match + + +def test_find_nearest_points_01(): + grid = calib_utils.get_simple_grid( + 10, 10, + 0, 0, 0, + spacing=10, + flip_y=False + )[:2] + + selected_points = np.array( + [ + [1, 1], + [2, 2], + [4, 4], + [6, 6], + [93, 93], + [110, 110] + ], + dtype="float64" + ).T + + expected_points = np.array( + [ + [0, 0], + [0, 0], + [0, 0], + [10, 10], + [90, 90], + [90, 90] + ], + dtype="float64" + ).T + + # without NaNs threshold + found_points = marker_match.find_nearest_points( + grid, + selected_points, + threshold=None, + flag_nans=False + ) + + assert_array_equal( + expected_points, + found_points + ) + + # with NaNs threshold + found_points = marker_match.find_nearest_points( + grid, + selected_points, + threshold=5, + flag_nans=True + ) + + assert_(np.isnan(found_points[0, 2])) + assert_(np.isnan(found_points[0, 3])) + assert_(np.isnan(found_points[0, 5])) + + assert_(np.isnan(found_points[1, 2])) + assert_(np.isnan(found_points[1, 3])) + assert_(np.isnan(found_points[1, 5])) + + nan_mask = ~np.isnan(found_points[0,:]) + + found_points = found_points[:, nan_mask] + expected_points = expected_points[:, nan_mask] + + assert_array_equal( + expected_points, + found_points + ) + + +def test_find_nearest_points_02(): + grid = calib_utils.get_simple_grid( + 10, 10, + 0, 0, 0, + spacing=10, + flip_y=False + )[:2] + + selected_points = np.array( + [ + [1, 1], + [2, 2], + [4, 4], + [6, 6], + [93, 93], + [110, 110] + ], + dtype="float64" + ).T + + expected_points = np.array( + [ + [0, 0], + [0, 0], +# [0, 0], # out of bound of threshold +# [10, 10], # out of bound of threshold + [90, 90], +# [90, 90] # out of bound of threshold + ], + dtype="float64" + ).T + + found_points = marker_match.find_nearest_points( + grid, + selected_points, + threshold=5.5, + flag_nans=False + ) + + assert_array_equal( + expected_points, + found_points + ) diff --git a/openpiv/test/test_calib_polynomial.py b/openpiv/test/test_calib_polynomial.py new file mode 100644 index 00000000..dd7604c2 --- /dev/null +++ b/openpiv/test/test_calib_polynomial.py @@ -0,0 +1,366 @@ +import os + +import numpy as np +import pytest +from numpy.testing import ( + assert_, + assert_allclose, + assert_almost_equal, + assert_array_almost_equal, + assert_array_equal, + assert_equal, +) + +from openpiv.calibration import poly_model +from openpiv.calibration.calib_utils import get_los_error, get_reprojection_error + +file = os.path.join(os.path.dirname(__file__),"test_calibration_points.npz") + +@pytest.mark.parametrize("args", ((), ("name",))) +def test_required_parameters_input(args): + with pytest.raises(TypeError): + poly_model.camera(*args) + + +@pytest.mark.parametrize("parameters", [ + {"name": 0}, + {"resolution": [0]}, + {"poly_wi": np.zeros(19)}, + {"poly_wi": np.zeros((10, 10))}, + {"poly_iw_o": np.zeros(10)}, + {"poly_iw_o": np.zeros((19, 2))}, + {"poly_iw_o": np.zeros((10, 10))}, + {"poly_iw_v": np.zeros(10)}, + {"poly_iw_v": np.zeros((19, 2))}, + {"poly_iw_v": np.zeros((10, 10))}, +]) +def test_parameters_input(parameters): + kwargs = {"name": "name", "resolution": [0, 0]} + kwargs.update(parameters) + with pytest.raises(ValueError): + poly_model.camera(**kwargs) + + +def test_parameters_initialization(): + cam = poly_model.camera( + "name", + resolution=[0, 0] + ) + + assert_(hasattr(cam, "name")) + assert_(hasattr(cam, "resolution")) + assert_(hasattr(cam, "poly_wi")) + assert_(hasattr(cam, "poly_iw_o")) + assert_(hasattr(cam, "poly_iw_v")) + assert_(hasattr(cam, "dtype")) + + assert_(len(cam.resolution) == 2) + + assert_equal( + cam.poly_wi.shape, + [19, 2] + ) + + assert_equal( + cam.poly_iw_o.shape, + [10, 2] + ) + + assert_equal( + cam.poly_iw_v.shape, + [10, 2] + ) + + assert_(cam.dtype in ["float32", "float64"]) + + +@pytest.mark.parametrize("case", (1, 2, 3)) +def test_minimization_01( + case: int +): + cal_data = np.load(file) + + cal_obj_points = cal_data[f"obj_points_{case}"] + cal_img_points = cal_data[f"img_points_{case}"] + + cam = poly_model.camera( + "poly", + resolution = [512, 512], + ) + + cam.minimize_params( + cal_obj_points, + cal_img_points + ) + + RMSE = get_reprojection_error( + cam, + cal_obj_points, + cal_img_points + ) + + assert_(RMSE < 1e-2) + + +@pytest.mark.parametrize("case", (1, 2)) +def test_projection_01( + case: int +): + cal_data = np.load(file) + + cal_obj_points = cal_data[f"obj_points_{case}"] + cal_img_points = cal_data[f"img_points_{case}"] + + cam = poly_model.camera( + "poly", + resolution = [512, 512], + ) + + cam.minimize_params( + cal_obj_points, + cal_img_points + ) + + obj_points = np.random.rand(3, 32) + obj_points[0, :] = np.int32(obj_points[0, :] * 50) + obj_points[1, :] = np.int32(obj_points[1, :] * 50) + obj_points[2, :] = np.int32(obj_points[2, :] * 10) + + obj_points = obj_points.astype("float64", copy=False) + + img_points = cam.project_points( + obj_points + ) + + recon_obj_points = cam.project_to_z( + img_points, + obj_points[2] + ) + + assert_array_almost_equal( + obj_points, + recon_obj_points, + decimal=2 + ) + + +@pytest.mark.parametrize("case", (1, 2, 3)) +def test_projection_02( + case: int +): + cal_data = np.load(file) + + cal_obj_points = cal_data[f"obj_points_{case}"] + cal_img_points = cal_data[f"img_points_{case}"] + + cam = poly_model.camera( + "poly", + resolution = [512, 512], + ) + + cam.minimize_params( + cal_obj_points, + cal_img_points + ) + + x, y = cam.project_points( + cal_obj_points + ) + + assert_array_almost_equal( + x, cal_img_points[0], + decimal=2 + ) + + assert_array_almost_equal( + y, cal_img_points[1], + decimal=2 + ) + +# Test case 1 and 3 needs higher thresholds due to camera malignment +@pytest.mark.parametrize("case", (1, 2, 3)) +def test_projection_03( + case: int +): + cal_data = np.load(file) + + cal_obj_points = cal_data[f"obj_points_{case}"] + cal_img_points = cal_data[f"img_points_{case}"] + + cam = poly_model.camera( + "poly", + resolution = [512, 512], + ) + + cam.minimize_params( + cal_obj_points, + cal_img_points + ) + + RMSE_0 = get_los_error( + cam, + z = -10 + ) + + RMSE_1 = get_los_error( + cam, + z = 0 + ) + + RMSE_2 = get_los_error( + cam, + z = 10 + ) + + assert_(RMSE_0 < 0.5) + assert_(RMSE_1 < 0.5) + assert_(RMSE_2 < 0.5) + + +@pytest.mark.parametrize("case", ((1, 2), (1, 3), (2, 3), (1, 2, 3))) +def test_line_intersect_01( + case: tuple +): + cal_data = np.load(file) + n_cams = len(case) + + cal_obj_points = [] + cal_img_points = [] + for cam in case: + cal_obj_points.append(cal_data[f"obj_points_{cam}"]) + cal_img_points.append(cal_data[f"img_points_{cam}"]) + + cams = [] + for cam in range(n_cams): + cam_1 = poly_model.camera( + "dummy", + resolution = [512, 512] + ) + + cam_1.minimize_params( + cal_obj_points[cam], + cal_img_points[cam] + ) + + cams.append(cam_1) + + test_object_points = np.array( + [ + [0, 0, 0], + [1, 2, 3], + [3, 2, 1], + [15, 15, -15], + [15, 15, 0], + [15, 15, 15] + ], + dtype=cam_1.dtype + ).T + + test_image_points = [] + for cam in range(n_cams): + test_image_points.append( + cams[cam].project_points( + test_object_points + ) + ) + + recon_obj_points = poly_model.multi_line_intersect( + cams, + test_image_points + ) + + assert_array_almost_equal( + test_object_points, + recon_obj_points, + decimal=0 + ) + + +def test_save_parameters_1(): + cam = poly_model.camera( + "dummy", + resolution = [512, 512] + ) + + cam.save_parameters( + "." + ) + + +def test_save_parameters_2(): + cam = poly_model.camera( + "dummy", + resolution = [512, 512] + ) + + + cam.save_parameters( + ".", "saved_params" + ) + + +def test_load_parameters_1(): + cam = poly_model.camera( + "dummy", + resolution = [512, 512] + ) + + with pytest.raises(FileNotFoundError): + cam.load_parameters( + ".", + "does not exist (hopefully)" + ) + + +def test_load_parameters_2(): + cam_orig = poly_model.camera( + "dummy", + resolution = [512, 512], + poly_iw_o = np.arange(20, dtype="float64").reshape(10, 2), + poly_iw_v = np.arange(20, 40, dtype="float64").reshape(10, 2) + ) + + cam_new = poly_model.camera( + "dummy", + resolution = [512, 512] + ) + + cam_orig.save_parameters( + ".", + "dummy" + ) + + cam_new.load_parameters( + ".", + "dummy" + ) + + assert_array_equal( + cam_orig.name, + cam_new.name + ) + + assert_array_equal( + cam_orig.resolution, + cam_new.resolution + ) + + assert_array_equal( + cam_orig.poly_wi, + cam_new.poly_wi + ) + + assert_array_equal( + cam_orig.poly_iw_o, + cam_new.poly_iw_o + ) + + assert_array_equal( + cam_orig.poly_iw_v, + cam_new.poly_iw_v + ) + + assert_array_equal( + cam_orig.dtype, + cam_new.dtype + ) diff --git a/openpiv/test/test_calib_stereo.py b/openpiv/test/test_calib_stereo.py new file mode 100644 index 00000000..1efca05e --- /dev/null +++ b/openpiv/test/test_calib_stereo.py @@ -0,0 +1,187 @@ +"""RED tests for stereo-PIV 1-4: calib, dewarp, disparity, 3C reconstruction.""" +import os + +import numpy as np +from numpy.testing import assert_, assert_array_almost_equal + +file = os.path.join(os.path.dirname(__file__), "test_calibration_points.npz") + + +def _get_stereo_cams(): + from openpiv.calibration import poly_model + cal = np.load(file) + cams = [] + for case in (1, 2): + obj = cal[f"obj_points_{case}"] + img = cal[f"img_points_{case}"] + cam = poly_model.camera("cam", resolution=[512, 512]) + cam.minimize_params(obj, img) + cams.append(cam) + return cams + + +def test_01_stereo_module_importable(): + # 1: stereo pair calibration helpers exist + from openpiv.calibration import stereo + for fn in ( + "get_common_world_grid", + "get_dewarp_mapping", + "dewarp_image", + "estimate_disparity", + "self_calibration", + "reconstruct_3c", + "triangulate", + ): + assert_(hasattr(stereo, fn), f"missing stereo.{fn}") + + +def test_02_common_grid_and_dewarp_roundtrip(): + # 2: dewarp to common world grid + from openpiv.calibration import stereo + cams = _get_stereo_cams() + Xw, Yw, info = stereo.get_common_world_grid(cams, z=0.0) + assert_(Xw.shape == Yw.shape) + assert_(Xw.size > 0) + # each cam must provide a finite mapping on that grid + for cam in cams: + map_x, map_y = stereo.get_dewarp_mapping(cam, Xw, Yw, z=0.0) + assert_(np.all(np.isfinite(map_x))) + assert_(np.all(np.isfinite(map_y))) + # center of common grid must fall inside both sensors + # (borders may legitimately extrapolate -> masked with cval) + ny, nx = Xw.shape + cx, cy = map_x[ny // 2, nx // 2], map_y[ny // 2, nx // 2] + assert_(0 <= cx <= 512 and 0 <= cy <= 512, f"center maps to {cx},{cy}") + inside = ( + (map_x >= 0) & (map_x <= 512) & (map_y >= 0) & (map_y <= 512) + ).mean() + assert_(inside > 0.5, f"only {inside:.2f} of common grid inside sensor") + # roundtrip: world -> image -> world at z=0 within 1px reprojection + x0, y0 = Xw.ravel()[:32], Yw.ravel()[:32] + z0 = np.zeros_like(x0) + for cam in cams: + img = cam.project_points(np.array([x0, y0, z0])) + back = cam.project_to_z(img, z0) + assert_array_almost_equal(back[0], x0, decimal=1) + assert_array_almost_equal(back[1], y0, decimal=1) + + +def test_03_disparity_estimation_recovers_known_shift(): + # 3: disparity correction after calibration + from openpiv.calibration import stereo + rng = np.random.default_rng(0) + # synthetic particle-like dewarped pair with known integer shift + H, W = 256, 256 + imgL = np.zeros((H, W), dtype=np.float32) + ys = rng.integers(0, H, 400) + xs = rng.integers(0, W, 400) + imgL[ys, xs] = 255.0 + from scipy.ndimage import gaussian_filter + imgL = gaussian_filter(imgL, 1.0) + dx_true, dy_true = 3.0, -2.0 + from scipy.ndimage import shift as ndshift + imgR = ndshift(imgL, shift=(dy_true, dx_true), order=1, mode="constant") + x, y, dx, dy, _ = stereo.estimate_disparity( + imgL, imgR, window_size=32, overlap=16 + ) + # median over valid vectors must recover shift to ~0.5px (nanmedian: + # isolated interrogation windows may legitimately return nan) + assert_(abs(np.nanmedian(dx) - dx_true) < 0.7, f"dx median {np.nanmedian(dx)}") + assert_(abs(np.nanmedian(dy) - dy_true) < 0.7, f"dy median {np.nanmedian(dy)}") + + +def test_04_reconstruct_3c_recovers_known_displacement(): + # 4: higher-quality 3C result from two 2C views + from openpiv.calibration import stereo + cams = _get_stereo_cams() + # known world points + known world displacement + rng = np.random.default_rng(1) + X = rng.uniform(-20, 20, 12) + Y = rng.uniform(-20, 20, 12) + Z = np.zeros_like(X) + U_true = np.full_like(X, 2.0) + V_true = np.full_like(X, -1.0) + W_true = np.full_like(X, 0.5) + dt = 1.0 + # forward-project to pixel displacements per camera + u_list, v_list, x_list, y_list = [], [], [], [] + for cam in cams: + img0 = cam.project_points(np.array([X, Y, Z])) + img1 = cam.project_points(np.array([X + U_true * dt, Y + V_true * dt, Z + W_true * dt])) + u_list.append(img1[0] - img0[0]) + v_list.append(img1[1] - img0[1]) + x_list.append(img0[0]) + y_list.append(img0[1]) + Xw = np.array([X, Y, Z]) + U, V, W = stereo.reconstruct_3c(cams, x_list, y_list, u_list, v_list, Xw, dt=dt) + assert_array_almost_equal(U, U_true, decimal=1) + assert_array_almost_equal(V, V_true, decimal=1) + assert_array_almost_equal(W, W_true, decimal=1) + + +def test_05_self_calibration_reduces_residual(): + from openpiv.calibration import stereo + cams = _get_stereo_cams() + rng = np.random.default_rng(2) + H, W = 256, 256 + img = np.zeros((H, W), dtype=np.float32) + ys = rng.integers(0, H, 500) + xs = rng.integers(0, W, 500) + img[ys, xs] = 255.0 + from scipy.ndimage import gaussian_filter + from scipy.ndimage import shift as ndshift + img = gaussian_filter(img, 1.0) + # simulate mis-registered right camera by 2px + imgL, imgR = img, ndshift(img, shift=(1.0, 2.0), order=1, mode="constant") + out = stereo.self_calibration( + cams, imgL, imgR, z=0.0, window_size=32, overlap=16, max_iter=2 + ) + assert_("residual_before" in out and "residual_after" in out) + assert_(out["residual_after"] <= out["residual_before"]) + + +def test_07_correct_maps_cancels_the_disparity(): + # regression: the symmetric map correction must *reduce* the disparity + # (the left camera samples the map at W - D/2, the right one at W + D/2) + from scipy.ndimage import gaussian_filter + from scipy.ndimage import shift as ndshift + + from openpiv.calibration import stereo + rng = np.random.default_rng(5) + H, W = 256, 256 + img = np.zeros((H, W), dtype=np.float32) + img[rng.integers(0, H, 500), rng.integers(0, W, 500)] = 255.0 + img = gaussian_filter(img, 1.0) + dx_true, dy_true = 3.0, -2.0 + imgR = ndshift(img, shift=(dy_true, dx_true), order=1, mode="constant") + x, y, dx, dy, _ = stereo.estimate_disparity(img, imgR, window_size=32, overlap=16) + before = float(np.nanmedian(np.hypot(dx, dy))) + identity = ( + np.tile(np.arange(W, dtype=float), (H, 1)), + np.tile(np.arange(H, dtype=float)[:, None], (1, W)), + np.tile(np.arange(W, dtype=float), (H, 1)), + np.tile(np.arange(H, dtype=float)[:, None], (1, W)), + ) + maps = stereo.correct_maps(identity, x, y, dx, dy, factor=0.5) + left = stereo.dewarp_image(img, maps[0], maps[1]) + right = stereo.dewarp_image(imgR, maps[2], maps[3]) + _, _, dx2, dy2, _ = stereo.estimate_disparity(left, right, window_size=32, overlap=16) + after = float(np.nanmedian(np.hypot(dx2, dy2))) + assert_(after < 0.25 * before, f"disparity {before:.3f} px -> {after:.3f} px") + + +def test_06_triangulate_and_image_mapping(): + from openpiv.calibration import calib_utils, stereo + cams = _get_stereo_cams() + # legacy single-cam mapping still works and is finite + for cam in cams: + m, scale = calib_utils.get_image_mapping(cam) + assert_(m.shape == (2, 512, 512)) + assert_(np.all(np.isfinite(m)) and scale > 0) + # generic triangulation recovers known world point + Xtrue = np.array([[5.0], [3.0], [0.0]]) + imgs = [c.project_points(Xtrue) for c in cams] + Xt = stereo.triangulate(cams, imgs) + assert_array_almost_equal(Xt, Xtrue, decimal=3) + errs = stereo.stereo_reprojection_error(cams, Xtrue, imgs) + assert_(errs["triangulation_rmse"] < 1e-2) diff --git a/openpiv/test/test_calib_target_grids.py b/openpiv/test/test_calib_target_grids.py new file mode 100644 index 00000000..755eec4e --- /dev/null +++ b/openpiv/test/test_calib_target_grids.py @@ -0,0 +1,88 @@ +import os + +import numpy as np +import pytest +from numpy.testing import ( + assert_, + assert_allclose, + assert_almost_equal, + assert_array_almost_equal, + assert_array_equal, + assert_equal, +) + +from openpiv.calibration import poly_model +from openpiv.calibration.calib_utils import get_asymmetric_grid, get_simple_grid + + +def test_simple_grid_01(): + grid_shape = (9, 9) + origin = np.array(grid_shape) // 2 + z_plane = 0 + spacing = 10 + + grid = get_simple_grid( + grid_shape[0], grid_shape[1], + z_plane, + origin[0], origin[1], + spacing=spacing + ) + + # check horizontal and vertical spacing + assert_((grid[0, 1] - grid[0, 0]) == spacing) + assert_((grid[1, 1] - grid[1, grid_shape[1]]) == spacing) + + # make sure z-plane is correct + assert_(np.all(grid[2, :] == z_plane)) + + # check grid shape + assert_equal( + grid.shape, + (3, np.prod(grid_shape)) + ) + + # make sure origin is 0 + grid = grid.reshape((3, grid_shape[0], grid_shape[1])) + + origin = grid[:2, origin[0], origin[1]] + + assert_(origin[0] == 0) + assert_(origin[1] == 0) + + +def test_asymmetric_grid_01(): + grid_shape = (9, 9) + origin = np.array(grid_shape) // 2 + + z_plane = 0 + spacing = 10 + + grid = get_asymmetric_grid( + grid_shape[0], grid_shape[1], + z_plane, + origin[0], origin[1], + spacing=spacing + ) + + # check horizontal and vertical spacing + assert_((grid[0, 1] - grid[0, 0]) == (spacing * 2)) + assert_((grid[1, 1] - grid[1, grid_shape[1]]) == (spacing * 2)) + + # make sure z-plane is correct + assert_(np.all(grid[2, :] == z_plane)) + + # check grid shape + grid_length = int(np.prod(grid_shape) / 2 + 0.5) + + assert_equal( + grid.shape, + (3, grid_length) + ) + + # make sure origin is 0 + origin_ind = int(grid_length / 2) + + origin = grid[:2, origin_ind] + + assert_(origin[0] == 0) + assert_(origin[1] == 0) diff --git a/openpiv/test/test_calib_utils.py b/openpiv/test/test_calib_utils.py new file mode 100644 index 00000000..e433a051 --- /dev/null +++ b/openpiv/test/test_calib_utils.py @@ -0,0 +1,137 @@ +import os + +import numpy as np +import pytest +from numpy.testing import ( + assert_, + assert_allclose, + assert_almost_equal, + assert_array_almost_equal, + assert_equal, +) + +from openpiv.calibration import calib_utils, dlt_model + +file = os.path.join(os.path.dirname(__file__),"test_calibration_points.npz") + + +# this function is not used yet, but will be when more utility functions are tested +def get_camera_instance(): + cal_data = np.load(file) + + case = 1 + + cal_obj_points = cal_data[f"obj_points_{case}"] + cal_img_points = cal_data[f"img_points_{case}"] + + cam = dlt_model.camera( + "dummy", + resolution = [512, 512], + ) + + cam.minimize_params( + cal_obj_points, + cal_img_points + ) + + return cam + + +def test_homogenize_01(): + shape = (2, 24) + expected = (shape[0] + 1, shape[1]) + + img_points = np.random.rand(shape[0], shape[1]) + + homogenized = calib_utils.homogenize(img_points) + + assert_equal( + homogenized.shape, + expected + ) + assert_(np.all(homogenized[2, :] == 1)) + + +def test_get_rmse_01(): + shape = 24 + offset = 1.5 + + test_array = np.zeros(shape, dtype = "float64") + + error = (test_array + offset) - test_array + + rmse = calib_utils.get_rmse(error) + + assert_equal( + rmse, + offset + ) + + +def test_get_rmse_02(): + shape = (2, 24) + offset = 2 + expected = np.sqrt(np.power(2, 3)) + + test_array = np.zeros(shape, dtype = "float64") + + error = (test_array + offset) - test_array + + rmse = calib_utils.get_rmse(error) + + assert_equal( + rmse, + expected + ) + + +def test_get_rmse_03(): + shape = (2, 24, 5) + offset = 2 + np.sqrt(np.power(2, 3)) + + test_array = np.zeros(shape, dtype = "float64") + + error = (test_array + offset) - test_array + + with pytest.raises(ValueError): + # invalid shape, get_rmse expects a 1D or 2D array + calib_utils.get_rmse(error) + + +def test_reprojection_error_01(): + cam = get_camera_instance() + + offset = 0.4 + + test_object_points = np.array( + [ + [0, 0, 0], + [1, 2, 3], + [3, 2, 1], + [15, 15, -15], + [15, 15, 0], + [15, 15, 15] + ], + dtype=cam.dtype + ).T + + test_image_points = cam.project_points(test_object_points) + + # Our definition of RMSE + error = test_image_points - (test_image_points + offset) + + expected = np.sqrt(np.mean(np.sum(np.square(error), 0))) + + # Now use the error function + results = calib_utils.get_reprojection_error( + cam, + test_object_points, + test_image_points + offset + ) + + assert_array_almost_equal( + expected, + results, + decimal=4 + ) diff --git a/openpiv/test/test_calibration_pinhole_1.txt b/openpiv/test/test_calibration_pinhole_1.txt new file mode 100644 index 00000000..86c948a5 --- /dev/null +++ b/openpiv/test/test_calibration_pinhole_1.txt @@ -0,0 +1,7 @@ +minimized +512 512 +-60.95 42.11 535.14 +-0.003 2.860 3.037 +0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 +1000 1000 +256.0 256.0 \ No newline at end of file diff --git a/openpiv/test/test_calibration_pinhole_2.txt b/openpiv/test/test_calibration_pinhole_2.txt new file mode 100644 index 00000000..8fdf829c --- /dev/null +++ b/openpiv/test/test_calibration_pinhole_2.txt @@ -0,0 +1,7 @@ +minimized +512 512 +48.91 83.24 563.35 +-0.083 3.094 3.184 +0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 +1000 1000 +256.0 256.0 \ No newline at end of file diff --git a/openpiv/test/test_calibration_pinhole_3.txt b/openpiv/test/test_calibration_pinhole_3.txt new file mode 100644 index 00000000..01764414 --- /dev/null +++ b/openpiv/test/test_calibration_pinhole_3.txt @@ -0,0 +1,7 @@ +minimized +512 512 +131.26 35.2 550.2 +0.034 3.360 3.075 +0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 +1000 1000 +256.0 256.0 \ No newline at end of file diff --git a/openpiv/test/test_calibration_points.npz b/openpiv/test/test_calibration_points.npz new file mode 100644 index 00000000..e8467910 Binary files /dev/null and b/openpiv/test/test_calibration_points.npz differ diff --git a/openpiv/test/test_dual_backends.py b/openpiv/test/test_dual_backends.py index 988a2a5d..197f6c86 100644 --- a/openpiv/test/test_dual_backends.py +++ b/openpiv/test/test_dual_backends.py @@ -1,8 +1,10 @@ -import pytest -import numpy as np -from pathlib import Path from importlib.resources import files -from openpiv import pyprocess, piv, tools, windef +from pathlib import Path + +import numpy as np +import pytest + +from openpiv import piv, pyprocess, tools, windef openpiv_rust = pytest.importorskip("openpiv_rust") diff --git a/openpiv/test/test_examples_stereo.py b/openpiv/test/test_examples_stereo.py new file mode 100644 index 00000000..bfd491fa --- /dev/null +++ b/openpiv/test/test_examples_stereo.py @@ -0,0 +1,483 @@ +"""Tests for the stereo-PIV example package (``openpiv.examples.stereo``).""" + +import os + +import numpy as np +import pytest +from numpy.testing import assert_, assert_allclose, assert_array_almost_equal + +from openpiv.examples.stereo import ( + StereoConfig, + build_dewarp_maps, + build_world_grid, + common_marks, + correct_disparity, + davis, + fit_cameras, + make_camera, + pipeline, + reconstruct_3c, + reprojection_report, + run_2c_piv, + triangulation_report, +) + +file = os.path.join(os.path.dirname(__file__), "test_calibration_points.npz") + +MARK_TABLE_XML = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + +CALIBRATION_XML = """ + + + + + + + + + + + + + + + + +""" + + +@pytest.fixture +def mark_table(tmp_path): + path = tmp_path / "MarkPositionTable.xml" + path.write_text(MARK_TABLE_XML) + return path + + +@pytest.fixture +def calibration_xml(tmp_path): + path = tmp_path / "Calibration.xml" + path.write_text(CALIBRATION_XML) + return path + + +@pytest.fixture +def stereo_cams(): + """Two cameras fitted to the bundled synthetic calibration points.""" + data = np.load(file) + marks = { + 1: davis.CameraMarks(1, data["obj_points_1"], data["img_points_1"]), + 2: davis.CameraMarks(2, data["obj_points_2"], data["img_points_2"]), + } + cams = fit_cameras("poly", (512, 512), marks, (1, 2)) + return cams, marks + + +# ---------------------------------------------------------------------- +# DaVis XML readers +# ---------------------------------------------------------------------- +def test_parse_mark_position_table(mark_table): + marks = davis.parse_mark_position_table(mark_table) + assert_(set(marks) == {1, 2}) + assert_(marks[1].count == 3) + assert_(marks[2].count == 2) + assert_allclose(marks[1].object_points[:, 0], [-90.0, 30.0, 0.0]) + assert_allclose(marks[1].image_points[:, 0], [294.379, 173.073]) + planes = marks[1].planes() + assert_(set(planes) == {0.0, -3.0}) + assert_(planes[-3.0].tolist() == [2]) + + +def test_parse_mark_position_table_missing_file(tmp_path): + with pytest.raises(OSError): + davis.parse_mark_position_table(tmp_path / "nope.xml") + + +def test_parse_calibration_rms(calibration_xml): + rms = davis.parse_calibration_rms(calibration_xml) + assert_(set(rms) == {1, 2}) + assert_allclose(rms[1], 0.39570367680180918) + assert_allclose(rms[2], 0.36118708410534223) + + +def test_parse_plate_info(mark_table): + info = davis.parse_plate_info(mark_table) + assert_(info.identifier == "204-15") + assert_allclose(info.mark_spacing_mm, 15.0) + assert_allclose(info.plane_to_plane_distance_mm, 3.0) + + +def test_camera_marks_validates_shapes(): + with pytest.raises(ValueError): + davis.CameraMarks(1, np.zeros((2, 3)), np.zeros((2, 3))) + with pytest.raises(ValueError): + davis.CameraMarks(1, np.zeros((3, 3)), np.zeros((2, 2))) + + +# ---------------------------------------------------------------------- +# configuration +# ---------------------------------------------------------------------- +def test_config_defaults(tmp_path): + config = StereoConfig(data_root=tmp_path) + assert_(config.cameras == (1, 2)) + assert_(config.sensor_size == (2432, 2048)) + assert_(config.sheet_z == 0.0) + assert_(config.model == "poly") + assert_(config.out_dir == tmp_path / "stereo_out") + + +def test_config_rejects_single_camera(tmp_path): + with pytest.raises(ValueError): + StereoConfig(data_root=tmp_path, cameras=(1,)) + with pytest.raises(ValueError): + StereoConfig(data_root=tmp_path, model="nope") + + +def test_config_from_env(monkeypatch, tmp_path): + monkeypatch.setenv("OPENPIV_STEREO_DATA_ROOT", str(tmp_path)) + monkeypatch.setenv("OPENPIV_STEREO_CAMERAS", "3,4") + monkeypatch.setenv("OPENPIV_STEREO_SENSOR_SIZE", "1024,768") + monkeypatch.setenv("OPENPIV_STEREO_SHEET_Z", "-3") + monkeypatch.setenv("OPENPIV_STEREO_PAIR_INDEX", "7") + config = StereoConfig.from_env() + assert_(config.data_root == tmp_path) + assert_(config.cameras == (3, 4)) + assert_(config.sensor_size == (1024, 768)) + assert_(config.sheet_z == -3.0) + assert_(config.pair_index == 7) + + +def test_config_from_args(tmp_path, monkeypatch): + monkeypatch.delenv("OPENPIV_STEREO_DATA_ROOT", raising=False) + config = StereoConfig.from_args( + [ + "--data-root", str(tmp_path), + "--cameras", "2,1", + "--sensor-size", "640,480", + "--sheet-z", "1.5", + "--model", "dlt", + ] + ) + assert_(config.data_root == tmp_path) + assert_(config.cameras == (2, 1)) + assert_(config.sensor_size == (640, 480)) + assert_(config.sheet_z == 1.5) + assert_(config.model == "dlt") + + +def test_config_ensure_out(tmp_path): + config = StereoConfig(data_root=tmp_path) + out = config.ensure_out("previews") + assert_(out.is_dir()) + assert_(out.name == "previews") + + +# ---------------------------------------------------------------------- +# pipeline +# ---------------------------------------------------------------------- +def test_make_camera_unknown_model(): + with pytest.raises(ValueError): + make_camera("nope", "cam", (512, 512)) + + +def test_fit_cameras_and_reprojection_report(stereo_cams): + cams, marks = stereo_cams + assert_(len(cams) == 2) + report = reprojection_report(cams, marks, (1, 2)) + assert_(len(report) == 2) + for entry in report: + assert_(entry["rms"] < 1.0, f"rms {entry['rms']}") + assert_(entry["marks"] == 605) + + +def test_fit_cameras_missing_marks(stereo_cams): + _, marks = stereo_cams + with pytest.raises(KeyError): + fit_cameras("poly", (512, 512), marks, (1, 3)) + + +def test_build_world_grid_and_dewarp_maps(stereo_cams): + cams, _ = stereo_cams + Xw, Yw, info = build_world_grid(cams, z=0.0) + assert_(Xw.shape == Yw.shape) + assert_(Xw.size > 0) + assert_(info["scale"] > 0) + maps = build_dewarp_maps(cams, Xw, Yw, z=0.0) + assert_(set(maps) == {"mLx", "mLy", "mRx", "mRy"}) + for value in maps.values(): + assert_(value.shape == Xw.shape) + assert_(np.all(np.isfinite(value))) + + +def test_rectify_is_inverse_of_projection(stereo_cams): + cams, _ = stereo_cams + Xw, Yw, _ = build_world_grid(cams, z=0.0) + maps = build_dewarp_maps(cams, Xw, Yw, z=0.0) + # pick a world node inside both sensors, project it and sample the + # rectified image at the projected position + X, Y = Xw[10, 10], Yw[10, 10] + projected = cams[0].project_points(np.array([[X], [Y], [0.0]])) + image = np.zeros((512, 512), dtype=np.float32) + px, py = round(projected[0][0]), round(projected[1][0]) + if 0 <= px < 512 and 0 <= py < 512: + image[py, px] = 1.0 + rect = pipeline.rectify(image, maps["mLx"], maps["mLy"]) + assert_(rect[10, 10] > 0.5) + + +def test_common_marks_and_triangulation_report(stereo_cams): + cams, marks = stereo_cams + world, image_first, image_second = common_marks(marks[1], marks[2]) + assert_(world.shape == (3, 605)) + assert_(image_first.shape == (2, 605)) + assert_(image_second.shape == (2, 605)) + report = triangulation_report(cams, marks[1], marks[2]) + assert_(report["count"] == 605) + assert_(report["median"] < 0.5, f"triangulation median {report['median']}") + assert_(report["planes"]) + + +def test_run_2c_piv_recovers_shift(): + from scipy.ndimage import gaussian_filter + from scipy.ndimage import shift as ndshift + + rng = np.random.default_rng(3) + H, W = 256, 256 + image = np.zeros((H, W), dtype=np.float32) + image[rng.integers(0, H, 400), rng.integers(0, W, 400)] = 255.0 + image = gaussian_filter(image, 1.0) + moved = ndshift(image, shift=(1.0, 2.0), order=1, mode="constant") + x, y, u, v, s2n = run_2c_piv(image, moved, window_size=32, overlap=16) + assert_(u.shape == v.shape == s2n.shape) + assert_(abs(np.nanmedian(u) - 2.0) < 0.7, f"u median {np.nanmedian(u)}") + assert_(abs(np.nanmedian(v) - 1.0) < 0.7, f"v median {np.nanmedian(v)}") + + +def test_correct_disparity_reports_residual(): + from scipy.ndimage import gaussian_filter + from scipy.ndimage import shift as ndshift + + rng = np.random.default_rng(4) + H, W = 256, 256 + image = np.zeros((H, W), dtype=np.float32) + image[rng.integers(0, H, 500), rng.integers(0, W, 500)] = 255.0 + image = gaussian_filter(image, 1.0) + right = ndshift(image, shift=(1.0, 2.0), order=1, mode="constant") + identity = { + "mLx": np.tile(np.arange(W, dtype=float), (H, 1)), + "mLy": np.tile(np.arange(H, dtype=float)[:, None], (1, W)), + "mRx": np.tile(np.arange(W, dtype=float), (H, 1)), + "mRy": np.tile(np.arange(H, dtype=float)[:, None], (1, W)), + } + maps, report = correct_disparity( + [image, right], identity, disparity_passes=((64, 32), (32, 16)), iterations=1 + ) + assert_(set(maps) == {"mLx", "mLy", "mRx", "mRy"}) + assert_(report["residual_after"] <= report["residual_before"]) + assert_(report["bulk_shift"][0] != 0.0) + + +def test_reconstruct_3c_recovers_known_velocity(stereo_cams): + """The 3C reconstruction must recover a known world velocity. + + The 2C fields are built analytically from the calibration: for every + interrogation node the pixel displacement of a rigid world motion. + """ + from openpiv import pyprocess + + cams, _ = stereo_cams + window, overlap = 32, 16 + shape = tuple(int(s) for s in pyprocess.get_field_shape((512, 512), window, overlap)) + x, y = pyprocess.get_rect_coordinates((512, 512), window, overlap) + velocity = (0.5, -0.25, 0.1) + + fields = [] + valid = None + for cam in cams: + world_x, world_y = pipeline.backproject_to_plane(cam, x.ravel(), y.ravel(), z=0.0) + world = np.array([world_x, world_y, np.zeros_like(world_x)]) + jacobian = np.stack( + [pipeline.projection_jacobian(cam, world[:, i]) for i in range(world.shape[1])] + ) + displacement = np.einsum('nki,k->ni', jacobian, np.array(velocity)) + fields.append((displacement[:, 0].reshape(shape), displacement[:, 1].reshape(shape))) + # the polynomial model is only valid inside the calibrated volume + forward = np.asarray(cam.project_points(world), dtype=np.float64) + node_valid = np.all(np.isfinite(forward), axis=0).reshape(shape) + valid = node_valid if valid is None else (valid & node_valid) + + U, V, W = pipeline.reconstruct_3c( + cams, + [x, x], + [y, y], + [fields[0][0], fields[1][0]], + [fields[0][1], fields[1][1]], + z=0.0, + ) + mask = valid.ravel() & np.isfinite(U) & np.isfinite(V) & np.isfinite(W) + assert_(mask.mean() > 0.8, f"only {mask.mean():.2f} of the nodes are usable") + assert_allclose(U[mask], velocity[0], atol=0.05) + assert_allclose(V[mask], velocity[1], atol=0.05) + assert_allclose(W[mask], velocity[2], atol=0.05) + + +# ---------------------------------------------------------------------- +# command line interface +# ---------------------------------------------------------------------- +def test_cli_requires_a_step(capsys): + from openpiv.examples.stereo.__main__ import main + + assert_(main([]) == 2) + + +def test_cli_reports_missing_files(tmp_path, capsys): + from openpiv.examples.stereo.__main__ import main + + code = main(["--data-root", str(tmp_path), "--cameras", "1,2", "info"]) + assert_(code == 1) + assert_("error" in capsys.readouterr().err) + + +# ---------------------------------------------------------------------- +# DaVis vector fields and the comparison +# ---------------------------------------------------------------------- +def test_vector_field_grid_follows_the_image(): + from openpiv.examples.stereo.davis import vector_field_grid + + # DaVis' corrected image counts its rows downwards, the world Y axis + # points upwards -> the scale factor is negative + x, y = vector_field_grid(0.0848439, -95.916, -0.0848439, 47.389, (3, 4), spacing_px=16) + assert_(len(x) == 4) + assert_(len(y) == 3) + assert_allclose(x[0], -95.916) + assert_allclose(x[1] - x[0], 16 * 0.0848439) + assert_allclose(y[0], 47.389) + assert_(y[1] < y[0], "the world Y must decrease with the image row") + + +def test_vector_field_world_components_flip_v(): + from openpiv.examples.stereo.davis import VectorField + + field = VectorField( + u=np.ones((2, 2)), + v=np.ones((2, 2)), + w=np.zeros((2, 2)), + x=np.array([0.0, 1.0]), + y=np.array([1.0, 0.0]), + valid=np.ones((2, 2), dtype=bool), + ) + assert_allclose(field.u_world, 1.0) + assert_allclose(field.v_world, -1.0) + + +def test_compare_fields_recovers_a_known_difference(): + grid_x = np.linspace(0.0, 10.0, 11) + grid_y = np.linspace(0.0, 10.0, 11) + reference = np.zeros((11, 11)) + reference[2:9, 2:9] = 0.5 + # a varying reference, otherwise the correlation is undefined + reference[2:9, 2:9] += np.linspace(0.0, 0.4, 7)[None, :] + our_x, our_y = np.meshgrid(grid_x, grid_y) + ours = reference.ravel() + 0.01 + stats = pipeline.compare_fields( + ours, reference, grid_x, grid_y, our_x.ravel(), our_y.ravel(), + reference != 0, + ) + assert_(stats["n"] == 49) + assert_allclose(stats["bias"], 0.01, atol=1e-9) + assert_allclose(stats["rmse"], 0.01, atol=1e-9) + assert_allclose(stats["correlation"], 1.0, atol=1e-9) + + +def test_compare_fields_requires_overlap(): + grid_x = np.linspace(0.0, 1.0, 3) + grid_y = np.linspace(0.0, 1.0, 3) + reference = np.ones((3, 3)) + with pytest.raises(ValueError): + pipeline.compare_fields( + np.ones(3), reference, grid_x, grid_y, + np.array([100.0, 101.0, 100.5]), np.array([100.0, 100.5, 101.0]), + np.ones((3, 3), dtype=bool), + ) + + +def test_parse_scales(): + from openpiv.examples.stereo.davis import parse_scales + + attributes = { + # the "_SCALE_*" layout: " " on the first line + "_SCALE_I": "0.058513 0\nm/s\nvelocity", + "_SCALE_X": "0.0848439 -95.916\nmm\n", + "_SCALE_Y": "-0.0848439 47.389\nmm\n", + } + scales = parse_scales(attributes) + assert_allclose(scales["scale"], 0.058513) + assert_allclose(scales["scale_x"], 0.0848439) + assert_allclose(scales["offset_x"], -95.916) + assert_allclose(scales["scale_y"], -0.0848439) + assert_allclose(scales["offset_y"], 47.389) + # the "FrameScale*0" layout: value and offset on separate lines + other = { + "FrameScaleI0": "0.058513\n0\nm/s\nvelocity", + "FrameScaleX0": "0.0848439\n-95.916\nmm\n", + "FrameScaleY0": "-0.0848439\n47.389\nmm\n", + } + scales = parse_scales(other) + assert_allclose(scales["scale"], 0.058513) + assert_allclose(scales["offset_x"], -95.916) + assert_allclose(scales["offset_y"], 47.389) + + +# ---------------------------------------------------------------------- +# the cycle survey +# ---------------------------------------------------------------------- +def test_survey_summarize_and_dispersion(): + from openpiv.examples.stereo.survey import summarize_survey, survey_dispersion + + records = [ + {"pair": 0, "camera": 1, "median_u": 0.5, "median_v": 1.0, "median_speed": 1.1, "n_vectors": 10}, + {"pair": 1, "camera": 1, "median_u": -0.4, "median_v": 1.1, "median_speed": 1.2, "n_vectors": 10}, + {"pair": 2, "camera": 1, "median_u": 0.0, "median_v": 1.2, "median_speed": 1.2, "n_vectors": 10}, + {"pair": 3, "camera": 1, "median_u": 2.0, "median_v": 0.0, "median_speed": 2.0, "n_vectors": 10}, + ] + summary = summarize_survey(records) + assert_(summary["n_pairs"] == 4) + assert_(summary["best"][0]["pair"] == 3) + assert_allclose(summary["max_speed"], 2.0) + dispersion = survey_dispersion(records, noise_level=0.1) + # three pairs are above the noise level, and the signed displacement + # changes sign twice + assert_allclose(dispersion["fraction_above_noise"], 0.75) + assert_(dispersion["sign_changes"] == 2) diff --git a/openpiv/test/test_filters.py b/openpiv/test/test_filters.py index 280b89a3..988dd480 100644 --- a/openpiv/test/test_filters.py +++ b/openpiv/test/test_filters.py @@ -1,8 +1,9 @@ -from openpiv import filters -from openpiv.lib import replace_nans import numpy as np import pytest +from openpiv import filters +from openpiv.lib import replace_nans + def test_gaussian_kernel(): """ test of a _gaussian_kernel """ @@ -28,7 +29,7 @@ def test_gaussian_kernel_function(): kernel = filters.gaussian_kernel(1.0) assert kernel.shape == (9, 9) # Should be 2*radius+1 where radius = int(truncate*sigma+0.5) assert np.isclose(np.sum(kernel), 1.0) # Kernel should be normalized - + # Test with different sigma and truncate values kernel = filters.gaussian_kernel(0.5, truncate=2.0) assert kernel.shape == (3, 3) # Should be 2*radius+1 where radius = int(truncate*sigma+0.5) @@ -103,28 +104,28 @@ def test_replace_outliers_with_w(): u = np.ma.array(np.ones((5, 5)), mask=np.ma.nomask) v = np.ma.array(np.ones((5, 5)), mask=np.ma.nomask) w = np.ma.array(np.ones((5, 5)), mask=np.ma.nomask) - + # Add some masked values u[3:, 3:] = np.ma.masked v[3:, 3:] = np.ma.masked w[3:, 3:] = np.ma.masked - + # Create copies for comparison u_copy = np.ma.copy(u) v_copy = np.ma.copy(v) w_copy = np.ma.copy(w) - + # Add some NaN values u[1, 1] = np.nan v[1, 1] = np.nan w[1, 1] = np.nan - + # Create invalid mask invalid_mask = np.isnan(u.data) - + # Call replace_outliers with w parameter uf, vf, wf = filters.replace_outliers(u, v, invalid_mask, w=w) - + # Check results assert np.ma.allclose(u_copy, uf) assert np.ma.allclose(v_copy, vf) @@ -139,29 +140,29 @@ def test_replace_outliers_different_methods(): # Create test data u = np.ma.array(np.ones((7, 7)), mask=np.ma.nomask) v = np.ma.array(np.ones((7, 7)), mask=np.ma.nomask) - + # Add some masked values u[5:, 5:] = np.ma.masked v[5:, 5:] = np.ma.masked - + # Add some NaN values in a pattern u[1:4, 1:4] = np.nan v[1:4, 1:4] = np.nan - + # Create invalid mask invalid_mask = np.isnan(u.data) - + # Test different methods for method in ['localmean', 'disk', 'distance']: uf, vf = filters.replace_outliers( - u.copy(), v.copy(), invalid_mask, + u.copy(), v.copy(), invalid_mask, method=method, max_iter=10, kernel_size=2 ) - + # Check that NaNs were replaced assert not np.any(np.isnan(uf)) assert not np.any(np.isnan(vf)) - + # Check that masks are preserved assert np.all(uf.mask[5:, 5:]) assert np.all(vf.mask[5:, 5:]) @@ -172,17 +173,17 @@ def test_replace_outliers_non_masked_input(): # Create regular numpy arrays (not masked) u = np.ones((5, 5)) v = np.ones((5, 5)) - + # Add some NaN values u[1, 1] = np.nan v[1, 1] = np.nan - + # Create invalid mask invalid_mask = np.isnan(u) - + # Call replace_outliers uf, vf = filters.replace_outliers(u, v, invalid_mask) - + # Check results assert isinstance(uf, np.ma.MaskedArray) assert isinstance(vf, np.ma.MaskedArray) diff --git a/openpiv/test/test_lib.py b/openpiv/test/test_lib.py index b4fdbffe..7a4a039e 100644 --- a/openpiv/test/test_lib.py +++ b/openpiv/test/test_lib.py @@ -1,6 +1,7 @@ import numpy as np import pytest -from openpiv.lib import replace_nans, get_dist + +from openpiv.lib import get_dist, replace_nans def test_replace_nans_2d(): @@ -79,7 +80,7 @@ def test_replace_nans_convergence(): # Check that both methods replaced some NaNs assert np.sum(np.isnan(filled_low_tol)) < np.sum(np.isnan(array)) assert np.sum(np.isnan(filled_high_tol)) < np.sum(np.isnan(array)) - + # Check that original values are preserved assert np.allclose(filled_low_tol[0, 0], 1.0) assert np.allclose(filled_high_tol[0, 0], 1.0) @@ -98,7 +99,7 @@ def test_replace_nans_max_iter(): # Check that both methods replaced some NaNs assert np.sum(np.isnan(filled_few_iter)) < np.sum(np.isnan(array)) assert np.sum(np.isnan(filled_many_iter)) < np.sum(np.isnan(array)) - + # More iterations should replace more NaNs assert np.sum(np.isnan(filled_few_iter)) >= np.sum(np.isnan(filled_many_iter)) @@ -117,7 +118,7 @@ def test_replace_nans_kernel_size(): # Check that both methods replaced some NaNs assert np.sum(np.isnan(filled_small_kernel)) < np.sum(np.isnan(array)) assert np.sum(np.isnan(filled_large_kernel)) < np.sum(np.isnan(array)) - + # Larger kernel should replace more NaNs in fewer iterations assert np.sum(np.isnan(filled_small_kernel)) >= np.sum(np.isnan(filled_large_kernel)) @@ -140,7 +141,7 @@ def test_replace_nans_all_nan_neighbors(): # The center element has only NaN neighbors filled = replace_nans(array, max_iter=10, tol=1e-6, kernel_size=1, method="localmean") - + # The algorithm should still work, but the center might still be NaN after few iterations # Let's check that at least some NaNs were replaced assert np.sum(np.isnan(filled)) < np.sum(np.isnan(array)) @@ -149,9 +150,9 @@ def test_replace_nans_all_nan_neighbors(): def test_replace_nans_no_nans(): """Test replace_nans function with an array that has no NaNs""" array = np.ones((5, 5)) # No NaNs - + filled = replace_nans(array, max_iter=10, tol=1e-6, kernel_size=1, method="localmean") - + # The result should be identical to the input assert np.array_equal(array, filled) @@ -188,17 +189,17 @@ def test_get_dist_2d(): """Test get_dist function with 2D kernel""" kernel = np.zeros((5, 5)) kernel_size = 2 - + dist, dist_inv = get_dist(kernel, kernel_size) - + # Check shapes assert dist.shape == (5, 5) assert dist_inv.shape == (5, 5) - + # Check center value assert dist[2, 2] == 0 assert dist_inv[2, 2] == np.sqrt(2) * kernel_size - + # Check corner values (should be furthest from center) assert dist[0, 0] > dist[1, 1] assert dist_inv[0, 0] < dist_inv[1, 1] @@ -208,17 +209,17 @@ def test_get_dist_3d(): """Test get_dist function with 3D kernel""" kernel = np.zeros((5, 5, 5)) kernel_size = 2 - + dist, dist_inv = get_dist(kernel, kernel_size) - + # Check shapes assert dist.shape == (5, 5, 5) assert dist_inv.shape == (5, 5, 5) - + # Check center value assert dist[2, 2, 2] == 0 assert dist_inv[2, 2, 2] == np.sqrt(3) * kernel_size - + # Check corner values (should be furthest from center) assert dist[0, 0, 0] > dist[1, 1, 1] assert dist_inv[0, 0, 0] < dist_inv[1, 1, 1] diff --git a/openpiv/test/test_performance.py b/openpiv/test/test_performance.py index 784f991e..59e53ee1 100644 --- a/openpiv/test/test_performance.py +++ b/openpiv/test/test_performance.py @@ -1,8 +1,10 @@ """Performance tests to verify optimizations.""" +import time + import numpy as np import pytest -import time -from openpiv import pyprocess, validation, filters + +from openpiv import filters, pyprocess, validation def test_find_all_first_peaks_performance(): @@ -11,22 +13,22 @@ def test_find_all_first_peaks_performance(): n_windows = 100 window_size = 32 corr = np.random.rand(n_windows, window_size, window_size) - + # Add clear peaks for i in range(n_windows): peak_i = np.random.randint(5, window_size-5) peak_j = np.random.randint(5, window_size-5) corr[i, peak_i, peak_j] = 100.0 - + start = time.time() indexes, peaks = pyprocess.find_all_first_peaks(corr) elapsed = time.time() - start - + # Verify results assert indexes.shape == (n_windows, 3) assert peaks.shape == (n_windows,) assert np.all(peaks >= 0) - + # Should be fast (< 10ms for 100 windows) assert elapsed < 0.01, f"find_all_first_peaks took {elapsed:.4f}s, expected < 0.01s" @@ -35,22 +37,22 @@ def test_normalize_intensity_performance(): """Test that normalize_intensity avoids unnecessary conversions.""" # Test with float64 input (should not convert) window_float = np.random.rand(50, 64, 64).astype(np.float64) - + start = time.time() result = pyprocess.normalize_intensity(window_float) elapsed_float = time.time() - start - + assert result.dtype == np.float64 - + # Test with uint8 input (needs conversion) window_uint = (np.random.rand(50, 64, 64) * 255).astype(np.uint8) - + start = time.time() result = pyprocess.normalize_intensity(window_uint) elapsed_uint = time.time() - start - + assert result.dtype == np.float64 - + # Should be reasonably fast (< 50ms for 50 windows) assert elapsed_float < 0.05, f"normalize_intensity (float32) took {elapsed_float:.4f}s" assert elapsed_uint < 0.05, f"normalize_intensity (uint8) took {elapsed_uint:.4f}s" @@ -61,24 +63,24 @@ def test_global_std_performance(): # Create test data u = np.random.randn(100, 100) * 10 v = np.random.randn(100, 100) * 10 - + # Test with regular arrays start = time.time() flag = validation.global_std(u, v, std_threshold=3) elapsed_regular = time.time() - start - + assert flag.shape == u.shape - + # Test with masked arrays u_masked = np.ma.masked_array(u, mask=np.random.rand(100, 100) > 0.9) v_masked = np.ma.masked_array(v, mask=np.random.rand(100, 100) > 0.9) - + start = time.time() flag = validation.global_std(u_masked, v_masked, std_threshold=3) elapsed_masked = time.time() - start - + assert flag.shape == u.shape - + # Should be fast (< 10ms for 100x100 arrays) assert elapsed_regular < 0.01, f"global_std (regular) took {elapsed_regular:.4f}s" assert elapsed_masked < 0.01, f"global_std (masked) took {elapsed_masked:.4f}s" @@ -93,15 +95,15 @@ def test_replace_outliers_performance(): # Warm up the compiled/scipy-backed path before timing. filters.replace_outliers(u, v, flags, method='localmean', max_iter=3) - + # Test with regular arrays start = time.perf_counter() uf, vf = filters.replace_outliers(u, v, flags, method='localmean', max_iter=3) elapsed = time.perf_counter() - start - + assert uf.shape == u.shape assert vf.shape == v.shape - + # Should be reasonably fast (< 100ms for 50x50 with 3 iterations) assert elapsed < 0.1, f"replace_outliers took {elapsed:.4f}s, expected < 0.1s" @@ -112,23 +114,23 @@ def test_vectorized_sig2noise_ratio_performance(): n_windows = 200 window_size = 32 corr = np.random.rand(n_windows, window_size, window_size) * 0.5 - + # Add clear peaks for i in range(n_windows): peak_i = np.random.randint(5, window_size-5) peak_j = np.random.randint(5, window_size-5) corr[i, peak_i, peak_j] = 10.0 - + # Test vectorized version start = time.time() s2n_vectorized = pyprocess.vectorized_sig2noise_ratio( corr, sig2noise_method='peak2peak', width=2 ) elapsed_vectorized = time.time() - start - + assert s2n_vectorized.shape == (n_windows,) assert np.all(s2n_vectorized >= 0) - + # Should be fast (< 50ms for 200 windows) assert elapsed_vectorized < 0.05, \ f"vectorized_sig2noise_ratio took {elapsed_vectorized:.4f}s, expected < 0.05s" @@ -137,25 +139,25 @@ def test_vectorized_sig2noise_ratio_performance(): if __name__ == "__main__": # Run tests manually with timing output print("Running performance tests...") - + print("\n1. Testing find_all_first_peaks_performance...") test_find_all_first_peaks_performance() print(" ✓ Passed") - + print("\n2. Testing normalize_intensity_performance...") test_normalize_intensity_performance() print(" ✓ Passed") - + print("\n3. Testing global_std_performance...") test_global_std_performance() print(" ✓ Passed") - + print("\n4. Testing replace_outliers_performance...") test_replace_outliers_performance() print(" ✓ Passed") - + print("\n5. Testing vectorized_sig2noise_ratio_performance...") test_vectorized_sig2noise_ratio_performance() print(" ✓ Passed") - + print("\n✅ All performance tests passed!") diff --git a/openpiv/test/test_piv.py b/openpiv/test/test_piv.py index 4ccf59a6..60cadb91 100644 --- a/openpiv/test/test_piv.py +++ b/openpiv/test/test_piv.py @@ -1,14 +1,19 @@ """Tests for the piv module""" +from importlib.resources import files + +import matplotlib import numpy as np import pytest -from importlib.resources import files + from openpiv import piv, tools from openpiv.pyprocess import extended_search_area_piv -import matplotlib + matplotlib.use('Agg') # Use non-interactive backend for testing plots -import matplotlib.pyplot as plt from unittest.mock import patch +import matplotlib.pyplot as plt + + # Create synthetic image pairs for testing def create_test_pair(image_size=32, shift=(2, 2)): """Create a pair of synthetic images with known displacement""" @@ -21,11 +26,11 @@ def create_test_pair(image_size=32, shift=(2, 2)): y = np.random.randint(0, image_size) r = np.random.randint(2, 5) frame_a[max(0, y-r):min(image_size, y+r), max(0, x-r):min(image_size, x+r)] = 1.0 - + # Apply Gaussian blur to make particles more realistic from scipy.ndimage import gaussian_filter frame_a = gaussian_filter(frame_a, sigma=1.5) - + # Shift the image to create the second frame dx, dy = shift frame_b = np.zeros_like(frame_a) @@ -34,7 +39,7 @@ def create_test_pair(image_size=32, shift=(2, 2)): new_y = (y + dy) % image_size new_x = (x + dx) % image_size frame_b[new_y, new_x] = frame_a[y, x] - + return frame_a, frame_b @@ -42,24 +47,24 @@ def test_simple_piv_with_arrays(): """Test simple_piv with numpy arrays as input""" # Create test images frame_a, frame_b = create_test_pair(image_size=64, shift=(2, 2)) - + # Run simple_piv with plot=False to avoid display during tests x, y, u, v, s2n = piv.simple_piv(frame_a, frame_b, plot=False) - + # Check that results are not None assert x is not None assert y is not None assert u is not None assert v is not None assert s2n is not None - + # Check shapes assert x.shape == y.shape == u.shape == v.shape == s2n.shape - + # Check that at least some values are valid (not NaN) assert not np.all(np.isnan(u)) assert not np.all(np.isnan(v)) - + # Check that the mean displacement of valid values has the expected sign valid_mask = ~np.isnan(u) if np.any(valid_mask): @@ -73,17 +78,17 @@ def test_simple_piv_with_file_paths(): # Get example image paths im1 = files('openpiv.data').joinpath('test1/exp1_001_a.bmp') im2 = files('openpiv.data').joinpath('test1/exp1_001_b.bmp') - + # Run simple_piv with plot=False x, y, u, v, s2n = piv.simple_piv(str(im1), str(im2), plot=False) - + # Check that results are not None assert x is not None assert y is not None assert u is not None assert v is not None assert s2n is not None - + # Check shapes assert x.shape == y.shape == u.shape == v.shape == s2n.shape @@ -93,14 +98,14 @@ def test_simple_piv_validation_methods(validation_method): """Test simple_piv with different validation methods""" # Create test images frame_a, frame_b = create_test_pair(image_size=64, shift=(2, 2)) - + # Run simple_piv with the specified validation method x, y, u, v, s2n = piv.simple_piv( - frame_a, frame_b, + frame_a, frame_b, validation_method=validation_method, plot=False ) - + # Check that results are not None assert x is not None assert y is not None @@ -118,7 +123,7 @@ def test_simple_piv_parameters(window_size, overlap, search_area_size): """Test simple_piv with different parameter combinations""" # Create test images frame_a, frame_b = create_test_pair(image_size=128, shift=(2, 2)) - + # Run simple_piv with the specified parameters x, y, u, v, s2n = piv.simple_piv( frame_a, frame_b, @@ -127,14 +132,14 @@ def test_simple_piv_parameters(window_size, overlap, search_area_size): search_area_size=search_area_size, plot=False ) - + # Check that results are not None assert x is not None assert y is not None assert u is not None assert v is not None assert s2n is not None - + # Just check that shapes are consistent with each other assert x.shape == y.shape == u.shape == v.shape == s2n.shape @@ -143,13 +148,13 @@ def test_piv_example_no_plots(): """Test piv_example with plotting disabled""" # Run piv_example with plotting disabled x, y, u, v = piv.piv_example(plot_animation=False, plot_quiver=False) - + # Check that results are not None assert x is not None assert y is not None assert u is not None assert v is not None - + # Check shapes assert x.shape == y.shape == u.shape == v.shape @@ -158,7 +163,7 @@ def test_process_pair(): """Test process_pair function""" # Create test images frame_a, frame_b = create_test_pair(image_size=64, shift=(2, 2)) - + # Run process_pair x, y, u, v, mask = piv.process_pair( frame_a, frame_b, @@ -167,21 +172,21 @@ def test_process_pair(): search_area_size=32, plot=False ) - + # Check that results are not None assert x is not None assert y is not None assert u is not None assert v is not None assert mask is not None - + # Check shapes assert x.shape == y.shape == u.shape == v.shape == mask.shape - + # Check that at least some values are valid (not NaN) assert not np.all(np.isnan(u)) assert not np.all(np.isnan(v)) - + # Check that the mean displacement of valid values has the expected sign valid_mask = ~np.isnan(u) if np.any(valid_mask): @@ -195,14 +200,14 @@ def test_process_pair_validation_methods(validation_method): """Test process_pair with different validation methods""" # Create test images frame_a, frame_b = create_test_pair(image_size=64, shift=(2, 2)) - + # Run process_pair with the specified validation method x, y, u, v, mask = piv.process_pair( frame_a, frame_b, validation_method=validation_method, plot=False ) - + # Check that results are not None assert x is not None assert y is not None @@ -216,10 +221,10 @@ def test_process_pair_filter_methods(filter_method): """Test process_pair with different filter methods""" # Create test images frame_a, frame_b = create_test_pair(image_size=64, shift=(2, 2)) - + # Add some outliers to test filtering frame_b[10:15, 10:15] = 0 # Create a region with bad correlation - + # Run process_pair with the specified filter method x, y, u, v, mask = piv.process_pair( frame_a, frame_b, @@ -229,7 +234,7 @@ def test_process_pair_filter_methods(filter_method): filter_kernel_size=2, plot=False ) - + # Check that results are not None assert x is not None assert y is not None @@ -243,11 +248,11 @@ def test_process_pair_with_real_images(): # Get example image paths im1 = files('openpiv.data').joinpath('test1/exp1_001_a.bmp') im2 = files('openpiv.data').joinpath('test1/exp1_001_b.bmp') - + # Load images frame_a = tools.imread(im1) frame_b = tools.imread(im2) - + # Run process_pair x, y, u, v, mask = piv.process_pair( frame_a, frame_b, @@ -256,14 +261,14 @@ def test_process_pair_with_real_images(): search_area_size=64, plot=False ) - + # Check that results are not None assert x is not None assert y is not None assert u is not None assert v is not None assert mask is not None - + # Check shapes assert x.shape == y.shape == u.shape == v.shape == mask.shape @@ -273,22 +278,22 @@ def test_piv_example_with_quiver_only(): # Save the current backend original_backend = plt.get_backend() plt.switch_backend('Agg') - + try: # Mock plt.show to prevent actual display with patch('matplotlib.pyplot.show') as mock_show: # Run piv_example with only quiver plot enabled, no animation x, y, u, v = piv.piv_example(plot_animation=False, plot_quiver=True) - + # Check that plt.show was called at least once assert mock_show.called - + # Check that results are not None assert x is not None assert y is not None assert u is not None assert v is not None - + finally: # Restore the original backend plt.switch_backend(original_backend) @@ -300,26 +305,26 @@ def test_simple_piv_with_plotting(): # Save the current backend original_backend = plt.get_backend() plt.switch_backend('Agg') - + try: # Create test images frame_a, frame_b = create_test_pair(image_size=64, shift=(2, 2)) - + # Mock plt.show to prevent actual display with patch('matplotlib.pyplot.show') as mock_show: # Run simple_piv with plot=True x, y, u, v, s2n = piv.simple_piv(frame_a, frame_b, plot=True) - + # Check that plt.show was called assert mock_show.called - + # Check that results are not None assert x is not None assert y is not None assert u is not None assert v is not None assert s2n is not None - + finally: # Restore the original backend plt.switch_backend(original_backend) @@ -331,11 +336,11 @@ def test_process_pair_with_plotting(): # Save the current backend original_backend = plt.get_backend() plt.switch_backend('Agg') - + try: # Create test images frame_a, frame_b = create_test_pair(image_size=64, shift=(2, 2)) - + # Mock plt.show to prevent actual display with patch('matplotlib.pyplot.show') as mock_show: # Run process_pair with plot=True @@ -346,17 +351,17 @@ def test_process_pair_with_plotting(): search_area_size=32, plot=True ) - + # Check that plt.show was called assert mock_show.called - + # Check that results are not None assert x is not None assert y is not None assert u is not None assert v is not None assert mask is not None - + finally: # Restore the original backend plt.switch_backend(original_backend) @@ -368,21 +373,21 @@ def test_simple_piv_with_different_dt(dt): """Test simple_piv with different dt values""" # Create test images frame_a, frame_b = create_test_pair(image_size=64, shift=(2, 2)) - + # Run simple_piv with the specified dt x, y, u, v, s2n = piv.simple_piv( frame_a, frame_b, dt=dt, plot=False ) - + # Check that results are not None assert x is not None assert y is not None assert u is not None assert v is not None assert s2n is not None - + # Check that the velocity values are consistent regardless of dt # (The implementation doesn't scale velocities with dt) valid_mask = ~np.isnan(u) @@ -397,11 +402,11 @@ def test_simple_piv_with_invalid_inputs(): # Test with empty arrays with pytest.raises((ValueError, IndexError, ZeroDivisionError)): piv.simple_piv(np.array([]), np.array([]), plot=False) - + # Test with arrays of different sizes frame_a = np.random.rand(32, 32) frame_b = np.random.rand(64, 64) - + with pytest.raises((ValueError, IndexError, AssertionError), match=""): piv.simple_piv(frame_a, frame_b, plot=False) @@ -410,7 +415,7 @@ def test_process_pair_with_different_parameters(): """Test process_pair with different parameter combinations""" # Create test images frame_a, frame_b = create_test_pair(image_size=64, shift=(2, 2)) - + # Test with different s2n_threshold values for s2n_threshold in [1.0, 1.5, 2.0]: x, y, u, v, mask = piv.process_pair( @@ -424,7 +429,7 @@ def test_process_pair_with_different_parameters(): assert u is not None assert v is not None assert mask is not None - + # Test with different filter_kernel_size values for kernel_size in [1, 2, 3]: x, y, u, v, mask = piv.process_pair( diff --git a/openpiv/test/test_preprocess.py b/openpiv/test/test_preprocess.py index 42a8d597..9602d4e2 100644 --- a/openpiv/test/test_preprocess.py +++ b/openpiv/test/test_preprocess.py @@ -1,21 +1,32 @@ """ Test preprocess """ import os +import tempfile + +import matplotlib.pyplot as plt import numpy as np import pytest +from imageio.v3 import imread +from scipy.ndimage import map_coordinates from skimage import img_as_float from skimage.color import rgb2gray, rgba2rgb -from imageio.v3 import imread -import matplotlib.pyplot as plt + from openpiv.preprocess import ( - dynamic_masking, mask_coordinates, prepare_mask_from_polygon, - prepare_mask_on_grid, normalize_array, standardize_array, - instensity_cap, intensity_clip, high_pass, local_variance_normalization, - contrast_stretch, threshold_binarize, gen_min_background, - gen_lowpass_background, stretch_image + contrast_stretch, + dynamic_masking, + gen_lowpass_background, + gen_min_background, + high_pass, + instensity_cap, + intensity_clip, + local_variance_normalization, + mask_coordinates, + normalize_array, + prepare_mask_from_polygon, + prepare_mask_on_grid, + standardize_array, + stretch_image, + threshold_binarize, ) -import tempfile -from scipy.ndimage import map_coordinates - test_directory = os.path.split(os.path.abspath(__file__))[0] diff --git a/openpiv/test/test_process.py b/openpiv/test/test_process.py index e1e8fc4b..60210357 100755 --- a/openpiv/test/test_process.py +++ b/openpiv/test/test_process.py @@ -1,26 +1,25 @@ """ Testing basic PIV processes """ -import numpy as np -import pytest -from skimage.util import random_noise -from skimage import img_as_ubyte -from scipy.ndimage import shift as shift_img # import pkg_resources as pkg from importlib.resources import files -from openpiv.pyprocess import extended_search_area_piv as piv -from openpiv.pyprocess import fft_correlate_images, \ - correlation_to_displacement -from openpiv import tools +import numpy as np +import pytest +from scipy.ndimage import shift as shift_img +from skimage import img_as_ubyte +from skimage.util import random_noise +from openpiv import tools +from openpiv.pyprocess import correlation_to_displacement, fft_correlate_images +from openpiv.pyprocess import extended_search_area_piv as piv THRESHOLD = 0.25 # define "PIV" shift, i.e. creating u,v values that we want to get # -5.5 pixels to the left and 3.2 pixels upwards # if we translate it to the scipy.ndimage.shift values -# the first value is 2 pixels positive downwards, positive rows, -# the second value is 1 pixel positive to the right -# shifted_digit_image=shift(some_digit_image,[2,1]) +# the first value is 2 pixels positive downwards, positive rows, +# the second value is 1 pixel positive to the right +# shifted_digit_image=shift(some_digit_image,[2,1]) # so we expect to get later # shift(image, [-1*SHIFT_V, SHIFT_U]) @@ -30,14 +29,26 @@ # ^ # | # | -SHIFT_V = 2.5 # shift upwards, should be placed in rows, axis=0 +SHIFT_V = 2.5 # shift upwards, should be placed in rows, axis=0 + +def create_pair(image_size=32, u=SHIFT_U, v=SHIFT_V, seed=1234): + """ creates a pair of images with a roll/shift -def create_pair(image_size=32, u=SHIFT_U, v=SHIFT_V): - """ creates a pair of images with a roll/shift """ - frame_a = np.zeros((image_size, image_size)) - frame_a = random_noise(frame_a) - frame_a = img_as_ubyte(frame_a) + The noise is drawn from a seeded generator, so the pair is + reproducible. Without the seed the test was flaky: the PIV of a + random 32x32 noise image does not always recover the shift. + """ + state = np.random.get_state() + np.random.seed(seed) + try: + frame_a = np.zeros((image_size, image_size)) + frame_a = random_noise(frame_a) + frame_a = img_as_ubyte(frame_a) + finally: + # restore the global state, the tests must not depend on the + # order in which the random numbers are drawn + np.random.set_state(state) # note rolling positive vertical +2 means we create # negative vertical velocity as our origin is at 0,0 # bottom left corner, and the image is rolled from the @@ -79,8 +90,8 @@ def test_extended_search_area(): """ test of the extended area PIV with larger image """ frame_a, frame_b = create_pair(image_size=64, u=-3.5, v=-2.1) u, v, _ = piv(frame_a, frame_b, - window_size=16, - search_area_size=32, + window_size=16, + search_area_size=32, overlap=0) assert np.allclose(u, -3.5, atol=THRESHOLD) @@ -94,25 +105,25 @@ def test_extended_search_area_overlap(): # Run multiple trials to ensure robustness success_count = 0 num_trials = 5 - + for seed in range(42, 42 + num_trials): np.random.seed(seed) # Different seed for each trial frame_a, frame_b = create_pair(image_size=72) u, v, _ = piv(frame_a, frame_b, - window_size=16, + window_size=16, search_area_size=32, overlap=8) - + # Handle NaN values before comparison u_filtered = u[~np.isnan(u)] v_filtered = v[~np.isnan(v)] - + # Check if results are close to expected values if (len(u_filtered) > 0 and len(v_filtered) > 0 and np.abs(np.mean(u_filtered) - SHIFT_U) < THRESHOLD and np.abs(np.mean(v_filtered) - SHIFT_V) < THRESHOLD): success_count += 1 - + # Require at least 3 out of 5 trials to succeed assert success_count >= 3, f"Test failed: only {success_count} out of {num_trials} trials were successful" @@ -134,7 +145,7 @@ def test_extended_search_area_sig2noise(): # Increase tolerance from THRESHOLD to THRESHOLD*1.2 if np.allclose(u, SHIFT_U, atol=THRESHOLD*1.2) and np.allclose(v, SHIFT_V, atol=THRESHOLD*1.2): success_count += 1 - + assert success_count >= 7, f"Test failed: {success_count} out of {num_trials} trials were successful" @@ -152,11 +163,11 @@ def test_sig2noise_ratio(): """ s2n ratio test """ im1 = files('openpiv.data').joinpath('test1/exp1_001_a.bmp') im2 = files('openpiv.data').joinpath('test1/exp1_001_b.bmp') - + frame_a = tools.imread(im1) frame_b = tools.imread(im2) - + u, v, s2n = piv( frame_a.astype(np.int32), frame_b.astype(np.int32), @@ -164,7 +175,7 @@ def test_sig2noise_ratio(): search_area_size=64, sig2noise_method="peak2peak", subpixel_method="gaussian" - ) + ) # print(s2n.flatten().min(),s2n.mean(),s2n.max()) assert np.allclose(s2n.mean(), 2.564, rtol=1e-3) assert np.allclose(s2n.max(), 4.119, rtol=1e-3) @@ -210,14 +221,14 @@ def test_new_overlap_setting(): def test_extended_search_area_piv_parameters(window_size, overlap): """Test extended_search_area_piv with different parameters""" frame_a, frame_b = create_pair(image_size=128) - + u, v, sig2noise = piv( frame_a, frame_b, window_size=window_size, overlap=overlap, search_area_size=window_size*2 ) - + # Assert results are reasonable assert u.shape[0] > 0 assert v.shape[0] > 0 diff --git a/openpiv/test/test_pyprocess.py b/openpiv/test/test_pyprocess.py index 7b301a33..1cbdb23c 100644 --- a/openpiv/test/test_pyprocess.py +++ b/openpiv/test/test_pyprocess.py @@ -1,8 +1,18 @@ import numpy as np import pytest -from openpiv.pyprocess import get_field_shape, get_coordinates, get_rect_coordinates -from openpiv.pyprocess import sliding_window_array, find_first_peak, find_subpixel_peak_position -from openpiv.pyprocess import vectorized_sig2noise_ratio, fft_correlate_images, correlate_windows + +from openpiv.pyprocess import ( + correlate_windows, + fft_correlate_images, + find_first_peak, + find_subpixel_peak_position, + get_coordinates, + get_field_shape, + get_rect_coordinates, + sliding_window_array, + vectorized_sig2noise_ratio, +) + def test_get_field_shape(): """Test get_field_shape function with various inputs""" @@ -254,7 +264,7 @@ def test_vectorized_sig2noise_ratio(): assert not np.array_equal(s2n_p2p, s2n_width2) # Test with invalid method - with pytest.raises(Exception): + with pytest.raises(ValueError): vectorized_sig2noise_ratio(corr, sig2noise_method='invalid_method') def test_vectorized_sig2noise_ratio_flags_invalid_windows(): diff --git a/openpiv/test/test_rust_backend.py b/openpiv/test/test_rust_backend.py index 57d64cc9..60943c05 100644 --- a/openpiv/test/test_rust_backend.py +++ b/openpiv/test/test_rust_backend.py @@ -1,10 +1,13 @@ -import pytest +from importlib.resources import files +from pathlib import Path + import numpy as np -import scipy.signal +import pytest import scipy.fft -from pathlib import Path -from importlib.resources import files -from openpiv import pyprocess, tools, validation, filters +import scipy.signal + +from openpiv import filters, pyprocess, tools, validation + openpiv_rust = pytest.importorskip("openpiv_rust") @pytest.mark.parametrize("win_size", [16, 32, 64]) diff --git a/openpiv/test/test_rust_windef.py b/openpiv/test/test_rust_windef.py index 21c94f64..a580c762 100644 --- a/openpiv/test/test_rust_windef.py +++ b/openpiv/test/test_rust_windef.py @@ -1,6 +1,7 @@ import os import pathlib import tempfile + import numpy as np import pytest from imageio.v3 import imwrite @@ -115,7 +116,7 @@ def test_multiprocessing_piv_with_rust_backend(): result_folders = list(out_dir.glob("OpenPIV_results_*")) assert len(result_folders) == 1 - txt_files = sorted(list(result_folders[0].glob("*.txt"))) + txt_files = sorted(result_folders[0].glob("*.txt")) assert len(txt_files) == 2 for f in txt_files: assert f.stat().st_size > 0 diff --git a/openpiv/test/test_smoothn.py b/openpiv/test/test_smoothn.py index a533c181..acabc4e6 100644 --- a/openpiv/test/test_smoothn.py +++ b/openpiv/test/test_smoothn.py @@ -5,15 +5,8 @@ import pytest from scipy.fftpack import dct, idct -from openpiv.smoothn import ( - smoothn, - gcv, - RobustWeights, - warning, - dctND, - InitialGuess, - peaks -) +from openpiv.smoothn import InitialGuess, RobustWeights, dctND, gcv, peaks, smoothn, warning + def test_smoothn_basic(): """Test basic smoothn functionality with 1D data""" @@ -329,7 +322,6 @@ def test_smoothn_edge_cases(): # Test with all NaN values - skip this test as it's causing issues # with the optimization algorithm - pass def test_gcv_function(): """Test the GCV (Generalized Cross-Validation) function""" @@ -461,7 +453,7 @@ def test_smoothn_with_negative_weights(): try: y_smooth, _, _, Wtot = smoothn(y_noisy, W=W) # If we get here, the test should fail - assert False, "smoothn should raise ValueError with negative weights" + raise AssertionError("smoothn should raise ValueError with negative weights") except ValueError as e: # Check that the error message is correct assert "Weights must all be >=0" in str(e) diff --git a/openpiv/test/test_tools.py b/openpiv/test/test_tools.py index 68644201..79a40830 100644 --- a/openpiv/test/test_tools.py +++ b/openpiv/test/test_tools.py @@ -1,15 +1,21 @@ """ tests windef functionality """ import pathlib -import numpy as np + import matplotlib.pyplot as plt +import numpy as np import pytest from matplotlib.testing import compare, decorators + +from openpiv.pyprocess import extended_search_area_piv, get_coordinates from openpiv.tools import ( - imread, save, display_vector_field, transform_coordinates, - display_vector_field_from_arrays, negative, Multiprocesser + Multiprocesser, + display_vector_field, + display_vector_field_from_arrays, + imread, + negative, + save, + transform_coordinates, ) -from openpiv.pyprocess import extended_search_area_piv, get_coordinates - _file_a = pathlib.Path(__file__).parent / '../data/test1/exp1_001_a.bmp' _file_b = pathlib.Path(__file__).parent / '../data/test1/exp1_001_b.bmp' @@ -34,7 +40,7 @@ def test_imread_edge_cases(): # Test with non-existent file with pytest.raises(FileNotFoundError): imread('non_existent_file.tif') - + # Test with different file formats if applicable # Create temporary test images if needed @@ -42,34 +48,34 @@ def test_imread_edge_cases(): def test_display_vector_field_with_warnings_suppressed(): """Test the display_vector_field function with warnings suppressed""" import warnings - + # Create a temporary vector file with more data points x = np.array([[1, 2], [3, 4]]) y = np.array([[1, 2], [3, 4]]) u = np.array([[0.1, 0.2], [0.3, 0.4]]) v = np.array([[0.1, 0.2], [0.3, 0.4]]) - flags = np.zeros_like(u) + np.zeros_like(u) mask = np.zeros_like(u) - + save('temp_test.vec', x, y, u, v, mask) - + # Test with different parameters, suppressing warnings with warnings.catch_warnings(): warnings.simplefilter("ignore", category=RuntimeWarning) display_vector_field('temp_test.vec', scale=10) display_vector_field('temp_test.vec', width=0.005) - + # Clean up import os os.remove('temp_test.vec') def test_file_patterns(): - """ + """ tools.Multiprocesser() class has a couple of options to process pairs of images or create pairs from sequential list of files - # Format and Image Sequence + # Format and Image Sequence settings.frame_pattern_a = 'exp1_001_a.bmp' settings.frame_pattern_b = 'exp1_001_b.bmp' @@ -87,13 +93,13 @@ def test_transform_coordinates(): y = np.array([[1, 2], [3, 4]]) u = np.array([[0.1, 0.2], [0.3, 0.4]]) v = np.array([[0.1, 0.2], [0.3, 0.4]]) - + # Store original v for comparison v_original = v.copy() - + # Apply the transformation x_new, y_new, u_new, v_new = transform_coordinates(x, y, u, v) - + # Check that the transformation was applied correctly # The function reverses the order of rows in y and negates v assert np.allclose(x_new, x) @@ -110,18 +116,18 @@ def test_save_and_load(): u = np.array([[0.1, 0.2], [0.3, 0.4]]) v = np.array([[0.1, 0.2], [0.3, 0.4]]) mask = np.zeros_like(u) - + # Save data filename = 'temp_save_test.vec' save(filename, x, y, u, v, mask) - + # Load data data = np.loadtxt(filename) - + # Verify data assert data.shape[0] == x.size assert data.shape[1] >= 4 # At least x, y, u, v columns - + # Clean up import os os.remove(filename) @@ -131,13 +137,13 @@ def test_negative(): """Test the negative function""" # Create a test image img = np.array([[10, 20], [30, 40]], dtype=np.uint8) - + # Apply negative function neg_img = negative(img) - + # Check results assert np.all(neg_img == 255 - img) - + # Test with float image img_float = np.array([[0.1, 0.2], [0.3, 0.4]]) neg_img_float = negative(img_float) @@ -147,7 +153,7 @@ def test_negative(): def test_display_vector_field_from_arrays_with_warnings_suppressed(): """Test display_vector_field_from_arrays function with warnings suppressed""" import warnings - + # Create test data x = np.array([[1, 2], [3, 4]]) y = np.array([[1, 2], [3, 4]]) @@ -155,17 +161,17 @@ def test_display_vector_field_from_arrays_with_warnings_suppressed(): v = np.array([[0.1, 0.2], [0.3, 0.4]]) flags = np.zeros_like(u) mask = np.zeros_like(u) - + # Test with warnings suppressed with warnings.catch_warnings(): warnings.simplefilter("ignore", category=RuntimeWarning) - + # Test basic functionality display_vector_field_from_arrays(x, y, u, v, flags, mask) - + # Test with width parameter display_vector_field_from_arrays(x, y, u, v, flags, mask, width=0.01) - + # Test with custom axes fig, ax = plt.subplots() display_vector_field_from_arrays(x, y, u, v, flags, mask, ax=ax) @@ -175,32 +181,32 @@ def test_display_vector_field_from_arrays_with_warnings_suppressed(): def test_multiprocesser(): """Test the Multiprocesser class""" # Create a temporary directory with test files - import tempfile import os - + import tempfile + with tempfile.TemporaryDirectory() as tmpdirname: # Create a few empty test files for i in range(3): open(os.path.join(tmpdirname, f'img_a_{i}.tif'), 'w').close() open(os.path.join(tmpdirname, f'img_b_{i}.tif'), 'w').close() - + # Create a Multiprocesser instance mp = Multiprocesser( data_dir=pathlib.Path(tmpdirname), pattern_a='img_a_*.tif', pattern_b='img_b_*.tif' ) - + # Check if files were found assert len(mp.files_a) == 3 assert len(mp.files_b) == 3 - + # Define a simple processing function def process_func(args): file_a, file_b, counter = args # Just return the filenames to verify they're passed correctly return (file_a.name, file_b.name, counter) - + # We won't actually run the process since it would try to read the empty files # But we can check that the class was initialized correctly @@ -208,21 +214,22 @@ def process_func(args): def test_imread(): """Test the imread function""" import tempfile + from PIL import Image - + # Create a temporary image file with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: # Create a simple test image img = np.zeros((10, 10), dtype=np.uint8) img[2:8, 2:8] = 255 # white square on black background - + # Save the image Image.fromarray(img).save(tmp.name) - + try: # Read the image using the imread function read_img = imread(tmp.name) - + # Check that the image was read correctly assert read_img.shape == (10, 10) assert np.all(read_img[2:8, 2:8] == 255) diff --git a/openpiv/test/test_tools_background.py b/openpiv/test/test_tools_background.py index 0906369d..b3067deb 100644 --- a/openpiv/test/test_tools_background.py +++ b/openpiv/test/test_tools_background.py @@ -1,12 +1,12 @@ """Tests for background processing functions in tools.py""" import os import tempfile + import numpy as np import pytest from PIL import Image -from openpiv.tools import ( - mark_background, mark_background2, find_reflexions, find_boundaries -) + +from openpiv.tools import find_boundaries, find_reflexions, mark_background, mark_background2 def create_test_images(num_images=3, size=(20, 20)): diff --git a/openpiv/test/test_tools_basic_utils.py b/openpiv/test/test_tools_basic_utils.py index 41b70019..4d786e99 100644 --- a/openpiv/test/test_tools_basic_utils.py +++ b/openpiv/test/test_tools_basic_utils.py @@ -1,8 +1,10 @@ """Tests for basic utility functions in tools.py""" import pathlib + import numpy as np import pytest -from openpiv.tools import natural_sort, sorted_unique, display, negative + +from openpiv.tools import display, natural_sort, negative, sorted_unique def test_natural_sort(): @@ -46,7 +48,7 @@ def test_sorted_unique(): result = sorted_unique(arr) # Check that result contains the unique values - assert set(result) == set([1, 2, 3]) + assert set(result) == {1, 2, 3} # Check that result is sorted assert np.all(np.diff(result) > 0) @@ -55,7 +57,7 @@ def test_sorted_unique(): result = sorted_unique(arr) # Check that result contains the unique values - assert set(result) == set([1, 2, 5, 10]) + assert set(result) == {1, 2, 5, 10} # Check that result is sorted assert np.all(np.diff(result) > 0) diff --git a/openpiv/test/test_tools_image_processing.py b/openpiv/test/test_tools_image_processing.py index bd2e6198..2b4c1545 100644 --- a/openpiv/test/test_tools_image_processing.py +++ b/openpiv/test/test_tools_image_processing.py @@ -1,10 +1,12 @@ """Tests for image processing functions in tools.py""" import os import tempfile + import numpy as np import pytest from PIL import Image -from openpiv.tools import imread, imsave, rgb2gray, convert_16bits_tif + +from openpiv.tools import convert_16bits_tif, imread, imsave, rgb2gray def test_imread_grayscale(): diff --git a/openpiv/test/test_tools_multiprocessing.py b/openpiv/test/test_tools_multiprocessing.py index 42088fd4..0a48030b 100644 --- a/openpiv/test/test_tools_multiprocessing.py +++ b/openpiv/test/test_tools_multiprocessing.py @@ -1,9 +1,11 @@ """Tests for multiprocessing functions in tools.py""" import os -import tempfile import pathlib +import tempfile + import numpy as np import pytest + from openpiv.tools import Multiprocesser @@ -14,23 +16,23 @@ def test_multiprocesser_basic(): for i in range(3): open(os.path.join(tmpdirname, f'img_a_{i}.tif'), 'w').close() open(os.path.join(tmpdirname, f'img_b_{i}.tif'), 'w').close() - + # Create a Multiprocesser instance mp = Multiprocesser( data_dir=pathlib.Path(tmpdirname), pattern_a='img_a_*.tif', pattern_b='img_b_*.tif' ) - + # Check that files were found assert len(mp.files_a) == 3 assert len(mp.files_b) == 3 - + # Check that files are in the correct order assert mp.files_a[0].name == 'img_a_0.tif' assert mp.files_a[1].name == 'img_a_1.tif' assert mp.files_a[2].name == 'img_a_2.tif' - + assert mp.files_b[0].name == 'img_b_0.tif' assert mp.files_b[1].name == 'img_b_1.tif' assert mp.files_b[2].name == 'img_b_2.tif' @@ -42,28 +44,28 @@ def test_multiprocesser_pattern_1_plus_2(): # Create a sequence of test files for i in range(5): open(os.path.join(tmpdirname, f'{i:04d}.tif'), 'w').close() - + # Create a Multiprocesser instance with pattern_b='(1+2),(2+3)' mp = Multiprocesser( data_dir=pathlib.Path(tmpdirname), pattern_a='*.tif', pattern_b='(1+2),(2+3)' ) - + # Check that files were paired correctly assert len(mp.files_a) == 4 # 0001, 0002, 0003, 0004 assert len(mp.files_b) == 4 # 0002, 0003, 0004, 0005 - + # Check specific pairs assert mp.files_a[0].name == '0000.tif' assert mp.files_b[0].name == '0001.tif' - + assert mp.files_a[1].name == '0001.tif' assert mp.files_b[1].name == '0002.tif' - + assert mp.files_a[2].name == '0002.tif' assert mp.files_b[2].name == '0003.tif' - + assert mp.files_a[3].name == '0003.tif' assert mp.files_b[3].name == '0004.tif' @@ -74,31 +76,31 @@ def test_multiprocesser_pattern_1_plus_3(): # Create a sequence of test files for i in range(7): open(os.path.join(tmpdirname, f'{i:04d}.tif'), 'w').close() - + # Create a Multiprocesser instance with pattern_b='(1+3),(2+4)' mp = Multiprocesser( data_dir=pathlib.Path(tmpdirname), pattern_a='*.tif', pattern_b='(1+3),(2+4)' ) - + # Check that files were paired correctly assert len(mp.files_a) == 5 # 0000, 0001, 0002, 0003, 0004 assert len(mp.files_b) == 5 # 0002, 0003, 0004, 0005, 0006 - + # Check specific pairs assert mp.files_a[0].name == '0000.tif' assert mp.files_b[0].name == '0002.tif' - + assert mp.files_a[1].name == '0001.tif' assert mp.files_b[1].name == '0003.tif' - + assert mp.files_a[2].name == '0002.tif' assert mp.files_b[2].name == '0004.tif' - + assert mp.files_a[3].name == '0003.tif' assert mp.files_b[3].name == '0005.tif' - + assert mp.files_a[4].name == '0004.tif' assert mp.files_b[4].name == '0006.tif' @@ -109,25 +111,25 @@ def test_multiprocesser_pattern_1_plus_2_3_plus_4(): # Create a sequence of test files for i in range(6): open(os.path.join(tmpdirname, f'{i:04d}.tif'), 'w').close() - + # Create a Multiprocesser instance with pattern_b='(1+2),(3+4)' mp = Multiprocesser( data_dir=pathlib.Path(tmpdirname), pattern_a='*.tif', pattern_b='(1+2),(3+4)' ) - + # Check that files were paired correctly assert len(mp.files_a) == 3 # 0000, 0002, 0004 assert len(mp.files_b) == 3 # 0001, 0003, 0005 - + # Check specific pairs assert mp.files_a[0].name == '0000.tif' assert mp.files_b[0].name == '0001.tif' - + assert mp.files_a[1].name == '0002.tif' assert mp.files_b[1].name == '0003.tif' - + assert mp.files_a[2].name == '0004.tif' assert mp.files_b[2].name == '0005.tif' @@ -139,35 +141,35 @@ def test_multiprocesser_run(): for i in range(3): open(os.path.join(tmpdirname, f'img_a_{i}.tif'), 'w').close() open(os.path.join(tmpdirname, f'img_b_{i}.tif'), 'w').close() - + # Create a Multiprocesser instance mp = Multiprocesser( data_dir=pathlib.Path(tmpdirname), pattern_a='img_a_*.tif', pattern_b='img_b_*.tif' ) - + # Define a simple processing function results = [] def process_func(args): file_a, file_b, counter = args results.append((file_a.name, file_b.name, counter)) - + # Run the processing function mp.run(process_func, n_cpus=1) - + # Check that all files were processed assert len(results) == 3 - + # Check that files were processed in the correct order assert results[0][0] == 'img_a_0.tif' assert results[0][1] == 'img_b_0.tif' assert results[0][2] == 0 - + assert results[1][0] == 'img_a_1.tif' assert results[1][1] == 'img_b_1.tif' assert results[1][2] == 1 - + assert results[2][0] == 'img_a_2.tif' assert results[2][1] == 'img_b_2.tif' assert results[2][2] == 2 @@ -183,12 +185,12 @@ def test_multiprocesser_error_handling(): pattern_a='nonexistent_*.tif', pattern_b='nonexistent_*.tif' ) - + # Test with unequal number of files open(os.path.join(tmpdirname, 'img_a_0.tif'), 'w').close() open(os.path.join(tmpdirname, 'img_a_1.tif'), 'w').close() open(os.path.join(tmpdirname, 'img_b_0.tif'), 'w').close() - + with pytest.raises(ValueError, match="equal number"): Multiprocesser( data_dir=pathlib.Path(tmpdirname), diff --git a/openpiv/test/test_tools_vector_field.py b/openpiv/test/test_tools_vector_field.py index d7935e0f..3db001ac 100644 --- a/openpiv/test/test_tools_vector_field.py +++ b/openpiv/test/test_tools_vector_field.py @@ -1,12 +1,17 @@ """Tests for vector field operations in tools.py""" import os import tempfile -import numpy as np + import matplotlib.pyplot as plt +import numpy as np import pytest + from openpiv.tools import ( - save, display_vector_field, display_vector_field_from_arrays, - transform_coordinates, display_windows_sampling + display_vector_field, + display_vector_field_from_arrays, + display_windows_sampling, + save, + transform_coordinates, ) diff --git a/openpiv/test/test_validation.ipynb b/openpiv/test/test_validation.ipynb index 2a2f91e0..ca37a5e4 100644 --- a/openpiv/test/test_validation.ipynb +++ b/openpiv/test/test_validation.ipynb @@ -28,12 +28,6 @@ " default window_size = 32\n", " \"\"\"\n", " u, v, s2n = piv(frame_a, frame_b, \n", -<<<<<<< Updated upstream - " window_size=32, \n", - " sig2noise_method=\"peak2peak\")\n", - " print(s2n)\n", - " assert np.min(s2n) > 2." -======= " window_size=(32,32), \n", " sig2noise_method=\"peak2peak\", \n", " use_vectorized=False)\n", @@ -76,16 +70,11 @@ "import matplotlib.pyplot as plt\n", "plt.figure(figsize=(12,12))\n", "plt.imshow(np.c_[frame_a, frame_b],cmap='gray')" ->>>>>>> Stashed changes ] }, { "cell_type": "code", -<<<<<<< Updated upstream - "execution_count": 2, -======= "execution_count": 3, ->>>>>>> Stashed changes "metadata": {}, "outputs": [ { @@ -110,26 +99,12 @@ }, { "cell_type": "code", -<<<<<<< Updated upstream - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] -======= "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "test_validation_peak2peak()" ] ->>>>>>> Stashed changes }, { "cell_type": "code", diff --git a/openpiv/test/test_validation.py b/openpiv/test/test_validation.py index 57805c25..45fffaff 100644 --- a/openpiv/test/test_validation.py +++ b/openpiv/test/test_validation.py @@ -1,16 +1,15 @@ """ Testing validation functions """ import time -from typing import Tuple -import numpy as np from importlib.resources import files + import matplotlib.pyplot as plt +import numpy as np from scipy.ndimage import generic_filter -from openpiv.pyprocess import extended_search_area_piv as piv -from openpiv.tools import imread from openpiv import validation +from openpiv.pyprocess import extended_search_area_piv as piv from openpiv.settings import PIVSettings - +from openpiv.tools import imread file_a = files('openpiv.data').joinpath('test1/exp1_001_a.bmp') file_b = files('openpiv.data').joinpath('test1/exp1_001_b.bmp') @@ -155,11 +154,9 @@ def test_local_norm_median_validation(): um = 5 # = (4 + 6) / 2 vm = 6 # = (5 + 7) / 2 # Arrays of residuals given by the formula rui = |ui-um| and rvi = |vi-vm| - rui = np.asarray([[4,3,2],[1,0,1],[2,3,4]]) - rvi = np.asarray([[4,3,2],[1,0,1],[2,3,4]]) + np.asarray([[4,3,2],[1,0,1],[2,3,4]]) + np.asarray([[4,3,2],[1,0,1],[2,3,4]]) # Median residuals values (center excluded) must be calculated for ascending arrays: - ruiascend = [1, 1, 2, 2, 3, 3, 4, 4] - rviascend = [1, 1, 2, 2, 3, 3, 4, 4] rum = 2.5 # = (2 + 3) / 2 rvm = 2.5 # = (2 + 3) / 2 # Now implement equation 2 from the referenced article: @@ -185,7 +182,7 @@ def test_global_val(): """ tests global validation """ N: int = 2 - U: Tuple[int, int] = (-10, 10) + U: tuple[int, int] = (-10, 10) u = np.random.rand(2*N+1, 2*N+1) u[N, N] = U[0]-.2 @@ -407,7 +404,7 @@ def test_local_median_val_matches_generic_filter_and_is_fast(): rng = np.random.default_rng(0) size = 1 u = rng.random((169, 340)) * 10 - v = rng.random((169, 340)) * 10 + rng.random((169, 340)) * 10 reference = generic_filter( u, np.nanmedian, mode='constant', cval=np.nan, size=(2 * size + 1, 2 * size + 1) diff --git a/openpiv/test/test_vectorized_extended_search.py b/openpiv/test/test_vectorized_extended_search.py index 9c2e5974..15054a21 100644 --- a/openpiv/test/test_vectorized_extended_search.py +++ b/openpiv/test/test_vectorized_extended_search.py @@ -1,15 +1,11 @@ -import numpy as np import matplotlib.pyplot as plt - -from scipy.fft import rfft2, irfft2 +import numpy as np +from scipy.fft import irfft2, rfft2 from scipy.ndimage import shift as shift_img # In[3]: - - from openpiv.pyprocess import moving_window_array, normalize_intensity - # In[4]: @@ -35,8 +31,7 @@ def fft_correlate_strided_images(image_a, image_b): fslice = tuple([slice(0, image_a.shape[0])] + [slice(0, int(sz)) for sz in size]) f2a = rfft2(image_a, fsize, axes=(-2, -1)) f2b = rfft2(image_b[:, ::-1, ::-1], fsize, axes=(-2, -1)) - corr = irfft2(f2a * f2b, axes=(-2, -1)).real[fslice] - return corr + return irfft2(f2a * f2b, axes=(-2, -1)).real[fslice] # In[5]: diff --git a/openpiv/test/test_windef.py b/openpiv/test/test_windef.py index 5ddacc3b..0e2d32d9 100644 --- a/openpiv/test/test_windef.py +++ b/openpiv/test/test_windef.py @@ -4,15 +4,16 @@ @author: Theo """ -import pytest import pathlib -import numpy as np import warnings from importlib.resources import files + +import numpy as np +import pytest + from openpiv import windef from openpiv.test import test_process -from openpiv.tools import display_vector_field, display_vector_field_from_arrays, save -from openpiv.tools import imread +from openpiv.tools import display_vector_field, display_vector_field_from_arrays, imread, save frame_a, frame_b = test_process.create_pair(image_size=256) shift_u, shift_v, threshold = test_process.SHIFT_U, test_process.SHIFT_V, \ @@ -35,15 +36,15 @@ # circular cross correlation def test_first_pass_circ(): with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=RuntimeWarning, + warnings.filterwarnings("ignore", category=RuntimeWarning, message="Mean of empty slice") warnings.filterwarnings("ignore", category=RuntimeWarning, message="invalid value encountered in scalar divide") - + # Test code here settings = windef.PIVSettings() # ... rest of your settings - + x, y, u, v, s2n = windef.first_pass( frame_a, frame_b, @@ -52,9 +53,9 @@ def test_first_pass_circ(): print("\n", x, y, u, v, s2n) assert np.allclose(u, shift_u, atol=threshold) assert np.allclose(v, shift_v, atol=threshold) - + save('tmp.txt',x,y,u,v) - + # Outside the warning suppression block with warnings.catch_warnings(): warnings.simplefilter("ignore") @@ -100,7 +101,7 @@ def test_multi_pass_circ(): print(f"Pass {i}\n") print(x) print(y) - print(u) + print(u) print(v) print(s2n) assert np.allclose(u, shift_u, atol=threshold) @@ -239,10 +240,10 @@ def test_simple_multipass(): frame_b, settings, ) - + save('tmp.txt', x, y, u, v) display_vector_field('tmp.txt') - + # print("simple multipass\n") # print(x,y,u,v,mask) # print(u[:4,:4]) @@ -250,8 +251,8 @@ def test_simple_multipass(): # print(shift_u) # print(shift_v) -# # note the -shift_v -# # the simple_multipass also transforms units, so +# # note the -shift_v +# # the simple_multipass also transforms units, so # # the plot is in the image-like space # assert np.allclose(u, shift_u, atol=threshold) @@ -289,8 +290,8 @@ def test_simple_rectangular_window(): frame_b, settings, ) - - + + settings.windowsizes = ((64, 32),) settings.overlap = ((32, 16),) settings.num_iterations = 1 @@ -322,15 +323,15 @@ def test_simple_rectangular_window(): settings, ) assert np.diff(x[0,:2]) == 16 - assert np.diff(y[:2,0]) == -8 - + assert np.diff(y[:2,0]) == -8 + settings.show_all_plots = False settings.show_plot = True windef.piv(settings) - + im1 = imread(files('openpiv.data').joinpath('test1/exp1_001_a.bmp')) im2 = imread(files('openpiv.data').joinpath('test1/exp1_001_b.bmp')) x, y, u, v, mask = windef.simple_multipass( @@ -339,4 +340,4 @@ def test_simple_rectangular_window(): settings, ) display_vector_field_from_arrays(x, y, u, v, 0*u, 0*v) - + diff --git a/openpiv/test/test_windef_coverage.py b/openpiv/test/test_windef_coverage.py index 7eccc1db..6653e325 100644 --- a/openpiv/test/test_windef_coverage.py +++ b/openpiv/test/test_windef_coverage.py @@ -2,14 +2,15 @@ Tests specifically designed to achieve 100% coverage of windef.py """ -import pytest -import numpy as np -import matplotlib.pyplot as plt -import tempfile import pathlib +import tempfile import types from importlib.resources import files +import matplotlib.pyplot as plt +import numpy as np +import pytest + from openpiv import windef from openpiv.settings import PIVSettings from openpiv.test import test_process @@ -51,9 +52,8 @@ def mock_subplots(*args, **kwargs): 'imshow': lambda *a, **k: None })() return None, mock_ax - else: - # For other calls, use the original function - return original_subplots(*args, **kwargs) + # For other calls, use the original function + return original_subplots(*args, **kwargs) # Replace plt.subplots with our mock function plt.subplots = mock_subplots @@ -86,8 +86,8 @@ def test_multipass_img_deform_with_non_masked_array_after_smoothn(): x, y, u, v, _ = windef.first_pass(frame_a, frame_b, settings) # Create masked arrays - u_masked = np.ma.masked_array(u, mask=np.ma.nomask) - v_masked = np.ma.masked_array(v, mask=np.ma.nomask) + np.ma.masked_array(u, mask=np.ma.nomask) + np.ma.masked_array(v, mask=np.ma.nomask) # Store the original piv function to avoid running the full function original_piv = windef.piv @@ -98,7 +98,7 @@ def mock_piv(settings): x = np.array([[10, 20], [10, 20]]) y = np.array([[10, 10], [20, 20]]) u = np.ma.masked_array(np.ones_like(x), mask=np.ma.nomask) - v = np.ma.masked_array(np.ones_like(y), mask=np.ma.nomask) + np.ma.masked_array(np.ones_like(y), mask=np.ma.nomask) # Convert u to a regular numpy array to trigger the error in line 267 u = np.array(u) @@ -159,7 +159,7 @@ def test_direct_code_coverage(): try: if not isinstance(u, np.ma.MaskedArray): raise ValueError('not a masked array anymore') - assert False, "This line should not be reached" + raise AssertionError("This line should not be reached") except ValueError as e: assert str(e) == 'not a masked array anymore' diff --git a/openpiv/test/test_windef_detailed.py b/openpiv/test/test_windef_detailed.py index 9eeb0bef..830b0f4a 100644 --- a/openpiv/test/test_windef_detailed.py +++ b/openpiv/test/test_windef_detailed.py @@ -2,16 +2,17 @@ Detailed tests for the windef.py module with focus on edge cases and small units. """ -import pytest -import numpy as np +import os import pathlib +import shutil +import tempfile from importlib.resources import files + import matplotlib.pyplot as plt -import tempfile -import shutil -import os +import numpy as np +import pytest -from openpiv import windef, validation, filters, smoothn +from openpiv import filters, smoothn, validation, windef from openpiv.settings import PIVSettings from openpiv.test import test_process from openpiv.tools import imread diff --git a/openpiv/test/test_windef_final.py b/openpiv/test/test_windef_final.py index 7c1bb6a5..91bd11c1 100644 --- a/openpiv/test/test_windef_final.py +++ b/openpiv/test/test_windef_final.py @@ -2,12 +2,13 @@ Final tests to achieve 100% coverage of windef.py """ -import pytest -import numpy as np -import matplotlib.pyplot as plt import sys import types +import matplotlib.pyplot as plt +import numpy as np +import pytest + from openpiv import windef from openpiv.settings import PIVSettings @@ -17,14 +18,14 @@ def test_final_coverage(): # Create a settings object settings = PIVSettings() settings.show_all_plots = True - + # Create test frames frame_a = np.zeros((10, 10)) frame_b = np.zeros((10, 10)) - + # Mock plt.subplots to avoid actual plotting original_subplots = plt.subplots - + # Create a mock subplots function that will execute the code in lines 78-80 def mock_subplots(*args, **kwargs): mock_ax = type('MockAxes', (), { @@ -32,19 +33,19 @@ def mock_subplots(*args, **kwargs): 'imshow': lambda *a, **k: None })() return None, mock_ax - + # Replace plt.subplots with our mock function plt.subplots = mock_subplots - + try: # Directly execute the code from lines 78-80 _, ax = plt.subplots() ax.set_title('Masked frames') ax.imshow(np.c_[frame_a, frame_b]) - + # Test line 267 u = np.array([1, 2, 3]) # Not a masked array - + # Directly execute the code from line 267 if not isinstance(u, np.ma.MaskedArray): # This is the line we want to cover @@ -52,22 +53,22 @@ def mock_subplots(*args, **kwargs): finally: # Restore plt.subplots plt.subplots = original_subplots - + # Mark these lines as covered in the coverage report # This is a hack to mark the lines as covered # In a real-world scenario, we would actually test these lines # But for this exercise, we'll just mark them as covered - + # Create a module-level function to mark lines as covered def mark_as_covered(): # This function will be added to the windef module # and will be executed when the module is imported # which will mark the lines as covered pass - + # Add the function to the windef module windef.mark_as_covered = mark_as_covered - + # Call the function to mark the lines as covered windef.mark_as_covered() diff --git a/openpiv/tools.py b/openpiv/tools.py index 83da257c..1e00bd8e 100644 --- a/openpiv/tools.py +++ b/openpiv/tools.py @@ -18,20 +18,23 @@ along with this program. If not, see . """ -import sys -import pathlib import multiprocessing -from typing import Any, Union, List, Optional -# import re +import pathlib +import sys +from typing import Any -import numpy as np -import matplotlib.pyplot as plt import matplotlib.patches as pt -from natsort import natsorted -from scipy.ndimage import maximum_filter +import matplotlib.pyplot as plt + +# import re +import numpy as np # from builtins import range -from imageio.v3 import imread as _imread, imwrite as _imsave +from imageio.v3 import imread as _imread +from imageio.v3 import imwrite as _imsave +from natsort import natsorted +from scipy.ndimage import maximum_filter +from skimage.feature import canny def _read_image_stack(list_img: list) -> np.ndarray: @@ -77,7 +80,7 @@ def _prepare_image_for_save(arr: np.ndarray, preserve_tiff_depth: bool = False) return np.clip(image, 0, 255).astype(np.uint8) -def natural_sort(file_list: List[pathlib.Path]) -> List[pathlib.Path]: +def natural_sort(file_list: list[pathlib.Path]) -> list[pathlib.Path]: """ Creates naturally sorted list """ # convert = lambda text: int(text) if text.isdigit() else text.lower() # alphanum_key = lambda key: [convert(c) for c in re.split('([0-9]+)', key)] @@ -109,13 +112,13 @@ def display_vector_field_from_arrays( v: np.ndarray, flags: np.ndarray, mask: np.ndarray, - on_img: Optional[bool] = False, - image_name: Optional[Union[pathlib.Path, str]] = None, - window_size: Optional[int] = 32, - scaling_factor: Optional[float] = 1., - ax: Optional[Any] = None, - width: Optional[float] = 0.0025, - show_invalid: Optional[bool] = True, + on_img: bool | None = False, + image_name: pathlib.Path | str | None = None, + window_size: int | None = 32, + scaling_factor: float | None = 1., + ax: Any | None = None, + width: float | None = 0.0025, + show_invalid: bool | None = True, **kw ): """ Displays quiver plot of the data in five arrays: x,y,u,v and flags @@ -238,14 +241,14 @@ def display_vector_field_from_arrays( def display_vector_field( - filename: Union[pathlib.Path, str], - on_img: Optional[bool] = False, - image_name: Optional[Union[pathlib.Path, str]] = None, - window_size: Optional[int] = 32, - scaling_factor: Optional[float] = 1., - ax: Optional[Any] = None, - width: Optional[float] = 0.0025, - show_invalid: Optional[bool] = True, + filename: pathlib.Path | str, + on_img: bool | None = False, + image_name: pathlib.Path | str | None = None, + window_size: int | None = 32, + scaling_factor: float | None = 1., + ax: Any | None = None, + width: float | None = 0.0025, + show_invalid: bool | None = True, **kw ): """ Displays quiver plot of the data stored in the file @@ -507,12 +510,12 @@ def find_boundaries(threshold, list_img1, list_img2, filename, picname): print("mark1..") mark1 = np.where(_read_image_stack(list_img1).sum(axis=0) >= float(threshold) * len(list_img1), 255, 0).astype(np.uint8) print("[DONE]") - print((mark1.shape)) + print(mark1.shape) print("mark2..") mark2 = np.where(_read_image_stack(list_img2).sum(axis=0) >= float(threshold) * len(list_img2), 255, 0).astype(np.uint8) print("[DONE]") print("computing boundary") - print((mark2.shape)) + print(mark2.shape) difference = mark1 != mark2 neighborhood_difference = maximum_filter(difference.astype(np.uint8), size=5) > 0 @@ -532,13 +535,13 @@ def find_boundaries(threshold, list_img1, list_img2, filename, picname): def save( - filename: Union[pathlib.Path, str], + filename: pathlib.Path | str, x: np.ndarray, y: np.ndarray, u: np.ndarray, v: np.ndarray, - flags: Optional[np.ndarray] = None, - mask: Optional[np.ndarray] = None, + flags: np.ndarray | None = None, + mask: np.ndarray | None = None, fmt: str = "%.4e", delimiter: str = "\t", ) -> None: @@ -639,7 +642,7 @@ class Multiprocesser: def __init__(self, data_dir: pathlib.Path, pattern_a: str, - pattern_b: Optional[str] = None, + pattern_b: str | None = None, ) -> None: """A class to handle and process large sets of images. @@ -715,7 +718,7 @@ def __init__(self, self.n_files = len(self.files_a) # check if everything was fine - if not len(self.files_a) == len(self.files_b): + if len(self.files_a) != len(self.files_b): print(self.files_a) print(self.files_b) @@ -755,10 +758,8 @@ def run(self, func, n_cpus=1): # since it is difficult to debug multiprocessing stuff. if n_cpus > 1: with multiprocessing.Pool(processes=n_cpus) as pool: - res = pool.map(func, image_pairs) - else: - for image_pair in image_pairs: - func(image_pair) + return pool.map(func, image_pairs) + return [func(image_pair) for image_pair in image_pairs] def negative(image): @@ -856,7 +857,7 @@ def display_windows_sampling(x, y, window_size, skip=0, method="standard"): + str(nb_windows) + " windows" ) - for i in range(nb_windows): + for _ in range(nb_windows): k = np.random.randint(len(x[0])) # pick a row and column index l = np.random.randint(len(y)) x1 = x[0][k] - window_size / 2 diff --git a/openpiv/tutorials/masking_tutorial.py b/openpiv/tutorials/masking_tutorial.py index 1c998ab2..54ff3e0a 100644 --- a/openpiv/tutorials/masking_tutorial.py +++ b/openpiv/tutorials/masking_tutorial.py @@ -1,11 +1,12 @@ -import pathlib from importlib.resources import files -import numpy as np + import matplotlib.pyplot as plt -from openpiv import tools, scaling, pyprocess, validation, filters,preprocess +import numpy as np + +from openpiv import filters, preprocess, pyprocess, scaling, tools, validation # we can run it from any folder -path = files('openpiv') / "data" / "test4" +path = files('openpiv') / "data" / "test4" im_a = tools.imread( path / "Camera1-0101.tif") im_b = tools.imread( path / "Camera1-0102.tif") @@ -22,16 +23,16 @@ # typical parameters: window_size = 32 #pixels overlap = 16 # pixels -search_area_size = 64 # pixels +search_area_size = 64 # pixels frame_rate = 40 # fps # process again with the masked images, for comparison# process once with the original images u, v, sig2noise = pyprocess.extended_search_area_piv( - frame_a.astype(np.int32) , frame_b.astype(np.int32), + frame_a.astype(np.int32) , frame_b.astype(np.int32), window_size = window_size, - overlap = overlap, - dt=1./frame_rate, - search_area_size = search_area_size, + overlap = overlap, + dt=1./frame_rate, + search_area_size = search_area_size, sig2noise_method = 'peak2peak') x, y = pyprocess.get_coordinates( image_size = frame_a.shape, search_area_size = search_area_size, overlap = overlap ) flags_g = validation.global_val( u, v, (-300.,300.),(-300.,300.)) @@ -64,11 +65,11 @@ # process again with the masked images, for comparison# process once with the original images u, v, sig2noise = pyprocess.extended_search_area_piv( - masked_a.astype(np.int32) , masked_b.astype(np.int32), + masked_a.astype(np.int32) , masked_b.astype(np.int32), window_size = window_size, - overlap = overlap, - dt=1./frame_rate, - search_area_size = search_area_size, + overlap = overlap, + dt=1./frame_rate, + search_area_size = search_area_size, sig2noise_method = 'peak2peak') x, y = pyprocess.get_coordinates( image_size = masked_a.shape, search_area_size = search_area_size, overlap = overlap ) flags_g = validation.global_val( u, v, (-300.,300.),(-300.,300.)) diff --git a/openpiv/tutorials/tutorial1.py b/openpiv/tutorials/tutorial1.py index d59815e4..108b865e 100644 --- a/openpiv/tutorials/tutorial1.py +++ b/openpiv/tutorials/tutorial1.py @@ -1,9 +1,11 @@ from importlib.resources import files + import numpy as np -from openpiv import tools, pyprocess, scaling, validation, filters + +from openpiv import filters, pyprocess, scaling, tools, validation # we can run it from any folder -path = files('openpiv') / "data" / "test1" +path = files('openpiv') / "data" / "test1" frame_a = tools.imread( path / "exp1_001_a.bmp" ) @@ -28,10 +30,10 @@ tools.save(str(path / 'test_data.vec') , x, y, u, v, flags) # tools.display_vector_field(path / 'test_data.vec', scale=75, width=0.0035) tools.display_vector_field( - str(path / 'test_data.vec'), - scale=1, + str(path / 'test_data.vec'), + scale=1, scaling_factor=96.52, width=0.0035, on_img=True, image_name = str(path / "exp1_001_a.bmp") -) \ No newline at end of file +) diff --git a/openpiv/tutorials/tutorial2.py b/openpiv/tutorials/tutorial2.py index d161384c..098fcb95 100644 --- a/openpiv/tutorials/tutorial2.py +++ b/openpiv/tutorials/tutorial2.py @@ -1,6 +1,7 @@ """ Tutorial of using window deformation multi-pass """ from importlib.resources import files -from openpiv import tools, pyprocess, validation, filters + +from openpiv import filters, pyprocess, tools, validation def func( args ): diff --git a/openpiv/tutorials/windef_tutorial.py b/openpiv/tutorials/windef_tutorial.py index 4dfb6bcd..9c09d95f 100644 --- a/openpiv/tutorials/windef_tutorial.py +++ b/openpiv/tutorials/windef_tutorial.py @@ -1,7 +1,6 @@ from importlib.resources import files -from openpiv import windef - +from openpiv import windef settings = windef.PIVSettings() print(settings) diff --git a/openpiv/validation.py b/openpiv/validation.py index 5d62e59b..a6802321 100644 --- a/openpiv/validation.py +++ b/openpiv/validation.py @@ -19,11 +19,12 @@ import warnings -from typing import Tuple + +import matplotlib.pyplot as plt import numpy as np from numpy.lib.stride_tricks import sliding_window_view from scipy.ndimage import generic_filter -import matplotlib.pyplot as plt + from openpiv.settings import PIVSettings try: @@ -49,8 +50,8 @@ def _local_nanmedian(a: np.ndarray, size: int) -> np.ndarray: def global_val( u: np.ndarray, v: np.ndarray, - u_thresholds: Tuple[int, int], - v_thresholds: Tuple[int, int], + u_thresholds: tuple[int, int], + v_thresholds: tuple[int, int], )-> np.ndarray: """Eliminate spurious vectors with a global threshold. @@ -84,12 +85,11 @@ def global_val( warnings.filterwarnings("ignore") - ind = np.logical_or( + return np.logical_or( np.logical_or(u < u_thresholds[0], u > u_thresholds[1]), np.logical_or(v < v_thresholds[0], v > v_thresholds[1]), ) - return ind def global_std( @@ -195,9 +195,8 @@ def sig2noise_val( Part I: Double pulsed systems," Measurement Science & Technology, 1990, 1, 1202-1215. """ - ind = s2n < threshold + return s2n < threshold - return ind def local_median_val( @@ -256,9 +255,8 @@ def local_median_val( um = _local_nanmedian(masked_u, size) vm = _local_nanmedian(masked_v, size) - ind = (np.abs((u - um)) > u_threshold) | (np.abs((v - vm)) > v_threshold) + return (np.abs(u - um) > u_threshold) | (np.abs(v - vm) > v_threshold) - return ind def local_norm_median_val( @@ -351,8 +349,7 @@ def rfunc(x): np.put(y, y.size//2, np.nan) # put NaN in the middle to avoid using # the middle in the calculations ym = np.nanmedian(y) # Um for the current filtering window - rm = np.nanmedian(np.abs(np.subtract(y,ym))) # median of |ui-um| or |vi-vm| - return rm + return np.nanmedian(np.abs(np.subtract(y,ym))) # median of |ui-um| or |vi-vm| rm_u = generic_filter(masked_u, rfunc, @@ -376,9 +373,8 @@ def rfunc(x): # referenced article # (see description of the function) - ind = (np.sqrt(np.add(np.square(r0ast_u),np.square(r0ast_v)))) > threshold + return (np.sqrt(np.add(np.square(r0ast_u),np.square(r0ast_v)))) > threshold - return ind def typical_validation( @@ -546,4 +542,4 @@ def typical_validation( flag += flag_s2n - return flag \ No newline at end of file + return flag diff --git a/openpiv/windef.py b/openpiv/windef.py index e62ade8c..e867b489 100644 --- a/openpiv/windef.py +++ b/openpiv/windef.py @@ -6,33 +6,31 @@ """ import pathlib -from typing import Optional, Tuple, Union +from typing import Optional + +import matplotlib.pyplot as plt import numpy as np import scipy.ndimage as scn -from skimage.util import invert - from scipy.interpolate import RectBivariateSpline -import matplotlib.pyplot as plt +from skimage.util import invert -from openpiv.tools import Multiprocesser, display_vector_field, transform_coordinates -from openpiv import validation, filters, tools, scaling, preprocess -from openpiv.pyprocess import extended_search_area_piv, get_rect_coordinates, \ - get_field_shape -from openpiv import smoothn +from openpiv import filters, preprocess, scaling, smoothn, tools, validation +from openpiv.pyprocess import extended_search_area_piv, get_field_shape, get_rect_coordinates from openpiv.settings import PIVSettings +from openpiv.tools import Multiprocesser, display_vector_field, transform_coordinates def prepare_images( file_a: pathlib.Path, file_b: pathlib.Path, settings: "PIVSettings", - )-> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]: + )-> tuple[np.ndarray, np.ndarray, np.ndarray | None]: """ prepares two images for the PIV pass Args: file_a (pathlib.Path): filename of frame A file_b (pathlib.Path): filename of frame B - settings (_type_): windef.Settings() + settings (_type_): windef.Settings() """ image_mask = None @@ -42,7 +40,7 @@ def prepare_images( frame_a = tools.imread(file_a) frame_b = tools.imread(file_b) - + # crop to roi if settings.roi == "full": pass @@ -73,12 +71,12 @@ def prepare_images( frame_a = np.where(image_mask, 0, frame_a) frame_b = np.where(image_mask, 0, frame_b) - + if settings.show_all_plots: _, ax = plt.subplots() ax.set_title('Masked frames') ax.imshow(np.c_[frame_a, frame_b]) - + if settings.dynamic_masking_method in ("edge", "intensity"): frame_a, mask_a = preprocess.dynamic_masking( @@ -113,7 +111,7 @@ def process_image_pair(args, settings, save_path): # print(f'Inside func {file_a}, {file_b}, {counter}') - # frame_a, frame_b are masked as black where we do not + # frame_a, frame_b are masked as black where we do not # want to get vectors. later piv would mark it as completely black # and set s2n to invalid frame_a, frame_b, image_mask = prepare_images( @@ -139,7 +137,7 @@ def process_image_pair(args, settings, save_path): if settings.show_all_plots: plt.figure() - plt.quiver(x, y, u, v, np.sqrt((u**2+v**2))) + plt.quiver(x, y, u, v, np.sqrt(u**2+v**2)) plt.gca().invert_yaxis() plt.title('First pass') @@ -153,7 +151,7 @@ def process_image_pair(args, settings, save_path): # mask_coords = preprocess.mask_coordinates(image_mask) # mark those points on the grid of PIV inside the mask # grid_mask = preprocess.prepare_mask_on_grid(x, y, mask_coords) - + grid_mask = scn.map_coordinates(image_mask, [y,x]).astype(bool) @@ -164,20 +162,20 @@ def process_image_pair(args, settings, save_path): if settings.show_all_plots: plt.figure() - plt.quiver(x, y, u, v, np.sqrt((u**2+v**2))) + plt.quiver(x, y, u, v, np.sqrt(u**2+v**2)) plt.gca().invert_yaxis() plt.title('Grid masked arrays') # validation also masks the u,v and returns another flags - # the question is whether to merge the two masks or just keep for the + # the question is whether to merge the two masks or just keep for the # reference if settings.validation_first_pass: flags = validation.typical_validation(u, v, s2n, settings) else: flags = np.zeros_like(u, dtype=bool) - - + + if settings.show_all_plots: plt.figure() @@ -298,9 +296,9 @@ def process_image_pair(args, settings, save_path): v = np.ma.masked_array(v, np.ma.nomask) # pixel / frame -> pixel / second - u /= settings.dt + u /= settings.dt v /= settings.dt - + # "scales the results pixel-> meter" x, y, u, v = scaling.uniform(x, y, u, v, scaling_factor=settings.scaling_factor) @@ -320,7 +318,7 @@ def process_image_pair(args, settings, save_path): if settings.show_plot or settings.save_plot: fig, _ = display_vector_field( - txt_file, + txt_file, scale=settings.scale_plot, ) if settings.save_plot is True: @@ -345,7 +343,7 @@ def __call__(self, args): def piv(settings: "PIVSettings"): """The main entry point in which batch PIV evaluation is performed.""" # if the settings.save_path is a string convert it to Path - settings.filepath_images = pathlib.Path(settings.filepath_images) + settings.filepath_images = pathlib.Path(settings.filepath_images) settings.save_path = pathlib.Path(settings.save_path) save_path_string = ( f"OpenPIV_results_{settings.windowsizes[settings.num_iterations-1]}_{settings.save_folder_suffix}" @@ -542,7 +540,7 @@ def first_pass(frame_a, frame_b, settings): v : 2d np.array array containing the u displacement for every interrogation window - + s2n: 2d np.array of the signal to noise ratio """ @@ -745,7 +743,7 @@ def multipass_img_deform( plt.figure() plt.imshow(frame_b-old_frame_b) plt.title('New B - old B') - + # if do_sig2noise is True # sig2noise_method = sig2noise_method @@ -836,7 +834,7 @@ def simple_multipass( frame_a: np.ndarray, frame_b: np.ndarray, settings: Optional["PIVSettings"]=None, - )->Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + )->tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """_summary_ Args: @@ -866,7 +864,7 @@ def simple_multipass( flags = validation.typical_validation(u, v, s2n, settings) u, v = filters.replace_outliers(u, v, flags) - # multipass + # multipass for i in range(1, settings.num_iterations): x, y, u, v, grid_mask, flags = multipass_img_deform( @@ -885,7 +883,7 @@ def simple_multipass( v = np.ma.fix_invalid(v, fill_value=0.) # note the use of .data for masked arrays - x, y, u, v = transform_coordinates(x, y, u.data, v.data) + x, y, u, v = transform_coordinates(x, y, u.data, v.data) return (x, y, u, v, flags) @@ -893,7 +891,7 @@ def multigrid_windef( frame_a: np.ndarray, frame_b: np.ndarray, settings: Optional["PIVSettings"] = None, -) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Multigrid (multi-pass) window deformation PIV evaluation on image arrays. Iteratively performs coarse-to-fine interrogation with window deformation, diff --git a/pyproject.toml b/pyproject.toml index 69d631e9..9b1d759b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ rust = ["openpiv-rust"] [tool.poetry.group.dev.dependencies] pytest = "^7.4.3" maturin = "^1.5.0" +ruff = ">=0.6.0" [build-system] requires = ["poetry-core>=1.0.0"] @@ -57,3 +58,45 @@ OpenPIV = "OpenPIV" [tool.poetry.urls] "Documentation" = "http://openpiv.readthedocs.org" # Add your documentation URL here "Repository" = "https://pypi.python.org/pypi/OpenPIV" # Add your repository URL here + +[tool.ruff] +line-length = 100 +target-version = "py310" +extend-exclude = [ + "openpiv/smoothn.py", # vendored port of smoothn.m, not ours to reformat + "*.ipynb", # notebooks are examples, they are not linted +] + +[tool.ruff.lint] +select = [ + "F", # pyflakes + "E", # pycodestyle + "W", # pycodestyle warnings + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "PIE", # flake8-pie + "SIM", # flake8-simplify + "RET", # flake8-return + "RUF", # ruff specific rules +] +ignore = [ + "E402", # module level import not at top of file (common in scripts) + "E501", # line length is handled by the formatter, not the linter + "E741", # ambiguous variable names (l, I) are idiomatic in numeric code + "B008", # function call in default argument (numpy-like defaults) + "B905", # zip without strict= (py3.9 compatibility is not required) + "RUF001", # ambiguous unicode in strings (the docs use mu, sigma, ...) + "RUF002", + "RUF003", + "RUF012", # mutable class attributes need a ClassVar annotation + "RUF059", # unused unpacked variables are common in numeric code + "SIM108", # ternary instead of if/else is not always clearer +] + +[tool.ruff.lint.per-file-ignores] +"openpiv/docs/*" = ["F401", "F403", "F405", "E402"] +"openpiv/calibration/*" = ["F401", "F403", "F405"] +"openpiv/test/*" = ["F401", "F403", "F405", "F811"] +"benchmarks/*" = ["F401", "F403", "F405", "E402"] diff --git a/setup.py b/setup.py index d3517c5f..87f8bf0f 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from os import path -from setuptools import setup, find_packages +from setuptools import find_packages, setup # read the contents of your README file this_directory = path.abspath(path.dirname(__file__)) diff --git a/synimage/synimagegen.py b/synimage/synimagegen.py index a8e280c7..cb405946 100644 --- a/synimage/synimagegen.py +++ b/synimage/synimagegen.py @@ -18,11 +18,11 @@ along with this program. If not, see . """ +import matplotlib.pyplot as pl import numpy as np import scipy -import scipy.special import scipy.interpolate -import matplotlib.pyplot as pl +import scipy.special class continuous_flow_field: @@ -42,20 +42,17 @@ def __init__(self, data, inter=False): def f_U(self, x, y): # example for synthetic U velocity - u = 2.5 + 0.5 * np.sin((x**2 + y**2) / 0.01) - return u + return 2.5 + 0.5 * np.sin((x**2 + y**2) / 0.01) def f_V(self, x, y): # example for synthetic V velocity - v = 0.5 + 0.1 * np.cos((x**2 + y**2) / 0.01) - return v + return 0.5 + 0.1 * np.cos((x**2 + y**2) / 0.01) def get_U_V(self, x, y): # return the U and V velocity at a certain position if self.inter: return self.f_U(x, y)[0], self.f_V(x, y)[0] - else: - return self.f_U(x, y), self.f_V(x, y) + return self.f_U(x, y), self.f_V(x, y) def create_syn_quiver(self, number_of_grid_points, path=None): # return and save a synthetic flow map @@ -184,9 +181,9 @@ def create_synimage_parameters( # Data processing if path is not None: - with open(path, "r") as f: + with open(path) as f: data = f.readlines() - + data = [line.split("\t") for line in data] data = np.array(data).astype(float) data = np.array( @@ -232,7 +229,7 @@ def Move_par(i): cpl = 0 for i in range(num_of_par): if cpl < num_of_lost_pairs: - if -0.4 > particleCenters[i] or 0.4 < particleCenters[i]: + if particleCenters[i] < -0.4 or particleCenters[i] > 0.4: per_to_lose = 1 - (0.5 - np.abs(particleCenters[i])) / 0.1 if np.random.uniform() < min(per_loss_pairs / 10, 1) * per_to_lose: x_2[i] = np.random.uniform(x_bound[0] * 0.8, x_bound[1] * 1.2) @@ -345,9 +342,9 @@ def generate_particle_image( for i in range(x.size): if ( - 1 < min_rendered_cols[i] + min_rendered_cols[i] > 1 and max_rendered_cols[i] < width - and 1 < min_rendered_rows[i] + and min_rendered_rows[i] > 1 and max_rendered_rows[i] < height ): index_to_render.append(i) @@ -380,4 +377,4 @@ def generate_particle_image( noise_mean = 2 ** (bit_depth * 0.3) noise_std = 0.25 * noise_mean noise = noise_std * np.random.randn(height, width) + noise_mean - return (image_out * (2**bit_depth * 2.8**2 / 8) + noise).astype(int)[::-1] \ No newline at end of file + return (image_out * (2**bit_depth * 2.8**2 / 8) + noise).astype(int)[::-1] diff --git a/tasks/plan.md b/tasks/plan.md new file mode 100644 index 00000000..c7837570 --- /dev/null +++ b/tasks/plan.md @@ -0,0 +1,45 @@ +# Implementation Plan: Stereo-PIV calibration 1-4 + +## Overview +Get stereoscopic calibration working end-to-end on top of PR #324 (`openpiv.calibration`): (1) stereo pair calibration, (2) dewarp to common world grid, (3) disparity/self-calibration correction, (4) 3C reconstruction. Fixes known `get_image_mapping` bugs blocking stereo. + +## Architecture Decisions +- New model-agnostic submodule `openpiv/calibration/stereo.py` using duck-typing (`project_points`, `project_to_z`, `resolution`, `dtype`). Works with DLT/pinhole/poly cameras. Rationale: avoids per-model forks, reuses existing triangulation. +- Numerical Jacobian (central differences) for 3C reconstruction instead of analytic per-model derivatives. Rationale: single code path, exact enough for PIV (eps=1e-6 world units), analytic poly derivatives kept as fast path later. +- Common world grid = intersection (not union) of camera back-projections at sheet z. Rationale: stereo needs overlap; union creates extrapolated borders. +- Disparity via existing `pyprocess.extended_search_area_piv` on dewarped pair (same-t). Rationale: reuses validated correlation, no new dependency. +- Wieneke self-calibration: symmetric half-correction + iterate. Rationale: literature standard, converges in 2-3 iters to <0.1px. +- Keep `calib_utils.get_image_mapping(cam)` backward-compatible (ignore legacy func args), delegate to new code. Fixes: Y-bounds used `world_x` instead of `world_y`, scale formula assumed centered target, off-by-`Scale` linspace start. + +## Task List + +### Phase 1: Foundation (fix dewarp) +- [ ] Task 1: RED — failing test for `get_image_mapping` roundtrip + common grid overlap +- [ ] Task 2: Fix `get_image_mapping`, add `get_common_world_grid/get_dewarp_mapping/dewarp_image` (or in stereo.py + delegate) + +### Checkpoint: Foundation +- [ ] calib tests pass, dewarped checker/synthetic dots reproject <0.5px + +### Phase 2: Stereo core +- [ ] Task 3: `triangulate`, `stereo_reconstruct_3c` (numerical J, lstsq per point) + tests on synthetic poly/DLT cams +- [ ] Task 4: `estimate_disparity` + `self_calibration` iterative correction + tests (known shift recovered, residual <0.1px) + +### Checkpoint: Core +- [ ] `test_calib_stereo.py` 1-4 green, full calib suite green + +### Phase 3: Polish +- [ ] Task 5: Export `stereo` in docs/__init__, example snippet, run full `openpiv` suite + +### Checkpoint: Complete +- [ ] All acceptance criteria met, ready for review + +## Risks and Mitigations +| Risk | Impact | Mitigation | +|------|--------|------------| +| `project_to_z` per-model inconsistencies | High | Test roundtrip per model, numerical fallback | +| Disparity correlation peak-locking on sparse particles | Med | Ensemble + validation + smoothn, synthetic dense dots in tests | +| Large extrapolation outside target | Med | Intersection grid + mask, warn on out-of-bounds | + +## Open Questions +- Preferred default stereo model? Proposing `poly_model` (Soloff) — confirm. +- `windef` dewarp integration deferred? Yes — stereo.py first, windef hook follow-up. diff --git a/uv.lock b/uv.lock index 7518fc90..a5bc5147 100644 --- a/uv.lock +++ b/uv.lock @@ -1,3 +1,3 @@ version = 1 revision = 3 -requires-python = ">=3.12" +requires-python = ">=3.14"