提示

返回博客列表

让马赛克跟着物体跑:视频运动跟踪与 ffmpeg 合成

客户有两个需求:

  1. 一段街拍视频里要给移动的车牌打码;
  2. 一段产品视频里要加一个跟着产品走的标注框和箭头。

两个需求本质上是同一件事:知道某个物体在每一帧的什么位置,然后在那个位置画点东西。

手动做法是逐帧调位置(K 帧),一分钟视频就要调几百次——不可能。自动做法是运动跟踪:在第一帧框定目标,算法自动跟出后续帧的位置,输出轨迹,再用 ffmpeg 把马赛克/贴纸合成上去。

这篇写完整的方案,包括跟踪失败的补救(这在实际项目里是常态)。

TL;DR:跟踪算法选择:CSRT(准确、较慢,适合离线处理)、KCF(快、对尺度变化敏感)、光流 LK(适合特征点跟踪)。流程:首帧框定 → 跟踪 → 输出轨迹 → 平滑/插值 → ffmpeg 合成。ffmpeg 侧用 sendcmd + overlay 让叠加物的位置随时间变化(overlay 的 x/y 只在 sendcmd 里改)。两个必须处理的现实:跟踪会漂移/丢失(要分段重新初始化 + 人工抽查关键帧);马赛克要留边缘余量(打码区域要比目标大一圈,否则边缘能看出轮廓)。

目录

一、应用场景先理清楚

场景 做法
移动马赛克(车牌、人脸、Logo) 跟踪 + 区域像素化后叠加
标注框 / 高亮框 跟踪 + 画矩形
贴纸 / 箭头跟随 跟踪 + overlay 图片
数据可视化(速度、轨迹线) 跟踪 + 动态绘制
物体移除 跟踪 + inpainting(复杂,另说)
文字跟随(给某人加名字条) 跟踪 + drawtext 动态位置

二、跟踪算法怎么选

算法 OpenCV 类 速度 准确度 尺度变化 遮挡 适合
CSRT TrackerCSRT_create 慢(~20fps) 高 ✅ 较好 离线处理首选
KCF TrackerKCF_create 快(~100fps) 中 ❌ 一般 实时、目标大小稳定
MOSSE TrackerMOSSE_create 极快 低 ❌ 差 极简场景
光流 LK calcOpticalFlowPyrLK 快 中(特征点) 部分 差 纹理丰富的小目标
MedianFlow TrackerMedianFlow_create 快 中 ❌ 差 运动平滑的场景
检测+跟踪混合 YOLO + CSRT 慢 最高 ✅ 好 重要内容

我的默认选择:

  • 离线批量处理 → CSRT(准确最重要,慢一点无所谓);
  • 目标稳定、要快 → KCF;
  • 跟踪经常丢 → 检测+跟踪混合(每隔 N 帧用检测器重新确认,或者丢失时重新检测)。

注意:OpenCV 的 Tracker 类在 opencv-contrib-python 里(不是 opencv-python):

pip install opencv-contrib-python

三、基本流程与代码

import cv2
import csv
from pathlib import Path


def track_object(video_path, init_box, tracker_type='CSRT', reinit_every=0):
    """init_box: (x, y, w, h) 第一帧里目标的框。返回 [(frame_idx, x, y, w, h, ok)]"""
    tracker = {
        'CSRT': cv2.TrackerCSRT_create,
        'KCF': cv2.TrackerKCF_create,
        'MOSSE': cv2.TrackerMOSSE_create,
    }[tracker_type]()

    cap = cv2.VideoCapture(video_path)
    ok, frame = cap.read()
    if not ok:
        raise RuntimeError('无法读取第一帧')
    tracker.init(frame, tuple(init_box))

    results = [(0, *init_box, True)]
    idx = 0
    while True:
        ok, frame = cap.read()
        if not ok:
            break
        idx += 1
        success, box = tracker.update(frame)
        if success:
            x, y, w, h = [float(v) for v in box]
            results.append((idx, x, y, w, h, True))
        else:
            # 跟踪失败:沿用上一帧(后面会用插值/重新初始化修正)
            px, py, pw, ph, _ = results[-1][1:6]
            results.append((idx, px, py, pw, ph, False))
        if reinit_every and idx % reinit_every == 0:
            # 周期性重新初始化(用当前位置作为新的初始框)
            px, py, pw, ph, _ = results[-1][1:6]
            tracker = {
                'CSRT': cv2.TrackerCSRT_create,
                'KCF': cv2.TrackerKCF_create,
                'MOSSE': cv2.TrackerMOSSE_create,
            }[tracker_type]()
            tracker.init(frame, (px, py, pw, ph))
    cap.release()
    return results


