Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Particle Image Velocimetry (PIV) analysis with OpenPIV. Use when extracting velocity fields from PIV image pairs, analyzing fluid dynamics or flow visualization experiments, cross-correlating interrogation windows, validating and replacing spurious PIV vectors, or computing vorticity, strain rate, and turbulence statistics from measured velocity fields.
.claude/skills/k-dense-ai-openpiv/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 21% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 133% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 90% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 143% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 192% | 0% |
OpenPIV (Open Particle Image Velocimetry) analyzes fluid flow from PIV image pairs. It covers preprocessing, cross-correlation, vector validation, outlier replacement, smoothing, and scaling to physical units.
Everything below is verified against openpiv 0.25.4. The API moves between releases — check inspect.signature() before trusting a snippet against a different version.
Use this skill when working with experimental PIV or flow-visualization image pairs: measuring 2D velocity fields, tuning interrogation-window parameters, validating vectors, or deriving vorticity, strain rate, and turbulence statistics. For simulating flow rather than measuring it, use a CFD skill instead.
Install OpenPIV:
bashuv pip install openpiv # Pin it when the analysis needs to be reproducible -- this is the version every # snippet below was checked against. uv pip install "openpiv==0.25.4"
Run PIV analysis on an image pair:
pythonimport numpy as np from openpiv import tools, pyprocess, validation, filters, scaling frame_a = tools.imread("image_a.bmp") frame_b = tools.imread("image_b.bmp") # Cross-correlate. Returns (u, v, s2n) whenever sig2noise_method is not None. u, v, s2n = pyprocess.extended_search_area_piv( frame_a.astype(np.int32), frame_b.astype(np.int32), window_size=32, overlap=12, dt=0.02, search_area_size=38, correlation_method="linear", # required for search_area_size > window_size sig2noise_method="peak2peak", ) x, y = pyprocess.get_coordinates( image_size=frame_a.shape, search_area_size=38, overlap=12, ) # flags is a boolean array: True marks a spurious vector. flags = validation.sig2noise_val(s2n, threshold=1.05) u, v = filters.replace_outliers(u, v, flags, method="localmean", max_iter=3, kernel_size=2) # Scale to physical units, then flip to image coordinates for plotting. x, y, u, v = scaling.uniform(x, y, u, v, scaling_factor=96.52) x, y, u, v = tools.transform_coordinates(x, y, u, v) tools.save("vectors.txt", x, y, u, v, flags)
Or use the bundled CLI, which wraps exactly that pipeline:
bashpython skills/openpiv/scripts/runner.py \ --image frame_a.bmp --image frame_b.bmp --output_dir results --verbose
Particle Image Velocimetry is an optical method for measuring fluid velocity by tracking illuminated tracer particles between two images.
Process flow:
frame_a, frame_b) separated by a known time dt.window_size — correlation window in pixels (typically 16–128). Larger windows give better correlation but coarser spatial resolution.
overlap — pixels shared between adjacent windows (typically 50–75% of window_size). Higher overlap raises vector density and cost, but adjacent vectors become correlated rather than independent.
search_area_size — the window searched in the second frame. Must be ≥ window_size; a few pixels larger accommodates larger displacements. Pair an extended search area with correlation_method="linear" — the default "circular" relies on FFT wrap-around and aliases large displacements into small ones. See references/advanced_algorithms.md.
Rules of thumb: keep the largest displacement under about a quarter of window_size, and aim for 5–10 particles per window.
s2n measures how distinct the correlation peak is. sig2noise_method controls how it is computed — "peak2mean" (the function default) or "peak2peak". The two are on different scales, so a threshold tuned for one is meaningless for the other. Typical peak2peak thresholds are 1.05–1.3.
pythonflags = validation.sig2noise_val(s2n, threshold=1.05) # flags is bool: True == spurious. `~flags` selects the good vectors.
Masking lives in openpiv.preprocess, not in an openpiv.masking module. It returns an (image, mask) tuple and expects a float image.
pythonfrom openpiv import preprocess # method="edges" for dark, sharp-edged objects; "intensity" for high-contrast objects. frame_a_masked, mask_a = preprocess.dynamic_masking( frame_a.astype(np.float64), method="intensity", filter_size=7, threshold=0.005 ) frame_b_masked, mask_b = preprocess.dynamic_masking( frame_b.astype(np.float64), method="intensity", filter_size=7, threshold=0.005 )
Feed the returned image into the correlation step — it already has the masked region zeroed. Do not multiply the original frame by mask: masking is already applied, and for method="edges" the mask comes back as uint8 0/255 rather than boolean, so multiplying rescales the image by 255.
Multi-pass (window deformation) lives in openpiv.windef, driven by a PIVSettings dataclass. pyprocess has no multi-pass entry point.
pythonimport numpy as np from openpiv import scaling, windef settings = windef.PIVSettings() settings.windowsizes = (64, 32, 16) # one entry per pass, decreasing (this is also the default) settings.overlap = (32, 16, 8) # same length as windowsizes settings.num_iterations = 3 # number of passes to actually run settings.sig2noise_threshold = 1.05 x, y, u, v, flags = windef.simple_multipass( frame_a.astype(np.int32), frame_b.astype(np.int32), settings ) # Output is in PIXELS PER FRAME -- convert yourself. scaling.uniform only divides # by scaling_factor, so apply dt separately. dt = 0.02 x, y, u, v = scaling.uniform(x, y, u, v, scaling_factor=96.52) u, v = u / dt, v / dt
simple_multipass already validates, replaces outliers, fills remaining NaNs with zeros, and calls transform_coordinates — do not repeat those steps.
Units trap: PIVSettings has dt and scaling_factor fields, but windef never uses either — first_pass calls extended_search_area_piv without dt, so the whole multi-pass chain works in pixels per frame. Setting settings.dt = 0.02 changes nothing about the returned values. Convert after the fact, as above.
For control over individual passes, windef.first_pass and windef.multipass_img_deform are the lower-level building blocks.
Every validator returns a boolean array where True marks a spurious vector.
python# Signal-to-noise flags = validation.sig2noise_val(s2n, threshold=1.05) # Global range -- takes (min, max) TUPLES, positionally or as u_thresholds/v_thresholds. flags = validation.global_val(u, v, (-300, 300), (-300, 300)) # Local median -- u_threshold and v_threshold are REQUIRED; size is the neighbourhood half-width. flags = validation.local_median_val(u, v, u_threshold=30.0, v_threshold=30.0, size=1) # Combine with boolean OR (not np.maximum -- these are bool arrays). flags = ( validation.sig2noise_val(s2n, threshold=1.05) | validation.global_val(u, v, (-300, 300), (-300, 300)) | validation.local_median_val(u, v, u_threshold=30.0, v_threshold=30.0) )
Set these thresholds in the units of u and v, not in pixels per frame. extended_search_area_piv divides by dt, so with dt=0.02 a 3 px/frame displacement arrives as 150 px/s. The thresholds above suit that case; the (-30, 30) figure that PIV literature and PIVSettings.min_max_u_disp use is a px/frame limit, and applying it to px/s output rejects the entire field. Either validate before scaling, or scale the thresholds by 1/dt too.
pythonu, v = filters.replace_outliers( u, v, flags, method="localmean", max_iter=3, tol=1e-3, kernel_size=2 )
method accepts "localmean", "disk", or "distance" — and only those three. An unrecognized name is not rejected; it falls through to an all-zero kernel and silently returns a useless field. Note that replacement fills the flagged positions with interpolated values — if you then overwrite them with NaN, the replacement was wasted. Choose one or the other:
python# Keep flagged vectors out of the analysis entirely, instead of interpolating them. u = np.where(flags, np.nan, u) v = np.where(flags, np.nan, v)
Smoothing is openpiv.smoothn.smoothn; there is no openpiv.smooth module. It returns a tuple whose first element is the smoothed field, and it does not accept NaN input.
pythonfrom openpiv.smoothn import smoothn u_smooth, *_ = smoothn(np.nan_to_num(u), s=0.5) # s: larger == smoother v_smooth, *_ = smoothn(np.nan_to_num(v), s=0.5) u_smooth = np.asarray(u_smooth)
display_vector_field reads a saved vectors file and calls plt.show() internally, so select a non-interactive backend for batch runs.
pythonimport matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from openpiv import tools fig, ax = plt.subplots(figsize=(8, 8)) tools.display_vector_field( "vectors.txt", ax=ax, scaling_factor=96.52, # same factor used in scaling.uniform, to map back onto the image scale=50, width=0.0035, on_img=True, image_name="frame_a.bmp", ) fig.savefig("vector_field.png", dpi=150, bbox_inches="tight") plt.close(fig)
pythonimport numpy as np import matplotlib.pyplot as plt fig, axes = plt.subplots(1, 3, figsize=(15, 5)) mag = np.sqrt(u**2 + v**2) for ax, field, title, cmap in [ (axes[0], mag, "Velocity Magnitude", "viridis"), (axes[1], u, "U Velocity", "RdBu_r"), (axes[2], v, "V Velocity", "RdBu_r"), ]: im = ax.imshow(field, cmap=cmap) ax.set_title(title) plt.colorbar(im, ax=ax) fig.tight_layout() fig.savefig("velocity_components.png") plt.close(fig)
scripts/analyze.py bundles these against a params.npz written by runner.py. It infers the physical grid spacing from the saved coordinates, so the derivatives come out per unit length:
pythonimport sys sys.path.insert(0, "skills/openpiv/scripts") from analyze import PIVAnalyzer piv = PIVAnalyzer("results/params.npz") vorticity = piv.compute_vorticity() # dv/dx - du/dy exx, eyy, exy = piv.compute_strain() stats = piv.compute_statistics() # u_mean, v_mean, rms_u, rms_v, tke piv.plot_vector_field(save_path="quiver.png")
The standalone forms, if you would rather compute them inline:
pythondef compute_vorticity(u, v, dx=1.0, dy=None): """Out-of-plane vorticity dv/dx - du/dy. Pass the physical grid spacing, not 1.0.""" dy = dx if dy is None else dy return np.gradient(v, dx, axis=1) - np.gradient(u, dy, axis=0)
The grid spacing is (window_size - overlap) / scaling_factor in physical units, so leaving dx=1.0 yields vorticity per grid cell, not per unit length.
Sign convention: runner.py ends with transform_coordinates, which relabels the grid into a right-handed y-up frame but leaves the rows in image order, so the saved y decreases as the row index grows. The standalone forms above assume the opposite, so on a params.npz field they return -du/dy and flip the sign of the vorticity and the shear strain — negate the axis=0 derivatives, or use PIVAnalyzer, which reads the orientation off the saved coordinates.
pythondef compute_strain(u, v, dx=1.0, dy=None): """Return (exx, eyy, exy) of the 2D strain-rate tensor.""" dy = dx if dy is None else dy du_dx = np.gradient(u, dx, axis=1) du_dy = np.gradient(u, dy, axis=0) dv_dx = np.gradient(v, dx, axis=1) dv_dy = np.gradient(v, dy, axis=0) return du_dx, dv_dy, 0.5 * (du_dy + dv_dx)
pythondef compute_statistics(u, v): """Single-frame spatial statistics. NOT Reynolds decomposition.""" u_prime = u - np.nanmean(u) v_prime = v - np.nanmean(v) rms_u, rms_v = np.nanstd(u_prime), np.nanstd(v_prime) return { "u_mean": np.nanmean(u), "v_mean": np.nanmean(v), "rms_u": rms_u, "rms_v": rms_v, "tke": 0.5 * (rms_u**2 + rms_v**2), }
Caveat: subtracting the spatial mean of one frame measures spatial variance, which equals turbulent intensity only for a homogeneous field. Genuine Reynolds decomposition needs an ensemble of image pairs: average over the time axis, then subtract that mean field from each realization.
bash# Basic run python skills/openpiv/scripts/runner.py \ --image img1.bmp --image img2.bmp --output_dir results --verbose # Tuned parameters with dynamic masking python skills/openpiv/scripts/runner.py \ --image frame_a.bmp \ --image frame_b.bmp \ --output_dir results \ --window_size 32 \ --overlap 12 \ --search_area 38 \ --dt 0.02 \ --scaling 96.52 \ --threshold 1.05 \ --mask dynamic \ --mask_method intensity \ --verbose
| Option | Default | Description | |--------|---------|-------------| | --image | required | Image file; specify exactly twice for the pair | | --output_dir | results | Output directory (created if absent) | | --window_size | 32 | Interrogation window size (px) | | --overlap | 12 | Window overlap (px) | | --search_area | 38 | Search area size (px), must be ≥ --window_size | | --dt | 0.02 | Time between frames (s) | | --scaling | 96.52 | Scaling factor, pixels per physical unit (e.g. px/mm) | | --threshold | 1.05 | peak2peak signal-to-noise threshold | | --mask | none | none or dynamic (openpiv.preprocess.dynamic_masking) | | --mask_method | intensity | edges or intensity, used only with --mask dynamic | | --drop_invalid | off | NaN out flagged vectors instead of keeping interpolated values | | --verbose | off | Print progress messages |
Verify an install end to end against OpenPIV's own bundled image pair:
bashpython skills/openpiv/scripts/run_example.py --output_dir /tmp/openpiv-demo
%.4e formatted, with a # x y u v flags mask comment headerx, y, u, v, flags arraystext# x y u v flags mask 2.1757e-01 3.5226e+00 -6.2220e-02 -2.7081e+00 0.0000e+00 0.0000e+00 4.8695e-01 3.5226e+00 -3.1587e-01 -2.9800e+00 0.0000e+00 0.0000e+00
flags is written as a float, 0 for a valid vector and 1 for a flagged one.
16/24 for finer resolution at the cost of noise.
sig2noise_method.
units straight (96.52 in OpenPIV's test1 tutorial data is px/mm).
s2n distribution — a low median means poor correlation, not a bad threshold.windef) for flows with large velocity gradients or displacements.advanced_algorithms.md — correlation and subpixel methods, multi-pass window deformation,PIVSettings fields, 3D and phase-separation modules
Load the reference when detailed algorithm or settings information is needed.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-17 | pass→pass | 37,631 | 16,431 | -56% | 1 | 1 | 0% | 3,005 | 8,370 | +179% | 0 | 0 | — |
case-01 | fail→pass | 36,456 | 10,270 | -72% | 1 | 1 | 0% | 6,244 | 7,567 | +21% | 0 | 0 | — |
case-02 | fail→pass | 18,473 | 17,063 | -8% | 1 | 1 | 0% | 3,807 | 8,870 | +133% | 0 | 0 | — |
case-03 | fail→pass | 28,169 | 23,976 | -15% | 1 | 1 | 0% | 5,928 | 11,243 | +90% | 0 | 0 | — |
case-04 | fail→pass | 12,417 | 57,404 | +362% | 1 | 1 | 0% | 2,348 | 5,696 | +143% | 0 | 0 | — |
case-05 | pass→pass | 10,245 | 8,232 | -20% | 1 | 1 | 0% | 1,994 | 6,429 | +222% | 0 | 0 | — |
case-06 | pass→pass | 8,919 | 5,004 | -44% | 1 | 1 | 0% | 1,772 | 5,976 | +237% | 0 | 0 | — |
case-07 | pass→pass | 5,858 | 4,495 | -23% | 1 | 1 | 0% | 1,366 | 5,935 | +334% | 0 | 0 | — |
case-08 | pass→pass | 10,357 | 5,549 | -46% | 1 | 1 | 0% | 2,148 | 5,992 | +179% | 0 | 0 | — |
case-09 | pass→pass | 5,826 | 3,325 | -43% | 1 | 1 | 0% | 1,198 | 5,635 | +370% | 0 | 0 | — |
case-10 | fail→pass | 10,074 | 4,656 | -54% | 1 | 1 | 0% | 2,074 | 6,053 | +192% | 0 | 0 | — |
case-11 | pass→pass | 6,876 | 5,256 | -24% | 1 | 1 | 0% | 1,436 | 6,022 | +319% | 0 | 0 | — |
case-18 | pass→pass | 11,742 | 10,531 | -10% | 1 | 1 | 0% | 2,457 | 7,384 | +201% | 0 | 0 | — |
case-12 | pass→pass | 8,214 | 45,531 | +454% | 1 | 1 | 0% | 1,838 | 5,917 | +222% | 0 | 0 | — |
case-13 | pass→pass | 13,870 | 6,650 | -52% | 1 | 1 | 0% | 2,407 | 6,304 | +162% | 0 | 0 | — |
case-14 | fail→pass | 13,166 | 3,854 | -71% | 1 | 1 | 0% | 2,358 | 5,815 | +147% | 0 | 0 | — |
case-15 | pass→pass | 14,274 | 6,098 | -57% | 1 | 1 | 0% | 2,752 | 6,506 | +136% | 0 | 0 | — |
case-16 | pass→pass | 18,306 | 14,730 | -20% | 1 | 1 | 0% | 3,622 | 7,788 | +115% | 0 | 0 | — |
case-19 | pass→pass | 11,357 | 51,588 | +354% | 1 | 1 | 0% | 2,067 | 6,037 | +192% | 0 | 0 | — |
case-20 | pass→pass | 8,375 | 2,749 | -67% | 1 | 1 | 0% | 1,609 | 5,558 | +245% | 0 | 0 | — |
case-21 | pass→pass | 8,934 | 9,129 | +2% | 1 | 1 | 0% | 1,583 | 6,886 | +335% | 0 | 0 | — |
case-22 | pass→pass | 10,163 | 7,003 | -31% | 1 | 1 | 0% | 2,208 | 6,403 | +190% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +27 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.