引子
一个家用摄像头,一天录 15-20GB。4K HEVC 画质,10 分钟一个文件,128MB。90% 以上的内容是空房间、静止走廊、无人在场的客厅。
你需要的是「有人经过」的片段——但丢帧不行、音画不同步不行、处理几个小时更不行。
我花了一段时间折腾摄像头视频的智能截取,最后沉淀出一套三级筛选 + 三步音画同步修复的方案。这篇文章把核心思路和落地细节写出来。
三级筛选:从 20GB 到 200MB

一开始我也想过直接用 YOLO 逐帧检测,算了一笔账:4K 视频 30fps,10 分钟就是 18000 帧,逐帧推理大约要跑 30 分钟。一天录 144 个 10 分钟文件,显然行不通。
三个级别的筛选各司其职:一级挡掉 90%,二级再挡 60%,剩下的才交给最重的 YOLO。
第一级:VAD 音频检测(最快)
原理很简单——有人活动的地方通常有声音。VAD(Voice Activity Detection)模型只处理音频轨,数据量是视频的千分之一,10 分钟音频几毫秒出结果。
处理链路:
音频 → ffmpeg PCM16 16kHz → Silero VAD → 人声区间
核心参数:
self.vad_threshold = 0.7 # 阈值越高越干净,但可能漏远处人声
self.vad_min_speech_len = 0.3 # 最短语音片段(秒),过滤短促噪音
需要小心的是内存:超过 1 小时的视频,全量加载音频会 OOM。我改用 soundfile 流式读取,每次 10 秒块,逐块检测:
chunk_size = 512 # ~32ms @16kHz
block_size = 16000 * 10 # 每次读10秒
with sf.SoundFile(tmp_path, 'r') as f:
while True:
data = f.read(block_size, dtype='float32')
if len(data) == 0: break
for i in range(0, len(data) - chunk_size, chunk_size):
chunk = torch.from_numpy(data[i:i + chunk_size]).unsqueeze(0)
prob = model(chunk, sr).item()
VAD 检测到的有声区域覆盖了绝大多数有人活动的时段。但还有 10% 的情况——人悄悄走过不说话——VAD 会漏掉,需要下一级补充。
第二级:帧差预筛选(轻量)
帧差判断的逻辑:连续两帧之间像素变化很小 -> 大概率无人经过 -> 跳过 YOLO。
所谓的「像素变化」用灰度图的 MSE(均方误差)衡量:
gray_curr = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY).astype(float)
gray_ref = cv2.cvtColor(self.no_person_frame, cv2.COLOR_BGR2GRAY).astype(float)
mse = np.mean((gray_curr - gray_ref) ** 2)
if mse < self.frame_diff_thresh: # MSE < 10.0
continue # 帧差认为无人,跳过
实际调优时发现几个细节:
- 参考帧必须是确认无人的帧(来自 YOLO 上一次的输出结果)
- 初始阶段没有参考帧,全部走 YOLO,直到第一次检测到「无人」
- 阈值
10.0是实测平衡点。MSE 低于 8 基本上是光线变化和传感器噪点的误报,高于 15 则可能漏掉缓慢移动的人
这一级能跳过约 60% 的帧,YOLO 只需要处理变化明显的那些。
第三级:YOLO 人体检测(最精确)
到这一步,输入已经大幅减少。YOLOv8n nano(6.2MB 的模型)只检测目标类别 class_id=0(人)。
三个优化点:
- 只检测 VAD 未标记的无声区间 —— 有声音的时段默认有人,不需要再跑 YOLO
- 批量推理 —— 攒够帧一次性送模型,MPS 加速下 3-5 倍提速
- 智能降采样 —— 宽边缩放到 640px 再输入,平衡速度与精度
for idx, timestamp in enumerate(all_points):
# 降采样 ...
if ref_frame_established and self.frame_diff_thresh > 0:
if mse < self.frame_diff_thresh:
continue # 帧差跳过
batch_frames.append(frame)
batch_timestamps.append(timestamp)
if len(batch_frames) >= self.batch_size:
results = self.yolo(batch_frames, classes=[0], conf=self.yolo_conf)
# 处理结果 + 更新参考帧
参数平衡经验:
| 参数 | 推荐值 | 说明 |
|---|---|---|
conf=0.5 |
置信度阈值 | 0.25 误检多(沙发误判为人),0.5 干净但可能漏远处人 |
batch_size |
8(GPU)/ 4(CPU) | 太大吃内存,太小浪费并行 |
| 采样间隔 | 2 秒 | 人正常行走不会漏 |
音画同步修复:最坑的一步

