- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 看到一张长得像照片的二维码时冒出的疑问
- 二维码由两种格子组成
- 照片放在哪里 —— 把一个模块切成九格
- 于是九格里有一格是随机的
- 抖动 —— 不做二值化,而是把误差流出去
- 核心思路 —— 先把数据模块的误差流出去
- 余量是一笔预算
- 真正上手时会绊住你的 —— 留白与放大
- 小结与出处
看到一张长得像照片的二维码时冒出的疑问
你大概见过那种看上去像黑白照片、扫一下却能打开链接的二维码。不是中间嵌了个 logo 那种程度,而是整张码就是一幅画。第一眼看会觉得挺厉害,第二个念头通常是:那玩意儿是怎么被读出来的。
答案比预想的简单,而且正因为简单才更重要。实务上真正该知道的,不是它为什么能被读出来,而是它拿什么当代价付掉了。本文沿着 Andrew T. 公开的误差扩散二维码生成器的说明文档,把那份代价说清楚。
二维码由两种格子组成
原始资料把二维码分成两部分。一部分是功能图形,也就是角上那种粗方块之类的图案,扫描器靠它找到码、定出方向和大小。另一部分是数据模块,也就是剩下的全部,真正的数据和头部信息装在这里。
关键在于读取顺序。扫描器先用功能图形确定码的坐标系,然后才去读数据模块。所以功能图形必须非常清晰,而数据模块相对留有可以折腾的余地。各家品牌做的那些有个性的二维码用的就是这份余地;按资料的说法,扫描的稳健性确实会掉一些,但折腾得挺多也大体还能读出来。
照片放在哪里 —— 把一个模块切成九格
这里出现的技法很有意思。原始资料介绍的做法是:把一个模块切成 3×3 的格子,只保留中间那一格为数据规定的颜色,剩下八格让给照片。
它能成立的原因就在前面那个顺序里。扫描器以功能图形给出的位置为基准,看的是每个模块的中心。只要中心对了,周围就是自由的。所以在一个格子里只守住中心那一格、把八格填成画面,码照样能读,画也照样看得见。
做出来的成品是一张低分辨率的 1 位黑白照片。原始资料举例的图像是 147×147 像素。这个数字除以 3 得到 49,正好对上一边为 49 个模块的二维码。也就是说,它是把 49 个模块各自切成 3 格之后的结果。
于是九格里有一格是随机的
这种做法带着无法回避的噪声。每个模块中心的那一格,不管照片长什么样,都必须是数据规定的颜色。数据和照片毫无关系,所以从画面的角度看,那一格的颜色事实上是随机的。
原始资料就是这样准确表述的:九个像素里有一个事实上是随机颜色。于是结果图像上会出现像撒了椒盐一样的斑点。147×147 的 1 位图像本来就粗糙,但粗糙里相当一部分正是来自这九分之一。
这时通常会冒出一个解法:把那些中心像素挑几个改成贴合画面的颜色。因为二维码有纠错,错几个也照样能读。中间嵌 logo 的二维码正是靠这个原理工作的。然而原始资料的结论不一样。后面会讲到。
抖动 —— 不做二值化,而是把误差流出去
得先把画面这一侧的事说清楚。把亮度压成黑白两个值时,如果用简单的二值化,中间调就会全部消失。所以通常会用网格图案去模拟中间调,但规则的网格本身也会显出一种花纹。
Floyd-Steinberg 误差扩散换了个思路。照搬原始资料的说明是这样的:从左上角开始,像平常一样对像素做二值化。亮度超过 50% 就变白,否则变黑。如果某个像素原本是 70%,把它变白就等于多给了 30% 的亮度。把这 30% 分摊给还没处理的周边像素,让它们相应变暗。等轮到那些像素做二值化时,这个值就被计入了。
整轮跑完之后,图像的各个部分在平均意义上更接近原来的亮度。而且出来的是不规则的图案而不是网格,看着没那么碍眼。
核心思路 —— 先把数据模块的误差流出去
现在两条线汇合了。原始资料真正的想法是把误差扩散跑两遍。
第二遍是普通的 Floyd-Steinberg。不一样的是第一遍:它只针对数据模块的中心像素。这些像素的颜色已经定死了,那就按定死的颜色涂上,并把这一刻产生的误差流到周围的八格里去。
这里的误差可能变得非常大。普通的抖动里颜色是我们自己挑的,所以误差不会超过 50%。可在这种情形下,有可能要把一个非常暗的区域里的像素涂成纯白,那就会产生 95% 的误差。原始资料说,这听上去像是坏事,但恰恰因为如此,把它流出去才重要。直接吞下就会在原地留下一个斑点,流出去则会让周围相应变暗,平均值就对上了。
于是前面搁置的那个问题有了答案。原始资料的结论是:把几个数据模块改成贴合画面的颜色,这种做法对画质帮助不大,却把扫描性能削得很厉害。因为只要把误差好好流出去,改颜色的必要性本身就几乎消失了。
余量是一笔预算
这是本文最想说的一点。提升画质的那根轴,和消耗纠错余量的那根轴,是两根不同的轴。第二遍误差扩散一粒余量都不花就改善了画面。而改动数据模块,则是在直接烧掉余量。
想一想这份余量是为什么存在的,就明白这个区分为何重要。二维码的纠错等级有四档,制定规格的电装波动(Denso Wave)说明其中使用最广的那一档能恢复 15%。等级提高,恢复能力会上升,但同样的数据就需要更大的码。而这份恢复能力,本来是留给印刷瑕疵、褶皱、污渍、倾斜角度和昏暗照明的。
也就是说,为了好看而花掉余量,等于把为现场恶劣条件准备的预算挪去做设计。原始资料也走到同一个结论:贴在大屏幕或海报上的码没问题,但如果是会被揉皱的纸质传单,那份余量就会派上用场。它还补了一句:你用自己的手机在笔记本屏幕上读得很顺,并不等于陌生人拿着旧手机能在昏暗处从印刷品上读出来。
真正上手时会绊住你的 —— 留白与放大
原始资料最后附上的两条实务注意事项,同样值得原样搬过来。
第一条是留白。生成器给出的是一张没有留白的小图,但二维码要被稳定读取,外面需要留白。而且这圈留白的颜色,必须和角上大方块内侧的颜色相反。通常是白色,但如果你做的是反色的码,那就需要黑色背景。
第二条是放大。浏览器把小图放大显示时,默认会做模糊插值。被糊掉的边界会让扫描器难以判定模块中心在哪里。如果不在 CSS 里把插值关掉,这个问题会悄悄发生。
这两条有个共同点:在制作者的屏幕上一切正常,只在使用者的环境里失败。漂亮二维码的失败,大多就是这个形状。
小结与出处
往二维码里放画面是你的自由。只是最好知道这份自由是从哪里借来的。扫描失败发生在用户手里而不是我们的屏幕上,而那时候东西已经印完了。
- How to make error-diffused QR codes —— 功能图形与数据模块的区分、3×3 的切分、九分之一的噪点、两遍误差扩散、95% 的误差、留白与放大的注意事项,全部出自这份文档的讲解。
- 生成器 与 源代码 —— 可以自己动手做一个。
- 电装波动关于纠错等级的文档 —— 我从这里确认了有四个等级、使用最广的那一档是 15%、以及提高等级会让码变大这三点。其余三个等级的准确数值在该页面上只以图片形式提供,所以我没有写进正文。
- 147 除以 3 得 49 这个计算,以及它对应一边 49 个模块的说法,是我从原始资料的数字里推出来的。