简介:ISO/IEC 23008-12:2017是图像文件格式领域的核心国际标准,面向多媒体开发者、编解码器实现者及移动互联网从业者,用于解决HEIF/HEIC高效图像存储与异构环境下的媒体交付问题。这份PDF为第一版原版标准,包内仅1个文件、整体大小906KB,内容涵盖HEVC编码背景、HEIF/HEIC文件结构、图像序列与元数据规则、兼容与互操作要求,并涉及加密与数字版权管理等安全机制,适合作为权威依据直接查阅规则原文。已有259人学习下载,可用于协议解析、编解码器实现、格式转换工具开发或测试用例设计。阅读后可快速定位Scope、Terms、Overview等基础章节,并对照正文理解HEIF如何组织单图、序列及元数据,避免因二手资料造成的理解偏差,为后续工程落地或合规评估提供清晰的原始文本支撑。
1. HEIF 与 HEIC:为什么一张照片能省下一半体积
同样一颗 1200 万像素的传感器,输出 JPEG 可能要 5~6MB,转存为 HEIC 后只剩 2~3MB,放大到屏幕上观感几乎看不出差别。很多人把这个账全记在 HEVC 编码头上,但 HEIC 真正的秘密不止是压缩算法,更是它背后的容器设计——ISO/IEC 23008-12 定义的 HEIF 图像文件格式。HEIF 把一张图片从「一串像素字节流」改写成「一组对象、操作符与属性的结构化数据库」:旋转图片不用重编码,裁剪图片不损失画质,连拍照片、深度图、HDR 图层可以共存在同一个文件里。搞懂这份标准,才算真正理解 HEIC 为什么能 work——同时也是后续所有兼容性「血泪经验」的地基。
2. 与 MP4 同源的 box 容器:HEIF 文件究竟是怎么组织的
2.1 从 JPEG 的扫描流到 HEIF 的 box 树
JPEG 的底层形态是「段 + 扫描数据」的字节流水线:文件以 0xFFD8 开头、0xFFD9 结尾,EXIF、缩略图、量化表全部内嵌在这条线性流中。解码器想知道一张图的宽高,必须先从头扫到 SOF 段;想渲染,就必须按顺序吞完一整条数据流。这是为「一次性播放」设计的格式,所有信息都摊在同一条时间轴上。
HEIF 走的是另一条路。它脱胎于 ISO 基础媒体文件格式(ISO BMFF)——也就是 MP4 所依赖的容器语法。文件不再是线性扫描流,而是一棵由 box 组成的树:每个 box 用「4 字节长度 + 4 字节类型」作为头部,内部可以继续嵌套子 box。读取文件不再依赖顺序,而是按类型做随机访问。23008-12 的第 6.2 节明确写着,HEIF 是对 ISO BMFF 的「派生」而非另起炉灶;这也是为什么一个 .heic 后缀的文件既是一张静态图,又能被当作迷你视频容器来存放图像序列。
2.2 最小 HEIF 骨架:ftyp、meta、mdat 三个 box 拆解
一个最简的单张 HEIF 文件,顶层通常只有三个平级 box:ftyp、meta、mdat。ftyp 声明文件品牌(brand)与兼容品牌;meta 是「索引区」,用 item 描述文件里有哪些图像、属性、派生关系;mdat 是「数据区」,真正存放 HEVC 编码后的码流。JPEG 里「数据与描述搅在一起」,HEIF 里「索引与数据彻底分离」——这就是为什么 HEIF 允许只改 meta 不动 mdat,就能实现旋转、裁剪、镜像这类以往必须重新解码的操作。
写一个最朴素的顶层 box 遍历,能直观看到这个骨架:
import struct def parse_top_boxes(path): boxes = [] with open(path, 'rb') as f: while True: header = f.read(8) if len(header) < 8: break size, box_type = struct.unpack('>I4s', header) if size == 1: # 64-bit extended size (size,) = struct.unpack('>Q', f.read(8)) boxes.append((box_type.decode('latin1'), size)) f.seek(size - 8, 1) # 跳过整个 box 内容 return boxes for box_type, size in parse_top_boxes('demo.heic'): print(f'{box_type}: {size} bytes')这段逻辑的核心在于 box header 的 size 字段:普通 box 的 size 是 32 位无符号整数,当 size 等于 1 时,真正的长度延伸到紧随其后的 8 字节 64 位字段——这是 ISO BMFF 为超大文件预留的扩展位。type 是 4 字符码,HEIF 里最常见的 brand 落在 heic、heix、mif1 这几个值上,分别对应「HEVC 主配置静态图」「HEVC 主静态配置」「通用图像文件」。这里有一个易错点:mdat 里的实际负载并不一定是 HEVC 码流。标准第 5.3 节的「多用途文件」允许同一个文件里存在多个数据区间,甚至把缩略图、音频轨和图像项都塞进同一条 mdat。解析器如果假设「见 mdat 就当图像数据读」,碰到复合文件就会直接翻车。
提示:某些 box 的开头还有 version/flags 字节。例如 meta box 的前 4 字节是「1 字节版本号 + 3 字节标志位」,解析嵌套结构前必须先跳过这 4 字节,否则所有子 box 的偏移都会错位。
理解 box 树之后,才能进入 HEIF 真正的核心创新:item 与派生。
3. 图像项与派生图像:无重编码就能裁剪旋转的底层机制
3.1 图像项是「文件里的另一个文件」:infe、iloc、iinf 三者关系
HEIF 对 JPEG 最关键的本体论颠覆在于:一张图不是「文件的全部」,而是「索引区里登记的一个项(item)」。标准第 6.1 节把文件分成单图像与图像集合,集合里可以包含多个 item,每个 item 有自己的 ID、类型、名称和数据位置。索引工作靠 meta box 里的 iinf、infe、iloc 三个子 box 协同完成。
infe 登记「是什么」:item_ID、item_type、item_name、content_type。iloc 登记「在哪」:用 extent_offset 与 extent_length 描述数据块在文件中的字节偏移与长度。iinf 是 item 条目的汇总表。举例来说,一个 HEIC 文件里有主图、缩略图、深度图三个 item,它们在 mdat 中各自占据不连续的区域,iloc 能精确指向每一段,解析器无需按顺序读文件,直接 seek 就能取回任意 item 的码流。
用代码解析 iloc 的核心逻辑长这样:
def parse_iloc(payload): version = payload[0] # 第4字节高4位=offset_size,低4位=length_size;第5字节高4位=base_offset_size offset_size = (payload[4] >> 4) & 0xF length_size = payload[4] & 0xF base_offset_size = (payload[5] >> 4) & 0xF item_count = int.from_bytes(payload[6:8], 'big') p = 8 entries = [] for _ in range(item_count): if version < 2: item_id = int.from_bytes(payload[p:p+2], 'big') p += 2 else: item_id = int.from_bytes(payload[p:p+4], 'big') p += 4 p += 2 # data_reference_index,通常为0 base_offset = int.from_bytes(payload[p:p+base_offset_size], 'big') p += base_offset_size extent_count = int.from_bytes(payload[p:p+2], 'big') p += 2 extents = [] for __ in range(extent_count): off = int.from_bytes(payload[p:p+offset_size], 'big') p += offset_size ln = int.from_bytes(payload[p:p+length_size], 'big') p += length_size extents.append((base_offset + off, ln)) entries.append((item_id, extents)) return entries这段代码把「item ID」与「文件偏移区间」的映射还原了出来。注意 extent 的 base_offset 与 extent_offset 是相对文件起点的绝对偏移,length 才是真正的保险丝——解析器必须用 length 约束读取范围,绝不能用相邻 extent 的间距去反推数据长度,因为 mdat 里可能塞着 padding 或其他轨道数据。很多自研解析器第一次崩溃,都发生在没读 iloc 就去 mdat 里「猜」边界。
3.2 派生操作符:grid、iovl、iden 怎么做到无损编辑
HEIF 在 6.6 节定义了一组特殊的 item 类型,叫派生图像项(derived image item)。它不直接持有编码码流,而是持有「输入 item ID + 操作符 + 参数」,解码器按需从输入项中「算出」输出图。三种核心操作符:
| item_type | 操作符含义 | 主要参数 | 典型场景 |
|---|---|---|---|
| iden | 恒等复制,把输入项直接作为输出 | 无 | 文件内部重引用,避免重复存储 |
| grid | 把输入项按二维格子拼接 | 行数、列数、输出宽高 | 全景图、分块解码、超大分辨率 |
| iovl | 把输入项按坐标叠加 | 画布宽高、各输入项的偏移坐标 | 贴图合成、带透明通道的 UI 素材 |
grid 的语义值得细拆。解码器拿到一个 grid 项时,先读它的尺寸配置——行数、列数、输出宽高,然后按序把每个输入 item 解码成子图,再根据「第 i 行第 j 列」的数学规则摆到对应位置。整个过程不产生新的编码数据,输出像素全部来自既有码流的解码结果。这意味着:把一张全景图切成的四块重拼起来,或者把九宫格截图合并,都可以只新建一个 grid 派生项,彻底绕开「重新编码一整套 JPEG」的老流程。
标准 6.6 节对 grid 的边界一致性有严格约束:输出图像的宽高由 grid 配置决定,输入子图按位置排布后,必须恰好铺满整个输出画布,允许重叠或间隙都被视为非法文件。这个设计让解码器不需要「猜」某个区域是否透明、是否空着,派生逻辑变成一个确定性的数学映射。
3.3 预派生编码图像与多层图像:在编码层已经为容器铺路
除了容器层的派生操作符,标准 6.4.7 节还定义了预派生编码图像(pre-derived coded image)。这类 item 的码流本身就不是「一张完整图」,而是 HEVC 里具备可拼接属性的编码单元,典型例子是 tile。HEVC 的 tile 边界天然支持独立解码,HEIF 便允许把这些 tile 单独登记为 item,或让一个 item 引用另一 item 内的某个 tile 区域。
多层图像(multi-layer image)也是同一思路:HEVC 的可扩展编码允许把基础层与增强层放进同一个文件的不同 item,播放时按需选择层的组合。这个机制面向 HDR 和 360° 内容——基础层兼容普通屏幕,增强层叠加后输出高动态范围画面。标准没有为「多层」发明复杂语法,而是复用 6.5.11 节的 layer selection 属性,在容器层描述「选第几层参与渲染」。
读到这里应该能体会:HEIF 的想象力是把「图像的语义」从编码器里剥离出来,交给容器层表达。旋转、裁剪、拼接、选层全部变成「对项做操作」,视频与图像的边界在这里开始模糊。
4. 属性系统:色彩、旋转、裁切为何不写在像素里
4.1 属性与项目分开存储:ipco + ipma 的映射机制
如果说 item 定义了「文件里有什么」,属性(property)则定义了「这些内容该怎么被理解和显示」。HEIF 的第 9.3 节定义了两个配套 box:ipco(Item Property Container)存放属性定义,ipma(Item Property Association)建立 item 与属性之间的映射。属性可以被多个 item 复用——多个 item 共享同一份旋转属性,逻辑上像「对象与类的关联」,而不是 JPEG 里每张图各自内嵌一份 EXIF。
ipma 的 entry 由 item_ID 和 property index 数组组成,property index 指向 ipco 里的第几个属性。这里有个几乎必踩的坑:property index 是 1-based,0 表示「无属性」。解析器如果直接用数组下标做 0-based 访问,会在第一个带属性的 item 上就发生错位。标准使用这种编号设计,本质上是为「无属性」保留一个显式的空值锚点。
4.2 解码配置与像素级细节:从 HEVC 参数集到 clap、pasp
HEIF 的每个编码图像 item 都必须关联一个解码器配置属性(decoder configuration),其中封装 HEVC 的 VPS、SPS、PPS 参数集。这个设计解决了一个被 JPEG 时代忽略的痛点:JPEG 的量化表和解码参数深埋在扫描流里,想「只取一张图的某一块」也得先解析完整头部;HEIF 把配置项抽到属性层,解析器可以先读配置,再按需跳读 mdat 里的目标样本。标准 6.5.2 节把它命名为 decoder configuration and initialization,并强调「初始化必须在解码任何样本之前完成」。
同样活跃在属性层的还有一组显示相关属性:clap(clean aperture,干净孔径,即无损裁剪)、pasp(像素宽高比)、rloc(相对位置)、irot(旋转 90/180/270)、imir(镜像)、colr(颜色信息)。这些属性的共同特征是「不动 mdat 的一个字节」。修改 irot 只是改 1 字节,图像显示方向立刻变化,背后的 HEVC 码流完全不变。这就是为什么 HEIF 的旋转、翻转操作能在毫秒级完成,也是 JPEG 无法企及的。
解析旋转与裁剪属性,逻辑非常轻:
def parse_irot(payload): # 只有1字节,低2位表示顺时针旋转90°的次数 value = payload[0] & 0x3 return {0: '0°', 1: '90°', 2: '180°', 3: '270°'}[value] def parse_clap(payload): # 4组「分子+分母」32位整数:宽度、高度、水平偏移、垂直偏移 nums = [] for i in range(0, 16, 4): n = int.from_bytes(payload[i:i+4], 'big') d = int.from_bytes(payload[i+4:i+8], 'big') nums.append(n / d if d else 0) return {'width': nums[0], 'height': nums[1], 'h_offset': nums[2], 'v_offset': nums[3]}注意 irot 的语义是「顺时针旋转 N 次」。多个属性同时存在时,标准对应用顺序有隐性约束:先应用 clap,再应用 irot/imir,否则旋转之后裁剪框的位置会与原意不符。这个顺序规范在 6.5.9 与 6.5.10 之间形成了绑定关系——不遵守它,渲染出来的画面区域会差出几个像素到整个视野。
4.3 属性的 essential 与 non-essential:容错设计的智慧
属性还有一个容易被忽略的分级:essential(关键)与 non-essential(非关键)。关键属性在 ipma 里带 essential 标志,解码器不支持就必须拒绝渲染,防止产出错误图像;非关键属性则允许跳过。这个分级是媒体容器设计里少见的「优雅降级」——比如一个「深度图」属性,看不懂的旧版解码器可以忽略它,依然正常显示主图;而颜色信息这种关键属性,看不懂就必须报错,不能乱渲。
这个设计的底层动机是生态兼容。文件格式的演进不可能一夜之间让所有终端跟上,标准委员会选择用「明确声明关键性」来代替「隐式要求所有实现支持全部特性」。实现者面对一个不认识的 non-essential 属性,最安全的操作是「假装没看见」;面对 essential 属性,则必须给出明确失败,而不是自作主张地猜一个默认值。
5. 常见问题排查:解析 HEIF 最容易翻车的五个细节
5.1 品牌与扩展名不一致:heic、heix、mif1 到底谁说了算
现象:一个后缀为 .heic 的文件,自研解析器读出来 major_brand 却是 mif1,按 HEVC 配置去初始化解码器直接失败。
原因:扩展名是给操作系统和用户看的,brand 才是给解析器看的。mif1 只承诺「符合通用 HEIF 规范」,并不承诺「里面一定用 HEVC」。同一容器里可能装着采用 JPEG 编码的 item,也可能装着其他编码的轨道。仅凭 major_brand 一个值就断定编码格式,等于把定位逻辑建立在文件名上。
解决:解析器必须先读 ftyp 的 compatible_brands 列表,再逐 item 查看它的 decoder configuration,确认到底是哪种编码格式。正确流程是:读 ftyp → 读 meta/iinf → 对每个 item 查属性 → 拿到解码器配置 → 再决定调用哪个解码库。顺序错了,后面全是黑匣子式报错。
5.2 派生项依赖缺失:引用链断裂时的幽灵报错
现象:打开一个 grid 派生项,解码器提示「输入项不存在」或直接崩溃;单独解码每个子图却都正常。
原因:HEIF 的派生项允许引用任意 item,包括另一个派生项。文件里可能存在「派生项 → 派生项 → 底层编码项」的多层引用链。部分实现只做了单层递归,遇到深层引用时找不到叶子节点的数据,抛出的错误信息却指向顶层的 mdat 偏移,让人误以为是文件损坏。
解决:递归解析 item 引用关系,先构建一张 item_ID → item 类型的依赖图,再检查每个派生项的输入是否都能回溯到真实的编码 item。建议对引用深度设上限(比如 8 层),超限直接判定文件非法,防止恶意构造的无限递归耗尽内存。这种绕圈引用在规范里被禁止,但实际文件中偶尔能碰到,防御性检查不能省。
5.3 属性顺序问题:irot 与 clap 先后不同,结果完全不同
现象:同一张带旋转和裁剪属性的 HEIF 图,在两个不同渲染库里显示出的画面区域不一致,一个显示的是旋转前的左上角,另一个显示的是旋转后的左上角。
原因:标准对显示属性的应用顺序有明确约定:先 clap,后 irot/imir。某个库实现时把顺序写反,裁剪逻辑发生在旋转之后,裁出来的内容自然对不上。这个顺序并不显眼,写在两个相邻条款里,容易被当成「建议」而非「必须」。
解决:所有客户端解析器统一按「clap → irot → imir」的顺序做合成投影。更稳妥的做法是把属性顺序写成独立的内部表示层,让上层渲染逻辑永远面对统一坐标,而不是各自维护一套属性应用流水线。协作开发时,这条顺序应该写进接口注释和回归测试用例,用测试锁住行为。
5.4 图像序列轨的样本依赖:随机访问不是真的随机
现象:从 HEIF 的图像序列轨里随机抽一个中间的样本,单独解码出来是花屏,但按顺序从头解码就正常。
原因:7.4.1 节的 direct reference samples list 允许样本直接引用相邻样本,当前帧可以引用前一帧做预测,但容器并未保证「第 N 帧的数据能独立解码」。图像序列轨本质上是视频轨道,HEVC 的帧间预测让孤立解码成为奢望。解析器没有先构建参考依赖链,就撞上了花屏。
解决:随机访问前先解析 sample group,找到最近的关键帧,从那里开始解码,再丢弃不需要的帧。不要试图「精确解出第 N 帧」,要接受「从上一个关键帧一路解过来」的代价。这个约束是帧间编码的物理事实,不是容器的缺陷。
5.5 完整性校验与篡改检测:哈希校验的是结构而不是语义
现象:一个 HEIF 文件被第三方工具编辑过,拍摄时间元数据变了,标准自带的完整性校验却依然通过。
原因:标准 8.4 节的完整性校验(integrity checks)对「文件结构」做哈希,保障的是「文件未被结构性篡改」,管不到 EXIF 等语义字段。把「完整性校验通过」理解成「元数据未被改动」,会在取证或版权场景里得出错误结论。
解决:需要防篡改时,要额外对关键 item 的负载数据做哈希,并把哈希值放到带数字签名的元数据项中,而不是依赖标准自带的 integrity check。理解 8.4 的边界,才能知道它在安全方案里该站哪班岗。
6. 从 HEIF 看容器设计:当「图像文件」变成「媒体数据库」
6.1 容器优先:标准把可扩展性设计成了一等公民
HEIF 的底层哲学可以浓缩成一句话:把图像文件从「一次性编码产物」改写成「可索引、可组合、可派生的对象系统」。JPEG 的每个像素都是为「最终显示」服务的;HEIF 的每个 item 和属性都是在为「将来的用途」预留空间。一个全景图,JPEG 存成一张大图,解码时整幅进内存;HEIF 存成多个 item 加一个 grid,想看局部就只解码对应 item,内存占用可以差一个数量级。
这个取舍的代价是格式复杂度。JPEG 解码器几百行 C 代码就能写完,HEIF 光解析 meta 就需要处理十余种 box 的版本分支。标准为此保留了不少在一个简单解码器看来「冗余」的设计,比如 64 位扩展 size、多版本 iloc、essential 标志。以后续实现者的视角回看,这种代码量换来的灵活度是否划算,取决于你的产品是否需要连拍序列、深度图、屏幕内容分割、HDR 多层的组合——而这些恰恰是现代手机相册每天都在产生的内容。
6.2 从 HEVC 到 AVIF:容器的生命力比编码更长久
对 HEIF 最常见的批评是「生态支持慢、授权风险高」。HEVC 本身的专利池让一部分平台长期不愿意接 HEIC,导致 HEIF 一度被误解为某个特定编码的私有外壳。后来 AVIF(使用 AV1 编码)以几乎同样的 HEIF 容器语法进入市场,大家才意识到:平台拒绝的是 HEVC 的授权成本,而不是 HEIF 的容器思想。AVIF 与 HEIF 同根同源,等于对容器层设计做了一次独立验证——同样的 box 结构、同样的 item 派生机制、同样的属性系统,换上更开放的编码就重新活了一遍。
我在这类跨格式兼容性问题上吃过的教训是:评估一个新图像格式,不要把「编码算法」和「容器结构」绑死在一起投票。同一份 23008-12 的容器语法,今天可以承载 HEVC,明天可以承载 AV1、JPEG,它是「图片版 MP4」。把视角切换到容器层之后,所有看似反直觉的行为——旋转不重编码、能塞视频轨、改元数据不改像素——就都顺理成章了。从那以后,我每次拿到陌生格式的样本文件,都会强制走一遍「先看 ftyp 品牌,再画 item 依赖图,最后确认属性顺序」的流程,再没被黑匣子式报错坑过。希望帮到你。
本文还有配套的精品资源,点击获取