客户有两个需求:
- 一段街拍视频里要给移动的车牌打码;
- 一段产品视频里要加一个跟着产品走的标注框和箭头。
两个需求本质上是同一件事:知道某个物体在每一帧的什么位置,然后在那个位置画点东西。
手动做法是逐帧调位置(K 帧),一分钟视频就要调几百次——不可能。自动做法是运动跟踪:在第一帧框定目标,算法自动跟出后续帧的位置,输出轨迹,再用 ffmpeg 把马赛克/贴纸合成上去。
这篇写完整的方案,包括跟踪失败的补救(这在实际项目里是常态)。
TL;DR:跟踪算法选择:CSRT(准确、较慢,适合离线处理)、KCF(快、对尺度变化敏感)、光流 LK(适合特征点跟踪)。流程:首帧框定 → 跟踪 → 输出轨迹 → 平滑/插值 → ffmpeg 合成。ffmpeg 侧用
sendcmd+overlay让叠加物的位置随时间变化(overlay 的 x/y 只在 sendcmd 里改)。两个必须处理的现实:跟踪会漂移/丢失(要分段重新初始化 + 人工抽查关键帧);马赛克要留边缘余量(打码区域要比目标大一圈,否则边缘能看出轮廓)。
目录
- 一、应用场景先理清楚
- 二、跟踪算法怎么选
- 三、基本流程与代码
- 四、跟踪失败怎么办(重点)
- 五、ffmpeg 合成:overlay 位置随时间变
- 六、马赛克跟踪的实现
- 七、标注框与箭头
- 八、性能
- 九、实测数据
- 十、坑清单
一、应用场景先理清楚
| 场景 | 做法 |
|---|---|
| 移动马赛克(车牌、人脸、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)])
初始框怎么来:
- 人工在第一帧画(最可靠);
- 自动检测(人脸检测/YOLO)给出初始框;
- 用一个简单的交互脚本(显示第一帧,鼠标框选)。
我会写个小工具显示第一帧让用户框选:
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
注意:
sendcmd要放在两个输入之后、overlay 之前(它作用于 overlay);- overlay 需要默认 x/y(不写默认为 0,0);
- 命令格式:
时间<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 发命令)。
关键细节:
- 区域要比目标大 20~30%——只框住车牌本身的话,边缘的像素会泄露轮廓(人眼能拼出来)。
- 马赛克强度(缩得多小):缩到 1/10 左右比较合适。太小看不出是什么,太大还能辨认。
- 边缘羽化:硬边缘的马赛克块很显眼,可以用
boxblur做个过渡(可选)。 - 不要只打一帧:整段视频里目标出现的所有帧都要打(甚至目标消失后 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 分钟 |
优化:
- 先降低跟踪用的分辨率(跟踪不需要全分辨率,缩到 640 宽再跟踪,速度快 3 倍,然后把坐标按比例放大回去);
- 不需要每帧跟踪:每 2~3 帧跟踪一次,中间插值(对慢速运动足够);
- 长视频分段并行(前面切片并行那篇)。
九、实测数据
案例 1:车牌打码(街拍,45 秒,车辆移动)
| 指标 | 结果 |
|---|---|
| 跟踪成功率 | 96.2%(失败 4 帧,插值补齐) |
| 马赛克贴合度(人工看) | 良好 |
| 处理耗时 | 跟踪 1.9 分钟 + 合成 1.2 分钟 |
| 人工修正 | 0 处 |
案例 2:产品标注(产品视频,2 分钟,产品旋转+移动)
| 指标 | 结果 |
|---|---|
| 跟踪成功率 | 88.5%(旋转和遮挡导致失败较多) |
| 处理耗时 | 跟踪 4.5 分钟 |
| 人工修正 | 3 处(重新框定后重跑) |
结论:
- 平移为主的目标(车辆、人)→ 自动跟踪很可靠;
- 有旋转/遮挡/尺度变化的目标 → 需要人工介入,通常几处修正;
- 即便如此,也比纯手动快得多(2 分钟视频手动调 3600 帧 vs 自动 5 分钟 + 修 3 处)。
十、坑清单
- 用
opencv-python想用 Tracker → 没有,装opencv-contrib-python。 - 选了 KCF 处理尺度变化大的目标 → 跟丢。用 CSRT 或加检测校正。
- 跟踪失败后不处理 → 叠加物停在原地(很尴尬)。标记 + 插值。
- 不输出失败率 → 不知道结果可不可信。一定要统计并展示。
- 马赛克框得太紧 → 边缘泄露轮廓。放大 20~30%。
- 马赛克放大用了 bilinear → 变成模糊而不是块状。用
flags=neighbor。 - 只打了目标出现的帧 → 前后闪现。前后各多打几帧。
- sendcmd 命令太多 → ffmpeg 慢。采样(每 2~5 帧一条)。
- sendcmd 的 overlay 没给默认 x/y → 第一帧在 (0,0)。
- 跟踪用了全分辨率 → 慢 3 倍。缩到 640 宽再跟踪。
- 坐标没按比例换算回去 → 降分辨率跟踪后坐标是错的。要乘回比例。
- 忘记处理音频 → 逐帧生成 PNG 序列时音轨丢了。单独抽音频再合并。
- drawtext 中文字体没指定 → 乱码或方框。指定
fontfile。 - 叠加内容超出画面 → 报错或者被裁。clamp 坐标。
- 不做人工抽查 → 跟踪漂移的片段会很滑稽。至少抽 5~10 帧看。
最后说说这类需求里我的一个判断原则。
自动跟踪的目标不是"完全不需要人",而是"把人的工作量从 N 帧降到几帧"。
一个 2 分钟的视频,手动调位置是 3600 次操作(不可能);自动跟踪 + 人工修正 3 处,是 20 分钟的工作。这个 100 倍的效率差就是这个技术的全部价值。
所以我从来不跟客户承诺"全自动"——我会说:"自动处理,成功率通常 90% 以上,剩下的我会标出来让你确认一下。"这个说法既诚实,也让客户能对结果负责。
还有一点:跟踪算法的选择本质上是"准确度 vs 速度"的权衡,而这个权衡应该由"处理量"决定:
- 处理 10 条视频 → 用 CSRT(慢但准,反正才十几分钟);
- 处理 1000 条视频 → 用 KCF + 检测校正(快,失败率高的再单独处理);
- 实时场景 → 只能用最快的(MOSSE/KCF)。
先问"有多少量",再决定用什么算法——这个顺序反了的话,要么慢得做不完,要么质量不达标。