去年做数字人素材质检时,我给自己搭了一套"动态量测":抽帧、算相邻帧差均值、看哪个区域数值高就认为哪里在动。跑了几十支素材,出了一堆结论,还挺像那么回事。
直到李哥看了一眼,说:"你 4 个身体好像都没动。"
那句话推翻的不只是结论,是整套指标。后来重做,才发现问题不是"参数没调好",而是指标本身测的东西和"动没动"根本不是一个东西。下面 5 条是重做之后定下来的铁律,每条都带当时的实测数字。如果你也在给视频素材做质检(数字人、口播、漫剧分镜,判断标准是一样的),这 5 条能省你至少两轮返工。
当时的判断依据是"帧间差均值":两支素材,一支我加了旋转,一支是原版,测出来 0.15 和 0.53。按这个数看,旋转的那支"动得更少",于是我判断旋转参数没生效。
实际是反的。0.15 和 0.53 都落在亚像素重采样噪声的量级里——视频解码本身就会因为色度二次采样、编码量化产生这个量级的差异。换句话说,这个指标对"结构化位移"完全不敏感,它测的是编码噪声的抖动。
正确的做法是测位移,而不是测差值。用相位相关(cv2.phaseCorrelate)在固定 ROI 上直接求位移量,输出 max dx / max dy(像素,折算到 1080 宽),并且必须区分两种口径:
同一个脚本换个 --ref FIRST|PREV 就是两套结论,混用会得出互相矛盾的判断。
这是最容易犯、也最致命的一条。我最初按包围盒高度百分比切分区:头部=上 45%,嘴部=30%~48%。听起来很合理,实际结果是把 "head" 切成了整个上半身,"mouth" 落到了双手交叠的腹部——于是我测出来的"嘴部动态",其实是手在动,结论完全反了。
后来固定成一套校准:分析分辨率 540×960,先用黑底非黑像素的包围盒顶部减 23 px 得到真实头顶 T,再按绝对偏移取四个区:
| 区域 | (x, y, w, h) | 用途 |
|---|---|---|
| face | (218, T+25, 76, 105) | 头部是否在动 |
| mouth | (236, T+75, 48, 40) | 口型是否开合 |
| torso | (160, T+140, 220, 220) | 躯干形变 |
| legs | (170, T+485, 200, 380) | 下半身是否同步 |
为什么锚点要用"顶部减 23"这种看起来很奇怪的值?因为包围盒顶部会被头发高光、抠图残边顶高几个像素,减 23 之后才与肉眼看到的头顶对齐。这类常数必须由目视核对得出,不能靠公式推。改 ROI 之前先出一张网格叠加图,把框画在画面上看一眼,比读十行日志都管用。
把"动"再拆一层:刚体运动(旋转/缩放/平移)和非刚性形变(肌肉、布料、表情)。前者内部像素关系不变,后者才是"活气"。
所以用运动能量热图(时间 98 分位 + 固定绝对标尺)看,结果非常直观:刚体运动只会亮一条轮廓线,非刚性形变是大面积亮。仅靠加旋转/缩放想让静态图"活起来",热图上永远只有轮廓,无论参数调多大。
另外一个实测数据很说明问题:用肖像动画方案驱动时,躯干位移恒在 0.0015 量级(基本等于不动)——它只驱动脸,身体原样保留源图。这就是"上下不协调"的物理原因,不是参数问题,只能换全身 avatar 才根治。
还有一个读数陷阱:cv2.phaseCorrelate 在低纹理区(黑底腿部)容易失锁,腿部位移必须用第二种方法交叉验证再采信,否则会得到一堆"腿在乱动"的假信号。
改完素材上线,切换瞬间"整体闪一下",锚点残差 1.27(正常应为 0)。查了两天,根因是:cv2.VideoCapture 解出来是 BGR,cv2.VideoWriter 再编回去,YUV↔RGB 的色彩矩阵不匹配,引入整体亮度偏置。
实测把 MAE 1.53 拆开看:其中 1.51 是亮度差,量级恰好接近 235−16——这是 TV range / full range 错配的典型指纹。
| 指标 | 改前(BGR 往返) | 改后(YUV 直通) |
|---|---|---|
| 重编损失 MAE | 1.53 | 0.18 |
| 亮度偏移 | 1.434 | 0.084 |
正解是全程 YUV420p 管道:ffmpeg 抽 -pix_fmt yuv420p 的 raw,numpy 只在 Y 平面做处理,再喂回编码器。
顺带一个判据修正:锚点残差不要追求 0。重编码本身有噪声底(本次实测 0.175),残差低于噪声底就是到位了。判"是否 HQ",先看亮度偏置有没有归零,而不是只看结构差。
-vsync 0同一对帧,两套读法给出相差 3.4 倍的距离:7.23 与 36.56。
根因:select=eq(n,N) 加 -vframes 1 输出 rawvideo 时,ffmpeg 默认的 vsync 会复制/丢帧,-vframes 1 取到的不是目标帧。更硬的铁证是测一支 10 秒平滑循环素材的首帧与次帧:不带 -vsync 0 测出 37.86,而可靠顺读法只有 0.562——相邻帧差本该小于 1,前者明显取错了帧。
# ① 抽单帧(必须 -vsync 0)
ffmpeg -v error -i in.mp4 -vf "select=eq(n\,113)" -vsync 0 -vframes 1 -f rawvideo -pix_fmt yuv420p -
# ② 顺读全部帧(加 -vsync 0 后索引精确)
ffmpeg -v error -i in.mp4 -vsync 0 -f rawvideo -pix_fmt yuv420p -
代价是真实的:这次事故让上一轮所有进/出帧距离读数全部作废(差 3 倍以上)。所以定了一条规矩——任何"跨素材比较距离"的结论,先跑一次读帧校准:同一支视频,顺读结果与 select 抽帧结果逐帧比对,差值应为 0.000;不为 0 就是取错帧了。
另外 cv2.VideoCapture 的 POS_FRAMES seek 对 H.264 也不精确。实测 cv2 顺读与 ffmpeg 抽帧差 0.0000,所以"顺读"这条路径是可信的,别为了省时间用 seek。
这条不是算法问题,是工程事故。曾经把抽帧与中间产物都落在本机工作区,堆到 6.2 G / 1040 个碎文件(raw 3.14 G / 5 个、npy 1.86 G / 21 个、png 692 个、mp4 123 个)。结果是 Windows Defender 实时扫描(MsMpEng)冲到 94% CPU,加上文件索引,整机卡到不能用。
李哥的原话是:"我重启了你。你把整个电脑拖的走不动了。"
关键认知:碎文件数比总体积更致命。6 G 的 5 个大文件杀不掉机器,692 个 png 可以。所以本机只留脚本、成品和文档,*.raw / *.npy / 抽帧 *.png / 中间版本 *.mp4 一律不落本机;工作区文件数控制在 500 以内;归档必须先校验 tar 条目数等于原文件数,才允许删源。
| 要判的问题 | 不要用 | 应该用 |
|---|---|---|
| 动没动 | 帧间差均值(噪声量级) | ROI 相位相关位移(max dx/dy,区分累积/帧间) |
| 哪里在动 | 四宫格缩略图 | 全分辨率运动能量热图(98 分位 + 固定绝对标尺) |
| 是不是"活"的 | 旋转/缩放等变换 | 非刚性形变量(热图是否大面积亮) |
| 重编码质量 | 只看结构差 | 亮度偏置是否归零 + 对比噪声底 |
| 取帧对不对 | 按经验信 select | 顺读 vs select 逐帧校准(应差 0.000) |
| 首尾能否无缝循环 | 目视 | 首帧/末帧同点比对 + 漂移量测 |
最后一条经验关于"给谁看":视频类结论必须在全分辨率下给人看。缩略图和拼格只能说明整体节奏,不能用来判微动、判清晰度——1~3 px 的位移在拼格里会被抹平,你会拿着"看不出来"的图去说服别人"确实动了",这仗必输。
因为视频解码本身就会产生亚像素级的重采样与量化噪声。我们实测"加了旋转"与"原版"两支素材分别是 0.15 和 0.53,都落在噪声量级,指标对结构化位移不敏感,结论会完全相反。要测位移请用相位相关。
出运动能量热图,取时间 98 分位并固定绝对标尺。刚体运动只亮轮廓线,非刚性形变是大面积亮。这张图比任何数值都快,且不会骗人。
大概率是 RGB↔YUV 往返导致的色彩矩阵/range 错配。我们实测 MAE 1.53 里有 1.51 是亮度差,量级接近 235−16(TV vs full range)。改成 YUV420p 全程直通后亮度偏移从 1.434 降到 0.084。
因为没有加 -vsync 0,默认 vsync 会复制或丢帧,-vframes 1 取到的不是目标帧。实测同一对帧读出 7.23 与 36.56,相差 3.4 倍。
碎文件的实时扫描开销远大于总体积。我们本机曾堆到 1040 个碎文件 / 6.2 G,Defender 单进程吃到 94% CPU,整机卡死。把抽帧与中间产物放到处理节点、本机只留成品,是唯一稳的做法。