提示

返回博客列表

自动给视频挑一张能看的封面:帧评分、构图与批量生成

我们有批视频要生成封面图(列表页展示用)。第一版实现很朴实:抽第 10 秒那一帧。

结果惨不忍睹:

  • 有 6 个视频的封面是黑屏(片头淡入还没结束);
  • 有 4 个是过场画面(镜头正在快速摇动,糊成一片);
  • 有 3 个正好抓到人闭眼的瞬间;
  • 还有几个是纯色背景(PPT 的过渡页)。

100 个视频里大概 20 个不能用,得人工重新挑。这活儿枯燥又费时间,于是我写了个自动评分脚本:先抽出一批候选帧,再按多个维度打分,选最高分的。改完之后人工干预率从 20% 降到 13%,而且剩下那 13% 是"审美偏好"问题(比如想要特定构图),不是"不能用"。

这篇写完整的做法:五个评分维度、权重怎么调、怎么批量跑,以及哪些情况必须人工兜底。

TL;DR:核心是多维度打分:清晰度(拉普拉斯方差)、曝光(亮度分布)、色彩丰富度(直方图熵)、人脸(有无/大小/是否睁眼)、构图(三分法/居中)。先均匀抽 30~60 个候选帧(跳过片头片尾),打分排序,取最高分。阈值兜底:最高分低于阈值就标记"人工挑选"——这一条很重要,它把"自动出错"变成"自动承认不确定"。性能上用关键帧跳帧 + 降采样抽帧,单个视频 5~10 秒出结果。

目录

一、为什么随机抽帧不行

视频里"不能用作封面"的帧远比想象的多:

问题帧 占比(实测) 特征
黑场/淡入 8% 亮度极低或极高,方差小
纯色/低信息 6% 直方图集中(PPT 过渡页、纯色背景)
运动模糊 11% 拉普拉斯方差低
过场(摇镜头) 7% 同上,模糊 + 构图乱
闭眼/表情怪 5% 需要人脸检测
构图差(主体在边缘) 9% 需要构图评估

加起来接近一半的帧有问题——所以"随便抽一帧"有一半概率翻车,跟我的实测(20% 不可用)量级吻合(有些问题帧勉强能用)。

二、候选帧怎么抽

不要从头到尾解码每一帧(太慢)。三步走:

1. 跳过片头片尾

SKIP_HEAD_RATIO = 0.03      # 跳过开头 3%
SKIP_TAIL_RATIO = 0.05      # 跳过结尾 5%

片头通常是 logo/黑场,片尾是字幕滚动——都不是好封面。

2. 均匀采样

在剩下的区间里均匀抽 30~60 帧:

# 每 N 秒抽一帧(比如 20 秒),只解关键帧加速
ffmpeg -i input.mp4 \
  -vf "select='eq(pict_type\,I)',scale=480:-2" \
  -vsync vfr -frames:v 60 \
  cand_%03d.jpg

select='eq(pict_type,I)' 只解关键帧——对 4 秒 GOP 的视频,解码量减少 90% 以上。

注意:关键帧的画质未必最好(I 帧是帧内编码,压缩率最低,画质通常比 B/P 帧好一点点,但也可能是场景切换的第一帧)。实测下来用关键帧做候选完全够。

3. 更精确的方式:指定时间点抽帧

如果想精确控制采样点(比如每 20 秒一帧):

import subprocess


def sample_frames(path, count=40, width=480, skip_head=0.03, skip_tail=0.05):
    """均匀采样候选帧,返回 numpy 图像列表和时间戳列表。"""
    duration = float(subprocess.run(
        ['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
         '-of', 'csv=p=0', path], capture_output=True, text=True, check=True).stdout)

    start = duration * skip_head
    end = duration * (1 - skip_tail)
    stamps = [start + (end - start) * i / (count - 1) for i in range(count)]

    frames = []
    for t in stamps:
        cmd = ['ffmpeg', '-v', 'error', '-ss', f'{t:.3f}', '-i', path,
               '-frames:v', '1', '-vf', f'scale={width}:-2',
               '-f', 'rawvideo', '-pix_fmt', 'bgr24', '-']
        # 需要知道输出高度,先用 probe 算
        ...
    return frames, stamps

