我们有批视频要生成封面图(列表页展示用)。第一版实现很朴实:抽第 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
权重的调参经验:
- 清晰度和曝光是"一票否决"级别的——糊了或者全黑,其他再好也没用。所以它们的权重最高,而且我在总分之外加了"硬性下限"(清晰度分 < 0.2 直接淘汰);
- 人脸权重不能太高——风景、动画、屏录这些本来就没有人脸,给太高会让这些视频选不出好帧;
- 构图权重最低——因为我的构图评估很粗糙,给高了会引入噪声。
硬性过滤(在打分之前先淘汰明显不行的):
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%)。优化:
- 先按清晰度排序,只对前 15 个做人脸检测(人脸检测不需要对所有候选做);
- 缩小检测图(480px 已经够);
- 多视频用进程池并行(一个视频一个进程,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")。
十、坑清单
- 抽帧不跳过片头 → 抽到黑场/logo。跳过开头 3%。
- 解码每一帧 → 慢几十倍。用关键帧跳帧。
- 分辨率太高做检测 → 慢。降到 480px 足够。
- 人脸检测对所有人做 → 慢。先按清晰度排序,只对前 N 个做。
- 人脸权重太高 → 没有人脸的内容(动画/风景)选不出好帧。权重 ≤ 0.2。
- 闭眼帧没过滤 → 加上眼睛检测,或者降低"检测不到眼睛"的分数。
- 纯色/黑屏没过滤 → 加硬性过滤(亮度 + 标准差)。
drawtext中文字体缺失 → 显式指定 fontfile,镜像里要装字体。- 封面图体积太大 → 列表页加载慢。控制在 300KB 内。
- 输出比例不对 → 列表页图片被拉伸。裁切到目标比例。
- 没有阈值兜底 → 自动选错了也不知道。必须有"需人工"标记。
- 人工流程太重 → 让人工看候选拼图,而不是重新拖进度条。
- 阈值不标定 → 拍脑袋定阈值一定不准。跑一批数据画分布。
- 忘记写回视频 → 文件管理器里没缩略图。加
attached_pic。 - 处理失败没有兜底 → 抽帧失败/视频损坏时,要有默认封面或者跳过并记录。
最后说说我对"自动化 + 人工"这个组合的看法,跟前面场景检测那篇是同一个道理,但这里更明显。
自动选封面的价值不在于"选得比人好",而在于"把 100 次人工决策变成 13 次"。
- 100 个视频全人工挑:每个至少 30 秒(拖进度条、看几帧、决定)→ 50 分钟,而且枯燥到让人出错;
- 自动 + 人工兜底:87 个自动出图 + 13 个看拼图 → 总共 1 分钟。
这里的关键设计是阈值兜底:让算法在"不确定"的时候主动说"我不确定",而不是硬给一个答案。一个会承认不确定的系统,比一个看起来很自信但经常错的系统有用得多。
这个思路我在别的场景也用:
- OCR 识别结果置信度低于阈值 → 标记人工校对;
- 视频质检判定为"可疑" → 隔离区待查,而不是直接判坏;
- 场景检测分数接近阈值 → 放进校验列表。
给自动化系统留一个"我不知道"的出口,是让它真正可用的前提。