长片图片叙事 · 装配与质检加固
面向「几十张图 → 10 分钟成片」的 ffmpeg 装配。前 10 镜都不会出问题,问题全在规模上。
本技能只讲四个规模化才暴露的失败模式与对应判据,通用流程仍走 image-story-video-wizard。
0. 先算帧数再动手
一切装配以音频帧长为准,不要以「每镜几秒」凑:
total_frames = round(audio_duration * fps)
第 i 镜 input_frames = base_frames_i + transition_frames # 给 xfade 补时长,末镜不补
第 i 镜 xfade_offset_frames = Σ(前 i 镜 base_frames)
验收:Σ input_frames - (n-1)*transition_frames == total_frames
先把这张表算完、断言通过,再碰 ffmpeg。装配完用 ffprobe -show_entries stream=nb_frames 复核,
差 1 帧以内才算过。不要靠 -shortest 兜底,那会把误差悄悄堆在结尾。
1. 失败模式 A:多分支 filtergraph 会爆
症状:把 N 路 scale + zoompan 同时挂进一张 filter_complex,速度掉到 0.4x,
一小时只出一千多帧,输出还是残缺的。
根因:ffmpeg 要为每条 zoompan 分支保留预放大后的整帧缓冲。
6x 预放大下每分支 9216×5184×3B ≈ 143MB,64 分支就是 9GB 级别 —— 内存换页,
不是 CPU 慢,所以加 -threads 没用。
解法:两段式
# 段1:每镜单独渲成中间片(每进程只持 1 路缓冲,可并行)
ffmpeg -loop 1 -framerate $FPS -t $DUR -i shot.png \
-vf "scale=$((W*6)):$((H*6)):flags=lanczos,\
zoompan=z='min(1+0.006*on/$FPS,1.15)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':\
d=1:s=${W}x${H}:fps=$FPS,setsar=1,format=yuv420p" \
-c:v libx264 -crf 12 -preset veryfast seg_$i.mp4
# 段2:只做 xfade 拼接(纯解码 + 混帧,秒级)
段1 用 ThreadPoolExecutor(max_workers=4);中间片用 crf 12(近似无损)避免二次压缩。
实测收益:单次调用 0.40x / 1h24m 未完成 → 两段式全片 119s。
中间片的 -t 要用 input_frames/fps,不是 base_frames/fps。
2. 失败模式 B:转场中点字幕叠影
症状:转场那 0.5 秒里,上下同时出现两句字幕 / 两个标题,像渲染 bug。
根因:字幕和画面同帧,xfade 混的是整帧,于是前镜字幕和后镜字幕被一起混。
解法:字幕独立成轨,最后 overlay 一次
画面轨 1536x864 ──lanczos──► 1920x1080 ─┐
字幕轨 1920x1080 RGBA (qtrle) ──────────┴─ overlay=format=auto ─► 母版
- 字幕层按 1920×1080 原生分辨率渲染成 RGBA PNG 序列,再
-c:v qtrle拼成.mov(qtrle 是无损 + 带 alpha,几十 GB 也别怕,只是中间产物)。 - 最后一条命令里做
scale+overlay+ 混音。这样: ① 转场只作用于画面,字幕不参与混帧; ② 字幕不被 resample,锐度是原生的; ③ 想改字幕只需重跑字幕轨 + 最后一条命令,不用重渲画面。
验收:抽 4 处以上转场中点帧,确认只有一组字幕、一组标题。
3. 失败模式 C:zoompan 推镜抖动
根因:zoompan 的 z/x/y 计算后按源像素整数取整,锯齿幅度 ≈ ±0.5 源像素。
源图越小,这个 ±0.5 占输出像素的比例越大,看起来就是推镜时整幅在「跳」。
解法:先 Lanczos 预放大再 zoompan,倍数按验收指标定:
| 预放大 | 实测抖动残差 (25fps, 0.6%/s, 1536 基线) | 判定 |
|---|---|---|
| 2x | 0.401 px | 不合格 |
| 4x | 0.128 px | 不合格(临界) |
| 6x | 0.075~0.089 px | 合格 |
预放大倍数 = 1 / 可接受残差(px) × 系数,验收线取 0.10px。
测量方法:取推镜中的 30 连续帧 → 灰度 → phaseCorrelate 求逐帧位移 →
对位移序列做一次线性拟合取残差 std。整幅匀速推镜的位移是线性的,残差就是抖动。
配合缩放搜索法验「推镜是否真的发生」(对前后帧做 1.00~1.25 的尺度扫描找最大相关)。
4. 失败模式 D:生成图自带伪影
4a. API 水印(右下角网点白字)
定位:跨图一致性响应。水印在整批里位置/字形完全一致而背景各异:
每张取「相对自身局部中值背景的正向对比度」→ 跨 N 张取平均
⇒ 背景互相抵消,只剩水印
据此得到 bbox。清理用整块外接矩形 + cv2.inpaint(..., INPAINT_NS, r≈9)。
⚠️ 不要用「贴合字形的遮罩」。半透明水印的对比度随底图明暗变化, 跨图平均只保得住压在暗底上的笔画,亮底上的整段笔画被漏掉 → inpaint 后留白色斑块。 外接矩形反而更干净。(实测残留:字形遮罩方案留斑块,矩形方案 98.0 → 2.1)
写盘必须走 PIL 或 cv2.imencode(...).tofile() —— 非 ASCII 路径下 cv2.imwrite 静默失败。
4b. 扫描线伪影(贯穿画面的细直线)
不要只用「该行与邻行差异 > 阈值」判废 —— 会把画面固有的直线结构 (屋脊、梁柱、桌沿、卷边)全部误报(实测首轮 20 张图 108 处误报)。
必须加两个维度:
| 维度 | 定义 | 伪影 | 画面结构 |
|---|---|---|---|
| profile | `mean_x max_c | P[r] - (P[r-4]+P[r+4])/2 | ` |
| 贯穿度 | 残差 > 0.45×max 的宽度占比 | ≥0.85(贯穿全幅) | <0.75 |
| 沿线恒定性 | 1 - std_x(D) / mean_x(D) |
≥0.55(强度几乎不变) | <0.55(沿线起伏) |
判废 = profile ≥ 35 且 贯穿度 ≥ 0.85 且 恒定性 ≥ 0.55。 加完这两维,误报从 108 处降到 1 处,与目视完全一致。
修补:相邻干净行逐列线性插值,不要 inpaint
for r in range(a, b + 1):
t = (r - up) / (dn - up) # up/dn 是紧邻的干净行
out[r] = (1 - t) * im[up] + t * im[dn]
inpaint 在整幅长条上会从左右边界向内传播,产生横向涂抹; 线性插值两端 t=0/1 天然无缝(实测跨带行差 2.1 vs 邻域 2.0)。 写脚本时带幂等保护(修前先复核该带是否仍是目标颜色)+ 原图备份。
5. 字幕/标题层的硬规则
5a. 时间轴必须从音频实测,禁止按字数推算
最容易出事、也最容易被忽略的一条。 用「句字数 × 平均语速」分配时长, 前几秒看着没问题,误差逐句累积,到片尾就是「音频早讲完了,字幕还在往前滚」。
解法:静音间隙强制对齐(连续语流也适用,只要停顿够多)
# 1. 解成 16k 单声道,取 10ms RMS 包络
# 2. 找所有 >=0.25s 的静音间隙 → 候选锚点 = 间隙起点 ∪ 间隙终点
# ⚠️ 只取起点会退化成畸形解(末句被塞进超长区间,s/字 明显离群)
# 3. DP 选 N 个句界,代价 = Σ|句时长 − 字数 × 均速|
# 4. local_snap:DP 只是全局最优,短句会坐进停顿里
# 对「有声占比 <0.55」的句,在 ±0.8s 内步进搜索语音最密处
验收指标(缺一不可):
- 每字时长的 min/中位/max 落在物理合理带(实测 0.094 / 0.196 / 0.373 s/字);
- 有声占比 <0.45 的句数 ≈ 0(修前 15 句 → 修后 1~2 句);
- 音频结束后仍有字幕的帧数 == 0;
- 有声却无字幕的帧数 == 0。
⚠️ 核验时基陷阱:别拿 100Hz 包络直接比 25fps 视频的帧号,会算出 −445s 这种假结论。 先把语音按 1/(fps) 窗口重采样到同一时基,再逐帧比对。 ⚠️ 「字幕落在静音的帧占比」30% 左右是正常的 —— 连续字幕本来就跨句内停顿。 不要把它当缺陷指标,真正的门禁是上面那两条「== 0」。
5b. 强制单行:缩字号,不要折行
折行必然出现两行。改为逐句按需缩字号:
def fit_single_line(text, base_px, max_w, min_scale=0.62):
# 从 base_px 逐级下调(下限 max(30, base_px*min_scale))
# 仍放不下再逐级收紧字距 (1.0 → 0.5 → 0.0)
# 都不行才退回折行(实务上不会触发)
实测:166 句全单行,仅 12 句缩了字号(62%96%),最长 46 字仍单行;
行高 4965px,左右边距 ≥97px(1920 画布,安全线 1720)。
并且缩字号后要按实际字体重算行高,否则垂直定位会漂。
5c. 去重口径:判「逐字相同」,不要判「包含」
若标题是从本镜旁白里提炼的关键短语(「孤证」「语多微文」这类), 它与完整句同屏是设计意图,不是 bug。
真正要抑制的只有两种:
- 标题核心字串 == 同镜某一句字幕的核心字串;
- 标题核心字串 == 同镜连续 2 句及以上字幕核心字串的拼接 (例:「真相不在正史里。在缝里。」= 末两句拼接 —— 只判精确相等会漏掉这种)。
用「包含关系」会误伤十几镜:标题题「孤证」,字幕里出现「孤证」就被抑制,把设计意图当缺陷。
比对前先剥掉所有标点与空白(,。、;:?!「」()“”—…),只留汉字/字母/数字。
验收:抑制镜的标题区高频能量应回落到背景水平,对照镜应明显更高。
5d. 分区:标题块与底部字幕的净空要量化
标题块左上、主字幕底部居中时,先算两者 bbox:
标题块 max_y 与 主字幕 min_y 之差就是净空。要求 > 0 且留足余量(实测取 611px)。
只靠目视「看着不重叠」在长片上不可靠 —— 某一镜多折一行就会撞。
折行用动态规划而非贪心:贪心会把「起居注」断成「起居 / 注」。 DP 的代价项:断在标点后加分、行填充均衡、禁止行首/行尾标点、禁止 <4 字的孤行。
6. 出片前 QA 清单
机器可判(写脚本,必须全过)
- 时长 == 音频时长 ±0.05s
- 分辨率 / 帧率 / 编码(v: h264, a: aac + 采样率 + 声道)
-
nb_frames== 计划帧数 ±1 - 字幕事件数、覆盖帧数 == 总帧数(无空隙、无重叠)
- 音频结束后残留字幕帧 == 0(防「音频讲完字幕还在滚」)
- 有声却无字幕的帧 == 0
- 每字时长 min/中位/max 落在物理合理带
- 折两行 == 0(逐句量字幕区行带数;注意标题块自带一行,须按 y 区间裁掉)
- 最宽字幕左右边距 ≥ 60px(防贴边/裁切)
- 推镜生效(缩放搜索相关系数 > 无缩放基线)
- 抖动残差 std < 0.10px
- 水印响应残留低于阈值
- 扫描线伪影:判废命中数 == 0(或已全部修补)
人眼必看(机器不可替代)
- 转场中点抽帧 ≥4 处:字幕/标题不叠影
- 标题区与字幕区零重叠(≥8 镜抽样)
- 全片等距抽 12 帧通看:色调连贯、无水印、无新伪影
- 首帧、末帧完整
- 完整看一遍
7. 环境注意(Windows)
7a. cv2.imwrite 静默失败 —— 一律改用 imencode
本机 Python 环境下 cv2.imwrite() 会返回 False 且文件不落盘,
且不限于非 ASCII 路径 —— 同样是 review/ 这个纯 ASCII 相对路径,
np.full((470,1800,3),16,np.uint8) 能写成功,而绘制过的同尺寸数组就写失败。
cv2.imencode('.png', img)[1].tobytes() + write_bytes() 则始终正常。
def save_png(img, path):
path.parent.mkdir(parents=True, exist_ok=True)
ok, buf = cv2.imencode(".png", img)
if not ok:
raise RuntimeError(f"PNG 编码失败: {path}")
path.write_bytes(buf.tobytes())
if not path.exists() or path.stat().st_size == 0: # 落盘断言,别信返回值
raise RuntimeError(f"PNG 落盘失败: {path}")
教训:写图后一定要
assert path.exists()。imwrite的返回值在这台机器上不可信, 曾因此浪费多轮排查 —— 脚本一路打印「成功」,磁盘上什么都没有。
7b. Path(__file__).resolve().parent 不是项目根
脚本放 tools/qc/ 时,.parent 解析到 tools/qc/,出图会悄悄落到
tools/qc/review/…。项目根用 Path(__file__).resolve().parents[2]。
出图后立刻打印绝对路径 + exists + 文件大小,别只打印相对路径。
7c. cv2.putText 不能渲染中文
Hershey 字体只有 ASCII。传中文会画出乱码方块/问号。
核验图(核验板、波形图、泳道图)上的所有标注一律用英文;
真需要中文就换成 PIL + 中文字体(本机无宋体,只有仿宋 simfang / 楷体 simkai)。
7d. OpenCV 颜色是 BGR
想要橙色写 (72, 140, 235);写成 (200, 120, 72) 出来是蓝色。
7e. 其他
- ffmpeg 路径参数加引号即可,但中间文件尽量放 ASCII 目录。
- 中文/长滤镜图:拆到
-filter_complex_script文件里(用;\n连接), 别塞命令行(Windows 命令行长度上限会截断)。 - 中间产物目录(段文件、字幕 PNG、字幕轨 mov)体积可达数十 GB, 出片后确认再清;母版和报告另存。