性能提示:-ss 放在 -i 之前(快速 seek)。40 帧 × 每次 seek ≈ 几秒。

三、五个评分维度

1. 清晰度(最重要)

拉普拉斯方差是经典的清晰度度量:清晰图像的高频多,拉普拉斯算子的响应方差大。

import cv2
import numpy as np


def score_sharpness(img) -> float:
    """拉普拉斯方差,越大越清晰。"""
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    lap = cv2.Laplacian(gray, cv2.CV_64F)
    return float(lap.var())

参考值(480px 宽的图):

场景 方差
严重模糊 < 50
一般 100~300
清晰 300~800
非常锐利 / 高噪声 > 1000

归一化:用一个 S 形函数映射到 0~1:

def norm_sharpness(v, lo=50, hi=600):
    x = (v - lo) / (hi - lo)
    return float(np.clip(x, 0, 1))

2. 曝光

太黑或太亮的帧都不能用。

def score_exposure(img) -> float:
    """亮度分布评分:既不欠曝也不过曝,且动态范围合理。"""
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    mean = float(gray.mean())
    # 理想均值在 110~170 之间
    if mean < 60 or mean > 220:
        return 0.0
    score_mean = 1.0 - abs(mean - 140) / 80.0
    # 过曝/欠曝像素比例(惩罚纯黑纯白)
    over = float((gray > 250).mean())
    under = float((gray < 5).mean())
    penalty = min(1.0, (over + under) * 5)
    return float(np.clip(score_mean - penalty, 0, 1))

3. 色彩丰富度(排除纯色/黑屏)

用灰度直方图的熵:熵低说明像素值集中在少数几个档(纯色画面),熵高说明层次丰富。

def score_colorfulness(img) -> float:
    """色彩丰富度:HSV 的饱和度 + 直方图熵。"""
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    sat = float(hsv[:, :, 1].mean()) / 255.0

    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    hist = cv2.calcHist([gray], [0], None, [32], [0, 256]).flatten()
    p = hist / hist.sum()
    p = p[p > 0]
    entropy = float(-(p * np.log2(p)).sum()) / 5.0       # 32 档的最大熵是 5

    return float(np.clip(0.5 * min(sat * 2, 1.0) + 0.5 * entropy, 0, 1))

纯黑画面:熵接近 0,饱和度 0 → 得分接近 0。完美排除。

4. 人脸(有人脸的封面点击率更高)

def load_face_detector():
    path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
    return cv2.CascadeClassifier(path)


def score_face(img, detector) -> tuple:
    """人脸评分 + 人脸位置(用于构图评分)。返回 (score, face_box)。"""
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    faces = detector.detectMultiScale(gray, scaleFactor=1.1,
                                      minNeighbors=5, minSize=(30, 30))
    if len(faces) == 0:
        return 0.0, None
    h, w = img.shape[:2]
    # 取最大的脸
    x, y, fw, fh = max(faces, key=lambda f: f[2] * f[3])
    area_ratio = (fw * fh) / (w * h)
    # 人脸占画面 3%~25% 比较合适
    if area_ratio < 0.01:
        return 0.2, (x, y, fw, fh)        # 太小,勉强
    if area_ratio > 0.4:
        return 0.3, (x, y, fw, fh)        # 太大(怼脸),一般不好看
    score = 1.0 - abs(area_ratio - 0.12) / 0.12
    return float(np.clip(score, 0, 1)), (x, y, fw, fh)

闭眼问题:用眼睛检测(Haar 有 haarcascade_eye.xml)——如果在人脸区域内检测不到眼睛,很可能是闭眼:

def has_open_eyes(img, face_box, eye_detector):
    x, y, fw, fh = face_box
    roi = cv2.cvtColor(img[y:y + fh, x:x + fw], cv2.COLOR_BGR2GRAY)
    eyes = eye_detector.detectMultiScale(roi, scaleFactor=1.1, minNeighbors=4)
    return len(eyes) >= 1