def write_track_csv(results, fps, out_path):
    with open(out_path, 'w', newline='', encoding='utf-8') as f:
        w = csv.writer(f)
        w.writerow(['frame', 'time', 'x', 'y', 'w', 'h', 'ok'])
        for idx, x, y, bw, bh, ok in results:
            w.writerow([idx, f'{idx / fps:.3f}', f'{x:.1f}', f'{y:.1f}',
                        f'{bw:.1f}', f'{bh:.1f}', int(ok)])

初始框怎么来:

  1. 人工在第一帧画(最可靠);
  2. 自动检测(人脸检测/YOLO)给出初始框;
  3. 用一个简单的交互脚本(显示第一帧,鼠标框选)。

我会写个小工具显示第一帧让用户框选:

def pick_roi_interactive(video_path):
    cap = cv2.VideoCapture(video_path)
    ok, frame = cap.read()
    cap.release()
    if not ok:
        return None
    roi = cv2.selectROI('选择跟踪目标', frame, fromCenter=False, showCrosshair=True)
    cv2.destroyWindow('选择跟踪目标')
    return tuple(int(v) for v in roi)

四、跟踪失败怎么办(重点)

实际项目里,跟踪失败是常态,原因包括:

  • 目标被遮挡(走过柱子后面);
  • 目标出画面又回来;
  • 快速运动导致模糊;
  • 尺度变化太大(KCF 尤其明显);
  • 相似干扰物(跟踪器"跳"到别的东西上)。

四种补救手段:

1. 失败标记 + 插值

跟踪失败时标记为 ok=0,然后用前后成功帧插值填补:

import numpy as np


def interpolate_failures(results):
    """对 ok=False 的帧,用前后最近的成功帧线性插值。"""
    ok_flags = np.array([r[5] for r in results])
    boxes = np.array([[r[1], r[2], r[3], r[4]] for r in results], dtype=float)

    idx_ok = np.where(ok_flags)[0]
    if len(idx_ok) < 2:
        return results
    for i in np.where(~ok_flags)[0]:
        prev = idx_ok[idx_ok < i]
        nxt = idx_ok[idx_ok > i]
        if len(prev) and len(nxt):
            a, b = prev[-1], nxt[0]
            t = (i - a) / (b - a)
            boxes[i] = boxes[a] * (1 - t) + boxes[b] * t
        elif len(prev):
            boxes[i] = boxes[prev[-1]]
        elif len(nxt):
            boxes[i] = boxes[nxt[0]]
    return [(r[0], *boxes[i], r[5]) for i, r in enumerate(results)]

2. 分段处理:把视频按"跟踪可靠"的区间分段,每段单独初始化(在段首人工或自动框定)。

3. 检测+跟踪混合:每隔 N 帧用检测器(人脸/YOLO)重新确认位置,如果偏差太大就重新初始化跟踪器。

def detect_and_correct(frame, tracker, box, detector, max_drift=50):
    """用检测器周期性校正。"""
    detected = detector(frame)          # 返回 (x,y,w,h) 或 None
    if detected is None:
        return box
    cx_t = box[0] + box[2] / 2
    cy_t = box[1] + box[3] / 2
    cx_d = detected[0] + detected[2] / 2
    cy_d = detected[1] + detected[3] / 2
    if abs(cx_t - cx_d) > max_drift or abs(cy_t - cy_d) > max_drift:
        # 漂移过大,用检测结果重新初始化
        tracker = cv2.TrackerCSRT_create()
        tracker.init(frame, tuple(detected))
        return detected
    return box

4. 人工抽查 + 修正关键帧:输出轨迹后,抽几帧看贴合度,如果某段明显偏离,就在那一帧重新框定并重新跑。

我的实际流程:自动跑一遍 → 输出"失败帧比例" → 如果 > 5% 就人工介入(分段或者换算法)。

五、ffmpeg 合成:overlay 位置随时间变

问题:overlay 的 x/y 要跟着轨迹变。

做法 1:overlay 表达式(轨迹是简单函数时)

overlay 的 x/y 支持表达式,变量包括 t(时间)、n(帧号)、main_w/main_h 等:

