Skill Tracer
Skills › Scientific Agent Skills › openpiv

openpiv

skill · 495 lines · BSD-3-Clause · skills/openpiv/SKILL.md

The facts

Its description

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

The description the agent reads to decide when to use it.

From its settingsline 3

Tools it may use, or may not

allowed-tools: Read Write Edit Bash

Tools it may use while it runs without asking you first.

From its settingsline 6

Runs scripts

python skills/openpiv/scripts/runner.py, scripts/analyze.py, python skills/openpiv/scripts/run_example.py

Scripts the text names. Found by matching words, so treat them as pointers.

Found by matching wordsline 90 line 325 line 403 line 407 line 443

Files it mentions

scripts/analyze.py

Files the text names, such as ones to read or write. Found by matching words, so treat them as pointers.

Found by matching wordsline 325

Licence

BSD-3-Clause, from the skill file

The terms its author shares it under, which let it be shown here.

From its settingsline 4 licence text

Other settings

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. metadata: version: "1.3" skill-author: OpenPIV Team tested-against: "openpiv 0.26.1" last-reviewed: "2026-10-01"

More settings at the top of the file, shown as written.

From its settingsline 5 line 7
Other files in this skill (5) Close

The text

Open on GitHub

Most marks come from matching words in the file, so treat them as pointers.

