提示

返回博客列表

自动跳过片头曲:视频指纹与片段级重复检测的实战

我们在整理一个系列视频库(40 集),想给每集加上"跳过片头"的标记。

第一反应是算文件哈希——然后立刻意识到不对:每集的片头画面虽然一样,但整集内容完全不同,文件哈希必然不同。文件级去重解决不了这个问题。

真正需要的是"片段级重复检测":在两段不同的视频里,找出"哪一段时间的内容是一样的"。

我最后用帧级感知哈希 + 时间轴序列匹配做到了,准确率够用,还能顺带发现"某一集里混进了另一集的片段"这种内容问题。这篇写完整实现,包括加速方法和踩的坑。

TL;DR:做法是给每个视频生成一条"哈希时间轴"(每秒 1 帧的 dHash),然后在两条时间轴上做序列对齐匹配——找 A 的某个窗口和 B 的某个窗口哈希序列高度相似。关键点:帧哈希要归一化(统一尺寸、灰度、去黑边),匹配要容忍少量差异(汉明距离阈值 + 允许一定比例的不匹配帧),比对是 O(n²) 所以要加索引(用首帧哈希做倒排,或者先用音频指纹粗筛)。实测:40 集的片头检测准确率 100%,单集指纹生成约 40 秒,两两比对加索引后从 780 次比较降到 60 次。

目录

一、问题定义:文件级 vs 片段级

类型 问题 方法
文件级去重 这两个文件是不是完全一样? MD5/SHA/xxhash(项目里那篇去重文章讲过)
近重复检测 这两个文件是不是同一个内容的转码版本? 感知哈希(pHash)+ 元数据比对
片段级重复 A 视频的第 3~5 分钟,是不是和 B 视频的第 10~12 分钟一样? 时间轴序列匹配(本文)

片段级的典型场景:

  • 片头片尾(系列剧每集都一样);
  • 素材复用(同一段素材用在多个成片里);
  • 重复内容混入(剪辑错误导致某段出现两次);
  • 广告插播(同一条广告在不同视频里出现)。

二、帧级感知哈希:dHash 怎么算

感知哈希(perceptual hash)的核心思想:把图像压缩成一个短比特串,使得"看起来相似的图"哈希也相似(用汉明距离度量)。

dHash(差异哈希) 是最简单好用的一种:

  1. 转灰度;
  2. 缩放到 9×8(宽 9、高 8);
  3. 逐行比较相邻像素:如果左边 > 右边记 1,否则记 0;
  4. 得到 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 秒。这个是我们人工看了好几遍都没发现的——自动检测的价值就在这里。

十、坑清单

  1. 用文件哈希做片段去重 → 完全无效。需要时间轴序列。
  2. 用原始分辨率算哈希 → 分辨率不同的版本对不上。统一缩放。
  3. 没处理黑边 → 4:3 加边后的版本完全对不上。先 cropdetect。
  4. 水印/字幕干扰 → 汉明距离升高。裁掉角落区域,或者放宽阈值。
  5. 逐对算汉明距离(Python 循环) → 慢到不能用。numpy 向量化 + 查表 popcount。
  6. 全量 O(n²) 比对 → 集数一多就爆炸。倒排索引 + 候选筛选。
  7. 比对全片 → 没必要。片头只看前 3 分钟。
  8. 采样率太高 → 数据量暴涨但精度提升有限。1 fps 够用。
  9. 采样率太低 → 短片段(< 10 秒)漏检。按最短目标片段长度定。
  10. 阈值不标定 → 不同内容差异大。用已知重复的一对标定阈值。
  11. 只比对画面 → 静音片头、音频不同的版本漏检。加音频指纹。
  12. chromaprint 段长换算错 → 用 duration / len(fingerprint) 算,别假设固定值。
  13. 把"素材复用"当片头 → 用"至少 N 集共有 + 位置约束"过滤。
  14. 内存装不下指纹 → 大规模时用 numpy 存文件或数据库。但 64bit × 3600 只有 29KB/小时,一般没问题。
  15. 没有人工抽查 → 自动结果要有抽样验证。我们抽了 5 集人工核对,全部正确才敢写入。

最后说说这个功能的实际价值。

片头检测听起来是个"锦上添花"的功能(播放器上多一个"跳过片头"按钮),但做完之后我发现它带来的附加价值更大:

  1. 内容质量检查:发现了剪辑重复、素材错位这些人工不易发现的问题;
  2. 去重统计:知道库里到底有多少内容是重复的(这个项目里是 4.7%);
  3. 章节自动生成的基础:指纹匹配能对齐"不同版本的同一内容",为后续的元数据复用打基础。

还有一点方法论上的体会:当"直接比对"太慢时,先想想能不能用"签名 + 索引"把比较量降下来。这个思路到处都能用:

  • 文件去重:先比大小,再比部分哈希,最后全文件哈希;
  • 数据库查询:先用索引缩小范围,再精确匹配;
  • 视频指纹:先用共享哈希找候选,再做序列对齐。

先粗筛后精算,几乎是所有大规模比对问题的通用解法。我们这次把 780 对降到 62 对,用的就是这个最朴素的思路——没有用什么高级算法,只是"先挑出有可能的,再仔细看"。

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

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

顶部