ffmpeg -i in.mp4 -i label.png \
  -filter_complex "[0:v][1:v]overlay=x='100+t*20':y='200'" \
  out.mp4

(这表示叠加物每秒向右移动 20 像素。)

只适合规则运动。

做法 2:sendcmd(真正的任意轨迹)★推荐

生成命令文件:

# overlay.cmd
0.000   overlay x 100, y 200;
0.500   overlay x 112, y 205;
1.000   overlay x 130, y 212;
...
ffmpeg -i in.mp4 -i label.png \
  -filter_complex "[0:v][1:v]sendcmd=f=overlay.cmd,overlay" \
  -c:v libx264 -crf 20 out.mp4

注意:

  1. sendcmd 要放在两个输入之后、overlay 之前(它作用于 overlay);
  2. overlay 需要默认 x/y(不写默认为 0,0);
  3. 命令格式:时间<TAB>滤镜名 参数 值;。

生成命令文件的代码:

def write_overlay_commands(track, fps, out_path, offset_x=0, offset_y=0,
                           sample_every=2):
    """track: [(frame, x, y, w, h, ok)],叠加物左上角放在 (x, y)。"""
    lines = []
    for i, (idx, x, y, bw, bh, ok) in enumerate(track):
        if idx % sample_every:
            continue
        t = idx / fps
        px = int(x + offset_x)
        py = int(y + offset_y)
        lines.append(f'{t:.3f}\toverlay x {px}, y {py};')
    Path(out_path).write_text('\n'.join(lines), encoding='utf-8')

采样:每 2 帧写一条(30fps 下就是每秒 15 条)——命令太多 ffmpeg 处理慢,太少会不跟手。2~5 帧一条比较合适。

做法 3:逐帧生成 PNG 序列(最灵活,最慢)

如果叠加内容是动态绘制的(比如要画会变化的框、文字),可以用 OpenCV 逐帧画好输出 PNG 序列,再用 ffmpeg 合成:

def render_frames(video_path, track, out_dir):
    cap = cv2.VideoCapture(video_path)
    idx = 0
    for frame, (_, x, y, bw, bh, ok) in zip(iter_frames(cap), track):
        if ok:
            cv2.rectangle(frame, (int(x), int(y)),
                          (int(x + bw), int(y + bh)), (0, 255, 0), 3)
        cv2.imwrite(f'{out_dir}/{idx:06d}.png', frame)
        idx += 1
    cap.release()


# 合成
# ffmpeg -framerate 30 -i %06d.png -i audio.m4a -c:v libx264 -crf 18 -c:a copy out.mp4

慢但灵活——适合叠加内容复杂的场景。

六、马赛克跟踪的实现

流程:跟踪 → 裁出区域 → 像素化 → 叠回原位置。

ffmpeg -i in.mp4 \
  -filter_complex "\
[0:v]crop=w=120:h=40:x='...':y='...',scale=12:4,scale=120:40:flags=neighbor[mos];\
[0:v][mos]overlay=x='...':y='...'" \
  out.mp4

参数说明:

  • crop 裁出目标区域(要比目标大一圈,留边缘余量);
  • scale=12:4 缩小到很小(这就是"像素化"的原理);
  • scale=120:40:flags=neighbor 放大回去,用 neighbor(最近邻)保持块状感(用 bilinear 会变成模糊而不是马赛克);
  • overlay 叠回原位。

动态位置用 sendcmd(同时给 crop 和 overlay 发命令)。

关键细节:

  1. 区域要比目标大 20~30%——只框住车牌本身的话,边缘的像素会泄露轮廓(人眼能拼出来)。
  2. 马赛克强度(缩得多小):缩到 1/10 左右比较合适。太小看不出是什么,太大还能辨认。
  3. 边缘羽化:硬边缘的马赛克块很显眼,可以用 boxblur 做个过渡(可选)。
  4. 不要只打一帧:整段视频里目标出现的所有帧都要打(甚至目标消失后 1~2 帧也要打,防止闪现)。

七、标注框与箭头

静态框(位置固定):

-vf "drawbox=x=100:y=200:w=300:h=200:color=red@0.8:t=5"

动态框(跟着目标):用 sendcmd 改 drawbox 的 x/y,或者用做法 3(逐帧绘制)。

箭头/贴纸:准备一个 PNG(带透明通道),用 overlay 叠加,位置用 sendcmd 控制。

文字跟随(给某人加名字条):