1---
2name: openpiv
3description: Performs 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.3"
9 skill-author: OpenPIV Team
10 tested-against: "openpiv 0.26.1"
11 last-reviewed: "2026-10-01"
12---
14# OpenPIV
16## Overview
18OpenPIV (Open Particle Image Velocimetry) analyzes fluid flow from PIV image pairs. It covers
19preprocessing, cross-correlation, vector validation, outlier replacement, smoothing, and scaling to
20physical units.
22Targets **openpiv 0.26.1**. Synthetic checks ran on Python 3.13, NumPy 2.5.3, SciPy 1.18.1,
23scikit-image 0.26.0, and Matplotlib 3.11.2. Rust was not installed; use `backend="scipy"` for the
24tested path. See [review evidence](references/advanced_algorithms.md#review-sources-and-verification).

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.

Targets openpiv 0.26.1. Synthetic checks ran on Python 3.13, NumPy 2.5.3, SciPy 1.18.1, scikit-image 0.26.0, and Matplotlib 3.11.2. Rust was not installed; use backend="scipy" for the tested path. See review evidence.

Raw lines 16 to 25
26## When to use
28Use this skill when working with experimental PIV or flow-visualization image pairs: measuring 2D
29velocity fields, tuning interrogation-window parameters, validating vectors, or deriving vorticity,
30strain rate, and turbulence statistics. For *simulating* flow rather than measuring it, use a CFD
31skill instead.

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.

Raw lines 26 to 32
33## Quick Start
35Install OpenPIV:
37```bash
38uv pip install openpiv
40# Pin it when the analysis needs to be reproducible -- this is the version every
41# snippet below was checked against.
42uv pip install "openpiv==0.26.1"
43```
45Run PIV analysis on an image pair:
47```python
48import numpy as np
49from openpiv import tools, pyprocess, validation, filters, scaling
51frame_a = tools.imread("image_a.bmp")
52frame_b = tools.imread("image_b.bmp")
54# Always returns (u, v, s2n); s2n is all NaN if sig2noise_method=None.
55u, v, s2n = pyprocess.extended_search_area_piv(
56 frame_a.astype(np.float32),
57 frame_b.astype(np.float32),
58 window_size=32,
59 overlap=12,
60 dt=0.02,
61 search_area_size=38,
62 correlation_method="linear", # circular also supports extended search
63 normalized_correlation=True,
64 backend="scipy",
65 sig2noise_method="peak2peak",
68x, y = pyprocess.get_coordinates(
69 image_size=frame_a.shape,
70 search_area_size=38,
71 overlap=12,
72 center_on_field=False, # matches sliding-window positions
75# flags is a boolean array: True marks a spurious vector.
76flags = validation.sig2noise_val(s2n, threshold=1.05) | ~np.isfinite(s2n)
77flags |= ~np.isfinite(u) | ~np.isfinite(v)
78u, v = filters.replace_outliers(u, v, flags, method="localmean", max_iter=3, kernel_size=2)
80# Scale, then convert to right-handed image-boundary coordinates.
81x, y, u, v = scaling.uniform(x, y, u, v, scaling_factor=96.52)
82y, v = frame_a.shape[0] / 96.52 - y, -v
84tools.save("vectors.txt", x, y, u, v, flags)
85```
87Or use the bundled CLI, which also checks inputs and preserves masks and processing parameters:
89```bash
90python skills/openpiv/scripts/runner.py \
91 --image frame_a.bmp --image frame_b.bmp --output_dir results --verbose
92```

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.26.1"

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

# Always returns (u, v, s2n); s2n is all NaN if sig2noise_method=None.
u, v, s2n = pyprocess.extended_search_area_piv(
    frame_a.astype(np.float32),
    frame_b.astype(np.float32),
    window_size=32,
    overlap=12,
    dt=0.02,
    search_area_size=38,
    correlation_method="linear",   # circular also supports extended search
    normalized_correlation=True,
    backend="scipy",
    sig2noise_method="peak2peak",
)

x, y = pyprocess.get_coordinates(
    image_size=frame_a.shape,
    search_area_size=38,
    overlap=12,
    center_on_field=False,         # matches sliding-window positions
)

# flags is a boolean array: True marks a spurious vector.
flags = validation.sig2noise_val(s2n, threshold=1.05) | ~np.isfinite(s2n)
flags |= ~np.isfinite(u) | ~np.isfinite(v)
u, v = filters.replace_outliers(u, v, flags, method="localmean", max_iter=3, kernel_size=2)

# Scale, then convert to right-handed image-boundary coordinates.
x, y, u, v = scaling.uniform(x, y, u, v, scaling_factor=96.52)
y, v = frame_a.shape[0] / 96.52 - y, -v

tools.save("vectors.txt", x, y, u, v, flags)

Or use the bundled CLI, which also checks inputs and preserves masks and processing parameters:

python skills/openpiv/scripts/runner.py \
    --image frame_a.bmp --image frame_b.bmp --output_dir results --verbose
Raw lines 33 to 93
Runs code python skills/openpiv/scripts/runner.py :90
94## Core Concepts

Core Concepts

Raw lines 94 to 95
96### PIV Fundamentals
98Particle Image Velocimetry is an optical method for measuring fluid velocity by tracking illuminated
99tracer particles between two images.
101**Process flow:**
1031. Capture an image pair (`frame_a`, `frame_b`) separated by a known time `dt`.
1042. Divide the images into interrogation windows.
1053. Cross-correlate matching windows to find peak displacement.
1064. Validate vectors (signal-to-noise, global range, local median).
1075. Replace spurious vectors with interpolated values.
1086. Scale pixel displacements to physical units.

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.
Raw lines 96 to 109
110### Interrogation Window Parameters
112**`window_size`** — correlation window in pixels (typically 16–128). Larger windows give better
113correlation but coarser spatial resolution.
115**`overlap`** — pixels shared between adjacent windows (typically 50–75% of `window_size`). Higher
116overlap raises vector density and cost, but adjacent vectors become correlated rather than
117independent.
119**`search_area_size`** — the window searched in the second frame. Must be ≥ `window_size`; a few
120pixels larger accommodates larger displacements. Both `"circular"` and `"linear"` support extended
121search. The CLI chooses zero-padded `"linear"` with `normalized_correlation=True` for it; this
122does not recover arbitrary displacement or eliminate spurious peaks. Grid stride is
123`search_area_size - overlap`, so overlap must be smaller than the search area.
125Rules of thumb: keep the largest displacement under about a quarter of `window_size`, and aim for
1265–10 particles per window.

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. Both "circular" and "linear" support extended search. The CLI chooses zero-padded "linear" with normalized_correlation=True for it; this does not recover arbitrary displacement or eliminate spurious peaks. Grid stride is search_area_size - overlap, so overlap must be smaller than the search area.

Rules of thumb: keep the largest displacement under about a quarter of window_size, and aim for 5–10 particles per window.

Raw lines 110 to 127
128### Signal-to-Noise Ratio
130`s2n` measures how distinct the correlation peak is. `sig2noise_method` controls how it is computed —
131`"peak2mean"` (the function default) or `"peak2peak"`. **The two are on different scales**, so a
132threshold tuned for one is meaningless for the other. Typical `peak2peak` thresholds are 1.05–1.3.
134```python
135flags = validation.sig2noise_val(s2n, threshold=1.05)
136# flags is bool: True == spurious. `~flags` selects the good vectors.
137```

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.
Raw lines 128 to 138
139## Common Operations

Common Operations

Raw lines 139 to 140
141### Dynamic Masking
143Masking lives in `openpiv.preprocess` and returns `(image, mask)`. Use floating-point copies
144to avoid unsigned subtraction artifacts. Inspect masks on both frames before analysis.
146```python
147from openpiv import preprocess
149# Intensity masking uses Otsu; the threshold argument is for edges.
150frame_a_masked, mask_a = preprocess.dynamic_masking(
151 frame_a.astype(np.float64), method="intensity", filter_size=7, threshold=0.005
153frame_b_masked, mask_b = preprocess.dynamic_masking(
154 frame_b.astype(np.float64), method="intensity", filter_size=7, threshold=0.005
156```
158Preserve the union `mask_a | mask_b` as excluded physical regions. The CLI samples it at
159interrogation centers, saves it, and keeps those velocities NaN after replacement. Windows
160straddling an object can still be biased: inspect/dilate masks for the experiment. In 0.26.1
161`method="edges"` indexes with uint8 instead of bool, potentially corrupting the image or failing.
162The CLI refuses it; use intensity masking or a verified boolean mask in a custom workflow.

Dynamic Masking

Masking lives in openpiv.preprocess and returns (image, mask). Use floating-point copies to avoid unsigned subtraction artifacts. Inspect masks on both frames before analysis.

from openpiv import preprocess

# Intensity masking uses Otsu; the threshold argument is for edges.
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
)

