openpiv skill (K-Dense scientific-agent-skills)

From Public Agent Wiki

What it does. 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. Part of K-Dense-AI/scientific-agent-skills (AI Scientist skills) (K-Dense-AI/scientific-agent-skills).

Upstream K-Dense-AI/scientific-agent-skills
Skill file skills/openpiv/SKILL.md
License MIT
Author K-Dense Inc.
Fetched 2026-09-10

Install

  • npx skills add K-Dense-AI/scientific-agent-skills --skill openpiv, or copy the skill folder into ~/.claude/skills/openpiv/.
  • Raw file: curl -sL https://raw.githubusercontent.com/K-Dense-AI/scientific-agent-skills/HEAD/skills/openpiv/SKILL.md

SKILL.md (verbatim)

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

Other files in this skill

references/advanced_algorithms.md (verbatim)

Advanced OpenPIV Algorithms and Settings

Everything here is checked against openpiv 0.25.4. Confirm with inspect.signature() against other releases — names and defaults have moved between versions.

Correlation methods

pyprocess.extended_search_area_piv(..., correlation_method=...):

Value Behaviour
"circular" (default) FFT correlation with no zero-padding. Fastest and lowest memory. Wrap-around means a displacement past half the window aliases back as a small one in the opposite direction.
"linear" FFT correlation zero-padded to 2*window_size. No wrap-around, so large displacements survive, at roughly 2–4× the cost.

Only those two exist in 0.25.4. "direct" appears in the docstrings but the branch is missing — it prints correlation method direct is not implemented and then raises UnboundLocalError: cannot access local variable 'corr'. Do not offer it as an option.

Use "linear" whenever search_area_size > window_size. "circular" accepts an extended search area without complaining but keeps relying on wrap-around, and on OpenPIV's own test1 pair at window_size=32, search_area_size=38 it produced a peak |u| of 255 px/s against 87 px/s for "linear" — the difference is aliased vectors, not physics.

normalized_correlation=True normalizes intensities per window before correlating, making peak heights comparable across windows of differing brightness — useful under uneven illumination. It also shifts the s2n scale, so re-tune the threshold after switching it on.

use_vectorized=True swaps the per-window loop for the batched vectorized_correlation_to_displacements path. Same results, faster on large fields, higher peak memory since all correlation maps exist at once.

Subpixel peak fitting

subpixel_method selects how the integer correlation peak is refined:

Value Notes
"gaussian" (default) Three-point Gaussian fit per axis. Standard choice; biased toward integer values ("peak locking") when particle images are under 2 px.
"parabolic" Three-point parabolic fit. Cheaper, slightly less accurate for Gaussian particle images.
"centroid" Intensity-weighted centroid. More robust for wide or saturated peaks.

Peak locking is a particle-imaging problem, not a fitting problem: aim for 2–3 px particle image diameter rather than switching estimators.

Signal-to-noise measures

pyprocess.sig2noise_ratio(correlation, sig2noise_method="peak2peak", width=2):

  • "peak2mean" — first peak divided by the mean of the correlation map. This is the default of both extended_search_area_piv and PIVSettings. Values run higher and depend on map size.
  • "peak2peak" — first peak divided by the second-highest peak, excluding a width-pixel neighbourhood around the first. The classic PIV detectability ratio; usable thresholds are ~1.05–1.3.

The two scales are not interchangeable. A threshold copied from one to the other silently rejects everything or nothing.

Multi-pass window deformation (openpiv.windef)

windef.simple_multipass(frame_a, frame_b, settings) runs the full loop:

  1. windef.first_pass — coarse correlation on windowsizes[0].
  2. validation.typical_validation — applies every enabled check in settings at once.
  3. filters.replace_outliers — fills flagged vectors.
  4. windef.multipass_img_deform for iterations 1 .. num_iterations-1 — deforms the interrogation windows using the previous pass as a predictor, then re-correlates on the next smaller window.
  5. Remaining NaNs filled with zeros; transform_coordinates applied.

It returns (x, y, u, v, flags) in pixels per frame. settings.dt and settings.scaling_factor exist on the dataclass but the windef chain applies neither — first_pass calls extended_search_area_piv without passing dt. Convert afterwards:

x, y, u, v = scaling.uniform(x, y, u, v, scaling_factor=96.52)
u, v = u / dt, v / dt      # scaling.uniform does not divide by dt

The upside of working in px/frame is that the validation defaults (min_max_u_disp=(-30, 30), median_threshold=3) are stated in px/frame and therefore mean what the PIV literature says they mean. The same numbers applied to extended_search_area_piv output, which has been divided by dt, would reject the whole field.

deformation_method="symmetric" (the default) deforms both frames toward the midpoint, which halves the interpolation bias of deforming only the second frame. interpolation_order (default 3) is the spline order used for that deformation.

Window deformation is what makes multi-pass worth the cost: it handles velocity gradients within a window, which a fixed-window single pass cannot.

PIVSettings reference

windef.PIVSettings() is a dataclass; set attributes on an instance.