注意:Haar 级联的准确率有限(侧脸、暗光、戴眼镜都会漏检)。它的价值在于"检测到了就能加分",而不是"没检测到就扣分"。所以我的权重给得不高(见下节)。

5. 构图

用三分法(rule of thirds):画面横竖各三等分,四个交点附近是视觉焦点。如果主体(人脸或者显著区域)在交点附近,构图分高。

def score_composition(img, face_box) -> float:
    """三分法构图评分。"""
    h, w = img.shape[:2]
    if face_box is not None:
        x, y, fw, fh = face_box
        cx, cy = x + fw / 2, y + fh / 2
    else:
        # 没有人脸时,用显著区域(简单的中心加权)
        cx, cy = w / 2, h / 2

    # 四个三分点
    points = [(w / 3, h / 3), (2 * w / 3, h / 3),
              (w / 3, 2 * h / 3), (2 * w / 3, 2 * h / 3)]
    # 到最近三分点的距离(归一化)
    d = min(np.hypot(cx - px, cy - py) for px, py in points)
    max_d = np.hypot(w / 3, h / 3)
    return float(np.clip(1 - d / max_d, 0, 1))

这个评分比较粗糙(真正的构图评估需要显著性检测 + 美学模型),但它能过滤掉"主体贴边"这种明显的问题。

四、综合打分与权重

WEIGHTS = {
    'sharpness': 0.35,       # 最重要:糊的完全不能用
    'exposure': 0.20,
    'colorfulness': 0.15,
    'face': 0.20,            # 有人脸加分,但权重不要太高(很多视频本来就没脸)
    'composition': 0.10,
}


def score_frame(img, detector, eye_detector=None) -> dict:
    parts = {}
    parts['sharpness'] = norm_sharpness(score_sharpness(img))
    parts['exposure'] = score_exposure(img)
    parts['colorfulness'] = score_colorfulness(img)

    face_score, box = score_face(img, detector)
    if box is not None and eye_detector is not None and not has_open_eyes(img, box, eye_detector):
        face_score *= 0.4            # 闭眼:大幅扣分(但不归零)
    parts['face'] = face_score
    parts['composition'] = score_composition(img, box)

    total = sum(parts[k] * WEIGHTS[k] for k in WEIGHTS)
    parts['total'] = total
    return parts

权重的调参经验:

  1. 清晰度和曝光是"一票否决"级别的——糊了或者全黑,其他再好也没用。所以它们的权重最高,而且我在总分之外加了"硬性下限"(清晰度分 < 0.2 直接淘汰);
  2. 人脸权重不能太高——风景、动画、屏录这些本来就没有人脸,给太高会让这些视频选不出好帧;
  3. 构图权重最低——因为我的构图评估很粗糙,给高了会引入噪声。

硬性过滤(在打分之前先淘汰明显不行的):

def hard_filter(img) -> bool:
    """硬性淘汰:返回 True 表示这一帧直接不要。"""
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    if gray.mean() < 25 or gray.mean() > 235:
        return False                       # 全黑/全白
    if gray.std() < 12:
        return False                       # 几乎纯色
    if norm_sharpness(score_sharpness(img)) < 0.15:
        return False                       # 严重模糊
    return True

五、生成最终的封面图

选好帧之后,还要处理成"封面尺寸":

1. 裁切到目标比例

列表页常见比例:16:9(横向)、2:3 或 3:4(海报式竖版)。

def crop_to_ratio(img, ratio=(16, 9)):
    """居中裁切到目标比例。有人脸时按人脸位置调整裁切中心。"""
    h, w = img.shape[:2]
    target = ratio[0] / ratio[1]
    if w / h > target:
        # 太宽,裁两边
        new_w = int(h * target)
        x = (w - new_w) // 2
        return img[:, x:x + new_w]
    else:
        # 太高,裁上下
        new_h = int(w / target)
        y = (h - new_h) // 3          # 偏上一点(人脸通常在上半部)
        return img[y:y + new_h, :]

2. 加标题文字(可选)

