最近整理一段运动相机拍的骑行素材,第一反应还是打开手机看陀螺仪数据、找IMU参数。但手里这份视频只有裸画面,没有传感器日志,我干脆用OpenCV做了一套纯视觉的EIS防抖:不做机械云台、不调陀螺仪,只用特征点追踪和透视变换把帧间抖动压下去。实测了几段不同场景的视频,防抖效果确实能接近手机自带稳定效果,而且门槛比想象中低不少。
如果你手里也有一堆“没写进元数据的普通视频”,又不想被MPU6050那些接线、I2C读取、姿态解算流程困住,这篇就特别适合你。我会把从光流估计、单应矩阵求解、轨迹平滑到透视补偿的整套流程拆开讲,附上可以直接跑的OpenCV代码,再做几组真实素材的对比和参数调优。看完你至少能明白:EIS 不只有陀螺仪一条路,OpenCV 的透视变换方案在绝大多数手持拍摄场景里,实用性非常强。
1. 为什么我不建议一上来就调陀螺仪
1.1 陀螺仪不是万能的:IMU 方案的隐性成本
很多做视频稳像的人一开口就是“用MPU6050读角速度、做姿态估计、结合图像反算运动”。这个思路没错,硬件级防抖和带IMU的电子稳像确实能拿到精确的角速度残差,但问题在于:你未必有陀螺仪数据。
手机拍摄的视频里,IMU数据一般是硬编码进传感器融合管线里的,普通视频文件根本拿不到原始角速度序列。运动相机、老摄像机、监控录像、或者直接从网络下载的视频素材,更不可能附带一份同步好的陀螺仪日志。就算你手头有块MPU6050,还要处理传感器坐标系和相机坐标系的标定问题,稍微没对准,融合出来的结果反而不如纯视觉。
另外陀螺仪本身存在零偏、温漂、量程饱和,尤其大幅晃动时角速度输出很容易削波。到这一步你还得写校准逻辑。做一次能用的硬件防抖 demo 不难,难的是把“能用”变成“各种场景都稳”。
1.2 纯视觉 EIS 的定位:大多数视频其实用不到高精度姿态
纯视觉 EIS 的核心思路是:不关心你实际的物理姿态是多少,只关心每一帧图像相对参考位置“偏离了多少”,然后把这个偏离平滑掉。
这个思路有几个先天优势。第一,它不需要额外传感器,普通 RGB 视频输入就够了。第二,它天然工作在像素坐标系里,做出来画面裁切、边缘补偿都是图像层面的操作,不会出现“角度补偿了但画面还是飘”的标定偏差。第三,对低频缓慢漂移、高频手抖混合在一起的手持视频,用一个合适的运动平滑策略就能处理得不错。
当然它不是银弹。纯视觉方案最怕大范围快速运动、遮挡、动态物体和低纹理场景。后面实测环节我会拿几种典型素材做对比,让你直观看到它的边界在哪里。
2. 透视变换防抖的原理:估什么、平什么、怎么补
2.1 先搞清楚“抖动”在图像里长什么样
手持相机拍摄时,帧间运动大致可以拆成两种成分:
- 高频小抖动:手部细微颤抖、走路颠簸、轻微呼吸起伏,时间上表现为几帧内快速往复,幅度一般不大。
- 低频漂移与大范围运动:身体转动、镜头跟随、步行转向等,会持续几秒甚至更长,幅度往往很大。
EIS防抖的目标不是把所有运动都抹掉。这话可能反直觉,但你要保留低频运动,否则画面会像“钉死在地面”,观看体验非常怪异。我们要做的是只削弱高频小抖动,尽量不动低频路径。
所以防抖流程天然分成三步:估算帧间运动、把运动轨迹中的高频成分平滑掉、再做一个反向补偿。这就是专业防抖里常说的“motion estimation / motion smoothing / motion compensation”。
2.2 为什么选透视变换而不是仿射或平移
OpenCV 里可以选的运动模型有好几种。简单的人脸或背景对齐可以用平移模型cv2.phaseCorrelate,更灵活一点用仿射变换cv2.estimateAffinePartial2D,而我这里用透视变换cv2.findHomography,原因很关键。
拿手机主摄举例,它通常有 24mm 到 28mm 的等效焦距,视场角不算特别大,但随身拍摄时手的旋转会造成强烈的画面倾斜、梯形畸变感。纯平移模型只能纠正水平/垂直位移,仿射模型能纠正旋转和均匀缩放,但面对 roll、俯仰引起的非均匀形变,效果还是会破绽。
透视变换是 3x3 的单应矩阵,自由度有8个,能把一个平面从任意视角映射到另一个视角。大部分手持拍摄场景可以近似成“相机在绕光心转动”,背景近似一个平面,帧间关系用单应矩阵表达非常合适。它的优点是:既能建模旋转透视,又不会像光流场那样过于自由,计算量也可控。
当然,如果场景前后景深度差异极大,或者画面里有明显的前景遮挡,单应假设就会失效。这属于适用边界,后面我会详细说怎么处理。
2.3 裁剪:所有 EIS 都必须付出的代价
任何电子稳像都会损失视场角,这一点一定要提前给需求方打预防针。
当你把画面从抖动位置“拉回”参考位置时,边缘必然出现空洞。常规做法有几种:一是直接放大画面并裁剪,用掉一部分边缘像素;二是用适合的边界填充方式。我实测下来,裁剪比例一般设置在 10% 到 20% 之间比较合理。裁剪越多,防抖余量越大,但画质损失越明显。相反,裁剪太少,大抖动时边缘还是会露出空洞。
我通常用scale = 0.9作为默认值,即在原始尺寸上保留 90% 的有效画面。后处理时再统一缩放回原来的输出分辨率,这样既掩盖了空洞,又不会让预览画幅忽大忽小。
3. 用 OpenCV 搭建 EIS 管线,一步步走到可运行
3.1 整体管线设计与依赖
这套实现只依赖opencv-python和numpy,代码尽量少用高级库,方便移植到 C++ 或嵌入式端。完整流程如下:
- 读取视频帧,转灰度。
- 在第一帧或上一帧检测角点特征。
- 用金字塔 LK 光流跟踪当前帧的特征点。
- 用 RANSAC 求解上一帧到当前帧的单应矩阵。
- 把相对变换累积成绝对轨迹。
- 对绝对轨迹做平滑。
- 计算补偿矩阵。
- 对当前帧做透视变换。
- 裁剪边缘,输出稳定的视频帧。
安装依赖直接:
pip install opencv-python numpy如果你需要跑完保存文件,顺带装一个imageio[ffmpeg]或直接用cv2.VideoWriter都行。我这里用cv2.VideoWriter示例,因为它是 OpenCV 自带接口,不增加额外负担。
3.2 特征点提取与光流跟踪
质量好的特征点是整套方案的基石。我用cv2.goodFeaturesToTrack提取 Shi-Tomasi 角点,它比单纯 ORB 特征对运动估计更稳。参数里maxCorners我设成 150 到 300,qualityLevel取 0.01,minDistance取 15 到 30。特征点太多会拖慢 OpenCV 的calcOpticalFlowPyrLK,太少又容易在 RANSAC 时退化。
光流这一步要把“上一帧的点”在当前帧找出来。对每个点,calcOpticalFlowPyrLK会在多尺度金字塔里搜索,winSize=(21,21)是常用配置,适合手持视频;运动幅度特别大的素材可以放大窗口到31,但运算量也上去了。
注意光流结果一定要做两道检查:一是status标志必须为 1,表示跟踪成功;二是目标点必须保持在上一步的 ROI 范围内。很多抖动边缘帧会出现特征点直接飞出画面,拿这些点去算单应矩阵会让结果崩掉。
3.3 帧间运动估计与 RANSAC 筛选
得到匹配点对后,我用cv2.findHomography(prev_pts, curr_pts, cv2.RANSAC, 1.0)估计单应矩阵。1.0是 RANSAC 的内点重投影误差阈值,单位是像素,这个值越小,单应矩阵的内点要求越严格,排除动态物体干扰的能力越强,但太小会把正常跟踪点也误杀。
在这步尤其要重视findHomography返回的 mask。它是每一个匹配点参与最终矩阵的“内点/外点”标记。如果画面中央有一片树叶在晃,或者有人走过,光流点会粘在人身上。靠 RANSAC 的动态物体剔除,能显著降低前景运动对全局运动估计的干扰。后面调参部分我还会专门讲这个问题。
3.4 轨迹平滑:低通滤波在 3x3 矩阵上要小心
拿到每一帧的相对变换矩阵H_rel后,我需要把它累积成“当前帧相对参考系”的绝对变换矩阵H_abs。用矩阵乘法累积:
H_abs = H_abs @ H_rel为什么要累积而不是直接拿findHomography(第一帧, 当前帧)?因为直接求解两帧之间的大跨度匹配很容易失败,尤其画面运动大的时候;而相邻帧的匹配点通常稳定,累积多步误差反而可控。
轨迹平滑就是对H_abs序列里的每一个矩阵元素做滑动窗口滤波。但有个大坑:3x3 矩阵元素直接做普通均值滤波,会导致缩放分量被压缩、平移量产生过冲,因为单应矩阵并不是数学意义上的“均匀向量空间”。
一个简单好用的做法是对矩阵做移动平均后再恢复比例,核心代码长这样:
window = 30 # 滑动窗口,常见范围 15~60 kernal = np.ones(window) / window # 保证矩阵尺度一致:每个3x3矩阵按右下角元素归一化 traj_norm = [H / H[2,2] for H in traj] # 分别对9个位置做均值滤波 smooth_traj = [] for i in range(9): smooth_traj.append(np.convolve([t.flatten()[i] for t in traj_norm], kernal, mode='same')) smooth_traj = np.stack(smooth_traj, axis=1).reshape(-1, 3, 3)对于实时视频,无法使用中心对称的np.convolve,可以改成一阶低通滤波:
alpha = 0.8 # 平滑系数,越大越平滑,但延迟越高 smooth = alpha * smooth + (1 - alpha) * current这种一阶 IIR 滤波器实现简单、延迟固定,缺点是相位失真略大。实际项目里我更喜欢对轨迹做二阶或高阶滤波,但因为要保证文章示例可读性,下面采用滑动窗口均值加一个可调的window,效果已经比很多手机原生防抖“可用”。
3.5 透视补偿加裁剪输出
平滑矩阵算出来以后,补偿矩阵很好求:
H_comp = smooth_H @ np.linalg.inv(H_abs)不要纠结矩阵左右乘的顺序,你只需要记住一件事:H_comp把“当前帧坐标”映射到“平滑后的参考坐标”。用cv2.warpPerspective作用到当前帧即可。最后裁剪:
H, W = frame.shape[:2] scale = 0.9 matrix = H_comp @ np.array([[scale, 0, W*(1-scale)/2], [0, scale, H*(1-scale)/2], [0, 0, 1]]) stable = cv2.warpPerspective(frame, matrix, (W, H))省略代码不太合适,我给你一段比较完整的示例,它能直接读入视频并输出稳定后的视频:
import cv2 import numpy as np INPUT_PATH = "input.mp4" OUTPUT_PATH = "output_stable.mp4" SMOOTH_WINDOW = 30 SCALE = 0.9 cap = cv2.VideoCapture(INPUT_PATH) fps = cap.get(cv2.CAP_PROP_FPS) width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) out = cv2.VideoWriter(OUTPUT_PATH, cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height)) ok, prev = cap.read() if not ok: print("无法读取视频") exit() prev_gray = cv2.cvtColor(prev, cv2.COLOR_BGR2GRAY) prev_pts = cv2.goodFeaturesToTrack(prev_gray, maxCorners=200, qualityLevel=0.01, minDistance=20) traj = [] H_abs = np.eye(3) while True: ok, frame = cap.read() if not ok: break cur_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) cur_pts, status, err = cv2.calcOpticalFlowPyrLK( prev_gray, cur_gray, prev_pts, None, winSize=(21,21), maxLevel=2, criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01) ) good_prev = prev_pts[status.flatten() == 1] good_cur = cur_pts[status.flatten() == 1] if len(good_prev) >= 8: H_rel, mask = cv2.findHomography(good_prev, good_cur, cv2.RANSAC, 1.0) if H_rel is not None: H_abs = H_abs @ H_rel else: H_rel = np.eye(3) else: H_rel = np.eye(3) # 保存归一化后的绝对轨迹 traj.append(H_abs / H_abs[2, 2]) # 更新特征点:跟踪点少于阈值时重新检测 if len(good_prev) < 50: prev_pts = cv2.goodFeaturesToTrack(cur_gray, maxCorners=200, qualityLevel=0.01, minDistance=20) else: prev_pts = good_cur.reshape(-1, 1, 2).astype(np.float32) prev_gray = cur_gray # 轨迹平滑 traj = np.array(traj) # (N,3,3) N = traj.shape[0] smooth_traj = np.copy(traj) for i in range(3): for j in range(3): smooth_traj[:, i, j] = np.convolve(traj[:, i, j], np.ones(SMOOTH_WINDOW)/SMOOTH_WINDOW, mode='same') # 重新读取并输出稳定帧 cap.set(cv2.CAP_PROP_POS_FRAMES, 0) frame_id = 0 while True: ok, frame = cap.read() if not ok: break H_abs = traj[frame_id] H_smooth = smooth_traj[frame_id] H_comp = H_smooth @ np.linalg.inv(H_abs) # 缩放+裁剪,抵消边界 transform = H_comp @ np.array([ [SCALE, 0, width*(1-SCALE)/2], [0, SCALE, height*(1-SCALE)/2], [0, 0, 1] ]) stable = cv2.warpPerspective(frame, transform, (width, height)) out.write(stable) frame_id += 1 cap.release() out.release()注意这段代码里我先读一遍算轨迹,第二遍做输出,属于“离线两遍”方案。优点是轨迹平滑可以拿到全局视野,不依赖累积误差;缺点是处理长视频时会多一次完整读取。实时视频则要把traj换成流式平滑,代码结构保持一致,改成在线更新即可。
4. 实测:什么样的素材收益大,什么场景容易翻车
4.1 我的测试素材和评价方式
我用三类典型素材做对比:
- 手持行走拍摄的公园道路,中景,无明显前景遮挡。
- 坐在车里隔着挡风玻璃拍街景,画面有持续平行位移和路面颠簸。
- 缓慢推近一座雕像,周围有很多行人来回走动。
评价分主观和客观两层。主观就是肉眼观察画面的明显晃动,客观我做了两个粗糙指标:一是画面边缘区域特征点的残差抖动幅度,二是输出视频在时间轴上的帧间平均像素位移,也算“粉刺感”低不低。
主观评价这种“稳不稳”的东西,确实没有一张图能说完,但对比看慢放视频,差异会非常明显。我后面给你的结论都基于慢放 0.25 倍速逐帧比对的结果。
4.2 三段典型素材的结果对比
| 场景 | 抖动类型 | 防抖效果 | 残余问题 |
|---|---|---|---|
| 手持行走道路 | 高频小抖动+低频转向 | 改善明显,边缘干净 | 大跨步瞬间偶尔有轻微果冻感 |
| 车内拍街景 | 低频平移+震动 | 中低频稳定,前景电线杆被明显“盯住” | 远处建筑有小幅呼吸感,靠近边缘形变略大 |
| 雕像周围行人 | 中频晃动+大规模动态物体 | 背景稳定,行人短暂拖影 | 行人占比过大时背景估计受污染,出现轻微摆动 |
第一类素材是最理想的情况,背景深度平整、特征点丰富、运动以旋转为主,单应矩阵模型几乎完美贴合。处理完以后,画面的稳定效果让我觉得已经够发朋友圈了。
第三类素材比较有意思,启用 RANSAC 后行人大部分时间能作为外点剔掉,但行人密集并大面积遮挡背景时,内点数量跌到危险区,矩阵估计就飘了。这也是纯视觉 EIS 的经典边界:你不能让“会动的东西”占画面太多面积。
4.3 和陀螺仪方案对比的感受
我手头没有完全一模一样的视频同时带陀螺仪和纯画面,但以前做过基于MPU6050的简单云台稳定项目,可以把感受说清楚。
陀螺仪方案的强项是高频抖动和快速运动时的相位响应。图像估计本质上依赖像素运动,画面模糊时光流很容易失准;陀螺仪的频率响应比图像高得多,100Hz甚至500Hz采样都很常见。体现在结果上,陀螺仪方案在大幅快速摇摄时更“跟手”,纯视觉方案在大甩镜头时容易出现轻微卡顿感,因为相邻帧的运动模糊破坏了特征跟踪。
但在普通手持视频、走路、轻微转镜这类日常场景,陀螺仪的优势没有想象中那么大。视觉方案赢在完全不需要额外硬件,以及“像素级对齐”这个天然属性。手机端的 EIS 为什么很多已经走纯视觉或视觉为主路线?就是因为普通用户的拍摄场景并不需要极端高频响应,纯视觉已经足够。
5. 调参和排坑:从“有效果”到“真正可用”
5.1 特征点数、金字塔层数和光流迭代这些参数别拍脑袋定
很多新手会把maxCorners拉到 1000 甚至 2000,以为特征点越多越稳。实际不是这样。
特征点太多,LK 光流的运行时间成倍上涨;而且大量弱角点会混进 RANSAC,虽然不会让结果立刻崩,但会让单应矩阵出现不必要的“抢权重”。我常用的平衡区间是 150 到 300。
maxLevel也别迷信越大越好。金字塔层数高确实能处理大位移,但层数过高会丢失细节,特征点在小尺度上位置容易偏掉。对 1080p 视频手持抖动,maxLevel=2就够了;如果你处理的素材是 4K 且画面运动巨大,才建议拉到 3。
winSize对“手抖”的敏感性影响也很大。窗口大能追到大幅度运动,但会把相邻不同目标的运动混在一起;窗口小则高频响应好,但大抖动时容易跟踪失败。我一般保持(21,21),如果你发现防抖结果在快速甩镜时出现大量空洞,可以尝试(31,31),再根据帧率调整。
5.2 动态物体是最大的“傲慢陷阱”
RANSAC 的 mask 并不是万能的。有一次我处理一段画面上有只猫跑过的素材,猫身花纹特征点特别密集,结果单应矩阵有一半被猫带走了,背景稳得像“水面上漂”。后来我把 mask 拉出来一统计,发现被当内点的匹配点几乎全是猫身上的点。
针对这个问题我做了两个改进:一是对findHomography设置更紧的阈值1.0,二是对 RANSAC 输出的外点位置在下一帧直接不参与追踪,丢一个“禁入区”。更狠一点的做法是把图像切分成网格,统计每个网格的内点占比,内点占比太低的网格整体降权。这个方法简单但有效,尤其对行人、车辆、树枝摇晃很有用。
5.3 低频漂移和高频抖动的分开处理
前面说了,轨迹里既要削高频,又要保低频。直接用单一滑动窗口滤波往往两头不讨好:窗口太短,高频抖动滤不干净;窗口太长,低频运动被吃掉,画面会变成“有人拽着相机”的滞后感。
我用的是两段式处理:先做一个较大的滑动窗口,比如window=60,得到基准低频轨迹;再用基准轨迹和原始轨迹相减,取一个衰减系数0.3去抑制中间频段。这样相当于在低频和高频之间留了一个“手感”区间,你可以简单粗暴地认为:smooth_strength越高,画面越稳定,但摇摄感丢失越多。
如果处理的视频里有明显“推拉镜头”,还要额外识别一个全局缩放因子的变化,否则平滑会把缩放也当成抖动抹掉,输出画面像在匀速缩放,看起来很假。
5.4 性能优化:能跑多快取决于哪一步
离线处理无所谓帧率,但如果你要接到实时预览里,性能瓶颈几乎都在两处:光流跟踪和warpPerspective。
光流速度取决于特征点数和图像分辨率。720p 分辨率下 200 个特征点,calcOpticalFlowPyrLK在普通笔记本 CPU 上大约 6 到 10ms;1080p 可能翻到 15ms 上下。若每秒要处理 30 帧,预算只有 33ms,还剩下透视和拷贝的时间,实际上是可以满足的。前提是不要再用 1500 个特征点。
warpPerspective在 1080p 上用 CPU 可能要 5 到 10ms。实时应用建议配合cv2.INTER_NEAREST做调试预览,验证逻辑正确后再换INTER_LINEAR出最终画面。换 GPU 端或使用cv2.cuda.warpPerspective之后,这个耗时会降到毫秒以下。
6. 如果想做产品级 EIS,我还会怎么拓展
6.1 传感器融合不是二选一
看到这里你可能觉得我彻底否定陀螺仪了。其实不是。真实产品比如现在的手机防抖,普遍是“视觉为主,IMU辅助融合”的混合方案。
视觉数据的优势是像素对齐精度高,缺点是对模糊、FPS骤降、低纹理敏感;IMU数据的优势是高频响应快、不依赖纹理,缺点是漂移和标定误差。把两者做一个松耦合或紧耦合卡尔曼滤波,能同时获得“画面稳定”和“转动跟手”两个好处。
我建议的进阶路线是这样的:
- 先用陀螺仪短时间预测帧间相对姿态,给视觉估计一个“先验”。
- 再用视觉特征点对齐修正陀螺仪的低频漂移。
- 最终传给轨迹平滑模块的是一个融合后的姿态序列,而非单一来源。
如果你只有纯视频,不要硬伪造出 IMU 字段,老老实实用视觉方案就好。硬加噪声很大的“伪数据”只会越调越乱。
6.2 路径规划、网格形变与 GPU 实时化
产品级防抖里,针对图像边缘的“不规则形变”问题,很多方案不直接用全局单应,而是引入网格化形变:把画面切成 16x16 或 32x32 的网格,每个网格单独求局部单应,再做全局平滑约束。这样既能保留完整视场角,又能避免广角镜头下的边缘弯曲。
这套思路我在 OpenCV 里也验证过简化版:先算全局单应作为初始值,再用cv2.remap对局部网格做二次微调。效果确实比纯全局单应好,尤其处理边缘有明显画面的 4K 广角素材时,呼吸感能压下去不少。
如果你要把这套代码搬到实时端,建议尽早把数据全量改成 GPU 上的GpuMat管线,因为它不止省了warpPerspective的时间,连光流金字塔计算也有 CUDA 版本。我之后有机会专门写一篇基于 CUDA OpenCV 的 EIS 优化实现,那部分的性能数据会比现在好看得多。
6.3 给边角洞穿上“遮羞布”:内填充与自由裁剪
最后分享一个实用小技巧。即便设置scale=0.9,大幅度抖动时边缘仍可能露出空洞。如果你不想让输出视频出现黑边,可以叠加两步后处理:一是对当前帧做边缘镜像或边缘拉伸,把空白区填起来;二是配合自适应缩放,把缩放比例随运动幅度动态调节。
镜像填充虽然对真实物理世界是“幻影”,但在背景单调的马路上效果非常好,观众几乎感知不到。这个技巧是我做摄像头防抖时常用的一招,算不上高级,但确实能避免成品视频出现黑色三角区,质感提升很明显。
我自己在调参的时候,最深的体会是:不要追求一个“万能参数”,EIS 防抖本质是个性能和手感的平衡问题。每次拿到新素材,我至少会测试三组窗口宽度,对比正常播放和慢放两种观感,再决定交不交付。
这套纯视觉 EIS 方案现在已经成了我处理普通视频的首选预处理步骤。我不需要知道相机传感器是什么型号,不需要读 I2C,也没什么接线麻烦,丢给脚本一顿跑,输出视频直接进剪辑轨。希望这文章能帮你少踩几个坑,也让你下次接视频防抖需求时,知道“不调陀螺仪”也能把活干得漂亮。