二维码说能恢复 30%,前提是墨水别撒开

上周在一家咖啡店,桌角那张点单码被杯子压出了一圈水渍,边缘磨得发白。我举着手机对了三秒,扫出来了。旁边那张贴在墙上的码完好无损,反而扫不出来——因为它印得太小。

这件事让我有点不爽。我一直以为二维码的容错是「坏掉多少面积」的问题,那天才发现它更像是「坏在哪个位置、坏成什么形状」的问题。回家我把这件事写成了代码:生成二维码,按各种方式涂坏,再丢给解码器看它认不认。

墙上一张被咖啡渍和划痕弄脏的二维码海报,边缘有些小方块已经磨白

下面所有数字都来自我写的脚本:Node v24.16.0,Linux x86_64,用 qrcode 生成符号、用 jsQR 解码。生成和解码都在内存里完成,不涉及真实相机和真实打印,所以下面讲的都是纠错码能救的部分——对焦糊掉、手抖、反光这类问题不在里面,那些跟 Reed-Solomon 一点关系都没有。

# 一、一半的小方块本来就不装数据

先说个跟纠错无关但很影响直觉的事实。

一个 v1 的二维码是 21×21,总共 441 个小方块。其中真正用来装内容的只有 208 个,剩下 233 个——52.8%——是定位角、分隔符、时序图案、格式信息、校正图形这些「功能图案」。它们存在的唯一目的是让扫描器知道「这是一张二维码、它朝哪边、多大、用的什么参数」。

版本越大,这个比例越低:

版本 尺寸 总模块 功能图案 占比
v1 21×21 441 233 52.8%
v2 25×25 625 266 42.6%
v5 37×37 1369 290 21.2%
v10 57×57 3249 481 14.8%
v20 97×97 9409 726 7.7%
v40 177×177 31329 1681 5.4%

所以那些印得小小的、只装一个短网址的码,有一半面积是「包装」。麻烦的是包装不参与纠错——污损落在定位角上,这张码就当场死掉,一个码字的冗余都救不了。对小码来说,随机撒开的脏点有超过一半的概率砸在这片不容错的区域上。

二维码网格特写,三个角的定位方块和密密麻麻的小模块

# 二、「能恢复 30%」说的是码字,不是小方块

你在很多地方都见过这句话:L 能恢复 7%、M 15%、Q 25%、H 30%。

问题在于「恢复」这个词没有指明单位。我把 v40(177×177,最大的那个)在四个等级下的码字构成算了一遍:

等级 数据码字 纠错码字 总码字 纠错码字占比 可纠错码字 可纠错码字 ÷ 总码字
L 2956 750 3706 20.2% 375 10.1%
M 2334 1372 3706 37.0% 686 18.5%
Q 1666 2040 3706 55.0% 1020 27.5%
H 1276 2430 3706 65.6% 1215 32.8%

最右边那列才是「能恢复百分之多少」的码字版本:L 10.1%、M 18.5%、Q 27.5%、H 32.8%。跟流传的 7/15/25/30 是一个量级,越高级越接近。

一个码字是 8 个小方块。 这是整篇文章里最要紧的一句话。

于是 32.8% 这个数字说的是「3706 个码字里可以有 1215 个出错」,而 1215 个码字 × 8 = 9720 个小方块,占 31329 个总方块的 31%(H 级,且假设这些错误恰好整整齐齐地分布在码字里)。听起来确实像「30% 的面积」。

但现实里的污损不会替你排好队。

# 三、一个码字里坏 1 个方块,和坏 8 个,价钱一样

Reed-Solomon 纠错的基本单位是一个码字(GF(256) 上的一个符号)。一个码字里错了 1 位和错了 8 位,对解码器来说是同一件事:这个符号错了。

我直接测了这件事。做法是把二维码的模块位置按标准顺序还原成码字序列,然后随机挑 k 个码字——第一种是把它 8 个方块全翻,第二种是每个码字只翻 1 个方块——看解码器还能不能认出来(50% 成功率的临界值):

版本 等级 理论可纠错码字 整码字全坏(实测) 每码字坏 1 位(实测)
v5 L 13 13 13
v5 M 24 21 22
v5 Q 36 28 27
v5 H 44 36 36
v40 L 375 230 243
v40 M 686 405 389
v40 Q 1020 589 591
v40 H 1215 703 723

两列实测值几乎重合。v5-L 那一行最干净:理论 13,整码字全坏 13,坏 1 位也是 13,一模一样。坏 1 个方块和坏 8 个方块,账单完全相同。

顺带说一句,v40 那几行的实测值只有理论上限的 55%~65%。这是 jsQR 这个纯 JS 解码器的实现水平,不是标准的极限——换 ZXing 那类解码器大概率更接近理论值。下面所有数字都建立在这一个解码器上,你把它当成「一个还算体面的普通解码器」就行。