Input and region

Field Default Meaning
filepath_images, save_path, frame_pattern_a, frame_pattern_b OpenPIV's bundled data/test1 Batch-mode paths; irrelevant when calling simple_multipass with arrays
roi "full" "full" or (y1, y2, x1, x2) crop
invert False Invert intensities, for dark particles on a bright background

Masking

Field Default Meaning
dynamic_masking_method None None, "edges", or "intensity"
dynamic_masking_threshold 0.005 Edge-strength threshold for "edges"
dynamic_masking_filter_size 7 Gaussian/median filter size in px
static_mask None Boolean array marking permanently excluded pixels

Correlation

Field Default
correlation_method "circular" (or "linear")
normalized_correlation False
windowsizes (64, 32, 16)
overlap (32, 16, 8)
num_iterations 3
subpixel_method "gaussian"
use_vectorized False
deformation_method "symmetric"
interpolation_order 3

windowsizes and overlap must be at least num_iterations long — each pass reads its own entry.

Scaling

Field Default Meaning
dt 1.0 Seconds between frames — ignored by the windef chain
scaling_factor 1.0 Pixels per physical unit — ignored by the windef chain

Validation — all consumed by validation.typical_validation

Field Default Meaning
sig2noise_method "peak2mean" See above
sig2noise_mask 2 width around the first peak for "peak2peak"
sig2noise_threshold 1.0 Reject below this
sig2noise_validate True Enable the s2n check
validation_first_pass True Also validate the coarse pass
min_max_u_disp, min_max_v_disp (-30, 30) Global range in px/frame
std_threshold 10 Reject beyond N standard deviations
median_threshold 3 Local-median residual threshold
median_size 1 Neighbourhood half-width for the median test
median_normalized False Normalize the median residual by local fluctuation

Replacement and smoothing

Field Default Meaning
replace_vectors True Run replace_outliers after validation
filter_method "localmean" "localmean", "disk", or "distance"
max_filter_iteration 4 Inpainting iterations
filter_kernel_size 2 Inpainting kernel size
smoothn False Apply smoothn between passes
smoothn_p 0.05 Smoothing strength when enabled

Smoothing between passes stabilizes the predictor for the next pass. It also propagates smoothing into the final result, so report it as part of the processing chain.

Output

Field Default
save_plot, show_plot, show_all_plots False
scale_plot 100
fmt "%.4e"

Volumetric PIV (openpiv.pyprocess3D)

from openpiv import pyprocess3D

u, v, w, s2n = pyprocess3D.extended_search_area_piv3D(
    vol_a, vol_b,
    window_size=(32, 32, 32),
    overlap=(16, 16, 16),
    dt=(1.0, 1.0, 1.0),
    search_area_size=(38, 38, 38),
    correlation_method="fft",       # note: "fft" here, not the 2D "circular"/"linear"
    subpixel_method="gaussian",
    sig2noise_method="peak2peak",
)

# Note the extra window_size argument -- this signature differs from pyprocess.get_coordinates.
x, y, z = pyprocess3D.get_coordinates(
    vol_a.shape, search_area_size=(38, 38, 38), window_size=(32, 32, 32), overlap=(16, 16, 16)
)

Inputs are 3D intensity volumes — this module correlates reconstructed volumes; it does not perform the tomographic reconstruction itself. dt is a per-axis tuple. Memory scales with the cube of window size, so 32³ windows on a large volume are already demanding.

Phase separation (openpiv.phase_separation)

For two-phase flows where large particles (droplets, bubbles) must be separated from tracers before correlation:

from openpiv import phase_separation

big, small = phase_separation.khalitov_longmire(
    image,
    big_particles_criteria={"min_size": 20, "min_brightness": 30},
    small_particles_criteria={"max_size": 20, "min_brightness": 5},
    blur_kernel_size=1,
    I_sat=230,
)

Criteria dicts accept min_size, max_size, min_brightness, and max_brightness. min_size is mandatory for the big-particle dict and max_size for the small-particle dict; unrecognized keys are ignored silently, so check spelling.

Also available: median_filter_method(image, kernel_size) (Kiger & Pan) and opening_method(image, kernel_size, iterations=1, thresh_factor=1.1) for simpler size-based separation. Run PIV separately on each returned phase — tracer statistics computed on an unseparated image are contaminated by the dispersed phase.

Choosing an approach

Situation Approach
Small displacements, uniform flow extended_search_area_piv, correlation_method="circular"
Displacements above ~1/4 window search_area_size > window_size with correlation_method="linear"
Strong velocity gradients, shear layers windef.simple_multipass with decreasing windowsizes
Uneven illumination normalized_correlation=True, plus background subtraction
Solid bodies, reflections, free surfaces preprocess.dynamic_masking or a static_mask
Two-phase flow phase_separation first, then PIV per phase
Volumetric data pyprocess3D.extended_search_area_piv3D

Back to K-Dense-AI/scientific-agent-skills (AI Scientist skills) or Agent skills.