4. OpenCV 基础操作:图像的读取、显示、保存与多帧序列预处理
好,咱们正式开始动手了。这一章我把它叫做「地基工程」——说白了,就是让你先把 OpenCV 这把刀磨利索。很多初学者一上来就调降噪参数、试各种滤波器,结果图像都读不对,折腾半天白费劲。我个人习惯是,不管项目多急,先把 I/O 流程跑通,再谈算法。
4.1 图像的读取:imread 的隐藏细节
OpenCV 读取图像用 cv2.imread(),这个函数大家都会用。但你真的用对了吗?
import cv2
# 默认以彩色模式读取
img = cv2.imread('noisy_frame_001.png')
# 以灰度模式读取
img_gray = cv2.imread('noisy_frame_001.png', cv2.IMREAD_GRAYSCALE)
# 保留原图通道(含Alpha通道)
img_unchanged = cv2.imread('noisy_frame_001.png', cv2.IMREAD_UNCHANGED)
这里有个坑,我踩过好几次——OpenCV 默认读取的是 BGR 顺序,不是 RGB。你想想看,如果你用 matplotlib 直接显示,颜色就全乱了。我在项目中遇到过一位同事,调了一下午的颜色增强,结果发现是通道顺序搞反了。
⚠️ 我曾经犯过的错: 用
cv2.imread() 读取 PNG 图片时,如果图片路径包含中文字符,OpenCV 会直接返回 None,而且不报错。解决方案是用 cv2.imdecode() 配合 np.fromfile()。
4.2 图像的显示与保存
显示图像用 cv2.imshow(),保存用 cv2.imwrite()。看起来简单,但有几个细节值得注意。
| 操作 | 函数 | 关键参数 | 我的经验 |
|---|---|---|---|
| 显示 | cv2.imshow() |
窗口名 + 图像矩阵 | 记得加 cv2.waitKey(),否则窗口一闪而过 |
| 保存 | cv2.imwrite() |
文件名 + 图像矩阵 | 支持通过参数控制 JPEG 质量、PNG 压缩级别 |
| 等待 | cv2.waitKey() |
延迟毫秒数(0 表示无限等待) | 返回值是按键的 ASCII 码,可以用来做交互 |
| 销毁 | cv2.destroyAllWindows() |
无 | 养成习惯,用完释放资源 |
# 显示图像
cv2.imshow('Noisy Frame', img)
key = cv2.waitKey(0) # 等待按键
if key == ord('s'): # 按 's' 键保存
cv2.imwrite('saved_frame.png', img)
cv2.destroyAllWindows()
💡 小技巧: 保存 JPEG 时可以用
[cv2.IMWRITE_JPEG_QUALITY, 95] 控制质量;保存 PNG 时用 [cv2.IMWRITE_PNG_COMPRESSION, 3] 平衡速度和体积。
4.3 多帧序列的加载策略
咱们做多帧合成降噪,核心就是处理一堆连续拍摄的帧。我一般用两种方式组织这些帧:
- 文件夹序列:帧按编号命名,如
frame_001.png、frame_002.png…… - 视频文件:直接读取视频流,逐帧提取
我个人更推荐第一种——调试方便,哪一帧有问题直接看文件。来看看代码怎么写:
import os
import cv2
import numpy as np
def load_frame_sequence(folder_path, start=1, end=10, ext='.png'):
"""
从文件夹加载多帧序列
"""
frames = []
for i in range(start, end + 1):
filename = f"frame_{i:03d}{ext}" # 如 frame_001.png
filepath = os.path.join(folder_path, filename)
if not os.path.exists(filepath):
print(f"[警告] 文件不存在: {filepath}")
continue
frame = cv2.imread(filepath, cv2.IMREAD_GRAYSCALE)
if frame is None:
print(f"[错误] 读取失败: {filepath}")
continue
frames.append(frame)
return np.array(frames) # 形状: (帧数, 高, 宽)
🔑 核心要点: 返回的 NumPy 数组形状是
(N, H, W),N 是帧数。这个结构后面做均值降噪、中值降噪时直接就能用,不用再 reshape。
4.4 预处理:对齐与裁剪
多帧序列加载进来之后,不能直接做合成。为什么?因为手持拍摄时,帧与帧之间会有微小的偏移。我做过一个测试,手持手机拍摄 10 帧,最大偏移能达到 3-5 个像素。如果不做对齐,合成结果就是模糊的。
预处理流程我总结为三步:
- 第一步:灰度转换——对齐算法在灰度图上跑更快
- 第二步:帧间配准——用相位相关或特征匹配计算偏移量
- 第三步:裁剪边缘——对齐后边缘会出现黑边,统一裁剪掉
def preprocess_frames(frames, crop_border=10):
"""
简单的帧间对齐 + 边缘裁剪
"""
# 以第一帧为参考
ref = frames[0]
aligned = [ref]
for i in range(1, len(frames)):
# 使用相位相关计算偏移
shift = cv2.phaseCorrelate(
np.float32(ref),
np.float32(frames[i])
)[0]
# 构建仿射变换矩阵(仅平移)
tx, ty = round(shift[0]), round(shift[1])
M = np.float32([[1, 0, tx], [0, 1, ty]])
# 应用变换
aligned_frame = cv2.warpAffine(
frames[i], M,
(ref.shape[1], ref.shape[0]),
borderMode=cv2.BORDER_REPLICATE
)
aligned.append(aligned_frame)
# 裁剪边缘,去掉对齐产生的无效区域
aligned = np.array(aligned)
if crop_border > 0:
aligned = aligned[:,
crop_border:-crop_border,
crop_border:-crop_border]
return aligned
⚠️ 注意:
cv2.phaseCorrelate() 只能检测平移,不能处理旋转和缩放。如果你的序列有较大旋转,需要用 cv2.findTransformECC() 或特征匹配。我在做无人机航拍降噪时就遇到过这个问题——风一吹,画面不仅平移还带旋转,相位相关直接失效。
4.5 本章知识体系
下面这张图把本章的核心流程串起来了,你可以把它当作操作手册:
4.6 完整示例:从加载到预处理
把上面所有步骤串起来,就是一个完整的预处理管线:
def main():
# 1. 加载多帧序列
print("[INFO] 加载帧序列...")
frames = load_frame_sequence('noisy_frames/', start=1, end=10)
print(f"[INFO] 加载完成,形状: {frames.shape}")
# 2. 显示第一帧检查
cv2.imshow('First Frame', frames[0])
cv2.waitKey(0)
# 3. 预处理:对齐 + 裁剪
print("[INFO] 开始帧间对齐...")
aligned_frames = preprocess_frames(frames, crop_border=15)
print(f"[INFO] 对齐完成,形状: {aligned_frames.shape}")
# 4. 保存预处理结果(可选)
np.save('aligned_frames.npy', aligned_frames)
print("[INFO] 预处理结果已保存")
cv2.destroyAllWindows()
if __name__ == '__main__':
main()
💡 我的习惯: 预处理后的数据我会先存成 .npy 文件,这样后面调降噪参数时不用反复加载和对齐,能省不少时间。尤其是帧数多的时候,一次对齐可能要跑几十秒。