Preserve the union mask_a | mask_b as excluded physical regions. The CLI samples it at interrogation centers, saves it, and keeps those velocities NaN after replacement. Windows straddling an object can still be biased: inspect/dilate masks for the experiment. In 0.26.1 method="edges" indexes with uint8 instead of bool, potentially corrupting the image or failing. The CLI refuses it; use intensity masking or a verified boolean mask in a custom workflow.

Raw lines 141 to 163
164### Multi-Pass Processing
166Multi-pass (window deformation) lives in `openpiv.windef`, driven by a `PIVSettings` dataclass.
167`pyprocess` has no multi-pass entry point.
169```python
170import numpy as np
171from openpiv import scaling, windef
173settings = windef.PIVSettings(
174 windowsizes=(64, 32, 16), overlap=(32, 16, 8), num_iterations=3,
175 backend="scipy", sig2noise_method="peak2peak", sig2noise_threshold=1.05,
178x, y, u, v, flags = windef.simple_multipass(
179 frame_a.astype(np.float32), frame_b.astype(np.float32), settings
182# Output is in PIXELS PER FRAME -- convert yourself. scaling.uniform only divides
183# by scaling_factor, so apply dt separately.
184dt = 0.02
185x, y, u, v = scaling.uniform(x, y, u, v, scaling_factor=96.52)
186u, v = u / dt, v / dt
187```
189`simple_multipass` validates, replaces outliers, fills remaining NaNs with zeros, and transforms
190coordinates. Keep its flags: repaired/zero-filled output is not an independent measurement.
191Always pass settings: its no-settings path has two window sizes but three iterations in 0.26.1.
193**Units trap:** `simple_multipass` and its alias `multigrid_windef` ignore `settings.dt` and
194`settings.scaling_factor`; convert their arrays as above. Batch `windef.piv(settings)` applies
195both before saving — do not scale its files again. The simple wrapper does not honor all batch
196preprocessing/output switches.
198For control over individual passes, `windef.first_pass` and `windef.multipass_img_deform` are the
199lower-level building blocks.

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(
    windowsizes=(64, 32, 16), overlap=(32, 16, 8), num_iterations=3,
    backend="scipy", sig2noise_method="peak2peak", sig2noise_threshold=1.05,
)

