视频关键帧检测算法:从信号处理到工程实践

> 来源: https://github.com/steipete/summarize(src/slides/scene-detection.ts

> 场景: 从视频中自动提取"翻页"等关键画面变化的帧

> 技术栈: ffmpeg scene 滤镜 + Average Hash + 自适应阈值

> 特点: 零 ML 依赖,纯信号处理,任何机器都能跑

📌 要解决什么问题?

你有一个 30 分钟的技术演讲视频,里面有 40 张幻灯片。你不想看完整个视频,只想看每张幻灯片的截图 + 对应时间点

问题是:怎么让程序自动找到"翻页"的那些时刻?

视频本质上是一连串连续的图片(帧)。大部分帧和上一帧几乎一样(演讲者站着不动),只有翻页的时候画面会突然大变。我们要找的就是这些"突变点"。

🧠 算法详解(三步走)

Step 1:自适应阈值校准

为什么不能用固定阈值?

不同视频的"变化幅度"完全不同:

视频类型正常帧间变化翻页/切镜变化
幻灯片演讲~0.01(几乎不动)~0.3(整屏变了)
旅游 vlog~0.1(一直在动)~0.3(切场景)
动画片~0.15(动作多)~0.4(切镜头)
静态 PPT 录屏~0.001(完全不动)~0.5(翻页)

如果用固定阈值 0.2:

所以需要先"看看"这个视频长什么样。

怎么看?——采样

从视频中均匀取 6 个时间点(覆盖 5% 到 95% 的进度),各截一帧:


一个 30 分钟的视频:
  
|-------|-------|-------|-------|-------|-------|
0min    3min    6min    12min   18min   24min   30min
  ↑       ↑       ↑       ↑       ↑       ↑
 采样1   采样2   采样3   采样4   采样5   采样6

帧的"指纹"——Average Hash

不能直接比较原图(1920×1080 = 200 万像素,太慢太浪费),所以极致压缩


原始帧 (1920×1080, 彩色, ~6MB)
    ↓ ffmpeg 缩小到 32×32
    ↓ 转成灰度(去掉颜色信息)
    ↓ 得到 1024 个数字(每个 0-255)

比如:
[142, 98, 201, 55, 180, 130, 77, 220, ...]

然后算"指纹":


1. 算平均值:(142+98+201+55+...) / 1024 = 128
2. 每个数字跟平均值比:
   142 ≥ 128 → 1
    98 < 128 → 0
   201 ≥ 128 → 1
    55 < 128 → 0
   ...
3. 得到:[1, 0, 1, 0, 1, 1, 0, 1, ...]  ← 1024 位的"指纹"

这就是 Average Hash(平均哈希)。把一张图压缩成 1024 位的二进制指纹。

核心思想:两张相似的图,它们的亮暗分布应该差不多,所以指纹也差不多。

比较两帧——汉明距离


帧 A 指纹: 1 0 1 1 0 0 1 0 1 1 ...(1024 位)
帧 B 指纹: 1 0 0 1 0 1 1 0 1 0 ...(1024 位)
               ↑       ↑           ↑
             不同     不同        不同

diff = 不同的位数 / 总位数 = 50 / 1024 = 0.049

用采样结果算最佳阈值

6 帧采样得到 5 个 diff 值:


帧1→帧2: 0.02  (没啥变化)
帧2→帧3: 0.25  (翻了好几页)
帧3→帧4: 0.03  (没变化)
帧4→帧5: 0.01  (没变化)
帧5→帧6: 0.04  (轻微变化)

排序后:[0.01, 0.02, 0.03, 0.04, 0.25]

统计:

阈值计算公式:


threshold = max(
  median × 0.15,   // 0.03 × 0.15 = 0.0045
  p75 × 0.2,       // 0.04 × 0.2  = 0.008
  p90 × 0.25       // 0.25 × 0.25 = 0.0625
)
// = 0.0625
// 然后 clamp 到 [0.05, 0.3] 范围
// 最终 threshold = 0.06

直觉:找一个值,能把"正常抖动"(0.01-0.04)和"真正翻页"(0.25)区分开。

Step 2:全视频扫描

拿到校准后的阈值(比如 0.06),用 ffmpeg 内置的 scene 滤镜扫描整个视频:


ffmpeg -i video.mp4 \
  -vf "select='gt(scene,0.06)',showinfo" \
  -f null -

ffmpeg 的 scene 滤镜对每一帧计算一个场景变化分数(0-1):


时间轴:  0:00   ...   5:30   5:31   ...   10:00   10:01   ...
score:   0.01         0.02   0.85         0.01    0.72
                              ↑                     ↑
                          翻页了!              又翻页了!

gt(scene, 0.06) 意思是:只选出 score > 0.06 的帧。