# 四、撒开的墨水,比一整块墨迹致命十倍

既然费用按码字算,那么两条显而易见的推论就来了:错误越集中越划算(一堆坏方块挤在同一个码字里,只收一份钱),越分散越亏(每个坏方块都落在不同的码字上,各收一份)。

我测了这两种极端。左边是「把 N 个坏方块随机撒在整个数据区」,右边是「同样多的坏方块挤成中心一个正方形」:

版本 等级 撒开:50% 成功率的临界值 撒开占全图 中心方块最大边长 方块最大面积占比
v5 L 14 个 1.02% 9(24.3%) 5.9%
v5 M 24 个 1.75% 11(29.7%) 8.8%
v5 Q 32 个 2.34% 14(37.8%) 14.3%
v5 H 42 个 3.07% 16(43.2%) 18.7%
v10 L 30 个 0.92% 15(26.3%) 6.9%
v10 M 54 个 1.66% 20(35.1%) 12.3%
v10 Q 79 个 2.43% 25(43.9%) 19.2%
v10 H 95 个 2.92% 27(47.4%) 22.4%
v20 L 84 个 0.89% 27(27.8%) 7.7%
v20 M 152 个 1.62% 33(34.0%) 11.6%
v20 Q 223 个 2.37% 41(42.3%) 17.9%
v20 H 259 个 2.75% 39(40.2%) 16.2%
v40 L 249 个 0.79% 51(28.8%) 8.3%
v40 M 410 个 1.31% 66(37.3%) 13.9%
v40 Q 660 个 2.11% 83(46.9%) 22.0%
v40 H 791 个 2.52% 91(51.4%) 26.4%

最直接的对比是这一组:v40 的 H 级,791 个坏方块撒在整张图上,一半的样本扫不出来;而784 个坏方块挤成中心一个 28×28 的方块,稳稳扫出。数量几乎一样,结局完全相反。

(表里的中心方块边长是一格一格试出来的、遇到第一个扫不出的边长就停,所以会有一两格的抖动——比如 v20 的 H 行停在 39,比 Q 行的 41 还小。这不代表 H 比 Q 差,只是那两格恰好踩在边界上。)

再看最右边那列:v40 上能遮掉的面积上限是 L 8.3% / M 13.9% / Q 22.0% / H 26.4%。

这四个数字我看了很久。它们跟流传的 7 / 15 / 25 / 30 挨得非常近。我猜那句「能恢复 X%」最初想表达的就是这个意思——一张码能被遮住多大一块还能扫——只不过在口口相传里,它被安到了「码字」头上,然后又被读者理解成了「任意位置任意形状的 30% 面积」。

而真实情况是:这个 X% 只在错误连成一整块的时候才成立。

左右两张二维码对比,一张撒满细小黑点,一张被一整块墨迹盖住中间

# 五、所以 logo 到底能贴多大

上面那张表的最右列,实际上就是「中心 logo 的面积上限」,而且是能直接拿去用的结论:

  • L 级:logo 面积别超过 8%(边长约 29%)
  • M 级:别超过 14%(边长约 37%)
  • Q 级:别超过 22%(边长约 47%)
  • H 级:别超过 26%(边长约 51%)

业界那句「logo 不要超过二维码面积的 20%」是对的,但它默认你用的是 Q 或 H。如果你图省事用了默认的 M——顺带说,qrcode 这个库的默认等级就是 M——那么 20% 已经越界了。

我用 v5 单独验了一遍「中心挖空」这件事(不是随机撒,是真的把中心一块涂黑):

logo 边长占尺寸 L M Q H
10%(4×4) 能扫 能扫 能扫 能扫
20%(7×7) 能扫 能扫 能扫 能扫
25%(9×9) 扫不出 能扫 能扫 能扫
30%(11×11) 扫不出 能扫 能扫 能扫
35%(13×13) 扫不出 扫不出 能扫 能扫
40%(15×15) 扫不出 扫不出 扫不出 能扫

一个 37×37 的小码,L 级连边长为 25% 的 logo 都扛不住,H 级能扛到 40%。如果你要往码中间贴东西,别犹豫,直接上 H。

店铺玻璃门上贴着一张二维码,正中间盖着一个圆形 logo

# 六、等级越高,方块越小——这笔账很多人没算

这是我在写这篇文章之前完全没想过的一层。

纠错等级提高,冗余变多,同样的内容需要更大的版本才能装下。而如果你把码打印成固定的物理尺寸,版本变大意味着每个小方块变小。

同一段 62 字节的内容(一条再普通不过的网址),四个等级下是这样:

