一张损坏JPEG文件的背后排查

11 MB 的"损坏 JPEG" 文件背后:原始位图的一场误会

一次扫描件打不开的排查记录。读完你会发现:它不是文件损坏,也不是加密,而是扫描仪把未压缩原始位图交给了调用方,但调用方把它当成 JPEG 存盘了。


1. 事发

某天收到同事反馈:用佳能文档扫描仪(imageFORMULA DR-M160 类似机型)扫出来的文件,在系统里打不开。

文件线索:

  • 扩展名:.jpg
  • 大小:约 11 MB(11,594,142 字节)
  • 创建软件:扫描仪配套软件 / 业务系统集成扫描接口
  • 同一个扫描任务,扫描仪自带软件的"另存为 JPG"只有 300 KB
  • 业务系统落盘的文件却始终 11 MB

2. 初判:它不是 JPEG

拿到文件后先看标准文件签名。JPEG 的开头应该是 FF D8 FF

文件类型 标准文件头
JPEG FF D8 FF
PNG 89 50 4E 47
BMP 42 4D
GIF 47 49 46 38

但该文件的前 16 字节是:

bash 复制代码
1e1 e6 e9 da df e2 d5 da dd df de ec e2 e1 ef e8

完全没有标准签名。用常见工具打开自然失败。

进一步扫描文件内部:

Marker 次数 说明
FF D8 FF 0 JPEG 头不存在
FF D9 78 JPEG 结束标记偶发出现,但位置随机
PNG 签名 89 50 4E 47 0 不是 PNG
BMP 头 42 4D 几例 疑似偶发,不是真正 BMP

结论:这不是被截断的 JPEG,也不是常见图像格式。


3. 字节分布透露的信息

3.1 熵很低

  • 信息熵:4.63 bit/字节(完全随机约 7.8~7.9)
  • 说明数据有结构、有规律,不是加密或强压缩的结果。

3.2 字节高度集中在高位

Top 高频字节:

字节 占比
0xFA 11.46%
0xF9 10.55%
0xFB 9.80%
0xF8 9.10%
0xFC 8.21%

基本都在 0xF0~0xFF 区间。这说明图像内容以接近白色的亮部为主,非常符合扫描白纸文档的特征。

3.3 存在 3 字节周期

相隔 3 字节的字节相同率高达 27.83% ,远高于其他步长。文件末尾则是 fa fd fc fa fd fc ... 的 3 字节重复。

这强烈暗示数据是每 3 字节一组,也就是 24-bit 彩色(RGB/BGR 每像素 3 字节)。


4. 关键计算:11 MB 是什么

如果这是一张 24-bit 彩色图像,每个像素 3 字节,那么总像素数为:

ini 复制代码
111,594,142 ÷ 3 = 3,864,714 像素

已知这台扫描仪扫描设置的分辨率是 200 dpi。A4 纸的尺寸是 210 mm × 297 mm,换算到 200 dpi:

yaml 复制代码
1宽  = 210 mm × 200 / 25.4 ≈ 1653 px2高  = 297 mm × 200 / 25.4 ≈ 2338 px3总像素 = 1653 × 2338 = 3,862,714

再乘以 3 字节:

ini 复制代码
11653 × 2338 × 3 = 11,594,142 字节

完全吻合。

所以这 11 MB 的文件,本质上就是:

200 dpi、A4、24-bit 彩色、未压缩的 RAW 位图数据。

只是它没有 BMP/TIFF 文件头,也没有 JPEG 压缩,却被写上了 .jpg 扩展名。


5. 还原图像验证

用 Python 按 1653 × 2338 × 3 解析数据并写入 PNG:

scss 复制代码
1import zlib, struct, binascii23W, H = 1653, 23384raw = open('sample-scan.bin', 'rb').read()5assert W * H * 3 == len(raw)67def chunk(typ, payload):8    c = struct.pack('>I', len(payload)) + typ + payload9    crc = binascii.crc32(typ + payload) & 0xffffffff10    return c + struct.pack('>I', crc)1112ihdr = struct.pack('>IIBBBBB', W, H, 8, 2, 0, 0, 0)13filtered = bytearray()14stride = W * 315for y in range(H):16    filtered.append(0)17    filtered += raw[y*stride:(y+1)*stride]1819png = b'\x89PNG\r\n\x1a\n'20png += chunk(b'IHDR', ihdr)21png += chunk(b'IDAT', zlib.compress(filtered))22png += chunk(b'IEND', b'')23open('restored-rgb.png', 'wb').write(png)

