视频关键帧检测算法:从信号处理到工程实践
> 来源: 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:
- 在幻灯片视频里 ✅ 完美
- 在 vlog 里 ❌ 检测出几百个"关键帧"
- 在录屏里 ❌ 可能漏掉一些小变化的翻页
所以需要先"看看"这个视频长什么样。
怎么看?——采样
从视频中均匀取 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
- diff ≈ 0 → 两帧几乎一样(没翻页)
- diff ≈ 0.05 → 轻微变化(演讲者动了动)
- diff ≈ 0.3 → 画面大变(翻页了!)
用采样结果算最佳阈值
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]
统计:
- 中位数 = 0.03("正常变化"大概是这个水平)
- P75 = 0.04(75% 的变化小于这个值)
- P90 = 0.25(只有 10% 的变化超过这个值)
阈值计算公式:
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 |
| 版权保护 | 各大内容平台 |
变种家族:
- aHash(Average Hash):本文用的,最简单最快
- pHash(Perceptual Hash):用 DCT 变换,精度更高
- dHash(Difference Hash):比较相邻像素差,抗缩放好
精度:pHash > dHash > aHash
速度:aHash > dHash > pHash
ffmpeg scene 滤镜
| 应用场景 | 说明 |
|---|---|
| 视频缩略图生成 | Netflix/YouTube 鼠标悬停预览 |
| 自动分场景 | 视频剪辑软件(DaVinci Resolve、Premiere) |
| 监控异常检测 | 安防摄像头"画面突变"报警 |
| 广告检测 | 电视/流媒体中自动标记广告边界 |
自适应阈值
| 应用场景 | 算法 |
|---|---|
| 图像二值化 | Otsu 算法(和本文思想完全一致) |
| 音频静音检测 | 采样噪底 → 找静音段 |
| 网络异常检测 | 采样正常流量 → 找异常峰值 |
| 心电图分析 | 采样基线 → 检测异常波形 |
核心思想都一样:先了解"正常"是什么样,再找"异常"。
📊 为什么不用 ML?
| 方案 | 依赖 | 速度 | 适用场景 |
|---|---|---|---|
| **本文方案**(信号处理) | 只需 ffmpeg | 极快 | 视觉变化明显的场景(翻页、切镜头) |
| CNN 关键帧检测 | GPU + 模型 | 慢 | 需要理解"内容"变了(同一场景换了主题) |
| CLIP + 聚类 | GPU + 大模型 | 很慢 | 需要语义理解("这帧在讲 A,那帧在讲 B") |
| TransNetV2 | GPU + 专用模型 | 中等 | 电影级镜头边界检测 |
对于"检测幻灯片翻页"这个场景,信号处理方案是最佳选择——因为翻页的视觉变化足够大,不需要"理解内容",只需要"检测变化"。
用大炮打蚊子不是好工程。
💡 可以怎么复用
如果你要做类似的功能,核心代码不到 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