ffmpeg -i frame.jpg -vf \
  "scale=1280:720,drawtext=fontfile=/usr/share/fonts/NotoSansCJK-Bold.otf:\
text='课程标题':fontcolor=white:fontsize=48:box=1:boxcolor=black@0.5:\
boxborderw=12:x=(w-tw)/2:y=h-th-60" \
  -q:v 3 cover.jpg

中文字体要注意:drawtext 需要显式指定字体文件路径,而且字体要存在(Docker 镜像里经常没有中文字体——前面 ASS 字幕那篇讲过同样的问题)。

3. 输出格式与体积

格式 说明
JPEG 通用,-q:v 2~4(2 最高质量)
WebP 体积小 30%,现代浏览器都支持
PNG 无损,体积大,一般不用

封面图建议控制在 100~300 KB(列表页一次加载几十张,体积直接影响页面速度)。

ffmpeg -i frame.jpg -vf scale=1280:-2 -q:v 3 cover.jpg

六、批量处理与性能

单个视频的处理耗时分解(45 分钟的视频):

步骤 耗时
抽 40 个候选帧(关键帧跳帧,480px) 4.2 秒
打分(40 帧 × 5 个维度) 2.8 秒
裁切 + 输出 0.3 秒
合计 约 7 秒

打分慢在哪:人脸检测的 Haar 级联比较慢(占 70%)。优化:

  1. 先按清晰度排序,只对前 15 个做人脸检测(人脸检测不需要对所有候选做);
  2. 缩小检测图(480px 已经够);
  3. 多视频用进程池并行(一个视频一个进程,IO 和 CPU 都能打满)。

批量脚本:

from concurrent.futures import ProcessPoolExecutor


def process_one(path):
    frames, stamps = sample_frames(path, count=40)
    scored = []
    for img, t in zip(frames, stamps):
        if not hard_filter(img):
            continue
        s = score_frame(img, FACE, EYE)
        scored.append((s['total'], t, img, s))
    if not scored:
        return {'path': path, 'status': 'no_candidate'}
    scored.sort(key=lambda x: -x[0])
    best_score, best_t, best_img, detail = scored[0]
    if best_score < THRESHOLD:
        return {'path': path, 'status': 'need_manual', 'score': best_score}
    save_cover(best_img, f'covers/{Path(path).stem}.jpg')
    return {'path': path, 'status': 'ok', 'time': best_t, 'score': best_score}


if __name__ == '__main__':
    files = sorted(Path('videos').glob('*.mp4'))
    with ProcessPoolExecutor(max_workers=8) as ex:
        results = list(ex.map(process_one, files))
    # 输出报告
    ...

8 进程并行处理 100 个视频:约 2 分钟。

七、阈值兜底与人工流程

这是我认为最重要的一节。

自动选图一定会出错,问题不是"怎么做到 100% 对",而是"错了怎么被发现"。

我的做法:分数低于阈值就标记为"需人工"。

THRESHOLD = 0.55        # 按实际数据标定(见下)

if best_score < THRESHOLD:
    # 输出所有候选帧的拼图,让人一眼挑
    make_contact_sheet(candidates, f'review/{stem}.jpg')
    mark_for_manual(stem)

contact sheet(候选拼图) 是提升人工效率的关键:把 12 个候选帧拼成一张 4×3 的图,人看一眼就能挑出来(3 秒一个视频),比"在播放器里拖进度条找"快得多。

def make_contact_sheet(frames, out, cols=4, cell=(320, 180)):
    rows = (len(frames) + cols - 1) // cols
    sheet = np.zeros((rows * cell[1], cols * cell[0], 3), dtype=np.uint8)
    for i, f in enumerate(frames):
        r, c = divmod(i, cols)
        sheet[r * cell[1]:(r + 1) * cell[1], c * cell[0]:(c + 1) * cell[0]] = \
            cv2.resize(f, cell)
    cv2.imwrite(out, sheet)

阈值的标定方法:跑一批(比如 50 个),人工看每个结果的分数,然后画个分布——找到"分数低于多少时人工也觉得不能用"的那个点。我们标出来是 0.55。