x, y, u, v, flags = windef.simple_multipass(
    frame_a.astype(np.float32), frame_b.astype(np.float32), 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 validates, replaces outliers, fills remaining NaNs with zeros, and transforms coordinates. Keep its flags: repaired/zero-filled output is not an independent measurement. Always pass settings: its no-settings path has two window sizes but three iterations in 0.26.1.

Units trap: simple_multipass and its alias multigrid_windef ignore settings.dt and settings.scaling_factor; convert their arrays as above. Batch windef.piv(settings) applies both before saving — do not scale its files again. The simple wrapper does not honor all batch preprocessing/output switches.

For control over individual passes, windef.first_pass and windef.multipass_img_deform are the lower-level building blocks.

Raw lines 164 to 200
201## Validation and Post-Processing

Validation and Post-Processing

Raw lines 201 to 202
203### Validation Methods
205Every validator returns a boolean array where **True marks a spurious vector**. Also reject
206nonfinite `u`, `v`, and `s2n`: `sig2noise_val` alone does not flag NaN.
208```python
209# Signal-to-noise
210flags = validation.sig2noise_val(s2n, threshold=1.05)
212# Global range -- takes (min, max) TUPLES, positionally or as u_thresholds/v_thresholds.
213flags = validation.global_val(u, v, (-300, 300), (-300, 300))
215# Local median -- u_threshold and v_threshold are REQUIRED; size is the neighbourhood half-width.
216flags = validation.local_median_val(u, v, u_threshold=30.0, v_threshold=30.0, size=1)
218# Combine with boolean OR (not np.maximum -- these are bool arrays).
219flags = (
220 validation.sig2noise_val(s2n, threshold=1.05)
221 | validation.global_val(u, v, (-300, 300), (-300, 300))
222 | validation.local_median_val(u, v, u_threshold=30.0, v_threshold=30.0)
224```
226**Set these thresholds in the units of `u` and `v`, not in pixels per frame.**
227`extended_search_area_piv` divides by `dt`, so with `dt=0.02` a 3 px/frame displacement arrives as
228150 px/s. The thresholds above suit that case; the `(-30, 30)` figure that PIV literature and
229`PIVSettings.min_max_u_disp` use is a px/frame limit, and applying it to px/s output rejects the
230entire field. Correlate with `dt=1` to validate displacements before conversion, or adjust
231thresholds to the actual velocity units (including both time and calibration factors).

Validation Methods

Every validator returns a boolean array where True marks a spurious vector. Also reject nonfinite u, v, and s2n: sig2noise_val alone does not flag NaN.

# 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. Correlate with dt=1 to validate displacements before conversion, or adjust thresholds to the actual velocity units (including both time and calibration factors).

Raw lines 203 to 232
233### Outlier Replacement
235```python
236u, v = filters.replace_outliers(
237 u, v, flags, method="localmean", max_iter=3, tol=1e-3, kernel_size=2
239```
241`method` accepts `"localmean"`, `"disk"`, or `"distance"`; an invalid name raises `ValueError`
242when filling missing cells. Use `kernel_size>=2` for `"distance"`: size 1 truncates all neighbor
243weights to zero in 0.26.1 and leaves holes. Replacement *fills* the flagged
244positions with interpolated values — if you then overwrite them with NaN, the replacement was
245wasted. Choose one or the other:
247```python
248# Keep flagged vectors out of the analysis entirely, instead of interpolating them.
249u = np.where(flags, np.nan, u)
250v = np.where(flags, np.nan, v)
251```

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"; an invalid name raises ValueError when filling missing cells. Use kernel_size>=2 for "distance": size 1 truncates all neighbor weights to zero in 0.26.1 and leaves holes. 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)
Raw lines 233 to 252
253### Smoothing
255Smoothing is `openpiv.smoothn.smoothn`; there is no `openpiv.smooth` module. It returns a tuple
256whose first element is the smoothed field. NaN/Inf are missing observations; supply a copy because
257it can modify input. Zero-filling holes first incorrectly treats them as measured zeros.
259```python
260from openpiv.smoothn import smoothn
262u_smooth, *_ = smoothn(np.asarray(u, dtype=float).copy(), s=0.5) # larger == smoother
263v_smooth, *_ = smoothn(np.asarray(v, dtype=float).copy(), s=0.5)
264u_smooth = np.asarray(u_smooth)
265```

Smoothing

Smoothing is openpiv.smoothn.smoothn; there is no openpiv.smooth module. It returns a tuple whose first element is the smoothed field. NaN/Inf are missing observations; supply a copy because it can modify input. Zero-filling holes first incorrectly treats them as measured zeros.

from openpiv.smoothn import smoothn

u_smooth, *_ = smoothn(np.asarray(u, dtype=float).copy(), s=0.5)  # larger == smoother
v_smooth, *_ = smoothn(np.asarray(v, dtype=float).copy(), s=0.5)
u_smooth = np.asarray(u_smooth)
Raw lines 253 to 266
267## Visualization
269### Vector Field Plotting
271`display_vector_field` reads a saved vectors file and calls `plt.show()` internally, so select a
272non-interactive backend for batch runs.
274```python
275import matplotlib
276matplotlib.use("Agg")
277import matplotlib.pyplot as plt
278from openpiv import tools
280fig, ax = plt.subplots(figsize=(8, 8))
281tools.display_vector_field(
282 "vectors.txt",
283 ax=ax,
284 scaling_factor=96.52, # same factor used in scaling.uniform, to map back onto the image
285 scale=50,
286 width=0.0035,
287 on_img=False,
289image = tools.imread("frame_a.bmp")
290height, width = image.shape
291ax.imshow(image, cmap="gray", origin="upper", zorder=-1,
292 extent=(0, width / 96.52, 0, height / 96.52))
293fig.savefig("vector_field.png", dpi=150, bbox_inches="tight")
294plt.close(fig)
295```
297Set image extents explicitly: the helper's `on_img=True` infers them from the last vector and
298can stretch overlays when windows leave unused margins. The CLI plots with the actual image bounds.

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=False,
)
image = tools.imread("frame_a.bmp")
height, width = image.shape
ax.imshow(image, cmap="gray", origin="upper", zorder=-1,
          extent=(0, width / 96.52, 0, height / 96.52))
