Particle Image Velocimetry
Openpiv

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…
--- name: openpiv description: 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. license: BSD-3-Clause compatibility: Requires Python 3.10+ with openpiv installed (uv pip install openpiv). numpy, scipy, scikit-image, and matplotlib arrive as dependencies. No network access needed after install. allowed-tools: Read Write Edit Bash metadata: version: "1.1" skill-author: OpenPIV Team tested-against: "openpiv 0.25.4" --- # OpenPIV ## Overview 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
Overview
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.
What you get
- vectors.txttab-delimited, %.4e formatted, with a # x y u v flags mask comment header
- params.npzNumPy archive with x, y, u, v, flags arrays
- vector_field.pngvector field drawn over the first frame
Manual install (npx, ZIP)
npx skills add K-Dense-AI/scientific-agent-skills --skill openpiv -g -a claude-codemkdir -p ~/.claude/skills && curl -sL https://codeload.github.com/K-Dense-AI/scientific-agent-skills/tar.gz/49c6e97775ea \
| tar -xz -C ~/.claude/skills --strip-components=2 scientific-agent-skills-49c6e97775ea/skills/openpivNew-Item -ItemType Directory -Force "$HOME\.claude\skills" | Out-Null; Invoke-WebRequest "https://m.agentalley.io/zip/67214c3213d8dac1f14b/openpiv.zip" -OutFile "$env:TEMP\openpiv.zip"; Expand-Archive "$env:TEMP\openpiv.zip" -DestinationPath "$HOME\.claude\skills" -ForceConnects with Agent Alley MCP · agentalley.io/mcp
Files included
SKILL.md16.6 KBreferences/1 file · 10.4 KBscripts/4 files · 15.0 KB
Read the files here
K-Dense-AI/
Show the full text470 lines
OpenPIV
Overview
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.
When to use
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.
Quick Start
Install OpenPIV:
uv 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:
import 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:
python skills/openpiv/scripts/runner.py \
--image frame_a.bmp --image frame_b.bmp --output_dir results --verbose
Core Concepts
PIV Fundamentals
Particle Image Velocimetry is an optical method for measuring fluid velocity by tracking illuminated tracer particles between two images.
Process flow:
- Capture an image pair (
frame_a,frame_b) separated by a known timedt. - Divide the images into interrogation windows.
- Cross-correlate matching windows to find peak displacement.
- Validate vectors (signal-to-noise, global range, local median).
- Replace spurious vectors with interpolated values.
- Scale pixel displacements to physical units.
Interrogation Window Parameters
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.
Signal-to-Noise Ratio
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.
flags = validation.sig2noise_val(s2n, threshold=1.05)
# flags is bool: True == spurious. `~flags` selects the good vectors.
Common Operations
Dynamic Masking
Masking lives in openpiv.preprocess, not in an openpiv.masking module. It returns an
(image, mask) tuple and expects a float image.
from 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 Processing
Multi-pass (window deformation) lives in openpiv.windef, driven by a PIVSettings dataclass.
pyprocess has no multi-pass entry point.
import 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.
Validation and Post-Processing
Validation Methods
Every validator returns a boolean array where True marks a spurious vector.
# 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.
Outlier Replacement
u, 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:
# 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
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.
from 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)
Visualization
Vector Field Plotting
display_vector_field reads a saved vectors file and calls plt.show() internally, so select a
non-interactive backend for batch runs.
import 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)
Custom Visualization
import 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)
Analysis Functions
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:
import 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:
Vorticity
def 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.
Strain Rate
def 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)
Turbulence Statistics
def 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.
CLI Usage
# 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
CLI Options
| 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:
python skills/openpiv/scripts/run_example.py --output_dir /tmp/openpiv-demo
Output Files
- vectors.txt — tab-delimited,
%.4eformatted, with a# x y u v flags maskcomment header - params.npz — NumPy archive with
x,y,u,v,flagsarrays - vector_field.png — vector field drawn over the first frame
# 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.
Best Practices
Parameter Selection
- Window size — 32×32 suits most cases. 64/128 for better correlation at coarser resolution; 16/24 for finer resolution at the cost of noise.
- Overlap — 50–75% of window size.
- Threshold — raise it to reject more vectors; always re-tune after switching
sig2noise_method. - Scaling factor — calibrate against a known reference such as a calibration grid, and keep the
units straight (
96.52in OpenPIV'stest1tutorial data is px/mm).
Image Quality
- Particles visible and evenly distributed, 5–10 per interrogation window
- No saturated or overexposed regions
- Minimal background noise; consider background subtraction across a run
Processing Tips
- Start from the defaults, then tune against the vector field you get.
- Inspect the
s2ndistribution — a low median means poor correlation, not a bad threshold. - Visualize early; obvious problems (uniform vectors, edge artifacts) show up immediately.
- Use multi-pass (
windef) for flows with large velocity gradients or displacements. - Mask reflections and solid boundaries rather than letting them generate vectors.
Resources
references/
advanced_algorithms.md— correlation and subpixel methods, multi-pass window deformation,PIVSettingsfields, 3D and phase-separation modules
Load the reference when detailed algorithm or settings information is needed.
| 1 | |
| 2 | name openpiv |
| 3 | description 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. |
| 4 | license BSD-3-Clause |
| 5 | compatibility Requires Python 3.10+ with openpiv installed (uv pip install openpiv). numpy, scipy, scikit-image, and matplotlib arrive as dependencies. No network access needed after install. |
| 6 | allowed-tools Read Write Edit Bash |
| 7 | metadata |
| 8 | version "1.1" |
| 9 | skill-author OpenPIV Team |
| 10 | tested-against "openpiv 0.25.4" |
| 11 | |
| 12 | |
| 13 | # OpenPIV |
| 14 | |
| 15 | ## Overview |
| 16 | |
| 17 | OpenPIV (Open Particle Image Velocimetry) analyzes fluid flow from PIV image pairs. It covers |
| 18 | preprocessing, cross-correlation, vector validation, outlier replacement, smoothing, and scaling to |
| 19 | physical units. |
| 20 | |
| 21 | Everything below is verified against **openpiv 0.25.4**. The API moves between releases — check |
| 22 | `inspect.signature()` before trusting a snippet against a different version. |
| 23 | |
| 24 | ## When to use |
| 25 | |
| 26 | Use this skill when working with experimental PIV or flow-visualization image pairs: measuring 2D |
| 27 | velocity fields, tuning interrogation-window parameters, validating vectors, or deriving vorticity, |
| 28 | strain rate, and turbulence statistics. For *simulating* flow rather than measuring it, use a CFD |
| 29 | skill instead. |
| 30 | |
| 31 | ## Quick Start |
| 32 | |
| 33 | Install OpenPIV: |
| 34 | |
| 35 | |
| 36 | uv pip install openpiv |
| 37 | |
| 38 | # Pin it when the analysis needs to be reproducible -- this is the version every |
| 39 | # snippet below was checked against. |
| 40 | uv pip install "openpiv==0.25.4" |
| 41 | |
| 42 | |
| 43 | Run PIV analysis on an image pair: |
| 44 | |
| 45 | |
| 46 | import numpy as np |
| 47 | from openpiv import tools, pyprocess, validation, filters, scaling |
| 48 | |
| 49 | frame_a = tools.imread("image_a.bmp") |
| 50 | frame_b = tools.imread("image_b.bmp") |
| 51 | |
| 52 | # Cross-correlate. Returns (u, v, s2n) whenever sig2noise_method is not None. |
| 53 | u, v, s2n = pyprocess.extended_search_area_piv( |
| 54 | frame_a.astype(np.int32), |
| 55 | frame_b.astype(np.int32), |
| 56 | window_size=32, |
| 57 | overlap=12, |
| 58 | dt=0.02, |
| 59 | search_area_size=38, |
| 60 | correlation_method="linear", # required for search_area_size > window_size |
| 61 | sig2noise_method="peak2peak", |
| 62 | ) |
| 63 | |
| 64 | x, y = pyprocess.get_coordinates( |
| 65 | image_size=frame_a.shape, |
| 66 | search_area_size=38, |
| 67 | overlap=12, |
| 68 | ) |
| 69 | |
| 70 | # flags is a boolean array: True marks a spurious vector. |
| 71 | flags = validation.sig2noise_val(s2n, threshold=1.05) |
| 72 | u, v = filters.replace_outliers(u, v, flags, method="localmean", max_iter=3, kernel_size=2) |
| 73 | |
| 74 | # Scale to physical units, then flip to image coordinates for plotting. |
| 75 | x, y, u, v = scaling.uniform(x, y, u, v, scaling_factor=96.52) |
| 76 | x, y, u, v = tools.transform_coordinates(x, y, u, v) |
| 77 | |
| 78 | tools.save("vectors.txt", x, y, u, v, flags) |
| 79 | |
| 80 | |
| 81 | Or use the bundled CLI, which wraps exactly that pipeline: |
| 82 | |
| 83 | |
| 84 | python skills/openpiv/scripts/runner.py \ |
| 85 | --image frame_a.bmp --image frame_b.bmp --output_dir results --verbose |
| 86 | |
| 87 | |
| 88 | ## Core Concepts |
| 89 | |
| 90 | ### PIV Fundamentals |
| 91 | |
| 92 | Particle Image Velocimetry is an optical method for measuring fluid velocity by tracking illuminated |
| 93 | tracer particles between two images. |
| 94 | |
| 95 | **Process flow:** |
| 96 | |
| 97 | Capture an image pair (`frame_a`, `frame_b`) separated by a known time `dt`. |
| 98 | Divide the images into interrogation windows. |
| 99 | Cross-correlate matching windows to find peak displacement. |
| 100 | Validate vectors (signal-to-noise, global range, local median). |
| 101 | Replace spurious vectors with interpolated values. |
| 102 | Scale pixel displacements to physical units. |
| 103 | |
| 104 | ### Interrogation Window Parameters |
| 105 | |
| 106 | **`window_size`** — correlation window in pixels (typically 16–128). Larger windows give better |
| 107 | correlation but coarser spatial resolution. |
| 108 | |
| 109 | **`overlap`** — pixels shared between adjacent windows (typically 50–75% of `window_size`). Higher |
| 110 | overlap raises vector density and cost, but adjacent vectors become correlated rather than |
| 111 | independent. |
| 112 | |
| 113 | **`search_area_size`** — the window searched in the second frame. Must be ≥ `window_size`; a few |
| 114 | pixels larger accommodates larger displacements. Pair an extended search area with |
| 115 | `correlation_method="linear"` — the default `"circular"` relies on FFT wrap-around and aliases large |
| 116 | displacements into small ones. See `references/advanced_algorithms.md`. |
| 117 | |
| 118 | Rules of thumb: keep the largest displacement under about a quarter of `window_size`, and aim for |
| 119 | 5–10 particles per window. |
| 120 | |
| 121 | ### Signal-to-Noise Ratio |
| 122 | |
| 123 | `s2n` measures how distinct the correlation peak is. `sig2noise_method` controls how it is computed — |
| 124 | `"peak2mean"` (the function default) or `"peak2peak"`. **The two are on different scales**, so a |
| 125 | threshold tuned for one is meaningless for the other. Typical `peak2peak` thresholds are 1.05–1.3. |
| 126 | |
| 127 | |
| 128 | flags = validation.sig2noise_val(s2n, threshold=1.05) |
| 129 | # flags is bool: True == spurious. `~flags` selects the good vectors. |
| 130 | |
| 131 | |
| 132 | ## Common Operations |
| 133 | |
| 134 | ### Dynamic Masking |
| 135 | |
| 136 | Masking lives in `openpiv.preprocess`, **not** in an `openpiv.masking` module. It returns an |
| 137 | `(image, mask)` tuple and expects a float image. |
| 138 | |
| 139 | |
| 140 | from openpiv import preprocess |
| 141 | |
| 142 | # method="edges" for dark, sharp-edged objects; "intensity" for high-contrast objects. |
| 143 | frame_a_masked, mask_a = preprocess.dynamic_masking( |
| 144 | frame_a.astype(np.float64), method="intensity", filter_size=7, threshold=0.005 |
| 145 | ) |
| 146 | frame_b_masked, mask_b = preprocess.dynamic_masking( |
| 147 | frame_b.astype(np.float64), method="intensity", filter_size=7, threshold=0.005 |
| 148 | ) |
| 149 | |
| 150 | |
| 151 | Feed the **returned image** into the correlation step — it already has the masked region zeroed. Do |
| 152 | not multiply the original frame by `mask`: masking is already applied, and for `method="edges"` the |
| 153 | mask comes back as `uint8` 0/255 rather than boolean, so multiplying rescales the image by 255. |
| 154 | |
| 155 | ### Multi-Pass Processing |
| 156 | |
| 157 | Multi-pass (window deformation) lives in `openpiv.windef`, driven by a `PIVSettings` dataclass. |
| 158 | `pyprocess` has no multi-pass entry point. |
| 159 | |
| 160 | |
| 161 | import numpy as np |
| 162 | from openpiv import scaling, windef |
| 163 | |
| 164 | settings = windef.PIVSettings() |
| 165 | settings.windowsizes = (64, 32, 16) # one entry per pass, decreasing (this is also the default) |
| 166 | settings.overlap = (32, 16, 8) # same length as windowsizes |
| 167 | settings.num_iterations = 3 # number of passes to actually run |
| 168 | settings.sig2noise_threshold = 1.05 |
| 169 | |
| 170 | x, y, u, v, flags = windef.simple_multipass( |
| 171 | frame_a.astype(np.int32), frame_b.astype(np.int32), settings |
| 172 | ) |
| 173 | |
| 174 | # Output is in PIXELS PER FRAME -- convert yourself. scaling.uniform only divides |
| 175 | # by scaling_factor, so apply dt separately. |
| 176 | dt = 0.02 |
| 177 | x, y, u, v = scaling.uniform(x, y, u, v, scaling_factor=96.52) |
| 178 | u, v = u / dt, v / dt |
| 179 | |
| 180 | |
| 181 | `simple_multipass` already validates, replaces outliers, fills remaining NaNs with zeros, and calls |
| 182 | `transform_coordinates` — do not repeat those steps. |
| 183 | |
| 184 | **Units trap:** `PIVSettings` has `dt` and `scaling_factor` fields, but `windef` never uses either — |
| 185 | `first_pass` calls `extended_search_area_piv` without `dt`, so the whole multi-pass chain works in |
| 186 | pixels per frame. Setting `settings.dt = 0.02` changes nothing about the returned values. Convert |
| 187 | after the fact, as above. |
| 188 | |
| 189 | For control over individual passes, `windef.first_pass` and `windef.multipass_img_deform` are the |
| 190 | lower-level building blocks. |
| 191 | |
| 192 | ## Validation and Post-Processing |
| 193 | |
| 194 | ### Validation Methods |
| 195 | |
| 196 | Every validator returns a boolean array where **True marks a spurious vector**. |
| 197 | |
| 198 | |
| 199 | # Signal-to-noise |
| 200 | flags = validation.sig2noise_val(s2n, threshold=1.05) |
| 201 | |
| 202 | # Global range -- takes (min, max) TUPLES, positionally or as u_thresholds/v_thresholds. |
| 203 | flags = validation.global_val(u, v, (-300, 300), (-300, 300)) |
| 204 | |
| 205 | # Local median -- u_threshold and v_threshold are REQUIRED; size is the neighbourhood half-width. |
| 206 | flags = validation.local_median_val(u, v, u_threshold=30.0, v_threshold=30.0, size=1) |
| 207 | |
| 208 | # Combine with boolean OR (not np.maximum -- these are bool arrays). |
| 209 | flags = ( |
| 210 | validation.sig2noise_val(s2n, threshold=1.05) |
| 211 | | validation.global_val(u, v, (-300, 300), (-300, 300)) |
| 212 | | validation.local_median_val(u, v, u_threshold=30.0, v_threshold=30.0) |
| 213 | ) |
| 214 | |
| 215 | |
| 216 | **Set these thresholds in the units of `u` and `v`, not in pixels per frame.** |
| 217 | `extended_search_area_piv` divides by `dt`, so with `dt=0.02` a 3 px/frame displacement arrives as |
| 218 | 150 px/s. The thresholds above suit that case; the `(-30, 30)` figure that PIV literature and |
| 219 | `PIVSettings.min_max_u_disp` use is a px/frame limit, and applying it to px/s output rejects the |
| 220 | entire field. Either validate before scaling, or scale the thresholds by `1/dt` too. |
| 221 | |
| 222 | ### Outlier Replacement |
| 223 | |
| 224 | |
| 225 | u, v = filters.replace_outliers( |
| 226 | u, v, flags, method="localmean", max_iter=3, tol=1e-3, kernel_size=2 |
| 227 | ) |
| 228 | |
| 229 | |
| 230 | `method` accepts `"localmean"`, `"disk"`, or `"distance"` — and only those three. An unrecognized |
| 231 | name is not rejected; it falls through to an all-zero kernel and silently returns a useless field. |
| 232 | Note that replacement *fills* the flagged |
| 233 | positions with interpolated values — if you then overwrite them with NaN, the replacement was |
| 234 | wasted. Choose one or the other: |
| 235 | |
| 236 | |
| 237 | # Keep flagged vectors out of the analysis entirely, instead of interpolating them. |
| 238 | u = np.where(flags, np.nan, u) |
| 239 | v = np.where(flags, np.nan, v) |
| 240 | |
| 241 | |
| 242 | ### Smoothing |
| 243 | |
| 244 | Smoothing is `openpiv.smoothn.smoothn`; there is no `openpiv.smooth` module. It returns a tuple |
| 245 | whose first element is the smoothed field, and it does not accept NaN input. |
| 246 | |
| 247 | |
| 248 | from openpiv.smoothn import smoothn |
| 249 | |
| 250 | u_smooth, *_ = smoothn(np.nan_to_num(u), s=0.5) # s: larger == smoother |
| 251 | v_smooth, *_ = smoothn(np.nan_to_num(v), s=0.5) |
| 252 | u_smooth = np.asarray(u_smooth) |
| 253 | |
| 254 | |
| 255 | ## Visualization |
| 256 | |
| 257 | ### Vector Field Plotting |
| 258 | |
| 259 | `display_vector_field` reads a saved vectors file and calls `plt.show()` internally, so select a |
| 260 | non-interactive backend for batch runs. |
| 261 | |
| 262 | |
| 263 | import matplotlib |
| 264 | matplotlib.use("Agg") |
| 265 | import matplotlib.pyplot as plt |
| 266 | from openpiv import tools |
| 267 | |
| 268 | fig, ax = plt.subplots(figsize=(8, 8)) |
| 269 | tools.display_vector_field( |
| 270 | "vectors.txt", |
| 271 | ax=ax, |
| 272 | scaling_factor=96.52, # same factor used in scaling.uniform, to map back onto the image |
| 273 | scale=50, |
| 274 | width=0.0035, |
| 275 | on_img=True, |
| 276 | image_name="frame_a.bmp", |
| 277 | ) |
| 278 | fig.savefig("vector_field.png", dpi=150, bbox_inches="tight") |
| 279 | plt.close(fig) |
| 280 | |
| 281 | |
| 282 | ### Custom Visualization |
| 283 | |
| 284 | |
| 285 | import numpy as np |
| 286 | import matplotlib.pyplot as plt |
| 287 | |
| 288 | fig, axes = plt.subplots(1, 3, figsize=(15, 5)) |
| 289 | |
| 290 | mag = np.sqrt(u**2 + v**2) |
| 291 | for ax, field, title, cmap in [ |
| 292 | (axes[0], mag, "Velocity Magnitude", "viridis"), |
| 293 | (axes[1], u, "U Velocity", "RdBu_r"), |
| 294 | (axes[2], v, "V Velocity", "RdBu_r"), |
| 295 | ]: |
| 296 | im = ax.imshow(field, cmap=cmap) |
| 297 | ax.set_title(title) |
| 298 | plt.colorbar(im, ax=ax) |
| 299 | |
| 300 | fig.tight_layout() |
| 301 | fig.savefig("velocity_components.png") |
| 302 | plt.close(fig) |
| 303 | |
| 304 | |
| 305 | ## Analysis Functions |
| 306 | |
| 307 | `scripts/analyze.py` bundles these against a `params.npz` written by `runner.py`. It infers the |
| 308 | physical grid spacing from the saved coordinates, so the derivatives come out per unit length: |
| 309 | |
| 310 | |
| 311 | import sys |
| 312 | sys.path.insert(0, "skills/openpiv/scripts") |
| 313 | from analyze import PIVAnalyzer |
| 314 | |
| 315 | piv = PIVAnalyzer("results/params.npz") |
| 316 | vorticity = piv.compute_vorticity() # dv/dx - du/dy |
| 317 | exx, eyy, exy = piv.compute_strain() |
| 318 | stats = piv.compute_statistics() # u_mean, v_mean, rms_u, rms_v, tke |
| 319 | piv.plot_vector_field(save_path="quiver.png") |
| 320 | |
| 321 | |
| 322 | The standalone forms, if you would rather compute them inline: |
| 323 | |
| 324 | ### Vorticity |
| 325 | |
| 326 | |
| 327 | def compute_vorticity(u, v, dx=1.0, dy=None): |
| 328 | """Out-of-plane vorticity dv/dx - du/dy. Pass the physical grid spacing, not 1.0.""" |
| 329 | dy = dx if dy is None else dy |
| 330 | return np.gradient(v, dx, axis=1) - np.gradient(u, dy, axis=0) |
| 331 | |
| 332 | |
| 333 | The grid spacing is `(window_size - overlap) / scaling_factor` in physical units, so leaving `dx=1.0` |
| 334 | yields vorticity per grid cell, not per unit length. |
| 335 | |
| 336 | **Sign convention:** `runner.py` ends with `transform_coordinates`, which relabels the grid into a |
| 337 | right-handed y-up frame but leaves the rows in image order, so the saved `y` *decreases* as the row |
| 338 | index grows. The standalone forms above assume the opposite, so on a `params.npz` field they return |
| 339 | `-du/dy` and flip the sign of the vorticity and the shear strain — negate the `axis=0` derivatives, or |
| 340 | use `PIVAnalyzer`, which reads the orientation off the saved coordinates. |
| 341 | |
| 342 | ### Strain Rate |
| 343 | |
| 344 | |
| 345 | def compute_strain(u, v, dx=1.0, dy=None): |
| 346 | """Return (exx, eyy, exy) of the 2D strain-rate tensor.""" |
| 347 | dy = dx if dy is None else dy |
| 348 | du_dx = np.gradient(u, dx, axis=1) |
| 349 | du_dy = np.gradient(u, dy, axis=0) |
| 350 | dv_dx = np.gradient(v, dx, axis=1) |
| 351 | dv_dy = np.gradient(v, dy, axis=0) |
| 352 | return du_dx, dv_dy, 0.5 * (du_dy + dv_dx) |
| 353 | |
| 354 | |
| 355 | ### Turbulence Statistics |
| 356 | |
| 357 | |
| 358 | def compute_statistics(u, v): |
| 359 | """Single-frame spatial statistics. NOT Reynolds decomposition.""" |
| 360 | u_prime = u - np.nanmean(u) |
| 361 | v_prime = v - np.nanmean(v) |
| 362 | rms_u, rms_v = np.nanstd(u_prime), np.nanstd(v_prime) |
| 363 | return { |
| 364 | "u_mean": np.nanmean(u), |
| 365 | "v_mean": np.nanmean(v), |
| 366 | "rms_u": rms_u, |
| 367 | "rms_v": rms_v, |
| 368 | "tke": 0.5 * (rms_u**2 + rms_v**2), |
| 369 | } |
| 370 | |
| 371 | |
| 372 | **Caveat:** subtracting the *spatial* mean of one frame measures spatial variance, which equals |
| 373 | turbulent intensity only for a homogeneous field. Genuine Reynolds decomposition needs an ensemble of |
| 374 | image pairs: average over the time axis, then subtract that mean field from each realization. |
| 375 | |
| 376 | ## CLI Usage |
| 377 | |
| 378 | |
| 379 | # Basic run |
| 380 | python skills/openpiv/scripts/runner.py \ |
| 381 | --image img1.bmp --image img2.bmp --output_dir results --verbose |
| 382 | |
| 383 | # Tuned parameters with dynamic masking |
| 384 | python skills/openpiv/scripts/runner.py \ |
| 385 | --image frame_a.bmp \ |
| 386 | --image frame_b.bmp \ |
| 387 | --output_dir results \ |
| 388 | --window_size 32 \ |
| 389 | --overlap 12 \ |
| 390 | --search_area 38 \ |
| 391 | --dt 0.02 \ |
| 392 | --scaling 96.52 \ |
| 393 | --threshold 1.05 \ |
| 394 | --mask dynamic \ |
| 395 | --mask_method intensity \ |
| 396 | --verbose |
| 397 | |
| 398 | |
| 399 | ### CLI Options |
| 400 | |
| 401 | | Option | Default | Description | |
| 402 | |--------|---------|-------------| |
| 403 | | `--image` | required | Image file; specify exactly twice for the pair | |
| 404 | | `--output_dir` | `results` | Output directory (created if absent) | |
| 405 | | `--window_size` | 32 | Interrogation window size (px) | |
| 406 | | `--overlap` | 12 | Window overlap (px) | |
| 407 | | `--search_area` | 38 | Search area size (px), must be ≥ `--window_size` | |
| 408 | | `--dt` | 0.02 | Time between frames (s) | |
| 409 | | `--scaling` | 96.52 | Scaling factor, pixels per physical unit (e.g. px/mm) | |
| 410 | | `--threshold` | 1.05 | `peak2peak` signal-to-noise threshold | |
| 411 | | `--mask` | `none` | `none` or `dynamic` (`openpiv.preprocess.dynamic_masking`) | |
| 412 | | `--mask_method` | `intensity` | `edges` or `intensity`, used only with `--mask dynamic` | |
| 413 | | `--drop_invalid` | off | NaN out flagged vectors instead of keeping interpolated values | |
| 414 | | `--verbose` | off | Print progress messages | |
| 415 | |
| 416 | Verify an install end to end against OpenPIV's own bundled image pair: |
| 417 | |
| 418 | |
| 419 | python skills/openpiv/scripts/run_example.py --output_dir /tmp/openpiv-demo |
| 420 | |
| 421 | |
| 422 | ## Output Files |
| 423 | |
| 424 | **vectors.txt** — tab-delimited, `%.4e` formatted, with a `# x y u v flags mask` comment header |
| 425 | **params.npz** — NumPy archive with `x`, `y`, `u`, `v`, `flags` arrays |
| 426 | **vector_field.png** — vector field drawn over the first frame |
| 427 | |
| 428 | |
| 429 | # x y u v flags mask |
| 430 | 2.1757e-01 3.5226e+00 -6.2220e-02 -2.7081e+00 0.0000e+00 0.0000e+00 |
| 431 | 4.8695e-01 3.5226e+00 -3.1587e-01 -2.9800e+00 0.0000e+00 0.0000e+00 |
| 432 | |
| 433 | |
| 434 | `flags` is written as a float, `0` for a valid vector and `1` for a flagged one. |
| 435 | |
| 436 | ## Best Practices |
| 437 | |
| 438 | ### Parameter Selection |
| 439 | |
| 440 | **Window size** — 32×32 suits most cases. 64/128 for better correlation at coarser resolution; |
| 441 | 16/24 for finer resolution at the cost of noise. |
| 442 | **Overlap** — 50–75% of window size. |
| 443 | **Threshold** — raise it to reject more vectors; always re-tune after switching |
| 444 | `sig2noise_method`. |
| 445 | **Scaling factor** — calibrate against a known reference such as a calibration grid, and keep the |
| 446 | units straight (`96.52` in OpenPIV's `test1` tutorial data is px/mm). |
| 447 | |
| 448 | ### Image Quality |
| 449 | |
| 450 | Particles visible and evenly distributed, 5–10 per interrogation window |
| 451 | No saturated or overexposed regions |
| 452 | Minimal background noise; consider background subtraction across a run |
| 453 | |
| 454 | ### Processing Tips |
| 455 | |
| 456 | Start from the defaults, then tune against the vector field you get. |
| 457 | Inspect the `s2n` distribution — a low median means poor correlation, not a bad threshold. |
| 458 | Visualize early; obvious problems (uniform vectors, edge artifacts) show up immediately. |
| 459 | Use multi-pass (`windef`) for flows with large velocity gradients or displacements. |
| 460 | Mask reflections and solid boundaries rather than letting them generate vectors. |
| 461 | |
| 462 | ## Resources |
| 463 | |
| 464 | ### references/ |
| 465 | |
| 466 | `advanced_algorithms.md` — correlation and subpixel methods, multi-pass window deformation, |
| 467 | `PIVSettings` fields, 3D and phase-separation modules |
| 468 | |
| 469 | Load the reference when detailed algorithm or settings information is needed. |
| 470 |
Details
- Updated
- Sep 21, 2026
- License
- MIT
- Stars on the repo
- ★ 45,977
- Checked
- Source
- K-Dense-AI/scientific-agent-skills ↗
- Creator
- K-Dense-AI · 168 listings
Similar skills
See all →
SkillAstropy Astronomy Library
SkillCirq Quantum Framework
SkillFluidSim Simulation Planner
SkillQuantum ML Framework
SkillReinforcement Learning Environments
SkillQuantum Circuit SimulatorMore from K-Dense-AI
SkillAdaptyv Bio Foundry API
SkillTime Series ML
SkillAnalytical Method Validator
SkillSingle-Cell Data Structure
SkillAutonomous Artifact Optimizer
SkillGene Regulatory Network Inferrer