原文:https://dev.to/remdore/i-flipped-one-bit-in-an-image-file-png-died-jpeg-lied-35b3(作者 @remdore)
一颗宇宙射线,一个损坏的SSD扇区,一次未完成的上传,一根劣质数据线。在你存储设备的某个地方,一张图片文件里的一个比特(bit)从0翻转成了1。图片会发生什么?
诚实的答案是,这完全取决于图像格式,而其中的差异远不止“有些格式更健壮”那么简单。我将同一张512×512的图片编码为PNG、JPEG、GIF、WebP、AVIF和BMP六种格式,在每个文件里精确地翻转一个比特,然后解码结果。每种格式测试四百次。
有两种格式的失效方式几乎是相反的,而看起来更健康的那个,恰恰是我最不信任的。

PNG 拒绝显示任何内容
先从最让我惊讶的结果说起。在PNG文件中进行的400次单比特翻转测试中:
死码: 400 变化: 0 完全一致: 0
无一例外。Pillow库拒绝解码其中任何一张图片。我原以为这只是某个库过于严格所致,于是用OpenCV(其底层使用libpng)重新处理了这些损坏的文件,结果完全一样:120次测试全部被拒绝,并给出了具体原因。
libpng error: IDAT: CRC error libpng error: bad adaptive filter value libpng error: IDAT: invalid bit length repeat
这正是该格式按设计应有的表现。PNG为每个数据块(chunk)存储CRC32校验和,因此被翻转的比特会被检测到而非被吸收。同时,图像数据是一个完整的DEFLATE压缩流,一个能通过校验和检查的翻转会使解压器失步,其后的所有数据都将变成乱码。
除非解码的是浏览器
然后,我将那些同样被拒绝的文件交给Chromium处理,它渲染了其中10张。
不过并非完全正确,而这正是有趣之处。我将每张图片绘制到Canvas元素上,并与原图进行逐像素比较。浏览器绘制出了48.6%的图像内容(中位数),并且每个被绘制的像素都与原始像素完全匹配。它解码到损坏点就停止,将剩余部分留白。
因此,一张发生比特腐坏的PNG图片,对大多数用户显示的是半张图,却给你的后端抛出一个异常。你的缩略图生成器、你的CI流水线、你的图像处理管道都会拒绝这个文件,而你的访客却能看到一部分。
JPEG 给你一张完整但处处错误的图片
现在对有损格式进行同样的测试,同样在浏览器中度量:
- PNG:浏览器拒绝 0/10,绘制图像比例 48.6%,像素匹配率 48.6%
- JPEG:浏览器拒绝 0/10,绘制图像比例 100%,像素匹配率 3.6%
- WebP:浏览器拒绝 0/10,绘制图像比例 100%,像素匹配率 2.3%
- GIF:浏览器拒绝 0/10,绘制图像比例 100%,像素匹配率 11.8%
- AVIF:浏览器拒绝 1/10,绘制图像比例 100%,像素匹配率 2.3%
- BMP:浏览器拒绝 0/10,绘制图像比例 100%,像素匹配率 100%
请仔细看最后一列。JPEG生成了一张完整、清晰、看似正常的图片,其中96%的像素颜色都是错的。
回顾截图,你就会明白为什么这容易被忽略:损坏的JPEG看起来没问题。损坏落在一个DC系数上,解码器愉快地将误差沿扫描过程向前传递,最终输出的是一张整体色调略微偏移的图片。没有任何迹象表明它“损坏了”。它只是坏了。
PNG给你半张图,并对此据实相告。JPEG给你一整张图,却悄悄撒了谎。
几乎不受影响的那个
BMP达到了100%绘制和100%匹配,这并非表格错误。它存储原始像素,没有压缩,没有校验和,也没有熵编码,一个比特翻转只改变了262,144个像素中一个像素的一个颜色通道。在400次测试中,其损害中位数小到无法计量。
这个完全没有错误检测的格式,反而是退化最平滑的,因为错误没有共享状态可以传播。正是压缩技术,将一个坏比特变成一个毁掉的文件。
然后我部署了它,平台自有其看法
我将实验包装成一个小型应用,让损坏可见而非仅仅描述,然后直接从一个公共Git仓库部署到DigitalOcean的App平台——无需注册表,无需CI,只需一个规格说明和克隆地址。大约两分钟后,它就构建完成并上线了。
然后它立即为一种格式返回了500错误:
File "/workspace/main.py", line 18, in encode im.save(buf, format=pil, ...) KeyError: 'WEBP'
那个Python构建包中的Pillow库没有WebP编码器。我的第一次修复让情况更糟,因为我用features.check("webp")来检查格式支持——该函数报告的是解码支持,并在一个完全无法写入该格式的构建上返回了True。正确的检查方式是看该格式是否在Image.SAVE字典中,这是Pillow在保存图片时实际查阅的注册表。解决了这个问题后,应用从列表中移除了WebP,只提供它能编码的四种格式。
这是一个意外发现的有用教训:实验在我自己的机器上运行完美,却是在另一个Python构建、另一家基础设施上,才暴露出我的能力检查一直问错了问题。
实际应用建议
如果图片很重要且后续还要处理,建议在旁边存储一个哈希值。这不是因为PNG格式没有校验和——它其实有很好的校验机制——而是因为JPEG、WebP和AVIF格式根本不会告诉你任何问题,等到有人发现颜色不对时,原图早已无从追溯。
如果图片要经过任何处理流水线,最好在接收时就进行解码,并将解码失败视为真正的错误而非可以忽略的问题。因为上文提到的那些格式可以清晰分为两组:其中只有一组会在出错时主动报错。PNG格式会抛出异常终止任务,这看起来挺烦人,但相比之下,JPEG、WebP和AVIF会默默生成一张完整的图片,什么都不说。解码器成功返回仅表示文件可解析,而对这三种格式而言,这根本无法说明文件中的字节是否与你存储的原始数据一致。
在搭建浏览器测试时,我还遇到一个实际问题。最初版本用 naturalWidth 大于零来判断图片是否“已渲染”,这是常规做法,但按此标准,所有损坏的PNG图片都显示为完美渲染。只有将图片绘制到画布上并统计像素时,才发现一半的画面根本没有被绘制出来。这个标志位告诉我解码过程已经开始,但并不代表它已完成——我差点就写下“浏览器完全忽略了PNG损坏”这样的结论。
因此,如果遇到用户报告“图片在浏览器里看着正常,但处理流程出错”的情况,这两种说法可能同时成立。Chromium 会绘制它能解析的部分然后停止;libpng 则直接拒绝文件;两者都没有故障。我花了一整个下午才弄清楚这一点,上方的数据就是测试结果。
演示项目、脚本和原始数据已发布在 [github.com/DimitrovK/bitrot-demo](https://github.com/DimitrovK/bitrot-demo)。用于生成截图的应用程序运行在 App Platform 的最小实例上,现已下架。
原文:https://dev.to/remdore/i-flipped-one-bit-in-an-image-file-png-died-jpeg-lied-35b3(作者 @remdore)



