提示

返回博客列表

自动找出镜头切换:场景检测、章节生成与精彩片段提取

客户给了个需求:300 小时的课程录像,每一讲要加上章节标记(学生能直接跳到某一节)。

第一反应是找人看。算了一下:300 小时,按 1.5 倍速看也要 200 小时,一个人看要一个月。不可能。

后来我用自动场景检测做了初筛:ffmpeg 跑一遍找出候选切换点,自己写脚本做过滤和合并,最后人工只校验。整个流程半天出结果,人工校验花了一天半。这篇写完整的做法,包括 ffmpeg 自带的检测为什么经常不准,以及我自己实现的版本。

TL;DR:ffmpeg 的场景检测是 -vf "select='gt(scene,0.4)',showinfo",一行命令就能拿到切换点时间戳。但它有三个毛病:对淡入淡出/渐变不敏感、运动剧烈的镜头会误报、阈值需要按内容调。生产用法是:先用每秒 2 帧降采样粗检测,再在原分辨率附近精定位;对结果做去除黑帧 + 合并过近的点 + 最小间隔约束;最后把场景合并成章节(限制最短章节时长)并写成 ffmetadata 章节文件。性能关键:不要解码每一帧,先用 -vf fps=2 或者按关键帧跳帧。

目录

一、先把三件事分开

很多人把这三者混为一谈,其实它们是不同的东西:

概念 是什么 用途
场景检测(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. 用固定阈值处理所有内容 → 静态内容漏检、动态内容误报。用自适应阈值。
  2. 检测不到淡入淡出 → 需要专门的渐变检测(连续中等差异)。
  3. 摇镜头/快速运动误报 → 用归一化后的结构差异,别只用像素差。
  4. 切换点太密(一个切换触发多帧) → 合并间隔 < 1.5 秒的点。
  5. 没过滤黑帧 → 转场黑屏会被当成切换。加黑帧检测过滤。
  6. 解码每一帧 → 慢几十倍。降采样(fps=2)+ 精定位。
  7. -frame_pts 没加 → 导出的缩略图文件名无法对应时间。
  8. 章节文件 START/END 单位搞错 → TIMEBASE=1/1000 时是毫秒。
  9. MP4 章节在某些播放器不显示 → 用 MKV 兜底,或者不依赖章节改用播放列表。
  10. 用 -c copy 截取片段但没对齐关键帧 → 片段开头花屏/时长不准。
  11. 章节太碎 → 没有导航价值。加 min_chapter 约束。
  12. 忘了章节的 END → 最后一个章节没有结束时间,播放器显示异常。
  13. 中文标题编码问题 → ffmetadata 文件要用 UTF-8,ffmpeg 读的时候正常,但某些工具会乱码。
  14. 精彩片段只看画面 → 漏掉"安静但重要"的内容(比如老师写板书)。结合音频和 ASR。
  15. 没有人工校验环节 → 自动检测一定有错,直接交付会出错。留人工环节但把它做轻(看缩略图而不是看视频)。

最后说说这件事给我的启发。

自动化的目标不是"完全取代人工",而是"把人工的工作量压缩到可接受的范围"。

我一开始的追求是"检测要 100% 准",花了很长时间调算法,最后发现:从 83% 提到 91% 花了大力气,但从 91% 提到 95% 要花的力气是十倍。而人工校验那 5% 的错误,只要 25 分钟。

所以最后我停在了 91%,把精力放在"让人工校验更快"上(导出缩略图、按分数排序、提供一键删除误报的小工具)。这个组合的总耗时最短。

这个思路在很多地方都适用:

  • 视频转码的画质:追求"完全无损"不如"达到目标 VMAF";
  • 字幕识别:Whisper 识别完人工校对,比追求 100% 准确率高得多;
  • 代码检查:静态扫描 + 人工 review,比追求零误报的扫描器实用。

找到"机器做到多少"和"人工补多少"的平衡点,比单纯追求算法指标更重要。 这个平衡点要靠算总账来定,而不是凭直觉。

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

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

顶部