fig.savefig("vector_field.png", dpi=150, bbox_inches="tight")
plt.close(fig)

Set image extents explicitly: the helper's on_img=True infers them from the last vector and can stretch overlays when windows leave unused margins. The CLI plots with the actual image bounds.

Raw lines 269 to 299
300### Custom Visualization
302```python
303import numpy as np
304import matplotlib.pyplot as plt
306fig, axes = plt.subplots(1, 3, figsize=(15, 5))
308mag = np.sqrt(u**2 + v**2)
309for ax, field, title, cmap in [
310 (axes[0], mag, "Velocity Magnitude", "viridis"),
311 (axes[1], u, "U Velocity", "RdBu_r"),
312 (axes[2], v, "V Velocity", "RdBu_r"),
313]:
314 im = ax.imshow(field, cmap=cmap)
315 ax.set_title(title)
316 plt.colorbar(im, ax=ax)
318fig.tight_layout()
319fig.savefig("velocity_components.png")
320plt.close(fig)
321```

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)
Raw lines 300 to 322
323## Analysis Functions
325`scripts/analyze.py` bundles these against a `params.npz` written by `runner.py`. It infers the
326physical grid spacing from the saved coordinates, so the derivatives come out per unit length:
328```python
329import sys
330sys.path.insert(0, "skills/openpiv/scripts")
331from analyze import PIVAnalyzer
333piv = PIVAnalyzer("results/params.npz")
334vorticity = piv.compute_vorticity() # dv/dx - du/dy
335exx, eyy, exy = piv.compute_strain()
336stats = piv.compute_statistics() # excludes flagged and masked vectors
337piv.plot_vector_field(save_path="quiver.png")
338```
340The standalone forms, if you would rather compute them inline:

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()             # excludes flagged and masked vectors
piv.plot_vector_field(save_path="quiver.png")

The standalone forms, if you would rather compute them inline:

Raw lines 323 to 341
Reads a file scripts/analyze.py :325
342### Vorticity
344```python
345def compute_vorticity(u, v, dx=1.0, dy=None):
346 """Out-of-plane vorticity dv/dx - du/dy. Pass the physical grid spacing, not 1.0."""
347 dy = dx if dy is None else dy
348 return np.gradient(v, dx, axis=1) - np.gradient(u, dy, axis=0)
349```
351For the single-pass extended-search grid, spacing is
352`(search_area_size - overlap) / scaling_factor` in physical units; it reduces to
353`(window_size - overlap) / scaling_factor` only when the two window sizes match.
354Prefer differences of the saved `x` and `y` coordinates, especially after multipass
355processing. Leaving `dx=1.0` yields vorticity per grid cell, not per unit length.
356See [OpenPIV coordinate generation](https://openpiv.readthedocs.io/en/stable/src/tutorial1.html).
358**Sign convention:** the CLI writes `y = image_height / scaling_factor - y` and negates `v`,
359keeping rows in image order. Saved `y` decreases down the array. The standalone forms need
360signed `dy` (negative here), or use `PIVAnalyzer`, which reads orientation. Positive `dy` changes
361the y-derivative contribution and can even cancel real rotation.

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)

