客户给了个需求:300 小时的课程录像,每一讲要加上章节标记(学生能直接跳到某一节)。
第一反应是找人看。算了一下:300 小时,按 1.5 倍速看也要 200 小时,一个人看要一个月。不可能。
后来我用自动场景检测做了初筛:ffmpeg 跑一遍找出候选切换点,自己写脚本做过滤和合并,最后人工只校验。整个流程半天出结果,人工校验花了一天半。这篇写完整的做法,包括 ffmpeg 自带的检测为什么经常不准,以及我自己实现的版本。
TL;DR:ffmpeg 的场景检测是
-vf "select='gt(scene,0.4)',showinfo",一行命令就能拿到切换点时间戳。但它有三个毛病:对淡入淡出/渐变不敏感、运动剧烈的镜头会误报、阈值需要按内容调。生产用法是:先用每秒 2 帧降采样粗检测,再在原分辨率附近精定位;对结果做去除黑帧 + 合并过近的点 + 最小间隔约束;最后把场景合并成章节(限制最短章节时长)并写成 ffmetadata 章节文件。性能关键:不要解码每一帧,先用-vf fps=2或者按关键帧跳帧。
目录
- 一、先把三件事分开
- 二、ffmpeg 自带的场景检测
- 三、它为什么经常不准
- 四、自己实现:更可控的检测
- 五、从"切换点"到"章节"
- 六、把章节写进视频文件
- 七、精彩片段与视频摘要
- 八、性能:别解码每一帧
- 九、实测数据
- 十、坑清单
一、先把三件事分开
很多人把这三者混为一谈,其实它们是不同的东西:
| 概念 | 是什么 | 用途 |
|---|---|---|
| 场景检测(scene detection) | 找"镜头切换"的那一帧 | 章节、摘要、分片点 |
| 章节(chapter) | 有意义的内容分段 | 播放器跳转、导航 |
| 精彩片段(highlight) | 内容上"重要"的片段 | 宣传片、集锦 |
关系:场景切换是原始信号,章节和精彩片段是在它之上的加工。
- 章节 = 场景切换点里"有意义的那些"(一个 3 秒的镜头切换不应成为章节);
- 精彩片段 = 需要额外信号(音频能量、运动强度、人脸、语音关键词),光靠场景切换找不出来。
二、ffmpeg 自带的场景检测
最简单的一行:
ffmpeg -i input.mp4 \
-filter:v "select='gt(scene,0.4)',showinfo" \
-f null - 2>&1 | grep showinfo
输出:
[Parsed_showinfo_1 @ 0x...] n: 0 pts:1024 pts_time:0.0213 ...
[Parsed_showinfo_1 @ 0x...] n: 312 pts:645120 pts_time:13.4400 ...
[Parsed_showinfo_1 @ 0x...] n: 887 pts:1830912 pts_time:38.1440 ...
pts_time 就是切换点的时间戳。
参数说明:
| 参数 | 含义 |
|---|---|
scene |
当前帧与上一帧的差异度(0~1) |
gt(scene,0.4) |
差异 > 0.4 就选中 |
showinfo |
打印帧信息(含时间戳) |
-f null - |
不输出文件,只跑滤镜 |
同时导出切换点的缩略图(很有用,人工校验时一眼就能看):
ffmpeg -i input.mp4 \
-filter:v "select='gt(scene,0.4)',scale=320:-2" \
-vsync vfr -frame_pts 1 \
scene_%04d.jpg
-frame_pts 1 让文件名用 PTS(这样文件名能对应到时间),-vsync vfr 避免重复帧。
只想要时间戳列表(新版 ffmpeg 更方便):
ffmpeg -i input.mp4 \
-filter:v "select='gt(scene,0.4)',metadata=print:file=scenes.txt" \
-f null -
# scenes.txt 里会有:
# frame:1234 pts:645120 pts_time:13.44
# lavfi.scene_score=0.523411
lavfi.scene_score 这个分数很有价值——它给你每个切换点的"强度",可以按强度排序,只保留最强的 N 个(做视频摘要时特别好用)。
三、它为什么经常不准
我用它处理课程录像时遇到了三个问题:
1. 渐变/淡入淡出检测不到
场景切换有两类:
- 硬切(cut):前一帧是 A,后一帧是 B,差异巨大 →
scene值很高(0.5+); - 渐变(dissolve/fade):几十帧内慢慢过渡 → 每一帧的差异都很小,
scene值可能只有 0.1~0.2,低于阈值。
课程录像里大量使用淡入淡出(PPT 切换),所以漏检严重。
2. 剧烈运动误报
镜头快速摇动、物体快速划过画面时,相邻帧差异也很大 → 误报为切换。
3. 阈值要按内容调
| 内容类型 | 合适的阈值 |
|---|---|
| 静态(PPT、访谈) | 0.2 ~ 0.3 |
| 常规视频 | 0.3 ~ 0.4 |
| 运动剧烈(赛事、动作片) | 0.5 ~ 0.6 |
没有通用值。我的做法是先用 0.3 跑一遍看结果数量,如果太多就调高,太少就调低。
4. 相邻切换点太近
一次真正的场景切换可能触发连续两三帧都超阈值(因为渐变过程),产生"重复"的切换点。要去重。
四、自己实现:更可控的检测
因为 ffmpeg 的检测不够用,我写了个 Python 版本,核心思路是用多种度量 + 自适应阈值。
import cv2
import numpy as np
class SceneDetector:
"""基于多种度量的场景切换检测。"""
def __init__(self, threshold: float = None, min_interval: float = 1.5):
self.threshold = threshold
self.min_interval = min_interval # 两个切换点之间的最小间隔(秒)
self.prev = None
self.scores = [] # (timestamp, score)
def _features(self, frame):
"""提取一帧的特征:灰度直方图 + 缩小后的灰度图 + 边缘。"""
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
small = cv2.resize(gray, (64, 36), interpolation=cv2.INTER_AREA)
hist = cv2.calcHist([gray], [0], None, [64], [0, 256])
hist = cv2.normalize(hist, hist).flatten()
edges = cv2.Canny(small, 50, 150)
return small.astype(np.float32), hist, edges.astype(np.float32) / 255.0
def _diff(self, a, b):
"""综合差异度:直方图距离 + 结构差异 + 边缘差异。"""
# 1. 直方图相关性(对亮度变化鲁棒)
hist_d = 1.0 - cv2.compareHist(
a[1].astype(np.float32), b[1].astype(np.float32), cv2.HISTCMP_CORREL)
# 2. 结构差异(对整体亮度变化不敏感)
mean_a, mean_b = a[0].mean(), b[0].mean()
if mean_a > 1 and mean_b > 1:
na, nb = a[0] / mean_a, b[0] / mean_b
struct_d = float(np.abs(na - nb).mean()) / 2.0
else:
struct_d = 0.0
# 3. 边缘差异(对内容变化敏感)
edge_d = float(np.abs(a[2] - b[2]).mean())
return 0.4 * hist_d + 0.4 * struct_d + 0.2 * edge_d
def feed(self, frame, timestamp):
"""喂一帧,返回是否是切换点。"""
feat = self._features(frame)
if self.prev is not None:
score = self._diff(self.prev[0], feat)
self.scores.append((timestamp, score))
self.prev = (feat, timestamp)
return None
def finalize(self):
"""所有帧喂完后,用自适应阈值挑出切换点。"""
scores = np.array([s for _, s in self.scores])
ts = [t for t, _ in self.scores]
if len(scores) == 0:
return []
if self.threshold is None:
# 自适应:均值 + 3 倍标准差(对大多数内容效果不错)
thr = scores.mean() + 3 * scores.std()
thr = min(max(thr, 0.15), 0.6) # 夹在合理区间
else:
thr = self.threshold
candidates = [(ts[i], scores[i]) for i in np.where(scores > thr)[0]]
# 后处理 1:合并间隔过近的点(保留分数最高的)
merged = []
for t, s in candidates:
if merged and t - merged[-1][0] < self.min_interval:
if s > merged[-1][1]:
merged[-1] = (t, s)
else:
merged.append((t, s))
return merged
三个度量的理由:
- 直方图:对"整体变亮/变暗"敏感,能抓淡入淡出的累积变化;
- 结构差异(归一化后):对亮度变化不敏感,能抓真正的内容变化;
- 边缘:对"画面里多了个东西"敏感,但对摇镜头不太敏感(摇镜头时边缘整体移动,差异反而没有像素差那么大)。
自适应阈值(mean + 3σ)比固定阈值好用——它会自动适配内容。但对于"整段都很平静、突然一个大切换"的视频,3σ 可能太高(因为标准差很小)。所以我夹在 [0.15, 0.6] 之间。
淡入淡出的额外处理:渐变的特点是"连续多帧都有中等强度的差异"。可以检测这种"持续中等差异"的区间:
def find_dissolves(scores, ts, window=15, min_score=0.08, min_sum=1.5):
"""找渐变区间:连续多帧的中等差异累积。"""
arr = np.array(scores)
out = []
for i in range(len(arr) - window):
seg = arr[i:i + window]
if (seg > min_score).all() and seg.sum() > min_sum:
# 渐变的中点作为切换点
out.append((ts[i + window // 2], float(seg.mean())))
# 去重(渐变区间会重叠)
return dedupe(out, min_interval=2.0)
五、从"切换点"到"章节"
场景切换点直接当章节会太碎。我做了三层过滤:
def scenes_to_chapters(scene_points, duration, min_chapter=60, max_chapters=30):
"""把场景切换点转成章节起点。"""
# 1. 去掉开头的(0 秒处不算切换)
pts = [t for t, s in scene_points if t > 3.0]
# 2. 强制最小章节时长
chapters = [0.0]
for t in pts:
if t - chapters[-1] >= min_chapter:
chapters.append(t)
# 3. 限制章节数量:太多就只保留分数最高的
if len(chapters) > max_chapters:
ranked = sorted(
[(t, s) for t, s in scene_points if t in chapters],
key=lambda x: -x[1],
)[:max_chapters - 1]
chapters = [0.0] + sorted(t for t, _ in ranked)
# 4. 补上结束时间
bounds = []
for i, start in enumerate(chapters):
end = chapters[i + 1] if i + 1 < len(chapters) else duration
bounds.append((start, end))
return bounds
关键参数:
| 参数 | 建议值 | 说明 |
|---|---|---|
min_chapter |
60~180 秒 | 太短的章节没有导航价值 |
max_chapters |
20~40 | 播放器的章节列表太长也不好用 |
| 开头跳过 | 3 秒 | 忽略片头 logo |
课程录像这种场景还有个特殊技巧:PPT 类内容的切换点往往对应"翻页",而这些切换点的分数分布很特殊(高分数、低噪声)。我最后给课程类内容用了更低的 min_chapter=45,因为一页 PPT 讲 45 秒很常见。
六、把章节写进视频文件
ffmpeg 用 ffmetadata 格式写章节:
;FFMETADATA1
[CHAPTER]
TIMEBASE=1/1000
START=0
END=45000
title=课程介绍
[CHAPTER]
TIMEBASE=1/1000
START=45000
END=123000
title=第一章:基础概念
[CHAPTER]
TIMEBASE=1/1000
START=123000
END=245000
title=第二章:进阶用法
TIMEBASE=1/1000 表示 START/END 的单位是毫秒。
写入:
ffmpeg -i input.mp4 -i chapters.txt -map_metadata 1 -c copy output.mp4
-map_metadata 1 表示用第二个输入(chapters.txt)的元数据。-c copy 不重新编码,秒完成。
注意容器差异:
| 容器 | 章节支持 |
|---|---|
| MKV | 最好,原生支持,工具链完善 |
| MP4 | 支持,但不同播放器/工具兼容性有差异(iOS 支持,部分安卓播放器不显示) |
| MP4(用于 HLS) | fMP4 也支持章节,但切片时要小心 |
我们最后同时出了 MP4 和 MKV 两个版本(MP4 给播放端,MKV 给归档),因为 MKV 的章节兼容性明显更好。
验证:
ffprobe -v error -show_chapters -of json output.mp4 | jq '.chapters[] | {start:.start_time, end:.end_time, title:.tags.title}'
Python 自动生成章节文件:
def write_chapters(bounds, titles, path):
lines = [';FFMETADATA1']
for (start, end), title in zip(bounds, titles):
lines += [
'[CHAPTER]',
'TIMEBASE=1/1000',
f'START={int(start * 1000)}',
f'END={int(end * 1000)}',
f'title={title}',
'',
]
with open(path, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines))
章节标题怎么来:自动生成的只能叫"第 N 章"。如果内容有配套的 PPT/大纲/字幕,可以从那里提取(比如字幕里出现"第二章"这样的关键词时,把时间点对齐过去)。我们后来做了一个"字幕关键词 + 场景点对齐"的方案,标题质量提升明显。
七、精彩片段与视频摘要
场景检测只能告诉你"画面变了",判断"这段重不重要"需要别的信号:
| 信号 | 怎么算 | 适合什么内容 |
|---|---|---|
| 音频能量 | 短时能量峰值(掌声、欢呼、音效) | 赛事、演唱会、演讲 |
| 运动强度 | 帧间差分 / 光流幅值 | 动作片、赛事 |
| 人脸/人物出现 | 人脸检测 | 访谈、剧集 |
| 语音关键词 | ASR 后的文本匹配 | 课程、会议 |
| 画面色彩丰富度 | 饱和度方差 | 风景、广告 |
一个简单但有效的组合(我们做课程集锦用的):
def highlight_score(audio_energy, motion, has_face):
"""综合精彩度打分(简化示例)。"""
return 0.4 * norm(audio_energy) + 0.4 * norm(motion) + 0.2 * float(has_face)
生成摘要(片花)的流程:
1. 把视频切成固定窗口(比如 5 秒)
2. 每个窗口打分
3. 取分数最高的 N 个窗口,且彼此间隔 > 30 秒
4. 按时间顺序拼接,窗口之间加 0.5 秒转场
5. 输出 60~120 秒的摘要
拼接命令:
# 用 concat demuxer(需要先按时间段截取)
ffmpeg -i input.mp4 -ss 120 -t 5 -c copy part1.mp4
ffmpeg -i input.mp4 -ss 480 -t 5 -c copy part2.mp4
# list.txt
ffmpeg -f concat -safe 0 -i list.txt -c copy summary.mp4
用 -c copy 截取要对齐关键帧(否则开头会花屏或者时长不准),前面讲截取那篇说过。要精确的话就重编码。
八、性能:别解码每一帧
这是最关键的一节。对 300 小时的视频做检测,如果解码每一帧:
300 小时 × 3600 秒 × 25 fps = 2700 万帧
每帧做一次特征提取(几十毫秒)——算下来要几天。
优化 1:降采样检测
先按每秒 2 帧(甚至 1 帧)抽取,做粗检测:
ffmpeg -i input.mp4 -vf "fps=2,scale=320:-2" -f rawvideo -pix_fmt bgr24 -
Python 直接从管道读:
import subprocess
W, H = 320, 180
cmd = ['ffmpeg', '-i', 'input.mp4', '-vf', f'fps=2,scale={W}:{H}',
'-f', 'rawvideo', '-pix_fmt', 'bgr24', '-an', '-']
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
frame_size = W * H * 3
idx = 0
detector = SceneDetector()
while True:
raw = proc.stdout.read(frame_size)
if len(raw) != frame_size:
break
frame = np.frombuffer(raw, np.uint8).reshape(H, W, 3)
detector.feed(frame, idx / 2.0) # fps=2,所以时间戳是 idx/2
idx += 1
scenes = detector.finalize()
300 小时 → 216 万帧 → 几分钟到几十分钟。
优化 2:粗检测后精定位
粗检测得到"第 120 秒附近有切换",精度是 ±0.5 秒。要精确到帧,只在那个时间点附近解码:
ffmpeg -ss 119 -i input.mp4 -t 2 -vf showinfo -f null - 2>&1 | grep showinfo
只在 ±1 秒的窗口里精扫,代价可以忽略。
优化 3:按关键帧跳帧
ffmpeg -i input.mp4 -vf "select='eq(pict_type,I)'" ...
只解码 I 帧(数量是总帧数的 1/100 左右)。但这样会漏掉非关键帧处的切换(大多数切换点确实是 I 帧——编码器通常在场景切换处插 I 帧,所以这个方法对硬切很有效,对渐变无效)。
我们最后的方案:默认的硬切用关键帧跳帧(最快)+ 渐变用降采样检测。
优化 4:多文件并行
用进程池并行处理多个文件(前面分布式那篇讲过)。300 小时的内容拆到 8 核机器上,2 小时跑完。
九、实测数据
对 12 个课程录像(总时长 18 小时)的实测:
| 方法 | 检出切换点 | 人工核对的准确率 | 耗时 |
|---|---|---|---|
ffmpeg scene=0.4 |
412 | 71% | 8 分钟 |
ffmpeg scene=0.25 |
1180 | 54%(误报多) | 9 分钟 |
| 自实现(固定阈值 0.35) | 520 | 83% | 15 分钟 |
| 自实现(自适应阈值)+ 后处理 | 487 | 91% | 15 分钟 |
准确率 91% 意味着什么:487 个切换点里大约 44 个是错的。人工校验时,每个点看一眼缩略图(3 秒判断),44 个错误 + 确认 443 个正确的 ≈ 25 分钟。加上漏检的(大约 30 个),总人工成本约 1 小时——比看 18 小时视频强太多了。
章节的效果:最终生成 214 个章节,人工调整了 18 个(合并过碎的、拆分过长的)。客户验收通过。
十、坑清单
- 用固定阈值处理所有内容 → 静态内容漏检、动态内容误报。用自适应阈值。
- 检测不到淡入淡出 → 需要专门的渐变检测(连续中等差异)。
- 摇镜头/快速运动误报 → 用归一化后的结构差异,别只用像素差。
- 切换点太密(一个切换触发多帧) → 合并间隔 < 1.5 秒的点。
- 没过滤黑帧 → 转场黑屏会被当成切换。加黑帧检测过滤。
- 解码每一帧 → 慢几十倍。降采样(fps=2)+ 精定位。
-frame_pts没加 → 导出的缩略图文件名无法对应时间。- 章节文件 START/END 单位搞错 →
TIMEBASE=1/1000时是毫秒。 - MP4 章节在某些播放器不显示 → 用 MKV 兜底,或者不依赖章节改用播放列表。
- 用
-c copy截取片段但没对齐关键帧 → 片段开头花屏/时长不准。 - 章节太碎 → 没有导航价值。加
min_chapter约束。 - 忘了章节的 END → 最后一个章节没有结束时间,播放器显示异常。
- 中文标题编码问题 → ffmetadata 文件要用 UTF-8,ffmpeg 读的时候正常,但某些工具会乱码。
- 精彩片段只看画面 → 漏掉"安静但重要"的内容(比如老师写板书)。结合音频和 ASR。
- 没有人工校验环节 → 自动检测一定有错,直接交付会出错。留人工环节但把它做轻(看缩略图而不是看视频)。
最后说说这件事给我的启发。
自动化的目标不是"完全取代人工",而是"把人工的工作量压缩到可接受的范围"。
我一开始的追求是"检测要 100% 准",花了很长时间调算法,最后发现:从 83% 提到 91% 花了大力气,但从 91% 提到 95% 要花的力气是十倍。而人工校验那 5% 的错误,只要 25 分钟。
所以最后我停在了 91%,把精力放在"让人工校验更快"上(导出缩略图、按分数排序、提供一键删除误报的小工具)。这个组合的总耗时最短。
这个思路在很多地方都适用:
- 视频转码的画质:追求"完全无损"不如"达到目标 VMAF";
- 字幕识别:Whisper 识别完人工校对,比追求 100% 准确率高得多;
- 代码检查:静态扫描 + 人工 review,比追求零误报的扫描器实用。
找到"机器做到多少"和"人工补多少"的平衡点,比单纯追求算法指标更重要。 这个平衡点要靠算总账来定,而不是凭直觉。