AI 做视觉任务时怎么自证结果正确
Note
AI 做完视觉任务后说"渲染成功了", 这句话里有多少是它真看见的? 我调试一个字幕预览时连续误判六次: 其中四次用了一个在正负样本上区分不开的指标, 一次是参数用错, 一次是从有效判据推出了越界结论. 这篇写那个指标的失效机制, 在它失效时该怎么办, 以及阈值具体取多少.
0x00 方框的像素比真字还少
我用非背景像素数判断渲染成功没有. 隔离字体目录后做了对照, 同一段文字:
- 真字形: 8994 像素
- 中文走 DejaVu (渲染成空方框): 7984 像素
- 无任何字形: 0 像素
阈值怎么调都没用: 真字 8994、方框 7984, 两个数落在同一个区间.
缺字时每个字符变成一个等宽空心方块, 笔画反而比真字细, 所以方框像素更少.
0x01 指标是一次有损投影
把画面压成一个标量, 等于把二维分布投影到一维. 投影必然丢信息, 丢掉什么由公式决定.
像素数的公式是灰度超过阈值的格子数. 它只反映覆盖面积, 与笔画长什么样无关. 一个实心方块和一个笔画复杂的字, 只要覆盖面积接近, 它就给出同一个值.
我实测了五个指标在三种变换下的表现. 条件是同一段 CJK 文本, 8 字与 16 字, 字号 60/90/120, 共 18 组 (3 字体 × 2 文本 × 3 字号):
| 指标 | 对字号 | 对字符数 | 对字体族 |
|---|---|---|---|
| 像素数 | +261% | +100% | -22% |
| 包围盒长宽比 | +0.9% | +101% | -0.6% |
| 归一化墨量 | +10.8% | +0.1% | -22% |
| 归一化宽度 | +0.1% | +0.4% | +0.6% |
| 包围盒填充率 | +7.3% | -0.6% | -22.5% |
像素数被三个变量同时驱动, 它读出的任何数字都无法归因到其中某一个. 长宽比对字号和字体族几乎不敏感 (0.9% 与 0.6%), 对字符数敏感 (101%), 所以它适合回答"这一行有多少个字"这类问题.
归一化宽度几乎不随任何东西变, 稳定落在 0.68 附近. 那是 CJK 全角字的步进宽度 (advance) 占 em 的比例, 一个排版常数. 注意它量的是墨迹框宽, 只是数值上接近 advance.
到这里能推出一件事: 指标对某组变换不变, 就必然对另一组敏感. 想知道一个指标能检出什么缺陷, 只需看它敏感的维度有没有被这个缺陷改变.
0x02 换指标没用, 控制变量才有用
上一条会推出一个很自然的反应: 换个更好的指标. 我测下来这条路走不通.
把四种缺陷和四种干扰分别作用在同一基线上, 记录每个指标的相对变化. 缺陷侧: 无字形让像素归零, .notdef 替换让长宽比掉 37.9%, 字体族错让填充率掉 22.5%, 子集缺字让归一化宽掉 25.5%.
干扰侧的数字更值得看: 字号从 90 降到 60, 像素掉 51.8%; 升到 120, 涨 74.0%; 字符数翻倍, 像素涨 99.9%、长宽比涨 101.0%.
跨条件比较时, 干扰幅度普遍大于缺陷幅度. 像素数的信噪比只有 0.11, 完全不可用.
同一条件下换一段文本, 像素只动 0.2%; 而缺陷在同条件下动 11.2%. 余量 56 倍.
所以同一个指标, 跨条件比就废, 同条件比就够用. 问题不在指标, 在对照条件有没有控制住.
0x03 复盘: 我卡住的原因是没有基线
按上一条回头看我当时的调试过程.
第一步我比的是同一片段、同一时刻的两次渲染, 条件其实是受控的. 两个数是 84932 与 81126, 差 4.5%. 第二步我算出同条件下"换一段文本"只让像素动 0.2%, 于是 4.5% 是它的 22 倍. 信号一直在. 第三步我卡住了, 因为不知道该拿什么当"正常值".
缺了参考值, 4.5% 就没法判断是缺陷还是正常波动.
任何标量指标都必须配一个同条件取得的参考值. 没有参考值, 这个指标不构成验收, 只构成一个数字.
顺带修正一个我自己的用词错误. 我之前把"换一段文本带来的 0.2% 变化"叫噪声, 这是错的, 那是不同输入的合法变化. 真正的噪声是同一输入重复渲染的差异, 我实测了 5 次, 逐像素完全相同, 连 ffmpeg 线程数从 1 改到 16 都没有一个像素的差异. 真噪声是 0.
这个区分有意义: 噪声为 0 意味着任何非零差异都可归因, 剩下的问题只是"归因到缺陷还是归因到合法变动".
0x04 差异法能从"整体不对"走到"哪几个字不对"
既然参考值是关键, 那能不能不靠人给? 可以, 用一份故意造错的对照当待测, 拿正确渲染当参考, 逐像素相减.
我拿完整字体做参考、子集缺两个字做待测, 差异是 2154 像素 (占参考非背景的 23.9%). 把差异往横轴投影, 落在两段: x[766,823] 与 x[830,885], 各宽约 57 像素. 57 像素恰好是一个 90 号 CJK 字的宽度, 于是我不只知道有地方不对, 还知道错的是两个字以及它们在第几个位置.
标量指标只能回答"整体像不像正常的". 差异法能回答"错在哪". 代价是要多渲染一次参考图, 那一次可以用故意造错的方式省掉人工标注. 两类缺陷的差异形状不同: 整体性的偏差 (位置偏、缩放不一致) 让差异弥散, 局部替换 (缺字、错字) 让差异落在离散区间.
0x05 按缺陷反查该看哪个量
把前面的实测整理成反查表. 判据是缺陷幅度大于该指标的所有干扰幅度.
| 要检出 | 可用指标 | 缺陷幅度 | 该指标的干扰上界 |
|---|---|---|---|
| 完全不显示 | 像素是否归零 | -100% | 无 (归零是绝对信号) |
| .notdef 替换 | 包围盒长宽比 | -37.9% | 字号 0.9% |
| 字体族错误 | 填充率、归一化墨量 | -22.5% / -22.0% | 归一化墨量对换文本敏感 14.5% |
| 子集缺字 | 归一化宽度 | -25.5% | 换文本 0.6% |
但光有这张表还不够. 反查表必须带前提条件. 判据是: 某个干扰的幅度一旦超过缺陷幅度的三分之一, 它就必须被固定.
按这条判据逐个算: 长宽比的缺陷幅度是 37.9%, 三分之一是 12.6%, 而它对字符数的响应是 101%、对换文本是 14.5%, 两者都超过 12.6%, 所以长宽比必须固定字符数和文本. 填充率的缺陷幅度是 22.5%, 三分之一是 7.5%, 它对字体族的响应是 22.4%、对文本是 13.1%, 所以填充率必须固定文本和字体族. 归一化宽度对四种干扰的响应都在 0.6% 以内, 四项都不用固定.
我按不带前提条件的这版判据写了验收脚本, 在 8 个用例上跑: 缺陷全部检出 (4/4), 但干扰误报 2 个 (2/4). 两例误报正是字符数变化与换一段文本.
0x06 阈值具体取多少: 先标定, 再分层
上面说的是"信号比干扰大才可用", 落到脚本里还得有具体数字. 定这个数字的流程是标定.
固定基线条件渲染一次取参考值; 逐个变动本层不该触发告警的输入, 每次只动一个, 记录每个指标的变化; 取这批变化绝对值的最大者, 就是干扰上界 U; 阈值取在 U 与该指标的缺陷幅度 D 之间. 我按这个流程实测的 U 与 D:
| 变动 | 像素 U | 长宽比 U | 填充率 U | 归一化宽 U |
|---|---|---|---|---|
| 字号 | 74.0% | 0.9% | 7.3% | 0.1% |
| 字符数 | 99.9% | 101.0% | 0.6% | 0.5% |
| 文本 | 0.2% | 14.5% | 13.1% | 0.6% |
| 字体族 | 21.9% | 0.6% | 22.4% | 0.6% |
表里的数取的是绝对变化率 (原测量带符号, 例如换文本让长宽比从 8.50 掉到 7.27, 即 -14.5%). 基线统一是「真字形 / 8 字 / 90 号」. 换文本那一行用的是同为 8 字的另一段. 不能用 7 字的句子, 否则分母不同.
缺陷幅度 D: 像素归零是 100%, 长宽比 37.9%, 填充率 22.5%, 归一化宽 25.5%.
把这些按"允许哪些输入变动"分成三层, 结论就清楚了.
L1 同一条字幕重复渲染. 什么都不该变, U 是 0, 四个指标全部可用.
L2 同一首歌的不同句子. 字号和字符数会变. 长宽比在这一层废掉 (它的 U 是 101%, 超过缺陷幅度 37.9%). 剩下三个都可用: 像素的余量刚好 1.0 倍 (它本来就会被字符数翻倍), 填充率 3.1 倍, 归一化宽 51 倍.
L3 跨作品复用同一套组件. 四项都变. 归一化宽还稳, 余量 42 倍; 像素的余量 1.0 倍; 长宽比废掉; 填充率在这一层也不可用, 因为它的干扰上界 22.4% 与缺陷幅度 22.5% 只差 0.1 个百分点, 阈值没法取在中间.
0x07 可执行件与它的绝对阈值
上面全是相对幅度, 脚本里需要绝对值. 这些值我在 render-check.py 里定死了, 并在 8 个用例上复测: 检出 4/4, 误报 0/4. 不带前提条件的版本误报 2/4, 差别只在脚本里 TOL 那张分层表的配置.
指标定义 (脚本里就是这么算的):
- 灰度化用
PIL.Image.convert("L"); 灰度 > 30 记为非背景, 30 取在背景 0 与抗锯齿边缘之间的低端 - 包围盒取墨迹框 (非背景像素的最小外接矩形), 不是字身框
- 包围盒长宽比 = 包围盒宽 / 包围盒高
- 填充率 = 非背景像素数 / 包围盒面积
- 归一化宽 = 包围盒宽 / 字符数 / ASS 的 Fontsize
- 归一化墨量 = 非背景像素数 / 字符数 / Fontsize 的平方 (这个量只用来说明原理, 可执行件里没有实现它; 脚本只算上面四个)
分层阈值 (TOL, 四元组依次为像素/长宽比/填充率/归一化宽; None 表示该层禁用):
| 层 | 允许变动 | 阈值四元组 |
|---|---|---|
| L1 | 无 | 0.0 / 0.10 / 0.10 / 0.10 |
| L2 | 字号, 字符数 | 0.99 / None / 0.10 / 0.10 |
| L3 | 字号, 字符数, 文本, 字体族 | 0.99 / None / None / 0.10 |
L1 那一栏的像素阈值写作 0.0 而不是严格 0, 是为了留一点浮点容差. 其余各项取在该指标的干扰上界与缺陷幅度之间, 具体取一倍余量. L2/L3 的像素阈值放到 0.99, 因为这两层允许字符数变化, 而字符数能让像素动 99.9%.
渲染与抽帧的完整命令:
# 渲染一帧 (子集字体目录用 fontsdir 指定, 配合隔离的 fontconfig)
ffmpeg -y -f lavfi -i "color=c=0x000000:s=1280x720:d=100:r=25" \
-vf "subtitles=in.ass:fontsdir=./fonts" -ss 10 -frames:v 1 out.png
# 子集化。输出必须是 ttf, libass/freetype 不读 woff2
pyftsubset NotoSansCJK-Regular.ttc --font-number=0 \
--text-file=chars.txt --output-file=fonts/f.ttf \
--layout-features=* --no-hinting
-ss 放在 -vf 之后 (输出侧). 抽样时刻必须从事件表里算出首个事件的真实起点, 不能用窗口参数.
标定用例的实际输入 (照这三组条件就能重建那 8 个用例):
| 用例 | 字体目录里放什么 | ASS 里写什么 Fontname | 文本 | 字号 |
|---|---|---|---|---|
| 基线 (真字形) | Noto Sans CJK 子集 | Noto Sans CJK JP | 用永不止息的光芒 | 90 |
| D1 无字形 | 空目录 | Noto Sans CJK JP | 同上 | 90 |
| D2 .notdef | DejaVuSans.ttf | DejaVu Sans | 同上 | 90 |
| D3 字体族错 | Noto Serif CJK 子集 | Noto Serif CJK JP | 同上 | 90 |
| D4 子集缺字 | 只含"用永不止息的"的子集 | Noto Sans CJK JP | 同上 | 90 |
| V1 字号 | Noto Sans CJK 子集 | Noto Sans CJK JP | 同上 | 60 与 120 |
| V2 字符数 | Noto Sans CJK 子集 | Noto Sans CJK JP | 同一句写两遍 (16 字) | 90 |
| V3 换文本 | Noto Sans CJK 子集 | Noto Sans CJK JP | 同为 8 字的另一段 | 90 |
字体隔离靠一个只含单个目录的 fontconfig 配置 (给 ffmpeg 设 FONTCONFIG_FILE 环境变量指向它), 这样系统字体不会参与 fallback, 否则 D1 会退化成本机能显示字体而不是空白.
参考图的选取约定: --ref 永远放同条件下的正确渲染, --test 放待测. 差异法那一节里"故意造错的对照"是拿来做 --test 的, 不是做 --ref 的.
检出告警后的处置: 先按告警类型换判据重查 (长宽比报警说明可能是字符数变了, 不是字形坏了); 确认是字形问题后查字体名匹配与子集覆盖; 仍无法归因时转人工看差异图, 而不是继续加阈值.
0x08 视觉任务的失败是静默的
文本任务里做错了, 通常有东西兜住: 类型检查会报, 测试会红, 构建会失败. 视觉任务没有这一层.
那次调试里有三处"操作成功但结果错". writeFile 返回成功 (wrote: true, bytes: 43264), 字形却不对. onReady 与 setTrack 都正常触发, 画面里没有字形. 非背景像素 81126 看起来像渲染成功了, 实际和错误状态只差 4.5%.
画面永远是有像素的, 不会抛异常. 所以视觉任务的反例必须由人主动构造, 不会自己冒出来.
0x09 五个具体的坑
抽帧抽到了空帧. 我用 -ss 100 得到全黑画面, 判断"渲染失败". 那段字幕的事件其实从 100.21 秒开始, 差了 0.21 秒. 改成 -ss 101 后正常渲染出 13368 个像素. 抽帧时刻必须从事件表算, 不能拿窗口参数直接套.
子集化把运行时依赖变成了编译期快照. 我把 1712 万字节的字体压到 43264 字节, 只保留当时用到的 129 个字符. 换一段内容就会有新字符缺失, 而缺失的表现和原来一模一样, 静默. 更麻烦的是这时人会以为是"组件坏了". 付款的人是之后复用这个组件去展示别的片段的人, 付款时机是第一次换内容的时候. 只要预览内容可变, 就必须有一条"重新抽字符再重新子集化"的流程, 并在流程末尾检查新字符的覆盖率.
字体格式的兼容性也是静默失败的. 子集化时我先用的 woff2 格式, 写进 Worker 后中文仍不显示, 且没有任何报错. 换成 ttf 后立刻正常, 因为 libass 底下的 freetype 不读 woff2. 这条约束写在可执行件里 (它是脚本 make_subset 函数的一句注释), 但文章里得单独讲: 它属于"工具本身的限制", 与前面讲的"判据选得不对"是两回事.
"观感不同"这类话必须落到可测的量上. 我曾在别处写过"四版字体渲染出来观感完全不同", 但从未测过. 补测后是: 四版在同一帧上两两逐像素差异率 3.06% 到 5.41%, 字面覆盖量 85317 到 94761 像素 (差 11%). 这个数字有区分能力: 如果四版其实一样, 它会给出接近 0 的值.
判据有效不等于结论有效. 我用 md5 比两份 ASS 得到相同值, 认为"这是两份可对照的独立样本". 哈希相同是对的 (两份都是 96e5325006a1c1f07f3b0a758d4c67fd), 但推不出独立样本, 因为前者是指向后者的符号链接. 正确做法是先 ls -la 看链接类型或比对 inode, 再算哈希. 这类错误和前几条性质不同. 前几条是判据本身没区分能力, 这条是从有效判据推出了超出它范围的结论. 第一种要换量, 第二种要少说一句话.
回到开头那六次误判: 其中四次缺的是"能在正负样本上区分开的量", 两次缺的是"这个量的正常值". 第一类靠控制对照条件解决, 第二类靠自造参考渲染解决.
本文没有引用站内其它文档。