For the single-pass extended-search grid, spacing is (search_area_size - overlap) / scaling_factor in physical units; it reduces to (window_size - overlap) / scaling_factor only when the two window sizes match. Prefer differences of the saved x and y coordinates, especially after multipass processing. Leaving dx=1.0 yields vorticity per grid cell, not per unit length. See OpenPIV coordinate generation.

Sign convention: the CLI writes y = image_height / scaling_factor - y and negates v, keeping rows in image order. Saved y decreases down the array. The standalone forms need signed dy (negative here), or use PIVAnalyzer, which reads orientation. Positive dy changes the y-derivative contribution and can even cancel real rotation.

Raw lines 342 to 362
363### Strain Rate
365```python
366def compute_strain(u, v, dx=1.0, dy=None):
367 """Return (exx, eyy, exy) of the 2D strain-rate tensor."""
368 dy = dx if dy is None else dy
369 du_dx = np.gradient(u, dx, axis=1)
370 du_dy = np.gradient(u, dy, axis=0)
371 dv_dx = np.gradient(v, dx, axis=1)
372 dv_dy = np.gradient(v, dy, axis=0)
373 return du_dx, dv_dy, 0.5 * (du_dy + dv_dx)
374```

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)
Raw lines 363 to 375
376### Turbulence Statistics
378```python
379def compute_statistics(u, v):
380 """Single-frame spatial statistics. NOT Reynolds decomposition."""
381 u_prime = u - np.nanmean(u)
382 v_prime = v - np.nanmean(v)
383 rms_u, rms_v = np.nanstd(u_prime), np.nanstd(v_prime)
384 return {
385 "u_mean": np.nanmean(u),
386 "v_mean": np.nanmean(v),
387 "rms_u": rms_u,
388 "rms_v": rms_v,
389 "tke": 0.5 * (rms_u**2 + rms_v**2),
390 }
391```
393**Caveat:** subtracting the *spatial* mean of one frame measures spatial variance, which equals
394turbulent intensity only under justified homogeneity/ergodicity assumptions. Reynolds decomposition
395needs an ensemble: subtract its mean field from each realization. The legacy `tke` key is only
396two-component spatial variance energy, not full turbulent kinetic energy. The analyzer excludes
397flagged vectors by default; `compute_statistics(include_interpolated=True)` includes repaired ones.

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 under justified homogeneity/ergodicity assumptions. Reynolds decomposition needs an ensemble: subtract its mean field from each realization. The legacy tke key is only two-component spatial variance energy, not full turbulent kinetic energy. The analyzer excludes flagged vectors by default; compute_statistics(include_interpolated=True) includes repaired ones.

Raw lines 376 to 398
399## CLI Usage
401```bash
402# Basic run
403python skills/openpiv/scripts/runner.py \
404 --image img1.bmp --image img2.bmp --output_dir results --verbose
406# Tuned parameters with dynamic masking
407python skills/openpiv/scripts/runner.py \
408 --image frame_a.bmp \
409 --image frame_b.bmp \
410 --output_dir results \
411 --window_size 32 \
412 --overlap 12 \
413 --search_area 38 \
414 --dt 0.02 \
415 --scaling 96.52 \
416 --threshold 1.05 \
417 --mask dynamic \
418 --mask_method intensity \
419 --verbose
420```

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
Raw lines 399 to 421
Runs code python skills/openpiv/scripts/runner.py :403
422### CLI Options
424| Option | Default | Description |
425|--------|---------|-------------|
426| `--image` | required | Image file; specify exactly twice for the pair |
427| `--output_dir` | `results` | Output directory (created if absent) |
428| `--window_size` | 32 | Interrogation window size (px) |
429| `--overlap` | 12 | Search-grid overlap (px), nonnegative and less than search area |
430| `--search_area` | 38 | Search area size (px), must be ≥ `--window_size` |
431| `--dt` | 0.02 | Time between frames (s) |
432| `--scaling` | 96.52 | Scaling factor, pixels per physical unit (e.g. px/mm) |
433| `--threshold` | 1.05 | `peak2peak` signal-to-noise threshold |
434| `--mask` | `none` | `none` or `dynamic` (`openpiv.preprocess.dynamic_masking`) |
435| `--mask_method` | `intensity` | Only intensity is usable in 0.26.1; edges is refused |
436| `--backend` | `scipy` | `scipy`, `auto`, or `rust`; Rust requires its extension |
437| `--drop_invalid` | off | NaN out flagged vectors instead of keeping interpolated values |
438| `--verbose` | off | Print progress messages |
440Verify an install end to end against OpenPIV's own bundled image pair:
442```bash
443python skills/openpiv/scripts/run_example.py --output_dir /tmp/openpiv-demo
444```

