我们在整理一个系列视频库(40 集),想给每集加上"跳过片头"的标记。
第一反应是算文件哈希——然后立刻意识到不对:每集的片头画面虽然一样,但整集内容完全不同,文件哈希必然不同。文件级去重解决不了这个问题。
真正需要的是"片段级重复检测":在两段不同的视频里,找出"哪一段时间的内容是一样的"。
我最后用帧级感知哈希 + 时间轴序列匹配做到了,准确率够用,还能顺带发现"某一集里混进了另一集的片段"这种内容问题。这篇写完整实现,包括加速方法和踩的坑。
TL;DR:做法是给每个视频生成一条"哈希时间轴"(每秒 1 帧的 dHash),然后在两条时间轴上做序列对齐匹配——找 A 的某个窗口和 B 的某个窗口哈希序列高度相似。关键点:帧哈希要归一化(统一尺寸、灰度、去黑边),匹配要容忍少量差异(汉明距离阈值 + 允许一定比例的不匹配帧),比对是 O(n²) 所以要加索引(用首帧哈希做倒排,或者先用音频指纹粗筛)。实测:40 集的片头检测准确率 100%,单集指纹生成约 40 秒,两两比对加索引后从 780 次比较降到 60 次。
目录
- 一、问题定义:文件级 vs 片段级
- 二、帧级感知哈希:dHash 怎么算
- 三、从帧哈希到视频指纹
- 四、序列匹配:找出重复的片段
- 五、加速:索引与粗筛
- 六、音频指纹:又准又快
- 七、片头片尾检测:多集共有片段
- 八、鲁棒性:缩放、水印、裁剪怎么办
- 九、完整实现与实测数据
- 十、坑清单
一、问题定义:文件级 vs 片段级
| 类型 | 问题 | 方法 |
|---|---|---|
| 文件级去重 | 这两个文件是不是完全一样? | MD5/SHA/xxhash(项目里那篇去重文章讲过) |
| 近重复检测 | 这两个文件是不是同一个内容的转码版本? | 感知哈希(pHash)+ 元数据比对 |
| 片段级重复 | A 视频的第 3~5 分钟,是不是和 B 视频的第 10~12 分钟一样? | 时间轴序列匹配(本文) |
片段级的典型场景:
- 片头片尾(系列剧每集都一样);
- 素材复用(同一段素材用在多个成片里);
- 重复内容混入(剪辑错误导致某段出现两次);
- 广告插播(同一条广告在不同视频里出现)。
二、帧级感知哈希:dHash 怎么算
感知哈希(perceptual hash)的核心思想:把图像压缩成一个短比特串,使得"看起来相似的图"哈希也相似(用汉明距离度量)。
dHash(差异哈希) 是最简单好用的一种:
- 转灰度;
- 缩放到 9×8(宽 9、高 8);
- 逐行比较相邻像素:如果左边 > 右边记 1,否则记 0;
- 得到 8×8 = 64 bit。
import cv2
import numpy as np
def dhash(frame, hash_size=8):
"""计算一帧的 dHash(64 bit)。"""
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if frame.ndim == 3 else frame
# 缩放到 (hash_size+1) x hash_size,用 INTER_AREA 减少噪点影响
small = cv2.resize(gray, (hash_size + 1, hash_size), interpolation=cv2.INTER_AREA)
# 相邻像素比较
diff = small[:, 1:] > small[:, :-1]
# 打包成 64 位整数
value = 0
for bit in diff.flatten():
value = (value << 1) | int(bit)
return value
def hamming(a: int, b: int) -> int:
"""汉明距离(不同位的个数)。"""
return bin(a ^ b).count('1')
为什么 dHash 而不是 pHash(DCT 哈希)?
| 算法 | 原理 | 对什么鲁棒 | 速度 |
|---|---|---|---|
| aHash | 均值比较 | 亮度变化 | 最快 |
| dHash | 相邻像素梯度 | 亮度、轻微缩放 | 快 |
| pHash | DCT 低频 | 缩放、压缩、轻微旋转 | 稍慢 |
对视频帧来说,相邻帧本来就高度相似,dHash 的梯度特性让"不同画面"的区分度更好。而且它快(每帧不到 1ms)。
相似度阈值:64 bit 的 dHash,经验值:
| 汉明距离 | 判断 |
|---|---|
| 0~5 | 几乎相同(同一帧的不同压缩) |
| 6~10 | 相似(同一场景的相邻帧、轻微变化) |
| 11~20 | 可能相似(画面变化较大) |
| > 20 | 不同 |
注意:不同的缩放算法、不同的 hash_size 会让阈值不一样,要在自己的数据上标定。
三、从帧哈希到视频指纹
单个帧的哈希没意义,有意义的是"哈希随时间的序列":
视频 A 的指纹:
t=0s → 0x3c8a...
t=1s → 0x3c8a...
t=2s → 0x91f2... ← 场景切换
...
生成指纹(每秒 1 帧):
import subprocess
import numpy as np
def extract_fingerprint(path: str, fps_sample: float = 1.0, width: int = 160):
"""抽取视频指纹:返回 (时间戳列表, dHash 列表)。"""
cmd = [
'ffmpeg', '-i', path,
'-vf', f'fps={fps_sample},scale={width}:-2',
'-f', 'rawvideo', '-pix_fmt', 'bgr24', '-an', '-',
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL)
# 先读一帧确定尺寸
hashes, stamps = [], []
idx = 0
# 逐块读(宽度可能因为 -2 调整,先探测)
buf = b''
H = None
while True:
chunk = proc.stdout.read(1 << 20)
if not chunk:
break
buf += chunk
if H is None:
# 从 ffmpeg 的 stderr 拿尺寸比较麻烦,这里用另一种方式:先 probe
pass
...
上面这段代码有个麻烦:需要先知道缩放后的高度(-2 是自动的)。更稳的做法是先 ffprobe 拿原始宽高,算出目标高度:
import json
import subprocess
def probe_size(path):
out = subprocess.run(
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=width,height', '-of', 'json', path],
capture_output=True, text=True, check=True).stdout
s = json.loads(out)['streams'][0]
return s['width'], s['height']
def extract_fingerprint(path, sample_fps=1.0, width=160):
w0, h0 = probe_size(path)
h = int(round(width * h0 / w0))
h -= h % 2 # 保证偶数
frame_bytes = width * h * 3
cmd = ['ffmpeg', '-v', 'error', '-i', path,
'-vf', f'fps={sample_fps},scale={width}:{h}',
'-f', 'rawvideo', '-pix_fmt', 'bgr24', '-an', '-']
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
hashes, stamps = [], []
idx = 0
while True:
raw = proc.stdout.read(frame_bytes)
if len(raw) != frame_bytes:
break
frame = np.frombuffer(raw, np.uint8).reshape(h, width, 3)
hashes.append(dhash(frame))
stamps.append(idx / sample_fps)
idx += 1
proc.stdout.close()
proc.wait()
return np.array(stamps), np.array(hashes, dtype=np.uint64)
采样率的选择:
| 采样率 | 精度 | 数据量(1 小时视频) |
|---|---|---|
| 1 fps | ±1 秒 | 3600 个哈希 = 29 KB |
| 2 fps | ±0.5 秒 | 7200 = 58 KB |
| 0.5 fps | ±2 秒 | 1800 = 14 KB |
1 fps 对片头检测够用(片头 90 秒,±1 秒的误差可接受),而且数据量小。
指纹的存储:64 bit × 3600 帧 = 29 KB/小时视频。40 集(每集 45 分钟)= 约 870 KB。完全可以放内存里比对,这也是这个方案实用的原因。
四、序列匹配:找出重复的片段
现在有两条哈希序列 A 和 B,要找"哪一段是一样的"。
朴素做法:对 A 的每个起点 i,和 B 的每个起点 j,比较长度 L 的窗口:
def match_windows(a: np.ndarray, b: np.ndarray, window: int = 30,
max_dist: int = 8, max_bad_ratio: float = 0.2):
"""找出 A 和 B 之间重复长度 >= window 的片段。
返回 [(a_idx, b_idx, length)]。"""
na, nb = len(a), len(b)
matches = []
for i in range(na - window):
for j in range(nb - window):
dists = np.array([hamming(a[i + k], b[j + k]) for k in range(window)])
bad = (dists > max_dist).mean()
if bad <= max_bad_ratio:
matches.append((i, j, window))
return matches
这个双重循环是 O(n_a × n_b × L)——对两个 3600 帧的序列,是 3600 × 3600 × 30 ≈ 4 亿次汉明距离计算。太慢了,必须优化。
优化 1:向量化汉明距离
不要逐对调 hamming(),用 numpy 的位运算批量算:
def hamming_matrix(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""计算 a 和 b 所有两两组合的汉明距离矩阵(a: N, b: M)。"""
# broadcast XOR,然后数每个数的 1 的个数
xor = a[:, None] ^ b[None, :]
# 用查表法数 bit(比 bin().count() 快得多)
return popcount64(xor)
_POP16 = np.array([bin(i).count('1') for i in range(1 << 16)], dtype=np.uint8)
def popcount64(x: np.ndarray) -> np.ndarray:
"""64 位整数的 popcount(分 4 段 16 位查表)。"""
out = np.zeros(x.shape, dtype=np.uint8)
for shift in range(0, 64, 16):
out += _POP16[(x >> shift) & 0xFFFF]
return out
优化 2:先做候选筛选
不要对每个 (i, j) 都算整窗口。先用第一帧做筛选:
def find_candidates(a, b, max_dist=8):
"""找出所有 a[i] ≈ b[j] 的位置对(首帧匹配)。"""
d = hamming_matrix(a, b)
ii, jj = np.where(d <= max_dist)
return list(zip(ii.tolist(), jj.tolist()))
对两个 3600 的序列,这一步是 1300 万次比较(numpy 几秒完成),筛出几千个候选。
优化 3:对候选做对角线扩展
重复片段在 (i, j) 平面上表现为对角线(i 增加多少,j 就增加多少)。所以:
def extend_diagonal(a, b, i, j, max_dist=8, max_bad_ratio=0.2, min_len=15):
"""从 (i,j) 开始沿对角线延伸,返回匹配长度。"""
length = 0
bad = 0
while i + length < len(a) and j + length < len(b):
d = hamming(a[i + length], b[j + length])
if d > max_dist:
bad += 1
if bad / (length + 1) > max_bad_ratio:
break
length += 1
return length if length >= min_len else 0
def find_repeats(a, b, max_dist=8, min_len=15):
"""找所有重复片段,返回 [(a_start, b_start, length)]。"""
results = []
used_i = set()
for i, j in find_candidates(a, b, max_dist):
if i in used_i:
continue
L = extend_diagonal(a, b, i, j, max_dist=max_dist, min_len=min_len)
if L:
results.append((i, j, L))
used_i.update(range(i, i + L)) # 避免重复报告
return results
这个"对角线扩展"的思路是关键:它把 O(n²×L) 变成了 O(候选数 × 平均长度),快了两个数量级。
五、加速:索引与粗筛
如果要比对 40 集(两两组合 = 780 对),即使每对要 5 秒,也是 65 分钟。优化思路:
1. 只比对需要比对的
- 片头检测:只需要比对"前 3 分钟"和"后 2 分钟",不用比全片。这一条直接把每段比对的数据量降了 10 倍。
- 系列归属:只在同一个系列内比对(元数据里有季/集信息)。
2. 用倒排索引
对所有视频的哈希建立"哈希值 → (视频ID, 位置)"的倒排。查询时只比对共享哈希的视频对:
from collections import defaultdict
def build_index(fingerprints: dict):
"""fingerprints: {video_id: np.array(hashes)}"""
index = defaultdict(list)
for vid, hashes in fingerprints.items():
for pos, h in enumerate(hashes):
index[h].append((vid, pos))
return index
def candidate_pairs(index, fingerprints, min_shared=5):
"""找出至少共享 min_shared 个哈希的视频对。"""
counter = defaultdict(int)
for h, entries in index.items():
vids = {vid for vid, _ in entries}
if len(vids) < 2:
continue
vids = sorted(vids)
for i in range(len(vids)):
for j in range(i + 1, len(vids)):
counter[(vids[i], vids[j])] += 1
return {pair: c for pair, c in counter.items() if c >= min_shared}
完全一样的帧哈希才会进同一个桶——这对"片头完全相同"的场景有效(同一段片头在不同集里是字节级相同的画面,只是编码不同,dHash 通常完全一致)。
实测:40 集的全比对从 780 对降到 62 对需要处理——省了 92%。
3. 分层比对
先粗(每 5 秒 1 帧)找候选,再细(每 1 秒 1 帧)确认。类似前面的"粗检测 + 精定位"。
六、音频指纹:又准又快
做到一半我发现一件事:检测片头曲,用音频指纹更合适。
理由:
- 片头曲的音频每集完全一样(画面可能有压制差异,音频也一样但更容易对齐);
- 音频指纹技术成熟(Shazam 就是干这个的);
- 数据量更小(一首 90 秒的曲子,指纹只有几 KB)。
chromaprint / fpcalc( acoustid 的开源方案):
# 安装(Debian/Ubuntu)
apt-get install libchromaprint-tools
# 生成指纹(输出 JSON,含指纹字符串和时长)
fpcalc -json -length 120 input.mp4 > fp.json
输出:
{
"duration": 120,
"fingerprint": "AQAAcwmSRImSRYmSRYmSRYmSRImSRYmSRYmSRImSRYmSRYmSRYmSRIm..."
}
指纹是一串 base64,每个 32 位整数代表一小段音频的特征(默认 ~0.37 秒一段)。
比对:找 A 的指纹序列在 B 里的对齐位置。本质和视频哈希序列是一样的"序列对齐"问题,但音频指纹是精确匹配(相同的音频 → 相同的指纹位),比对更快更准。
import base64
import struct
def decode_chromaprint(fp_b64: str):
"""把 chromaprint 的 base64 指纹解成整数列表。"""
raw = base64.b64decode(fp_b64)
n = len(raw) // 4
return list(struct.unpack(f'<{n}I', raw[:n * 4]))
def find_audio_match(fp_a: list, fp_b: list, min_len: int = 20):
"""找 A 的指纹在 B 中的最长精确匹配。"""
set_b = {}
for j, v in enumerate(fp_b):
set_b.setdefault(v, []).append(j)
best = None
for i, v in enumerate(fp_a):
for j in set_b.get(v, []):
# 沿对角线延伸
k = 0
while (i + k < len(fp_a) and j + k < len(fp_b)
and fp_a[i + k] == fp_b[j + k]):
k += 1
if k >= min_len and (best is None or k > best[2]):
best = (i, j, k)
return best
找到匹配后换算成时间:chromaprint 每段约 0.37 秒,所以 i × 0.37 就是秒数(具体值取决于版本,要用 duration / len(fingerprint) 算)。
我的最终方案:
1. 优先用音频指纹(快、准)→ 定位片头曲
2. 音频指纹失败(静音片头、无音频)→ 回退到视频哈希
3. 两者都有结果时交叉验证(时间戳应该接近)
对那 40 集,音频指纹 100% 命中,视频哈希 38/40 命中(有 2 集的片头画面被加了不同的字幕/logo)。
七、片头片尾检测:多集共有片段
单个视频对之间的重复,不一定是片头(可能是素材复用)。片头的特征是"多集都有,且位置在开头附近"。
def detect_intro(results, total_episodes, min_episodes=3, max_start=180):
"""从所有比对结果里找出片头。
results: [(vid_a, a_start, vid_b, b_start, length)]
"""
# 统计"某个内容片段出现在多少集的开头附近"
clusters = defaultdict(list)
for vid_a, a_start, vid_b, b_start, length in results:
if a_start <= max_start:
clusters[vid_a].append((a_start, length))
if b_start <= max_start:
clusters[vid_b].append((b_start, length))
intro = {}
for vid, spans in clusters.items():
if len(spans) + 1 < min_episodes:
continue
# 取出现次数最多的起点/长度(多数投票)
starts = [s for s, _ in spans]
lengths = [l for _, l in spans]
intro[vid] = (median(starts), median(lengths))
return intro
"至少 3 集共有的开头片段"才认定是片头——这一条过滤掉了偶发的素材复用。
片尾同理,只是位置改成"结尾附近"(duration - end < 180)。
八、鲁棒性:缩放、水印、裁剪怎么办
现实中的内容不会完全一样,常见的变化:
| 变化 | dHash 是否鲁棒 | 处理 |
|---|---|---|
| 重新编码(压缩) | ✅ 鲁棒 | 无 |
| 分辨率变化(1080p → 720p) | ✅ 鲁棒 | 缩放归一化(我们生成指纹时统一缩到 160px) |
| 亮度/对比度调整 | ✅ 鲁棒(dHash 是梯度) | 无 |
| 加水印/logo | ⚠️ 部分 | 水印只占小部分像素,汉明距离会升高但通常还在阈值内 |
| 加黑边(4:3 → 16:9) | ❌ 不鲁棒 | 必须先裁掉黑边(用 ffmpeg 的 cropdetect) |
| 裁剪/画面位移 | ❌ 不鲁棒 | 同上,或者用更鲁棒的特征 |
| 水平翻转 | ❌ | 极少见,可忽略 |
| 变速 | ❌ | 时间戳非线性,序列对不上 |
黑边处理很重要(老片源常见):
# 检测黑边
ffmpeg -i input.mp4 -vf cropdetect=24:16:0 -t 60 -f null - 2>&1 | grep -o 'crop=[0-9:]*' | tail -1
# crop=1920:1080:0:0
# 生成指纹时先裁
ffmpeg -i input.mp4 -vf "crop=1920:1080:0:0,fps=1,scale=160:-2" ...
水印的处理:如果水印固定在角落,可以在算 dHash 前裁掉角落区域(比如只用画面中央 80% 的区域)。这是个小技巧,效果立竿见影。
九、完整实现与实测数据
把上面的组合起来:
class FingerprintIndex:
def __init__(self):
self.fps = {} # video_id -> (stamps, hashes)
def add(self, video_id, path):
stamps, hashes = extract_fingerprint(path, sample_fps=1.0)
self.fps[video_id] = (stamps, hashes)
def find_repeats_between(self, a_id, b_id, **kw):
(sa, ha), (sb, hb) = self.fps[a_id], self.fps[b_id]
return find_repeats(ha, hb, **kw)
def detect_intro_outro(self, min_episodes=3):
# 遍历候选对 → 收集结果 → 聚类
...
实测(40 集系列,每集 45 分钟):
| 指标 | 数值 |
|---|---|
| 单集指纹生成耗时 | 42 秒(1 fps 采样) |
| 40 集全量指纹 | 28 分钟(8 进程并行后 4 分钟) |
| 指纹总大小 | 约 900 KB |
| 候选对(索引后) | 62 对(原本 780) |
| 比对耗时 | 3 分 20 秒 |
| 片头检出 | 40/40(音频指纹)/ 38/40(视频哈希) |
| 片头时间点误差 | ±1 秒(1 fps 采样) |
| 顺带发现的异常 | 3 处(2 处素材复用、1 处重复片段) |
那 1 处重复片段是剪辑事故:某一集里第 12 分钟的一段和第 28 分钟的一段重复了 40 秒。这个是我们人工看了好几遍都没发现的——自动检测的价值就在这里。
十、坑清单
- 用文件哈希做片段去重 → 完全无效。需要时间轴序列。
- 用原始分辨率算哈希 → 分辨率不同的版本对不上。统一缩放。
- 没处理黑边 → 4:3 加边后的版本完全对不上。先
cropdetect。 - 水印/字幕干扰 → 汉明距离升高。裁掉角落区域,或者放宽阈值。
- 逐对算汉明距离(Python 循环) → 慢到不能用。numpy 向量化 + 查表 popcount。
- 全量 O(n²) 比对 → 集数一多就爆炸。倒排索引 + 候选筛选。
- 比对全片 → 没必要。片头只看前 3 分钟。
- 采样率太高 → 数据量暴涨但精度提升有限。1 fps 够用。
- 采样率太低 → 短片段(< 10 秒)漏检。按最短目标片段长度定。
- 阈值不标定 → 不同内容差异大。用已知重复的一对标定阈值。
- 只比对画面 → 静音片头、音频不同的版本漏检。加音频指纹。
- chromaprint 段长换算错 → 用
duration / len(fingerprint)算,别假设固定值。 - 把"素材复用"当片头 → 用"至少 N 集共有 + 位置约束"过滤。
- 内存装不下指纹 → 大规模时用 numpy 存文件或数据库。但 64bit × 3600 只有 29KB/小时,一般没问题。
- 没有人工抽查 → 自动结果要有抽样验证。我们抽了 5 集人工核对,全部正确才敢写入。
最后说说这个功能的实际价值。
片头检测听起来是个"锦上添花"的功能(播放器上多一个"跳过片头"按钮),但做完之后我发现它带来的附加价值更大:
- 内容质量检查:发现了剪辑重复、素材错位这些人工不易发现的问题;
- 去重统计:知道库里到底有多少内容是重复的(这个项目里是 4.7%);
- 章节自动生成的基础:指纹匹配能对齐"不同版本的同一内容",为后续的元数据复用打基础。
还有一点方法论上的体会:当"直接比对"太慢时,先想想能不能用"签名 + 索引"把比较量降下来。这个思路到处都能用:
- 文件去重:先比大小,再比部分哈希,最后全文件哈希;
- 数据库查询:先用索引缩小范围,再精确匹配;
- 视频指纹:先用共享哈希找候选,再做序列对齐。
先粗筛后精算,几乎是所有大规模比对问题的通用解法。我们这次把 780 对降到 62 对,用的就是这个最朴素的思路——没有用什么高级算法,只是"先挑出有可能的,再仔细看"。