Openpiv

Particle Image Velocimetry (PIV) analysis with OpenPIV.

How to use it

  1. Hit Copy SKILL.md — or use the Claude Code line below to get every file.
  2. Claude: ⋯ → Download .md, then Customize → Skills → Add → Upload skill.
    ChatGPT: make a Project and paste it into Instructions.
    Neither? Paste it at the top of a new chat — it works for that chat.
  3. Describe your job in plain words. The AI follows the skill from there.
Claude Code — installs the whole folder, not just SKILL.md
npx degit K-Dense-AI/scientific-agent-skills/skills/openpiv#main ~/.claude/skills/openpiv

For one project only, change the path to .claude/skills/openpiv. This skill also uses vectors.txt, runner.py, advanced_algorithms.md — copying SKILL.md alone won't be enough. See the folder on GitHub.

Not working?
  • Check which app you pasted it into — the steps above name the right one.
  • Some skills need the paid tier of Claude or ChatGPT.
Step-by-step guide with screenshots · Ask in the forum

Paste into Claude, ChatGPT or Cursor.

Show the full text470 lines
openpiv/SKILL.md470 lines16.6 KBpushed 55d agoRawView on GitHub

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:

  1. Capture an image pair (frame_a, frame_b) separated by a known time dt.
  2. Divide the images into interrogation windows.
  3. Cross-correlate matching windows to find peak displacement.
  4. Validate vectors (signal-to-noise, global range, local median).
  5. Replace spurious vectors with interpolated values.
  6. 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, %.4e formatted, with a # x y u v flags mask comment header
  • params.npz — NumPy archive with x, y, u, v, flags arrays
  • 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

  1. 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.
  2. Overlap — 50–75% of window size.
  3. Threshold — raise it to reject more vectors; always re-tune after switching sig2noise_method.
  4. Scaling factor — calibrate against a known reference such as a calibration grid, and keep the units straight (96.52 in OpenPIV's test1 tutorial 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

  1. Start from the defaults, then tune against the vector field you get.
  2. Inspect the s2n distribution — a low median means poor correlation, not a bad threshold.
  3. Visualize early; obvious problems (uniform vectors, edge artifacts) show up immediately.
  4. Use multi-pass (windef) for flows with large velocity gradients or displacements.
  5. Mask reflections and solid boundaries rather than letting them generate vectors.

Resources

references/

  • 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.

1---
2name: openpiv
3description: 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.
4license: BSD-3-Clause
5compatibility: 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.
6allowed-tools: Read Write Edit Bash
7metadata:
8 version: "1.1"
9 skill-author: OpenPIV Team
10 tested-against: "openpiv 0.25.4"
11---
12 
13# OpenPIV
14 
15## Overview
16 
17OpenPIV (Open Particle Image Velocimetry) analyzes fluid flow from PIV image pairs. It covers
18preprocessing, cross-correlation, vector validation, outlier replacement, smoothing, and scaling to
19physical units.
20 
21Everything 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 
26Use this skill when working with experimental PIV or flow-visualization image pairs: measuring 2D
27velocity fields, tuning interrogation-window parameters, validating vectors, or deriving vorticity,
28strain rate, and turbulence statistics. For *simulating* flow rather than measuring it, use a CFD
29skill instead.
30 
31## Quick Start
32 
33Install OpenPIV:
34 
35```bash
36uv 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.
40uv pip install "openpiv==0.25.4"
41```
42 
43Run PIV analysis on an image pair:
44 
45```python
46import numpy as np
47from openpiv import tools, pyprocess, validation, filters, scaling
48 
49frame_a = tools.imread("image_a.bmp")
50frame_b = tools.imread("image_b.bmp")
51 
52# Cross-correlate. Returns (u, v, s2n) whenever sig2noise_method is not None.
53u, 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 
64x, 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.
71flags = validation.sig2noise_val(s2n, threshold=1.05)
72u, 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.
75x, y, u, v = scaling.uniform(x, y, u, v, scaling_factor=96.52)
76x, y, u, v = tools.transform_coordinates(x, y, u, v)
77 
78tools.save("vectors.txt", x, y, u, v, flags)
79```
80 
81Or use the bundled CLI, which wraps exactly that pipeline:
82 
83```bash
84python 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 
92Particle Image Velocimetry is an optical method for measuring fluid velocity by tracking illuminated
93tracer particles between two images.
94 
95**Process flow:**
96 
971. Capture an image pair (`frame_a`, `frame_b`) separated by a known time `dt`.
982. Divide the images into interrogation windows.
993. Cross-correlate matching windows to find peak displacement.
1004. Validate vectors (signal-to-noise, global range, local median).
1015. Replace spurious vectors with interpolated values.
1026. 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
107correlation but coarser spatial resolution.
108 
109**`overlap`** — pixels shared between adjacent windows (typically 50–75% of `window_size`). Higher
110overlap raises vector density and cost, but adjacent vectors become correlated rather than
111independent.
112 
113**`search_area_size`** — the window searched in the second frame. Must be ≥ `window_size`; a few
114pixels 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
116displacements into small ones. See `references/advanced_algorithms.md`.
117 
118Rules of thumb: keep the largest displacement under about a quarter of `window_size`, and aim for
1195–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
125threshold tuned for one is meaningless for the other. Typical `peak2peak` thresholds are 1.05–1.3.
126 
127```python
128flags = 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 
136Masking lives in `openpiv.preprocess`, **not** in an `openpiv.masking` module. It returns an
137`(image, mask)` tuple and expects a float image.
138 
139```python
140from openpiv import preprocess
141 
142# method="edges" for dark, sharp-edged objects; "intensity" for high-contrast objects.
143frame_a_masked, mask_a = preprocess.dynamic_masking(
144 frame_a.astype(np.float64), method="intensity", filter_size=7, threshold=0.005
145)
146frame_b_masked, mask_b = preprocess.dynamic_masking(
147 frame_b.astype(np.float64), method="intensity", filter_size=7, threshold=0.005
148)
149```
150 
151Feed the **returned image** into the correlation step — it already has the masked region zeroed. Do
152not multiply the original frame by `mask`: masking is already applied, and for `method="edges"` the
153mask comes back as `uint8` 0/255 rather than boolean, so multiplying rescales the image by 255.
154 
155### Multi-Pass Processing
156 
157Multi-pass (window deformation) lives in `openpiv.windef`, driven by a `PIVSettings` dataclass.
158`pyprocess` has no multi-pass entry point.
159 
160```python
161import numpy as np
162from openpiv import scaling, windef
163 
164settings = windef.PIVSettings()
165settings.windowsizes = (64, 32, 16) # one entry per pass, decreasing (this is also the default)
166settings.overlap = (32, 16, 8) # same length as windowsizes
167settings.num_iterations = 3 # number of passes to actually run
168settings.sig2noise_threshold = 1.05
169 
170x, 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.
176dt = 0.02
177x, y, u, v = scaling.uniform(x, y, u, v, scaling_factor=96.52)
178u, 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
186pixels per frame. Setting `settings.dt = 0.02` changes nothing about the returned values. Convert
187after the fact, as above.
188 
189For control over individual passes, `windef.first_pass` and `windef.multipass_img_deform` are the
190lower-level building blocks.
191 
192## Validation and Post-Processing
193 
194### Validation Methods
195 
196Every validator returns a boolean array where **True marks a spurious vector**.
197 
198```python
199# Signal-to-noise
200flags = validation.sig2noise_val(s2n, threshold=1.05)
201 
202# Global range -- takes (min, max) TUPLES, positionally or as u_thresholds/v_thresholds.
203flags = 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.
206flags = 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).
209flags = (
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
218150 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
220entire field. Either validate before scaling, or scale the thresholds by `1/dt` too.
221 
222### Outlier Replacement
223 
224```python
225u, 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
231name is not rejected; it falls through to an all-zero kernel and silently returns a useless field.
232Note that replacement *fills* the flagged
233positions with interpolated values — if you then overwrite them with NaN, the replacement was
234wasted. Choose one or the other:
235 
236```python
237# Keep flagged vectors out of the analysis entirely, instead of interpolating them.
238u = np.where(flags, np.nan, u)
239v = np.where(flags, np.nan, v)
240```
241 
242### Smoothing
243 
244Smoothing is `openpiv.smoothn.smoothn`; there is no `openpiv.smooth` module. It returns a tuple
245whose first element is the smoothed field, and it does not accept NaN input.
246 
247```python
248from openpiv.smoothn import smoothn
249 
250u_smooth, *_ = smoothn(np.nan_to_num(u), s=0.5) # s: larger == smoother
251v_smooth, *_ = smoothn(np.nan_to_num(v), s=0.5)
252u_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
260non-interactive backend for batch runs.
261 
262```python
263import matplotlib
264matplotlib.use("Agg")
265import matplotlib.pyplot as plt
266from openpiv import tools
267 
268fig, ax = plt.subplots(figsize=(8, 8))
269tools.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)
278fig.savefig("vector_field.png", dpi=150, bbox_inches="tight")
279plt.close(fig)
280```
281 
282### Custom Visualization
283 
284```python
285import numpy as np
286import matplotlib.pyplot as plt
287 
288fig, axes = plt.subplots(1, 3, figsize=(15, 5))
289 
290mag = np.sqrt(u**2 + v**2)
291for 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 
300fig.tight_layout()
301fig.savefig("velocity_components.png")
302plt.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
308physical grid spacing from the saved coordinates, so the derivatives come out per unit length:
309 
310```python
311import sys
312sys.path.insert(0, "skills/openpiv/scripts")
313from analyze import PIVAnalyzer
314 
315piv = PIVAnalyzer("results/params.npz")
316vorticity = piv.compute_vorticity() # dv/dx - du/dy
317exx, eyy, exy = piv.compute_strain()
318stats = piv.compute_statistics() # u_mean, v_mean, rms_u, rms_v, tke
319piv.plot_vector_field(save_path="quiver.png")
320```
321 
322The standalone forms, if you would rather compute them inline:
323 
324### Vorticity
325 
326```python
327def 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 
333The grid spacing is `(window_size - overlap) / scaling_factor` in physical units, so leaving `dx=1.0`
334yields vorticity per grid cell, not per unit length.
335 
336**Sign convention:** `runner.py` ends with `transform_coordinates`, which relabels the grid into a
337right-handed y-up frame but leaves the rows in image order, so the saved `y` *decreases* as the row
338index 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
340use `PIVAnalyzer`, which reads the orientation off the saved coordinates.
341 
342### Strain Rate
343 
344```python
345def 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```python
358def 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
373turbulent intensity only for a homogeneous field. Genuine Reynolds decomposition needs an ensemble of
374image pairs: average over the time axis, then subtract that mean field from each realization.
375 
376## CLI Usage
377 
378```bash
379# Basic run
380python skills/openpiv/scripts/runner.py \
381 --image img1.bmp --image img2.bmp --output_dir results --verbose
382 
383# Tuned parameters with dynamic masking
384python 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 
416Verify an install end to end against OpenPIV's own bundled image pair:
417 
418```bash
419python 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```text
429# x y u v flags mask
4302.1757e-01 3.5226e+00 -6.2220e-02 -2.7081e+00 0.0000e+00 0.0000e+00
4314.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 
4401. **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.
4422. **Overlap** — 50–75% of window size.
4433. **Threshold** — raise it to reject more vectors; always re-tune after switching
444 `sig2noise_method`.
4454. **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 
4561. Start from the defaults, then tune against the vector field you get.
4572. Inspect the `s2n` distribution — a low median means poor correlation, not a bad threshold.
4583. Visualize early; obvious problems (uniform vectors, edge artifacts) show up immediately.
4594. Use multi-pass (`windef`) for flows with large velocity gradients or displacements.
4605. 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 
469Load the reference when detailed algorithm or settings information is needed.
470 

Discussion

From GitHub

1 thread

Alternatives

Also in Physics & astronomy