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. 给我们的启示
- 排查"损坏图片"别急着修图 先看文件头、文件大小、字节结构。大小与像素数量精确吻合时,优先考虑"格式误判"。
- 对 TWAIN/ISIS 扫描接口要显式指定输出格式 如果需要 JPEG,应在扫描参数或保存步骤里把压缩打开;只取原始 buffer 落盘,得到的一定是 raw 位图。
- 原始位图是可以救回来的 只要知道 dpi、纸型、色彩模式,就可以反推出图像尺寸,再用 Python/OpenCV/PIL 加个头就能正常使用。
- 文件扩展名不可信 扩展名是
.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、灰度),只要替换对应的 W、H 和 'RGB' 即可。
9. 一句话总结
它不是 JPEG 坏了,而是扫描仪把原始位图 交给了你,你却把它当JPEG保存了。文件名会骗人,但字节大小和分辨率不会。