用 C++“硬解析”JPEG:从分段索引到 EXIF/XMP/ICC(基于 unknownparticles/jpeg-info)
很多业务场景并不需要把 JPEG 解码成像素(RGB/YCbCr),而是希望在不依赖第三方库的前提下,快速拿到图片结构与元数据:例如 EXIF(相机参数/GPS)、XMP(Adobe 元数据)、ICC Profile(色彩配置)、JFIF/Adobe APP14 等。典型需求包括:
- 批量质检图片:是否 progressive、尺寸是否合规、是否携带 ICC、是否存在异常 APP 段
- 仅提取某些关键 tag(如 Orientation / DateTimeOriginal / LensModel)
- 进行“流式读取”:避免一次性把大文件全部读入内存
本文基于 GitHub 项目 unknownparticles/jpeg-info,讲清楚如何用 C++17 以分区(segment)扫描 + 按需加载的方式“硬解析”JPEG。该项目明确列出了它解析的元数据类型与设计要点:先扫描构建段索引,再按需解析 JFIF、SOF、EXIF、XMP、ICC、Adobe 等,并强调对 SOS 后压缩数据的正确跳过与 APP 子类型识别等。 (GitHub)
1. JPEG 为什么适合“先索引、后按需解析”
JPEG 文件(JFI/JFIF/Exif 风格)在 SOI(FF D8)与 EOI(FF D9)之间,由一系列 marker 或 marker segment 组成。大多数 segment 都是“TLV”结构:FF xx + length(2 bytes) + payload。JFIF 的结构描述非常经典:SOI → APP0(JFIF) → … → SOF/DHT/DQT/… → SOS → 压缩数据 → EOI。 (维基百科)
关键点在于:
- 元数据几乎都在 SOS 之前(APP0/APP1/APP2/APP14/COM 等)
- SOS(Start of Scan,
FF DA)后面进入熵编码数据流,不再遵循“TLV segment”规律;扫描器必须使用规则正确跳过/定位到下一个 marker(通常是 EOI 或 reset markers 等) - 熵编码数据中会出现“byte stuffing”:出现
0xFF时会插入0x00进行转义,解码器/扫描器需把FF 00当成数据字节而不是 marker。 (ccoderun.ca)
因此,一个工程上更可靠、更省内存的策略是:
- 顺序扫描文件,记录每个 segment 的
marker/offset/length/app-subtype,形成索引 - 用户需要某类信息时,再按索引去读取对应 payload 并解析
- 对 SOS 段做特殊处理:跳过熵编码数据直到 EOI(或后续 marker),避免误判
unknownparticles/jpeg-info 的 README 也明确强调了这种设计:段索引、APP 子类型识别、SOS 数据跳过(含 0xFF00 stuffing)、ICC 多段拼接、EXIF IFD 解析、国际化等。 (GitHub)
2. 工程结构:模块化解析器 + 统一输出层
该仓库给出了比较清晰的模块拆分:jpeg_indexer 专门负责扫描构建段索引;parse_exif / parse_xmp / parse_icc / parse_jfif / parse_sof / parse_adobe / parse_com 分别处理各类 payload;format 统一格式化输出;i18n 做中英文国际化。 (GitHub)
这种拆分的价值在于:
- 解析逻辑可独立单测(拿到 payload buffer 就能测)
- 输出与解析解耦:同一份解析结果可以“全量打印”或“只打印指定字段”
- 便于以后扩展:比如加 IPTC(APP13)、Photoshop IRB、MakerNote 解析等
3. “硬解析”第一步:扫描构建 JPEG 分区(segment)索引
3.1 扫描的目标数据结构
索引至少要包含:
marker:例如0xFFE1(APP1)marker_offset:marker 开始位置payload_offset:payload 起始(通常 marker 后 4 bytes:marker(2)+length(2))payload_length:segment length - 2(因为 length 字段含自身 2 bytes)app_subtype:仅 APPn 需要:比如 APP1 是 EXIF 还是 XMP(通过 payload 前缀识别)
JFIF 对 length 的定义也很明确:length 是“数据字节数 + 2”。 (维基百科)
3.2 APP 子类型识别(决定后续解析器选择)
常见判定(工程上基本够用):
- APP0:payload 以
"JFIF\0"或"JFXX\0"开头 - APP1:可能是
"Exif\0\0"(Exif/TIFF)或"http://ns.adobe.com/xap/1.0/\0"(XMP) - APP2:可能是
"ICC_PROFILE\0"(ICC,且可能分多段) - APP14:通常是
"Adobe"开头(Adobe APP14) - APP13:常见为 Photoshop IRB / IPTC(后续可扩展)
项目也强调了“APP 子类型识别”和“ICC Profile 拼接”。 (GitHub)
3.3 SOS 段的特殊处理:别把熵编码数据当 segment
SOS(FF DA)仍然是一个 TLV segment,但它的 payload 结束后紧跟熵编码数据流。很多“解析器输出一堆 0xFF00/0xFFD0”垃圾段”的根源,就是把熵编码数据里的 FF 00 stuffing 或 reset marker(FFD0..FFD7)误当成新的段。
可靠策略:
-
读出 SOS segment 的 length,跳过其 payload
-
然后开始扫描熵编码数据:
- 遇到
0xFF 0x00:这是 stuffing,继续 - 遇到
0xFF D0..D7:这是 reset marker(合法),继续 - 遇到
0xFF+ 非0x00、非0xFF的字节:可能是下一个 marker(很多情况下是 EOIFFD9,也可能还有别的)
- 遇到
-
直到遇到 EOI 或文件结束
byte stuffing 的规则可以作为实现依据:熵编码数据里如果需要 0xFF,必须跟 0x00 转义。 (ccoderun.ca)
项目 README 也点到了“正确处理 SOS 后压缩图像数据(包括 0xFF00 stuffing)”。 (GitHub)
4. “硬解析”第二步:按需解析元数据(EXIF/XMP/ICC/JFIF/SOF)
4.1 SOF:拿到图像尺寸、采样、颜色分量
SOF0(baseline)/SOF2(progressive)等 SOFn 段 payload 里包含:
- precision(一般 8 bits)
- height/width
- components 数量
- 每个 component 的采样因子、量化表 id
这部分不需要进入熵编码解码,就能获得最关键的图像属性。Wikipedia 对 SOF0/SOF2 也做了简要归纳(宽高、分量、子采样等)。 (维基百科)
4.2 JFIF(APP0):版本、密度单位、缩略图信息
JFIF APP0 有固定结构(identifier、version、density、thumbnail 尺寸等),适合做快速一致性校验。JFIF 结构说明中对 APP0 段字段定义非常具体。 (维基百科)
4.3 EXIF(APP1):核心是 TIFF header + IFD 链表
Exif 存在于 APP1:"Exif\0\0" + TIFF header。TIFF/Exif 的关键点:
- TIFF header 8 字节:
II/MM(大小端)、magic 42、IFD0 offset - IFD = 条目数量 + N 个 tag entry + next IFD offset
- tag entry 描述:tagId、type、count、valueOrOffset(可能内联也可能指向偏移)
关于 TIFF header 与 IFD 的解释,可以参考 Exiv2 的文档总结:8 字节 header(endian、magic、IFD offset)。 (dev.exiv2.org)
工程实现上的两个要点:
- 偏移相对 TIFF header 起点(不是相对 APP1 起点),很多解析错误来自偏移基准不一致
- 按需读取 value:当
count*typeSize <= 4时 value 可能直接放在 entry 的 4 字节里,否则是 offset 指针
项目 README 也提到“支持 Big/Little Endian,解析 IFD0/EXIF/GPS 子 IFD”。 (GitHub)
4.4 XMP(APP1):XML 体积大,通常需要“有效内容裁剪”
XMP 常见形式是 APP1 payload 以 http://ns.adobe.com/xap/1.0/\0 开头,后面是 XML。但很多相机/软件会把 APP1 段填充到固定长度(例如 64KB),大量是空格或 \0。
工程上更好的做法:
- 识别 XMP header 后,不要“全量打印 64KB”
- 找到真正的 XML 范围(例如从
<?xpacket开始,到</x:xmpmeta>或<?xpacket end=结束) - 计算
effective_length与padding_length,输出时可默认打印前 N 行或仅输出摘要;需要--full再完整输出
这和你前面遇到的“XMP 段很大但有效 XML 很短”的情况一致:段长度是容器分配的空间,XML 有效内容远小于段长度。
4.5 ICC(APP2):支持多段拼接
ICC Profile 常见为 APP2:ICC_PROFILE\0 + 序号/总数 + profile slice。完整 profile 可能跨多个 APP2 段,需要按序号拼接。项目 README 明确包含“ICC Profile 拼接”。 (GitHub)
5. “硬解码像素”的扩展:从索引到 Huffman/IDCT 的路线图
如果你的目标不止是元数据,而是进一步“自己解码像素”(不依赖 libjpeg 等),那么段索引 + SOF/DQT/DHT/SOS 的解析结果就是下一步的输入:
-
读取 DQT(量化表)、DHT(哈夫曼表)、SOF(组件/采样)、DRI(重启间隔)、SOS(scan 参数)
-
在 SOS 后熵编码数据中进行 bitstream 解析:
- Huffman 解码得到系数(DC/AC)
- 反量化
- IDCT(可做整数 IDCT 优化)
- 颜色转换(YCbCr → RGB)
- 对 progressive JPEG 还要支持多次扫描与谱选择/逐次逼近(复杂度明显更高)
SOS/熵编码的复杂性可以从一些经验总结中看到:SOS 后是压缩数据流,且 progressive 与 baseline 的处理差异较大。 (Stack Overflow)
工程建议:
- 先把 baseline(SOF0)走通,再考虑 progressive(SOF2)
- 先支持 4:2:0 / 4:4:4,再补齐罕见采样
- 先实现正确性,再做 SIMD/并行优化(“硬解码加速”是后话)
6. CMake 构建与调试建议
该仓库使用 CMake,并要求 C++17 编译器;README 给出了基本编译与运行方式,以及 --lang 参数支持中英文。 (GitHub)
如果你要把它作为“库 + CLI”两用项目,建议:
-
把解析器做成
jpeginfo::core静态库目标(仅解析与数据结构) -
CLI
jpeg_info链接该库,负责参数解析与输出 -
输出层做两套:
--full:全量打印(适合调试)--keys Make,Model,DateTimeOriginal:指定字段(适合批处理/脚本调用)
-
增加
--json输出(方便后续接入 pipeline)
7. 常见坑与改进点
-
把熵编码流当 segment
如果出现超长的“0xFF00 分区列表”,本质就是 SOS 后的 stuffing/reset 被误识别成段。务必在 indexer 里把 SOS 后扫描逻辑单独实现。 -
Exif 偏移基准错误
TIFF 的 offset 相对 TIFF header 起点。多相机(Canon/Sony/Apple)文件里 APP1 payload 不同,错误基准会导致 IFD pointer 跑飞。 -
XMP 段长度 vs 有效 XML 长度
段长度可能是固定块(如 64KB),有效 XML 远小于该长度。默认打印应裁剪、并统计 padding,避免输出噪声。 -
ICC 多段拼接
只读单段会得到不完整 profile;必须按 seq/total 拼接,并校验总长度。 -
枚举值的人类可读映射
例如 Orientation、MeteringMode、LightSource、Flash 等,建议统一做tagId -> enum map,并保留原始数值,避免“Unknown”误导。
8. 总结
用 C++“硬解析”JPEG 的关键并不在于一次性把所有信息读出来,而在于:
- 先扫描构建索引(可视作 JPEG 的“目录表”)
- 按需读取与解析(只加载有用 segment)
- 对 SOS 熵编码数据做正确跳过(避免把数据流误判成 marker)
- 在 EXIF/XMP/ICC 等大 payload 上做“有效内容提取”,提升可读性与可用性
unknownparticles/jpeg-info 的项目结构和 README 已经把这套工程化思路讲得很清楚:段索引、APP 子类型识别、SOS stuffing、ICC 拼接、Exif IFD、国际化、跨平台等。 (GitHub)

1164

被折叠的 条评论
为什么被折叠?



