openpiv은(는) 무엇을 하나요?
개요
OpenPIV(Open Particle Image Velocimetry)는 PIV 이미지 쌍으로부터 유체 흐름을 분석합니다. 전처리, 상호 상관, 벡터 검증, 이상값 대체, 평활화 및 물리적 단위로의 스케일링을 다룹니다.
아래의 모든 내용은 openpiv 0.25.4에서 검증되었습니다. API는 릴리스 간에 변경됩니다 — 다른 버전의 스니펫을 신뢰하기 전에 inspect.signature()를 확인하세요.
사용 시기
실험적인 PIV 또는 유동 가시화 이미지 쌍(2D 속도장 측정, 조사창 매개변수 조정, 벡터 검증, 또는 와도, 변형률, 난류 통계량 도출)을 다룰 때 이 스킬을 사용하세요. 흐름을 측정하는 대신 시뮬레이션하려면 CFD 스킬을 대신 사용하세요.
빠른 시작
OpenPIV 설치:
uv pip install openpiv
# 분석을 재현 가능하게 해야 할 때 고정 — 아래의 모든 스니펫이 검증된 버전입니다.
uv pip install "openpiv==0.25.4"
이미지 쌍에서 PIV 분석 실행:
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")
# 상호 상관. sig2noise_method가 None이 아닐 때마다 (u, v, s2n)을 반환합니다.
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", # 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는 부울 배열입니다. True는 잘못된 벡터를 표시합니다.
flags = validation.sig2noise_val(s2n, threshold=1.05)
u, v = filters.replace_outliers(u, v, flags, method="localmean", max_iter=3, kernel_size=2)
# 물리적 단위로 스케일링한 후 플롯을 위해 이미지 좌표로 뒤집습니다.
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)
또는 정확히 그 파이프라인을 래핑하는 번들 CLI를 사용하세요:
python skills/openpiv/scripts/runner.py \
--image frame_a.bmp --image frame_b.bmp --output_dir results --verbose
핵심 개념
PIV 기초
PIV(입자 이미지 유속계)는 조명된 추적 입자 사이의 두 이미지 사이에서 유체 속도를 측정하는 광학 방법입니다.
프로세스 흐름:
- 알려진 시간 간격
dt로 분리된 이미지 쌍(frame_a,frame_b)을 캡처합니다. - 이미지를 조사창으로 나눕니다.
- 일치하는 창을 상호 상관시켜 최대 변위 피크를 찾습니다.
- 벡터를 검증합니다(신호 대 잡음비, 전역 범위, 국소 중앙값).
- 잘못된 벡터를 보간된 값으로 대체합니다.
- 픽셀 변위를 물리적 단위로 스케일링합니다.
조사창 매개변수
window_size — 픽셀 단위의 상관 창(일반적으로 16–128). 더 큰 창은 더 나은 상관 관계를 제공하지만 공간 해상도는 낮아집니다.
overlap — 인접한 창 사이에 공유되는 픽셀(일반적으로 window_size의 50–75%). 더 높은 중첩은 벡터 밀도와 비용을 높이지만 인접한 벡터는 독립적이지 않고 상관 관계가 됩니다.
search_area_size — 두 번째 프레임에서 검색되는 창 크기. window_size 이상이어야 하며, 더 큰 변위를 수용하기 위해 몇 픽셀 더 큰 것이 좋습니다. 확장된 검색 영역을 correlation_method="linear"와 함께 사용하세요. 기본값인 "circular"는 FFT 래핑에 의존하며 큰 변위를 작은 변위로 앨리어싱합니다. references/advanced_algorithms.md를 참조하세요.
경험 법칙: 가장 큰 변위를 window_size의 약 1/4 미만으로 유지하고 창당 5–10개의 입자를 목표로 하세요.
신호 대 잡음비
s2n은 상관 피크가 얼마나 뚜렷한지 측정합니다. sig2noise_method는 계산 방식을 제어합니다 — "peak2mean"(함수 기본값) 또는 "peak2peak". 두 방식은 서로 다른 스케일을 가지므로, 하나에 맞춰 조정된 임계값은 다른 방식에 대해 의미가 없습니다. 일반적인 peak2peak 임계값은 1.05–1.3입니다.
flags = validation.sig2noise_val(s2n, threshold=1.05)
# flags는 bool: True == 잘못된 값. `~flags`는 좋은 벡터를 선택합니다.
일반 작업
동적 마스킹
마스킹은 openpiv.preprocess에 있으며, openpiv.masking 모듈에 없습니다. (image, mask) 튜플을 반환하며 float 이미지를 입력으로 기대합니다.
from openpiv import preprocess
# method="edges"는 어둡고 날카로운 가장자리의 객체용; "intensity"는 고대비 객체용.
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
)
반환된 이미지를 상관 단계에 입력하세요. 이미 마스킹된 영역이 0으로 설정되어 있습니다. 원본 프레임에 mask를 곱하지 마세요. 마스킹은 이미 적용되었으며, method="edges"의 경우 마스크는 부울이 아닌 uint8 0/255로 반환되므로 곱하면 이미지가 255로 재조정됩니다.
다중 패스 처리
다중 패스(창 변형)는 PIVSettings 데이터클래스에 의해 구동되는 openpiv.windef에 있습니다. pyprocess에는 다중 패스 진입점이 없습니다.
import numpy as np
from openpiv import scaling, windef
settings = windef.PIVSettings()
settings.windowsizes = (64, 32, 16) # 패스당 하나씩 감소(이것이 기본값이기도 함)
settings.overlap = (32, 16, 8) # windowsizes와 동일한 길이
settings.num_iterations = 3 # 실제로 실행할 패스 수
settings.sig2noise_threshold = 1.05
x, y, u, v, flags = windef.simple_multipass(
frame_a.astype(np.int32), frame_b.astype(np.int32), settings
)
# 출력은 프레임당 픽셀 단위입니다. 직접 변환하세요. scaling.uniform은
# scaling_factor로 나누기만 하므로 dt를 별도로 적용하세요.
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는 이미 검증, 이상값 대체, 남은 NaN을 0으로 채우기, transform_coordinates 호출을 수행합니다. 이러한 단계를 반복하지 마세요.
단위 함정: PIVSettings에는 dt와 scaling_factor 필드가 있지만 windef는 둘 다 사용하지 않습니다. first_pass는 dt 없이 extended_search_area_piv를 호출하므로 전체 다중 패스 체인은 프레임당 픽셀 단위로 작동합니다. settings.dt = 0.02로 설정해도 반환된 값에는 아무런 변화가 없습니다. 위와 같이 사후에 변환하세요.
개별 패스를 제어하려면 windef.first_pass와 windef.multipass_img_deform이 더 낮은 수준의 구성 요소입니다.
검증 및 후처리
검증 방법
모든 검증 함수는 True가 잘못된 벡터를 표시하는 부울 배열을 반환합니다.
# 신호 대 잡음비
flags = validation.sig2noise_val(s2n, threshold=1.05)
# 전역 범위 — (min, max) 튜플을 위치 기반 또는 u_thresholds/v_thresholds로 받습니다.
flags = validation.global_val(u, v, (-300, 300), (-300, 300))
# 국소 중앙값 — u_threshold와 v_threshold는 필수; size는 이웃 반경입니다.
flags = validation.local_median_val(u, v, u_threshold=30.0, v_threshold=30.0, size=1)
# 부울 OR로 결합합니다(np.maximum이 아닙니다. 이들은 부울 배열입니다).
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)
)
이러한 임계값을 u와 v의 단위(프레임당 픽셀 아님)로 설정하세요.
extended_search_area_piv는 dt로 나누므로 dt=0.02인 경우 3px/프레임 변위는 150px/s가 됩니다. 위의 임계값은 해당 경우에 적합합니다. PIV 문헌과 PIVSettings.min_max_u_disp에서 사용하는 (-30, 30) 값은 px/프레임 제한이며, 이를 px/s 출력에 적용하면 전체 필드가 거부됩니다. 스케일링 전에 검증하거나 임계값도 1/dt로 스케일링하세요.
이상값 대체
u, v = filters.replace_outliers(
u, v, flags, method="localmean", max_iter=3, tol=1e-3, kernel_size=2
)
method는 "localmean", "disk", "distance" — 이 세 가지만 허용합니다. 인식되지 않는 이름은 거부되지 않습니다. 모든 커널이 0인 상태로 대체되어 조용히 쓸모없는 필드를 반환합니다. 대체는 플래그가 지정된 위치를 보간된 값으로 채운다는 점에 유의하세요. 이후에 NaN으로 덮어쓰면 대체가 낭비됩니다. 둘 중 하나를 선택하세요:
# 보간하는 대신 플래그가 지정된 벡터를 분석에서 완전히 제외합니다.
u = np.where(flags, np.nan, u)
v = np.where(flags, np.nan, v)
평활화
평활화는 openpiv.smoothn.smoothn이며, openpiv.smooth 모듈은 없습니다. 첫 번째 요소가 평활화된 필드인 튜플을 반환하며 NaN 입력을 허용하지 않습니다.
from openpiv.smoothn import smoothn
u_smooth, *_ = smoothn(np.nan_to_num(u), s=0.5) # s: 클수록 더 부드러움
v_smooth, *_ = smoothn(np.nan_to_num(v), s=0.5)
u_smooth = np.asarray(u_smooth)
시각화
벡터장 플로팅
display_vector_field는 저장된 벡터 파일을 읽고 내부적으로 plt.show()를 호출하므로 배치 실행을 위해 비대화형 백엔드를 선택하세요.
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, # 이미지에 다시 매핑하기 위해 scaling.uniform에 사용된 것과 동일한 계수
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)
맞춤 시각화
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)
분석 함수
scripts/analyze.py는 runner.py가 작성한 params.npz에 대해 이러한 기능을 번들로 제공합니다. 저장된 좌표에서 물리적 그리드 간격을 추론하므로 도함수는 단위 길이당 계산됩니다:
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")
인라인으로 계산하려는 경우의 독립형 형식:
와도
def compute_vorticity(u, v, dx=1.0, dy=None):
"""면외 와도 dv/dx - du/dy. 1.0이 아닌 물리적 그리드 간격을 전달하세요."""
dy = dx if dy is None else dy
return np.gradient(v, dx, axis=1) - np.gradient(u, dy, axis=0)
그리드 간격은 물리적 단위로 (window_size - overlap) / scaling_factor이므로 dx=1.0으로 두면 단위 길이가 아닌 그리드 셀당 와도가 생성됩니다.
부호 규칙: runner.py는 transform_coordinates로 끝나며, 그리드를 오른손 법칙 y-상향 프레임으로 다시 레이블링하지만 행을 이미지 순서로 남겨 두므로 저장된 y는 행 인덱스가 증가함에 따라 감소합니다. 위의 독립형 형식은 반대를 가정하므로 params.npz 필드에서 -du/dy를 반환하고 와도와 전단 변형률의 부호를 뒤집습니다. axis=0 도함수를 부정하거나 저장된 좌표에서 방향을 읽는 PIVAnalyzer를 사용하세요.
변형률
def compute_strain(u, v, dx=1.0, dy=None):
"""2D 변형률 텐서의 (exx, eyy, exy)를 반환합니다."""
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)
난류 통계
def compute_statistics(u, v):
"""단일 프레임 공간 통계. 레이놀즈 분해가 아닙니다."""
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),
}
주의 사항: 한 프레임의 공간적 평균을 빼면 공간적 분산이 측정되며, 이는 균질한 필드에 대해서만 난류 강도와 같습니다. 진정한 레이놀즈 분해는 이미지 쌍의 앙상블이 필요합니다. 시간 축에 대해 평균을 낸 다음 각 실현에서 해당 평균 필드를 뺍니다.
CLI 사용법
# 기본 실행
python skills/openpiv/scripts/runner.py \
--image img1.bmp --image img2.bmp --output_dir results --verbose
# 동적 마스킹을 사용한 튜닝된 매개변수
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 옵션
| 옵션 | 기본값 | 설명 |
|---|---|---|
--image | 필수 | 이미지 파일; 쌍을 위해 정확히 두 번 지정 |
--output_dir | results | 출력 디렉터리(없으면 생성) |
--window_size | 32 | 조사창 크기(픽셀) |
--overlap | 12 | 창 중첩(픽셀) |
--search_area | 38 | 검색 영역 크기(픽셀), --window_size 이상이어야 함 |
--dt | 0.02 | 프레임 간 시간(초) |
--scaling | 96.52 | 스케일링 계수, 물리적 단위당 픽셀 수(예: px/mm) |
--threshold | 1.05 | peak2peak 신호 대 잡음비 임계값 |
--mask | none | none 또는 dynamic (openpiv.preprocess.dynamic_masking) |
--mask_method | intensity | edges 또는 intensity, 사용 시 |
... (이하 생략)