还原后得到的是一张完整的 A4 扫描文档,表格、文字都清晰可读。

这也解释了另一个现象:

  • 扫描仪自带软件的"另存为 JPG"300 KB:因为它对原始位图做了 JPEG 压缩;
  • 业务系统落盘的 11 MB:因为它把原始位图直接写成了文件,根本没压缩。

6. 为什么 BMP 正常、JPG 不行

佳能 DR-M160 这类扫描仪通过 TWAIN/ISIS 驱动对外输出时,通常交付的是未压缩位图。至于最终存成 JPG、BMP、TIFF 还是 PDF,是上层软件负责封装和压缩。

如果上层软件直接把扫描缓冲区落盘:

  • .bmp 或者给自己加的 .jpg,文件大小都是 11 MB 左右;
  • 因为 .bmp 格式本身可以无压缩,所以写入头信息后反倒能正常打开;
  • .jpg 必须有 JPEG 压缩数据,直接塞 raw 位图进去,怎么看都是"损坏"。

所以,问题根因不是扫描仪坏了,也不是文件传输过程损坏,而是:

上层软件没有请求/执行 JPEG 压缩,误把原始位图当作 JPEG 保存。


7. 给我们的启示

  1. 排查"损坏图片"别急着修图 先看文件头、文件大小、字节结构。大小与像素数量精确吻合时,优先考虑"格式误判"。
  2. 对 TWAIN/ISIS 扫描接口要显式指定输出格式 如果需要 JPEG,应在扫描参数或保存步骤里把压缩打开;只取原始 buffer 落盘,得到的一定是 raw 位图。
  3. 原始位图是可以救回来的 只要知道 dpi、纸型、色彩模式,就可以反推出图像尺寸,再用 Python/OpenCV/PIL 加个头就能正常使用。
  4. 文件扩展名不可信 扩展名是 .jpg 不代表内容就是 JPEG。业务系统落盘时最好检查一下魔数(magic bytes),别只依赖文件名后缀。

8. 如果临时要救这些数据

假设你手里有一批同样的 11 MB "损坏 JPG",可以用下面这段脚本批量还原为 PNG:

python 复制代码
1import os2from PIL import Image34W, H = 1653, 233856for fname in os.listdir('.'):7    if not fname.lower().endswith('.jpg'):8        continue9    with open(fname, 'rb') as f:10        data = f.read()11    if len(data) != W * H * 3:12        continue13    img = Image.frombytes('RGB', (W, H), data)14    base, _ = os.path.splitext(fname)15    img.save(f'{base}.png', 'PNG')16    print(f'{fname} -> {base}.png')

如果扫描设置不同(比如 300 dpi、A5、灰度),只要替换对应的 WH'RGB' 即可。


9. 一句话总结

它不是 JPEG 坏了,而是扫描仪把原始位图 交给了你,你却把它当JPEG保存了。文件名会骗人,但字节大小和分辨率不会。

相关推荐
nnerddboy1 小时前
Rust教程06:ESP32-rust环境搭建
开发语言·后端·rust
步行cgn1 小时前
MyBatis resultMap 结果映射完全指南
后端
nnerddboy1 小时前
Rust教程03:函数,控制流与所有权
开发语言·后端·rust
山荷枝2 小时前
04-框架--SpringBoot
java·spring boot·后端
Undoom3 小时前
用TextIn xParse搞定批量简历解析,WorkBuddy里一句话就能完成
后端
乐橙开放平台4 小时前
一周上线校园透明化:listDeviceDetailsByPage 台账 + getKitToken + ImouPlayer 多路墙
后端·物联网·安全·音视频·智能家居
樊小肆4 小时前
# 你还在等DeepSeek官方 agent Harness‌? 来试试 DeepSeeker-Code吧
前端·人工智能·后端
樊小肆4 小时前
2568 万 token 才花 2 块 2:聊聊 DeepSeeker-Code 怎么吃满上下文缓存
前端·人工智能·后端
众人皆醒我独醉4 小时前
大模型训练优化:FSDP、DeepSpeed ZeRO 与混合精度
后端·面试·gpu