CLI Options

OptionDefaultDescription
--imagerequiredImage file; specify exactly twice for the pair
--output_dirresultsOutput directory (created if absent)
--window_size32Interrogation window size (px)
--overlap12Search-grid overlap (px), nonnegative and less than search area
--search_area38Search area size (px), must be ≥ --window_size
--dt0.02Time between frames (s)
--scaling96.52Scaling factor, pixels per physical unit (e.g. px/mm)
--threshold1.05peak2peak signal-to-noise threshold
--masknonenone or dynamic (openpiv.preprocess.dynamic_masking)
--mask_methodintensityOnly intensity is usable in 0.26.1; edges is refused
--backendscipyscipy, auto, or rust; Rust requires its extension
--drop_invalidoffNaN out flagged vectors instead of keeping interpolated values
--verboseoffPrint 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
Raw lines 422 to 445
Runs code python skills/openpiv/scripts/run_example.py :443
446## Output Files
448- **vectors.txt** — tab-delimited, `%.4e` formatted, with a `# x y u v flags mask` comment header
449- **params.npz** — `x`, `y`, `u`, `v`, `flags`, `mask`, `s2n`, timing/calibration/window parameters,
450 requested backend, and OpenPIV version. Coordinates use physical units; velocity uses units/s.
451- **vector_field.png** — vector field drawn over the first frame
453```text
454# x y u v flags mask
4552.1757e-01 3.5226e+00 -6.2220e-02 -2.7081e+00 0.0000e+00 0.0000e+00
4564.8695e-01 3.5226e+00 -3.1587e-01 -2.9800e+00 0.0000e+00 0.0000e+00
457```
459`flags` is float, `0` for valid and `1` for flagged. `mask=1` marks an excluded object region,
460independently of flags. Preserve both with the measured field.

Output Files

  • vectors.txt — tab-delimited, %.4e formatted, with a # x y u v flags mask comment header
  • params.npz — x, y, u, v, flags, mask, s2n, timing/calibration/window parameters, requested backend, and OpenPIV version. Coordinates use physical units; velocity uses units/s.
  • 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 float, 0 for valid and 1 for flagged. mask=1 marks an excluded object region, independently of flags. Preserve both with the measured field.

Raw lines 446 to 461
462## Best Practices

Best Practices

Raw lines 462 to 463
464### Parameter Selection
4661. **Window size** — 32×32 suits most cases. 64/128 for better correlation at coarser resolution;
467 16/24 for finer resolution at the cost of noise.
4682. **Overlap** — 50–75% of window size.
4693. **Threshold** — raise it to reject more vectors; always re-tune after switching
470 `sig2noise_method`.
4714. **Scaling factor** — calibrate against a known reference such as a calibration grid, and keep the
472 units straight (`96.52` in OpenPIV's `test1` tutorial data is px/mm).

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).
Raw lines 464 to 473
474### Image Quality
476- Particles visible and evenly distributed, 5–10 per interrogation window
477- No saturated or overexposed regions
478- Minimal background noise; consider background subtraction across a run

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
Raw lines 474 to 479
480### Processing Tips
4821. Start from the defaults, then tune against the vector field you get.
4832. Inspect the `s2n` distribution — a low median means poor correlation, not a bad threshold.
4843. Visualize early; obvious problems (uniform vectors, edge artifacts) show up immediately.
4854. Use multi-pass (`windef`) for flows with large velocity gradients or displacements.
4865. Mask reflections and solid boundaries rather than letting them generate vectors.

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.
Raw lines 480 to 487
488## Resources
490### references/
492- `advanced_algorithms.md` — correlation and subpixel methods, multi-pass window deformation,
493 `PIVSettings` fields, 3D and phase-separation modules
495Load the reference when detailed algorithm or settings information is needed.

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.

Raw lines 490 to 495

From K-Dense-AI/scientific-agent-skills at 5f2bc03, 2026-10-26. Not read by a model yet. Summary and techniques by Claude Opus 5.5 on 2026-11-06.