等级 版本 尺寸 总模块 打印成 2cm 见方时每模块
L v4 33×33 1089 0.606 mm
M v4 33×33 1089 0.606 mm
Q v6 41×41 1681 0.488 mm
H v7 45×45 2025 0.444 mm

从 L 提到 H,纠错能力翻了三倍多(375 → 1215 个可纠错码字),代价是每个方块从 0.606mm 缩到 0.444mm,小了 26.7%。

在小尺寸印刷上,这笔交易可能是亏的。方块越小,打印机的一点点扩散、纸张的一点点洇墨、镜头的一点点解析力不足,都会让整片区域一起失效——而整片失效是纠错码最怕的吗?不,恰恰相反,成片失效是它最擅长救的。真正麻烦的是边界模糊导致每个方块的判定都变成掷硬币,那就是撒开型的错误,回到上一节的表格:撒开型错误的预算只有零点几个百分点。

所以我的结论有点反直觉:如果你要把码印得很小,提高纠错等级未必是好事。 小尺寸下真正该做的是缩短内容——少一个字符可能就少一个版本,少一个版本就少 8 个模块。

顺带说说容量悬崖。字节模式下 M 级的跳档点是这样:

42 字符 → v3  29×29  ( 841 模块)
43 字符 → v4  33×33  (1089 模块)   +29.5%
63 字符 → v5  37×37  (1369 模块)   +25.7%
85 字符 → v6  41×41  (1681 模块)   +22.8%
107 字符 → v7  45×45 (2025 模块)   +20.5%

一个字符之差,面积多 25.7%。你往 URL 后面多加一个 ?,码就大了一圈,方块就小了一圈。那些长得吓人的带参追踪链接,代价是实打实的。

而 v40-L 的上限是 7089 个数字、4296 个大写字母数字、2953 个字节,换算成 UTF-8 中文只有 984 个字。v1-L 更惨,17 个字节,5 个汉字。中文在二维码里是三倍价钱,因为标准里的「汉字模式」是给日文 Shift-JIS 设计的(13 位一个字),中文基本都走字节模式,一个字 3 字节。

一张极小的二维码标签,上面的模块细到需要放大镜才看得清

# 七、真正让码扫不出来的,往往不是污损

最后这几条跟纠错一点关系都没有,但它们解释了我在生活里遇到的大部分「扫不出来」。

定位角被挡住,纠错码一点忙都帮不上。 我把 v5-H 的三个角的 8×8 区域分别整个涂黑、再整个涂白,六种组合全部扫不出。定位角不参与纠错,它是网格坐标系本身,坐标系错了,后面的码字全读错位置,再强的纠错也白搭。所以那些把二维码塞进圆角容器、让 logo 压住一角、或者用手指捏着角拍照的设计,是直接废掉整张码。

静默区。 规范要求四周留 4 个模块的白边。我实测下来,jsQR 连 0 静默区都能认出来——但这是解码器在帮你兜底,规范写的是 4,别人的扫码枪不一定这么好脾气。留 4 个模块,成本几乎是零。

反色。 白码黑底的二维码很常见,我测的 jsQR 认不出来。有些手机相机 App 能扫,有些不能,取决于解码器有没有做反转重试。别做反色码,这是纯粹的赌博。

旋转和镜像反而没事。 我把码左右镜像、旋转 90°、旋转 180°,jsQR 全部认出来了。这归功于那三个定位角——它们不在对角线上,所以能唯一确定这张码的朝向。这是二维码比一维条形码强的地方之一:你随便怎么举手机都行。

一只手拿着手机,正在扫咖啡桌上的一张二维码贴纸

# 八、写完之后的想法

二维码是 1994 年在日本电装(Denso Wave)做出来的,原昌宏那个小组的本意是给汽车零件做追溯——原来的条形码装不下太多字,工人的扫码枪还得对准,于是他们做了个能装几十倍信息、还能从一个角度瞬间读取的东西。Denso Wave 手里有专利,但公开表示不行使,这也是它能铺满全世界的原因之一。

三十多年过去,它被贴在了早餐摊、共享单车、共享充电宝和我的快递箱上,成了一个几乎没人会多看一眼的东西。

我这次把它拆开算了一遍,最想记下来的不是那些百分比,而是一个很朴素的道理:冗余是为某种特定的失败方式准备的,不是为所有失败方式准备的。 二维码的纠错码能扛住一整块墨迹,却扛不住撒开的灰尘;能扛住旋转和镜像,却扛不住一个被挡住的角。

我们给系统加的每一层容错大概都是这样。它买下的不是「更安全」,而是「在某种特定的坏法下更安全」。至于那种坏法是不是你真正会遇到的那种,得自己去试。

——而我发现,试的办法比想象的便宜:一台机器,一个下午,几万张被我涂坏的二维码。