三级筛选定位了有效片段后,最后一步是用 ffmpeg 剪出来。
最初我用的是最快的方案——-c copy 容器级裁剪(不重新编码)。结果出来的视频音画不同步——视频慢了几秒。
根因:关键帧不是均匀分布的
视频编码中,I 帧(关键帧)间隔可能长达 5-10 秒。用 -c copy 裁剪时,ffmpeg 必须从最近的 I 帧开始输出视频流,但音频起始点是你指定的裁剪时间。举例:
- 指定裁剪起点:第 0 秒
- 最近的 I 帧:第 3 秒
- 视频从第 3 秒开始,音频从第 0 秒开始
- 3 秒的偏移没有对齐 -> 音画不同步
三步法修复
放弃 -c copy 的直接裁剪,改为三条命令各司其职:
# Step 1: 视频从关键帧开始 stream copy(不带音频)
ffmpeg -y -ss {kf_start} -i input.mp4 -t {duration} -c:v copy -an -avoid_negative_ts make_zero tmp_video.mp4
# Step 2: 提取正确时间段的音频(重编码)
ffmpeg -y -ss {s} -t {e-s} -i input.mp4 -vn -c:a aac -b:a 32k tmp_audio.m4a
# Step 3: 合并,用 itsoffset 延迟音频对齐视频关键帧
ffmpeg -y -i tmp_video.mp4 -itsoffset {trim_s} -i tmp_audio.m4a -c:v copy -c:a copy -map 0:v:0 -map 1:a:0 output.mp4
其中 trim_s = 指定裁剪起点 - 最近关键帧时间,即「音频需要等待多少秒」才能对齐视频。
效果:文件体积只减少 10 倍(stream copy 不重编码),处理速度是重编码方案的 90 倍,音画完美同步。
关键帧定位
有没有工具可以直接知道视频的关键帧分布?ffprobe 可以:
ffprobe -v quiet -select_streams v:0 -show_entries packet=pts_time,flags \
-of csv=p=0 input.mp4 | grep ',K'
输出是所有关键帧的时间戳列表,用 bisect_right 找到目标时间前最近的 I 帧。
更多踩坑记录
VAD 长视频 OOM
1 小时以上的视频全量加载音频导致内存溢出。用 soundfile 分块流式读取解决。
帧差阈值选择
MSE 低于 8 大部分是误检。阈值 10.0 是平衡点——需在已确认为无人的参考帧基础上做比较。
-t 参数位置
-t 必须放在 -i 后面:ffmpeg -ss start -i input -t duration ...。部分 ffmpeg 版本会在 -i 前忽略 -t。
编码膨胀
原始 HEVC (H.265) 10 分钟 128MB,重编码为 H.264 Baseline 后膨胀到 813MB。真·6 倍膨胀。如果不是必须兼容旧设备,用 -c:v copy 保持原始编码。
降级兜底
VAD 可能因内存或音频格式失败,脚本支持 --skip-vad 纯 YOLO 模式——速度慢但不会丢事件。
防重复认领
分布式环境下多个处理器可能同时处理同一个文件。用 Redis SETNX 原子锁:
# SETNX claim:video:{file_id} {PROCESSOR_ID}
# 返回 1 -> 拿到锁
# TTL 3600s -> 崩溃自动释放
如果没有 Redis,基于文件的 flock 或者数据库行锁也可以替代。
总结
三级筛选的核心思路:按成本递增的顺序过滤。一分钟能处理的音频放在最前面,毫秒级的帧差计算放中间,最重的深度学习推理放最后——被它处理的数据已经是前两级筛过的零头。
这个思路不限于摄像头视频:任何数据量巨大的检测场景(IoT 传感器数据、日志异常检测、监控报表)都可以套用——先做便宜的过滤,再做贵的分析。
文章中用到的完整代码和配置参数已整理到 GitHub,包括 Docker 部署和 CI 配置。