判断JPEG是不是渐进式,搜文件头会被缩略图骗

最近要在商品图的上传链路里加一个检查,把渐进式 JPEG 挑出来单独走一条处理路径。起因是缩略图服务那边测过渐进式解码明显更慢。判断一张 JPEG 是不是渐进式时网上最常见的写法是在文件里找 FF C0 和 FF C2 这两组字节。先碰到 C0 就算基线式。先碰到 C2 就算渐进式。我第一版也是这么写的,结果自测时我拿一张照片转成渐进式去测,它却告诉我这是基线式。

那张照片是网上找的一张 Wikimedia Commons 图片。原图 5472×3648,带着完整的 EXIF。我用 jpegtran 把它无损转成渐进式时加了 -copy all 把元数据原样留下。转出来的文件我用两种办法判。一种是我那版全文件搜字节的写法。另一种是按段长逐段往后跳。结果前者说基线式,后者说渐进式。

我把文件用十六进制翻出来看。FF C0 第一次出现在偏移 0x4f6 处即第 1270 字节。真正的 FF C2 在 0xcc41 也就是第 52,289 字节。0x4f6 落在开头那个 APP1 段里,也就是 EXIF 所在的段。拍摄设备或修图软件写 EXIF 时常会在这里塞一张完整的小 JPEG 当预览。这张小图有自己的文件头和基线式的帧头。全文件搜字节的写法先撞上的就是它。文件里其实还有第二处 FF C0 在 0x4b49,落在 Photoshop 写的 APP13 段里。我猜也是一张预览图,没再细查。

问题出在没按 JPEG 的结构去读。JPEG 文件是一串段。每段以 FF 加一个标记字节开头,后面两个字节是这一段的长度。APP1 的长度字段把里面那张缩略图整个包了进去。按长度跳过去的话缩略图里的字节一个都不会碰到。帧头 SOF 一定出现在第一个扫描段 SOS 之前。扫描段后面跟的是压缩数据,里面出现的 FF 都会被编码器补上一个 00 所以冒不出假的标记。从头逐段往后跳就够了。遇到 SOF 就能下结论,遇到 SOS 还没见到 SOF 就说明文件有问题。改过以后的判断函数是这样的:

function frameKind(bytes) {
  let pos = 2;
  while (pos + 4 <= bytes.length) {
    const marker = bytes[pos + 1];
    if (marker === 0xDA) return 'no-sof';
    if (marker === 0xC0 || marker === 0xC1) return 'baseline';
    if (marker === 0xC2) return 'progressive';
    pos += 2 + ((bytes[pos + 2] << 8) | bytes[pos + 3]);
  }
  return 'no-sof';
}

这是删掉了文件头校验的精简版,线上那份还多判了开头的 FF D8 和段之间允许出现的填充字节。C1 是扩展的顺序式,解码行为和基线式一样,所以我把它一起归到了基线式里。C3 这类无损或算术编码的变体在商品图里基本见不到。函数碰到它们会继续往后跳,最后落到 no-sof 交给人工看。我拿它把手上几份文件逐项对了一遍。原始照片判基线式,转出来的那份判渐进式。Pillow 直接编出来的几份基线式和渐进式也都判对了。全文件搜字节的写法只在那一份带 EXIF 的渐进式上出错。别的几份都没有元数据,两种写法结果一样。

Python 那边其实不用自己写。Pillow 打开文件后看 info 里有没有 progressive 这个键就行,那份带 EXIF 的渐进式它也判对了,因为它打开文件时本身就是按段读文件头的,并不会去解码整张图。我们的上传服务跑在 Node 上,为了判这一个类型不想再引一个图像库进来,才自己写了这十几行。

这类错误在测试环境里很难暴露。自己造的测试图大多是程序编出来的,本来就不带 EXIF。前端导出的图也不会触发这个问题。我平时压图用的是图映 ImgIng,一个在浏览器里压缩、转换图片的在线工具。之前留存的两份它导出的 JPG 用两种写法判都是基线式,它走的是浏览器原生编码,本来就出不了渐进式。容易出事的是用户直接从相机或手机传上来的原图。还有一类是经过服务端转码却保留了元数据的图,那次我手上的样本就属于这一类。

检查里我后来又补了一条规则。判不出结果的文件不进渐进式那条路径而是直接记日志。链路里有类似判断的话可以找一张带 EXIF 的相机原图用 jpegtran -copy all -progressive 转一份再拿判断代码跑一次。判成基线式就说明搜字节的写法被缩略图骗了。

posted @ 2026-10-02 10:45  波特因子  阅读(3)  评论(0)    收藏  举报