简介:面向需要处理 JPEG 文件损坏问题的用户,JPEG-Repair 2.8.254 源代码包提供 JPEG-Repair 与 JpegDigger 两个实用工具,可修复损坏的 JPEG 头/标记、由坏扇区引起的损坏,并支持从存储卡中恢复丢失的 JPEG 文件,兼容 NEF、CR2、ORF、RW2、ARW、DNG、CR3、RAF 等主流 RAW 格式。资源共 3 个文件,整体仅 5KB,主要由 HTML 展示页面、.gitignore 版本控制配置和 .inscode 工程入口组成,是一个便于快速了解工具交互与工程结构的精简代码包。已有 63 人浏览学习。开发者可直接查看页面逻辑和工程配置,理解本地修复与恢复流程的展示方式,并基于源码继续扩展功能;对希望评估工具能力的普通用户来说,也可从源码层面了解其本地处理、不上传服务器的隐私特性及免费预览机制。
1. JPEG修复工具:你的照片打不开时,是否值得动手写代码
相机存储卡突然不识别、微信传图传一半失败、从网页另存下来的图片双击报错——这些场景里,JPEG修复工具是最后一道救命稻草。所谓JPEG修复工具,本质上不是魔法,而是一段读懂 JPEG 文件结构的代码:它检查文件头、文件尾和中间的段结构,把缺失的标记补回去,把错位的字节理正。我见过太多人第一反应是找收费软件,其实相当一部分“打不开”的 JPEG 只是丢了 SOI 或 EOI,几十行脚本就能救回来。这篇文章适合手里攒了一批坏图的摄影爱好者,也适合要给用户做图片自愈功能的开发者。先说明一个反直觉结论:大量坏图不是数据没了,而是结构缺了骨头,补上就好。
2. 先搞懂JPEG为什么会坏:文件结构与三种损坏模型
2.1 JPEG不是一张图,是一串段(Segment)
很多人以为 JPEG 是“一整块像素数据”,其实它是按“段”组织的字节流。每个段由标记码开头,标记码是 0xFF 加一个类型字节,例如 0xFFD8 表示图像开始(SOI),0xFFD9 表示图像结束(EOI)。除了这两个标记,其余段都自带一个两字节的长度字段,长度数值包含长度字段自身,但不包含标记码。这是整个修复工作的地基:只要能按长度字段跳着读,就能把每个段找出来。
完整的 JPEG 骨架通常长这样:SOI 之后是 APP0(JFIF 头,写分辨率、版本),常见还有 APP1(EXIF 信息),再往后是 DQT(量化表)、SOF0(帧参数,含宽高和采样方式)、DHT(霍夫曼表)、SOS(扫描开始),然后才是真正的图像压缩数据流,最后以 EOI 收尾。修复工具要做的,就是验证这些段是否齐全、顺序是否合理、数据流是否足够。
# 用十六进制快速看一个 JPEG 的头 512 字节 xxd broken.jpg | head -n 32如果第一行不是ffd8开头,说明 SOI 丢了;如果在文件末尾看不到ffd9,说明文件被截断。我一般先用这条命令给文件“验明正身”,再决定走哪条修复路线。
2.2 三种常见损坏模型:截断、头缺失、字节错位
截断是最常见的。文件从某个位置被砍断,尾部没有 EOI,甚至最后的 SOS 数据流都是半截的。这种损坏通常发生在存储卡写入过程中断电、U盘拔太快、网络下载中断。修复思路是补 EOI 然后尝试解码,解码器会尽量把能读的块都渲染出来,但下半截可能出现灰块或马赛克。
头缺失是第二种。文件开头被覆盖了几个字节,或者从错误偏移开始读取,导致解码器找不到 SOI。这种坏图往往显示“无法打开,文件损坏”,但后面的段结构其实完好。修复难度最低:补一个 SOI,必要时再补一个 APP0,让解码器能认出来。
字节错位是第三种,也最阴险。文件中间混入了几个无关字节,或者某个段长度字段被改写,解码器按错位往后跳,后面全乱。这种靠纯脚本修复很考验运气,通常要先定位错位点再手动调整长度字段。
2.3 用二进制视图给损坏判级:先看能打开到什么程度
动手写修复逻辑前,我建议先给文件判级:能不能显示缩略图、能不能解码到一半、解码器报什么错。Windows 图片查看器、Pillow、ImageMagick 的报错信息差异很大,同一张图用不同工具打开,结果可能完全不一样。这不是玄学,而是解码器容错策略不同。
# 判断文件究竟坏到什么程度 file broken.jpg python3 -c "from PIL import Image; im=Image.open('broken.jpg'); im.load(); print(im.size)"如果 file 命令能识别出 JPEG,说明头还在;如果 Pillow 能读取 size 但 load 报错,说明 SOF0 正常但数据流有问题;如果 size 都读不出来,多半是头或参数段损坏。表 1 是我常用的判级参考,覆盖了大多数实战场景。
| 现象 | 可能原因 | 修复优先级 |
|---|---|---|
| file 报 “data” 而不是 JPEG | SOI/APP0 缺失 | 高 |
| Pillow 能读 size 但 load 失败 | SOS 后数据流截断 | 中 |
| 解码到一半出现灰条 | 文件尾部缺 EOI | 高 |
| 整张图颜色异常 | APP0/APP1 长度或内容损坏 | 低 |
判完级再动手,能避免把还能用的文件越修越坏。
3. 从零写一个最小 JPEG 修复脚本:可复现代码与参数
3.1 选型:为什么用 Python 标准库而不是现成工具
常见做法是用 Python 标准库的 struct 和 os 直接处理字节流,不引 Pillow 也能完成结构修复。ImageMagick 的convert broken.jpg out.jpg有时能自动补头,但它对段长度字段错乱的情况毫无办法;dd 命令可以手工补字节,但只能做固定偏移的拼接,遇到需要按内容改长度的场景就麻烦。写代码的好处是逻辑透明、可批量、可嵌入自己的图片处理流程。
下面这份代码是我在类似场景下会先搭的最小修复器:它只做三件事——检查 SOI、检查 EOI、缺什么补什么。同时保留原有字节流,避免过度处理。注意:这段代码只处理单帧 JPEG,不处理 MJPG 或渐进式 JPEG,原因在第 5 章讲。
import sys import struct def build_jfif_app0(): """构造一个最小 APP0(JFIF 1.02) 段,用于头缺失时补救""" body = b"JFIF\x00" + struct.pack(">HHBB", 0x0102, 1, 0x0048, 0x0048) + b"\x00\x00" return b"\xFF\xE0" + struct.pack(">H", len(body) + 2) + body def fix_jpeg(src, dst): data = open(src, "rb").read() if len(data) < 4: raise ValueError("文件太小,无法判定为 JPEG") out = bytearray() # 1. 头缺失:补 SOI 和 APP0 if data[:2] != b"\xFF\xD8": out += b"\xFF\xD8" # SOI out += build_jfif_app0() # 让解码器能识别为 JFIF print("[修复] 补 SOI / APP0") out += data # 2. 尾缺失:补 EOI if out[-2:] != b"\xFF\xD9": out += b"\xFF\xD9" print("[修复] 补 EOI") open(dst, "wb").write(bytes(out)) if __name__ == "__main__": fix_jpeg(sys.argv[1], sys.argv[2])这段代码的逻辑分两层。第一层是判定:先检查源文件前两个字节是不是FF D8,不是就说明 SOI 丢了。第二层是补救:补 SOI 时同时补一个 APP0,因为很多解码器要求 SOI 后面直接跟 APP0 才算合法 JFIF 格式,只补 SOI 不给 APP0,部分老工具会拒绝渲染。build_jfif_app0里最关键的参数是0x0102,它代表 JFIF 1.02 版本;后面的0x0048是默认 72dpi 密度,单位字节用1表示英寸。这些数值不用记,照抄即可,但千万不要把版本号写成0x0100,否则部分解码器会按 1.00 的旧规范解析,可能出现奇怪的缩放。
3.2 参数说明与调用方式
调用时直接传源文件和目标文件:python3 fix_jpeg.py broken.jpg fixed.jpg。这里我用两个参数而不是覆盖原文件,是刻意留的“后悔药”。实际项目中我还会加一个--backup-dir参数,把原始损坏文件移到备份目录,防止修复逻辑有 bug 把源数据也毁了。补 EOI 看起来简单,但有一个坑:如果文件尾部本来就有残缺的半字节数据流,直接补 EOI 后解码器可能从残缺位置开始渲染出彩色噪点。
遇到这种情况,我一般会把脚本改成:先从尾部往前找最后一个完整的段标记,把标记后面的残缺数据全部丢弃,再补 EOI。参考代码如下,这段逻辑比单纯补尾更实用。
def trim_and_close(data): # 从尾部往前找 FF D9,找不到就找最后一个 FF tail_index = -1 for i in range(len(data) - 1, 0, -1): if data[i] == 0xD9 and data[i - 1] == 0xFF: tail_index = i - 1 break if tail_index < 0: # 没找到 EOI,尝试找最后一个完整标记 for i in range(len(data) - 1, 0, -1): if data[i] == 0xFF and i + 1 < len(data): return data[:i] + b"\xFF\xD9" return data[:tail_index + 2]trim 的核心思想是:宁可牺牲尾部少量数据,也不让非法字节混进解码流程。它从后往前扫二进制流,找到紧邻的FF D9就认为 EOI 还在,否则截到最后一个单字节FF处补 EOI。这个“找尾巴”的思路,比直接拼接 EOI 更适合真实损坏文件。
3.3 验证最小修复是否有效
修复完先别急着打开看。用 Pillow 做一次严格解码,能过才算有效:
python3 -c "from PIL import Image; im=Image.open('fixed.jpg'); im.verify(); print('ok')"注意verify()不会完整解码像素,它只检查文件结构完整性,速度很快。要真正看图像内容,还需要再调一次load()。很多时候verify()能过、load()才报错,说明数据流还是有问题,那就不是补标记能解决的了。
4. 把脚本扩展成批量修复工具:扫描、校验与止损
4.1 批量扫描:先找出哪些文件真的需要修
单文件脚本只能救急,遇到几百张照片从存储卡恢复出来的场景,必须批量。常见做法是先用 Pillow 打开每一张 JPEG,能正常 load 的跳过,打不开的收集到列表里,再交给修复脚本处理。这样避免把正常文件也过一遍修复逻辑,因为修复逻辑对健康文件是无意义写入。
from PIL import Image import os, glob def scan_broken(root_dir): broken = [] for path in glob.glob(os.path.join(root_dir, "**", "*.jpg"), recursive=True): try: with Image.open(path) as im: im.load() # 完整解码像素,不是 verify() except Exception as e: broken.append((path, str(e))) print(f"[损坏] {path} -> {e}") return broken这段扫描用glob递归匹配所有.jpg,大小写不敏感的话建议再补*.JPG。Pillow 的load()会把整张图解码到内存,坏图基本都会在这里抛异常;只用open()不调load()的话,很多截断图会被漏判为正常文件。扫描是整个批量修复的入口,宁可误判也不漏判,后面再靠结构校验去二次排查。
4.2 批量修复:串行执行并追加结构校验
拿到损坏列表后,逐个调用前面写的修复函数。修复不是终点,我会在修复后立刻做两层校验:第一层是强制解码,第二层是检查输出文件是否出现“全灰”或“大面积花屏”。
def batch_fix(broken_list, out_dir): os.makedirs(out_dir, exist_ok=True) report = [] for src, err in broken_list: dst = os.path.join(out_dir, os.path.basename(src)) try: fix_jpeg(src, dst) with Image.open(dst) as im: im.load() report.append((src, dst, "ok")) except Exception as e: report.append((src, dst, f"failed: {e}")) return report这里的逻辑说明:batch_fix不直接改原目录,而是统一输出到out_dir,保留原始文件作为底稿;修复成功的标准是 Pillow 能完整解码,而不是单纯文件结构合法。如果某张图补完头后解码报OSError: image file is truncated,说明 SOS 段之后的数据流仍然不够,这种图不能算修复成功,只能试着用更激进的方案去“抢救”,抢救不回来就进止损名单。
4.3 止损清单:什么时候该放弃修复
不是所有 JPEG 都值得修。我在实际处理中总结了一条止损线:文件小于 2KB 且找不到 SOF0 段的,修复成功率极低;找不到 SOS 段的,说明压缩数据流本身不存在,修了也只能得到一个空模板;文件里能被解码的 MCU 行数少于总行数一半的,修出来的图也是废图。这三类直接放弃比浪费时间更划算。
| 文件特征 | 能否修复 | 建议 |
|---|---|---|
| 有 SOI/SOF0,文件 > 2KB | 大概率可修 | 补 EOI 后完整解码 |
| 缺 SOI,但其余段完整 | 可修 | 补 SOI + APP0 |
| 找不到 SOS | 不可修 | 放弃 |
| 解码后行数少于一半 | 不值得修 | 放弃 |
止损不是偷懒,是避免用“看似修复成功”的废图骗过自己。批量脚本跑完后,我会把 report 里 failed 的条目单独存一份,方便后续人工用第三方工具复核。
5. JPEG修复避坑指南:五个真实翻车现场
5.1 能打开不等于修复成功
现象:修复后的文件用图片查看器能打开,但整张图四分之一区域是灰色色块。
原因:我把“能打开”当成了成功标准。图片查看器遇到不完整的 MCU 数据会跳过渲染,返回一个合法图像给你看,但内容已经缺失。补 EOI 只是让文件结构合法,并不能变出缺失的像素。
解决:修复后必须用 Pillow 的load()完整解码,再抽样对比修复后图像的右下角像素是否异常。最稳妥的是把修复图重新保存为 PNG,看保存过程是否报Truncated File Read警告。出现 warning 就说明数据流仍不完整。
5.2 补了EOI但下半截全是灰的
现象:执行完 fix_jpeg 后文件能打开,但画面从中间开始变灰,越到尾部越脏。
原因:我犯了一个低级错误——文件本来就是从中间截断的,我在截断处硬补了 EOI。解码器读到截断处发现数据流结束,直接从那里渲染灰色背景,等于把残缺区域暴露出来。
解决:改成先删尾部残缺数据再补 EOI,用前面trim_and_close的逻辑。血泪经验是:补 EOI 之前先问自己一句,这个文件的最后一个有效字节在哪,而不是盲目接一个FFD9上去。
5.3 自己拼的APP0让颜色全变绿
现象:给缺头的 JPEG 补完 SOI 和 APP0 后,图能打开,但整体颜色偏绿,像加了滤镜。
原因:APP0 段里的色彩编码参数不对。我一开始写的 APP0 只有JFIF\0和版本号,漏掉了密度单位字节,导致解码器按默认的 YCbCr 色彩空间去解析,而文件中段位存储的采样因子和 APP0 不匹配。
解决:不要再手搓 APP0,直接用我前面build_jfif_app0()的完整模板,一个字节都不要少。特别注意struct.pack(">HHBB", ...)的最后一个B是缩略图尺寸,填0x00 0x00表示无缩略图,填错会让解码器多读一段不存在的缩略图数据。
5.4 把多帧JPEG或渐进式JPEG当普通单帧处理
现象:某张图修复后只显示了第一帧,或者显示出的画面特别模糊,后来的细节一直不加载。
原因:那根本不是单帧 JPEG。有些相机(如旧款运动相机)或监控设备输出的是 Motion JPEG,文件里有多组SOI -> EOI;渐进式 JPEG 内部则有多次扫描段,每轮扫描逐渐清晰。我写的补尾逻辑遇到多帧文件,只补了最后一帧的 EOI,前面帧的损坏依然存在。
解决:修复前先用file命令确认格式。如果是 Motion JPEG,要按FFD8在文件里出现的次数分段处理,每段独立补 EOI;渐进式 JPEG 则需要保留 DHT 和 SOF 的完整顺序,不能随便删除中间的段。
5.5 修复时直接覆盖源文件
现象:修复脚本跑完后,源文件也变成报错状态,连当初的损坏原件都没了。
原因:我把输出路径直接写成了源文件路径,修复逻辑读取文件时把部分字节写回了旧文件,发生第二次损坏。
解决:永远先把源文件复制到备份目录,再对副本做修复。我现在的习惯是输出目录和源目录强制分开,脚本里用os.path.join(out_dir, ...)生成目标路径,绝不接受用户传入的覆盖式路径。后悔药不是每次都有,别拿现场照片赌运气。
6. 把修复做成手艺:量化验证与两个进阶技巧
6.1 修复质量的量化验证:不只靠眼睛看
主观判断很容易被画面干扰,尤其是修复后花屏区域不明显时。我习惯用直方图对比法:如果原文件有未损坏区域,用numpy对比修复后同一区域的像素直方图差异。差异小于 5% 算基本保真,大于 20% 说明修复过程可能改了颜色或采样。
from PIL import Image import numpy as np def hist_diff(a, b): ha = np.asarray(a.convert("RGB").resize((256, 256))).ravel() hb = np.asarray(b.convert("RGB").resize((256, 256))).ravel() return np.abs(ha.astype(int) - hb.astype(int)).mean() im1 = Image.open("broken.jpg") im2 = Image.open("fixed.jpg") print(hist_diff(im1, im2))这段代码把两张图缩放成同样尺寸后逐像素算平均差,差异单位是 0 到 255 的灰度值。平均差超过 20 就需要检查是不是补 APP0 时把色彩空间搞错了。
6.2 进阶技巧:用SOF0段批量生成“可恢复性”报告
修复几十张图之前,先给每张图生成一份报告,比盲修高效得多。SOF0 段里记录了宽、高和采样因子,只要这个段还在,文件至少能被部分解码。写个小脚本扫描所有损坏文件,提取 SOF0 里的尺寸信息,按宽高行数估算缺失比例,把缺失超过 50% 的图挑出来提前标记为“放弃”。
这个方法不需要完整解码,读取速度快,很适合批量处理前的预筛。它解决的核心问题是:修图最贵的是时间,预筛能把时间花在大概率救得回来的图上。
6.3 进阶技巧:联合RAW文件一起修复
很多相机同时保留 RAW 和 JPEG 两个版本。JPEG 损坏时,RAW 文件里通常还存着完整的 EXIF 信息和缩略图预览。我会尝试从同目录的.CR2或.NEF文件中提取缩略图,再用 Python 注入到修复后的 JPEG 里作为参考图。这样即使主体数据修不回来,至少保留一个可看的预览,方便后续人工决定要不要付费深度恢复。
这个技巧不是官方流程,但对大量拍摄素材的从业者很实用。我的习惯是:备份永远在修复前,验证永远在交付前,能救回来一半总比整张放弃强。希望这些踩坑记录和代码,能在你面对一堆打不开的 JPEG 时,少走点弯路。
本文还有配套的精品资源,点击获取