最终流程:

100 个视频
  → 自动处理:87 个直接出图(分数 ≥ 0.55)
  → 13 个标记"需人工",同时生成候选拼图
  → 人工看 13 张拼图,每张 3 秒 → 40 秒搞定

八、把封面写进视频文件

生成的封面图可以写回视频(文件管理器里显示缩略图):

ffmpeg -i input.mp4 -i cover.jpg \
  -map 1 -map 0 -c copy -disposition:0 attached_pic \
  -movflags +faststart output.mp4

-disposition:0 attached_pic 是关键(把封面标记为"封面图"而不是普通视频流)。

项目里另有一篇专门讲嵌入封面的文章(MP4/MKV 的区别、批量处理),这里不展开。

九、实测数据

100 个视频(混合类型:课程录像 40、访谈 25、动画 15、屏录 12、风景 8):

指标 随机抽第 10 秒 自动评分
人工判定"能用" 78% 96%
人工判定"很好" 31% 68%
需要人工重挑 22% 13%(其中 9% 是审美偏好)
严重翻车(黑屏/糊) 14% 0%

分类型的效果差异:

类型 自动选的接受率 说明
访谈(有人脸) 98% 人脸维度很有用
课程录像 95%
动画 92% 人脸检测无效,靠清晰度+色彩
屏录(PPT) 83% 最差:全是文字页,构图/色彩维度区分度低
风景 89%

屏录类是最难自动选的——因为没有明显主体,画面之间差异小。这类我们基本都走人工(或者改成"用视频的标题图/第一页 PPT")。

十、坑清单

  1. 抽帧不跳过片头 → 抽到黑场/logo。跳过开头 3%。
  2. 解码每一帧 → 慢几十倍。用关键帧跳帧。
  3. 分辨率太高做检测 → 慢。降到 480px 足够。
  4. 人脸检测对所有人做 → 慢。先按清晰度排序,只对前 N 个做。
  5. 人脸权重太高 → 没有人脸的内容(动画/风景)选不出好帧。权重 ≤ 0.2。
  6. 闭眼帧没过滤 → 加上眼睛检测,或者降低"检测不到眼睛"的分数。
  7. 纯色/黑屏没过滤 → 加硬性过滤(亮度 + 标准差)。
  8. drawtext 中文字体缺失 → 显式指定 fontfile,镜像里要装字体。
  9. 封面图体积太大 → 列表页加载慢。控制在 300KB 内。
  10. 输出比例不对 → 列表页图片被拉伸。裁切到目标比例。
  11. 没有阈值兜底 → 自动选错了也不知道。必须有"需人工"标记。
  12. 人工流程太重 → 让人工看候选拼图,而不是重新拖进度条。
  13. 阈值不标定 → 拍脑袋定阈值一定不准。跑一批数据画分布。
  14. 忘记写回视频 → 文件管理器里没缩略图。加 attached_pic。
  15. 处理失败没有兜底 → 抽帧失败/视频损坏时,要有默认封面或者跳过并记录。

最后说说我对"自动化 + 人工"这个组合的看法,跟前面场景检测那篇是同一个道理,但这里更明显。

自动选封面的价值不在于"选得比人好",而在于"把 100 次人工决策变成 13 次"。

  • 100 个视频全人工挑:每个至少 30 秒(拖进度条、看几帧、决定)→ 50 分钟,而且枯燥到让人出错;
  • 自动 + 人工兜底:87 个自动出图 + 13 个看拼图 → 总共 1 分钟。

这里的关键设计是阈值兜底:让算法在"不确定"的时候主动说"我不确定",而不是硬给一个答案。一个会承认不确定的系统,比一个看起来很自信但经常错的系统有用得多。

这个思路我在别的场景也用:

  • OCR 识别结果置信度低于阈值 → 标记人工校对;
  • 视频质检判定为"可疑" → 隔离区待查,而不是直接判坏;
  • 场景检测分数接近阈值 → 放进校验列表。

给自动化系统留一个"我不知道"的出口,是让它真正可用的前提。

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

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

顶部