-filter_complex "[0:v]drawtext=fontfile=xxx.otf:text='张工':fontsize=32:\
fontcolor=white:box=1:boxcolor=black@0.6:x='...':y='...'"

drawtext 的 x/y 也能用 sendcmd 控制,但注意文字宽度(tw)会让位置计算复杂。

八、性能

实测(1080p,30fps,CSRT):

环节 耗时(1 分钟视频)
跟踪(CSRT,每帧) 2.5 分钟
跟踪(KCF) 40 秒
光流 LK 30 秒
ffmpeg 合成(overlay + 编码) 1.5 分钟
逐帧绘制 PNG 序列 5~8 分钟

优化:

  1. 先降低跟踪用的分辨率(跟踪不需要全分辨率,缩到 640 宽再跟踪,速度快 3 倍,然后把坐标按比例放大回去);
  2. 不需要每帧跟踪:每 2~3 帧跟踪一次,中间插值(对慢速运动足够);
  3. 长视频分段并行(前面切片并行那篇)。

九、实测数据

案例 1:车牌打码(街拍,45 秒,车辆移动)

指标 结果
跟踪成功率 96.2%(失败 4 帧,插值补齐)
马赛克贴合度(人工看) 良好
处理耗时 跟踪 1.9 分钟 + 合成 1.2 分钟
人工修正 0 处

案例 2:产品标注(产品视频,2 分钟,产品旋转+移动)

指标 结果
跟踪成功率 88.5%(旋转和遮挡导致失败较多)
处理耗时 跟踪 4.5 分钟
人工修正 3 处(重新框定后重跑)

结论:

  • 平移为主的目标(车辆、人)→ 自动跟踪很可靠;
  • 有旋转/遮挡/尺度变化的目标 → 需要人工介入,通常几处修正;
  • 即便如此,也比纯手动快得多(2 分钟视频手动调 3600 帧 vs 自动 5 分钟 + 修 3 处)。

十、坑清单

  1. 用 opencv-python 想用 Tracker → 没有,装 opencv-contrib-python。
  2. 选了 KCF 处理尺度变化大的目标 → 跟丢。用 CSRT 或加检测校正。
  3. 跟踪失败后不处理 → 叠加物停在原地(很尴尬)。标记 + 插值。
  4. 不输出失败率 → 不知道结果可不可信。一定要统计并展示。
  5. 马赛克框得太紧 → 边缘泄露轮廓。放大 20~30%。
  6. 马赛克放大用了 bilinear → 变成模糊而不是块状。用 flags=neighbor。
  7. 只打了目标出现的帧 → 前后闪现。前后各多打几帧。
  8. sendcmd 命令太多 → ffmpeg 慢。采样(每 2~5 帧一条)。
  9. sendcmd 的 overlay 没给默认 x/y → 第一帧在 (0,0)。
  10. 跟踪用了全分辨率 → 慢 3 倍。缩到 640 宽再跟踪。
  11. 坐标没按比例换算回去 → 降分辨率跟踪后坐标是错的。要乘回比例。
  12. 忘记处理音频 → 逐帧生成 PNG 序列时音轨丢了。单独抽音频再合并。
  13. drawtext 中文字体没指定 → 乱码或方框。指定 fontfile。
  14. 叠加内容超出画面 → 报错或者被裁。clamp 坐标。
  15. 不做人工抽查 → 跟踪漂移的片段会很滑稽。至少抽 5~10 帧看。

最后说说这类需求里我的一个判断原则。

自动跟踪的目标不是"完全不需要人",而是"把人的工作量从 N 帧降到几帧"。

一个 2 分钟的视频,手动调位置是 3600 次操作(不可能);自动跟踪 + 人工修正 3 处,是 20 分钟的工作。这个 100 倍的效率差就是这个技术的全部价值。

所以我从来不跟客户承诺"全自动"——我会说:"自动处理,成功率通常 90% 以上,剩下的我会标出来让你确认一下。"这个说法既诚实,也让客户能对结果负责。

还有一点:跟踪算法的选择本质上是"准确度 vs 速度"的权衡,而这个权衡应该由"处理量"决定:

  • 处理 10 条视频 → 用 CSRT(慢但准,反正才十几分钟);
  • 处理 1000 条视频 → 用 KCF + 检测校正(快,失败率高的再单独处理);
  • 实时场景 → 只能用最快的(MOSSE/KCF)。

先问"有多少量",再决定用什么算法——这个顺序反了的话,要么慢得做不完,要么质量不达标。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部