输出一组时间戳:[5:31, 10:01, 15:22, 20:45, 25:10, 28:33]

并行加速

长视频(比如 2 小时)扫描很慢。解决办法是分段并行


2 小时视频,4 个 worker:

Worker 1: [0:00 - 0:30]   → 找到 [5:31, 12:20, 28:45]
Worker 2: [0:30 - 1:00]   → 找到 [35:10, 48:22]
Worker 3: [1:00 - 1:30]   → 找到 [62:15, 75:30, 88:41]
Worker 4: [1:30 - 2:00]   → 找到 [95:20, 110:05]

合并 + 排序 → [5:31, 12:20, 28:45, 35:10, 48:22, 62:15, 75:30, 88:41, 95:20, 110:05]

最多支持 16 个 worker 并行。

Step 3:过滤与提取

原始检测结果可能太密集(比如翻页动画会产生多个连续检测点):


原始: [5:31, 5:33, 5:35, 10:01, 10:02, 15:22, 20:45, 25:10, 25:11]
        ↑──太密了──↑            ↑密↑                       ↑密↑

第一层过滤——最小间隔

两个关键帧之间至少间隔 N 秒(由 --slides-min-duration 控制):


过滤后: [5:31, 10:01, 15:22, 20:45, 25:10]

第二层过滤——最大数量

最多保留 N 帧(由 --slides-max 控制,默认 6):


截断后: [5:31, 10:01, 15:22, 20:45, 25:10]  ← 5帧,不用截

最后——提取截图


# 在每个时间点截一帧高清图
ffmpeg -ss 5:31 -i video.mp4 -frames:v 1 -q:v 2 slide_1.jpg
ffmpeg -ss 10:01 -i video.mp4 -frames:v 1 -q:v 2 slide_2.jpg
...

可选步骤:对每张截图跑 tesseract OCR,识别出幻灯片上的文字。

🔍 核心算法的行业应用

Average Hash(感知哈希)

应用场景谁在用
以图搜图Google Images、TinEye
重复视频检测YouTube Content ID、抖音
图片去重iCloud 相册、Google Photos
版权保护各大内容平台

变种家族:

精度:pHash > dHash > aHash

速度:aHash > dHash > pHash

ffmpeg scene 滤镜

应用场景说明
视频缩略图生成Netflix/YouTube 鼠标悬停预览
自动分场景视频剪辑软件(DaVinci Resolve、Premiere)
监控异常检测安防摄像头"画面突变"报警
广告检测电视/流媒体中自动标记广告边界

自适应阈值

应用场景算法
图像二值化Otsu 算法(和本文思想完全一致)
音频静音检测采样噪底 → 找静音段
网络异常检测采样正常流量 → 找异常峰值
心电图分析采样基线 → 检测异常波形

核心思想都一样:先了解"正常"是什么样,再找"异常"。

📊 为什么不用 ML?

方案依赖速度适用场景
**本文方案**(信号处理)只需 ffmpeg极快视觉变化明显的场景(翻页、切镜头)
CNN 关键帧检测GPU + 模型需要理解"内容"变了(同一场景换了主题)
CLIP + 聚类GPU + 大模型很慢需要语义理解("这帧在讲 A,那帧在讲 B")
TransNetV2GPU + 专用模型中等电影级镜头边界检测

对于"检测幻灯片翻页"这个场景,信号处理方案是最佳选择——因为翻页的视觉变化足够大,不需要"理解内容",只需要"检测变化"。

用大炮打蚊子不是好工程。

💡 可以怎么复用

如果你要做类似的功能,核心代码不到 100 行:


# 伪代码版本
import subprocess

def detect_keyframes(video_path, threshold=0.3):
    """用 ffmpeg scene 滤镜检测关键帧"""
    cmd = [
        'ffmpeg', '-i', video_path,
        '-vf', f"select='gt(scene,{threshold})',showinfo",
        '-f', 'null', '-'
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    
    timestamps = []
    for line in result.stderr.split('\n'):
        if 'pts_time:' in line:
            time = float(line.split('pts_time:')[1].split(' ')[0])
            timestamps.append(time)
    
    return timestamps

def extract_frame(video_path, timestamp, output_path):
    """在指定时间点截图"""
    subprocess.run([
        'ffmpeg', '-ss', str(timestamp),
        '-i', video_path,
        '-frames:v', '1', '-q:v', '2',
        output_path
    ])

# 使用
timestamps = detect_keyframes("lecture.mp4", threshold=0.3)
for i, ts in enumerate(timestamps):
    extract_frame("lecture.mp4", ts, f"slide_{i+1}.jpg")

如果需要自适应阈值,加上前面的 Average Hash 采样校准即可。

🔗 延伸阅读

报告由深度研究助手自动生成 | 2026-03-08