资讯动态

HEIF 图片解析实战:从 ISO_IEC_23008-12_2017 box 结构到 libheif 解码

发布时间:2026/10/29 23:59:14 来源:尧图企业网站定制
简介ISO/IEC 23008-12:2017 是国际标准化组织与国际电工委员会联合发布的图像文件格式标准聚焦高效编码与异构环境下的媒体交付是理解 HEIF、HEIC 格式的权威依据。资源面向从事图像编解码、移动端媒体开发、流媒体与智能终端适配的工程师及标准研究者帮助厘清 HEVC 编码、图像序列与元数据存储、兼容性与互操作性等关键问题。压缩包共 1 个 PDF 文件约 906KB内容为该标准第一版正式文本涵盖范围、术语定义、总体要求及图像文件格式规范等章节可直接用于查阅条款、对照实现或撰写技术文档。目前已有 259 人学习浏览适合需要一手标准原文、希望深入掌握 HEIF/HEIC 底层机制的读者参考。1. ISO_IEC_23008-12_2017为什么你的 HEIF 图片在浏览器里打不开你从 iPhone 导出一张照片后缀是.heic双击能看拖进 Chrome 却一片空白用ffmpeg转码命令行报moov box not found想自己写个解析器翻到 ISO_IEC_23008-12_2017 这份标准发现它只告诉你 box 怎么排没告诉你解码器去哪找。这不是你环境的问题是 HEIF 这套东西从设计上就把「容器」和「编解码」拆成了两层。ISO_IEC_23008-12_2017 定义的正是上层容器——它基于 ISO base media file formatISO/IEC 14496-12规定了图片该怎么装进 box 结构里但具体像素怎么压它不管交给 HEVC 或 AVC 去办。所以一份.heic能不能被打开取决于三件事容器解析对不对、item 引用关系理没理清、底层编解码器有没有装。这篇笔记按「先看懂 box 结构 → 再跑通解析 → 再处理缩略图和辅助图 → 最后避坑」的顺序走适合做相册、IM 图片消息、云端转码服务的后端和客户端工程师。读完你能自己写一个最小 HEIF 解析器知道iloc、iinf、iprp这几个 box 到底在干什么也能判断某个库值不值得引入。2. 拆开一个 HEIF 文件从 ftyp 到 mdat 的 box 链路2.1 ISO base media file format 的 box 嵌套规则ISO_IEC_23008-12_2017 里所有数据都装在 box 里box 只有两种普通 box 和 full box。普通 box 头 8 字节前 4 字节是长度后 4 字节是类型full box 再多 1 字节 version 和 3 字节 flags。长度字段有个坑如果值是 1真实长度在后面 8 字节的largesize里如果值是 0表示这个 box 一直延伸到文件末尾。我见过有人写解析器只读 4 字节长度遇到大图直接越界。顶层结构通常是ftyp→meta→mdat。ftyp声明品牌HEIF 常见的是heic、heix、mif1、msf1。meta是 full box里面装着所有元数据它下面挂hdlr、pitm、iloc、iinf、iprp、iref。mdat就是裸的压缩码流本身不带任何结构信息全靠iloc里的偏移量去定位。iloc是整份标准里最关键的 box它告诉你每个 item 的数据在文件哪个位置、多长。它的结构随 version 变化version 0 用 16 位偏移和长度version 1 用 32 位version 2 用 64 位。offset_size和length_size这两个字段各占 4 bit可以取 0、4、8取 0 表示该字段不出现。很多解析库在这里翻车是因为没按offset_size动态读而是硬编码 4 字节。2.2 用 Python 读出一个 HEIC 的 box 树先装依赖只需要标准库加一个pillow用来对照验证pip install pillow下面这段代码递归打印 box 树遇到meta会继续往下钻遇到mdat就停import struct def read_box_header(f): 读取 box 头返回 (size, box_type, header_len) raw f.read(8) if len(raw) 8: return None size, box_type struct.unpack(I4s, raw) header_len 8 if size 1: # 64 位长度读 largesize size struct.unpack(Q, f.read(8))[0] header_len 16 elif size 0: # 延伸到文件末尾 cur f.tell() f.seek(0, 2) size f.tell() - cur header_len f.seek(cur) return size, box_type.decode(latin1), header_len def walk(f, depth0, endNone): 递归遍历 boxend 是当前层的结束偏移 while True: pos f.tell() if end is not None and pos end: break hdr read_box_header(f) if hdr is None: break size, box_type, header_len hdr print( * depth f{box_type} size{size}) body_start pos header_len body_end pos size # 只对容器 box 继续下钻mdat 是裸数据不解析 if box_type in (meta, iprp, ipco, iinf, iref, dinf): if box_type meta: f.seek(4, 1) # full box 的 versionflags walk(f, depth 1, body_end) f.seek(body_end) with open(sample.heic, rb) as f: walk(f)逻辑说明read_box_header处理了 size 为 1 和 0 两种特殊情况这是 ISO base media file format 的硬性规定。walk用end参数控制递归边界避免把mdat里的随机字节当成 box 头。meta是 full box所以下钻前要跳过 4 字节的 versionflags否则会把 flags 当长度读直接乱套。参数说明offset_size和length_size不在 box 头里在iloc的 body 里读iloc时要单独处理。如果你拿到的文件顶层第一个 box 不是ftyp说明文件被截断或者根本不是 HEIF。2.3 iinf 和 iref找到主图和它的从属关系iinf列出所有 item每个 item 有item_ID和item_type。主图的item_type是hvc1或hev1缩略图也是hvc1但会通过iref里的thmb引用指向主图。iref的每个条目包含from_item_ID和若干to_item_IDthmb表示缩略图关系auxl表示辅助图比如深度图、alpha 图。常见做法是先读pitm拿到主图 ID再在iinf里找到对应 item最后用iloc定位数据。如果你只读第一个hvc1item遇到带缩略图的文件可能拿到的是缩略图而不是主图。判断依据是pitm里的item_ID不是文件顺序。3. 把 HEVC 码流从 mdat 里抠出来并解码3.1 iloc 的偏移量计算与 hvcC 配置读取iloc的每个 item 可以有多个 extent每个 extent 有extent_offset和extent_length。偏移量是相对于文件开头的绝对偏移不是相对于mdat。base_offset如果非零要加到每个 extent 的偏移上。我一般先读iloc拿到所有 extent再按顺序拼接因为有些编码器会把一个 item 拆成多段存储。解码前还需要hvcCbox它在iprp→ipco下面装着 HEVC 的 VPS、SPS、PPS。没有这三个 NAL 单元解码器不知道分辨率、帧率、profile。hvcC的结构是1 字节 configurationVersion1 字节 general_profile_space 等然后是numOfArrays每个 array 有 1 字节 NAL 类型和若干 NAL 单元。常见做法是把hvcC里的 NAL 拼到码流前面再交给解码器。3.2 用 ffmpeg 验证码流是否完整在写自己的解码器之前先用ffmpeg确认码流本身没问题# 把 heic 里的主图码流导出为裸 HEVC ffmpeg -i sample.heic -c:v copy -f hevc main.h265 # 如果上面报错试试指定 item 提取 ffmpeg -i sample.heic -map 0:v:0 -c:v copy main.h265 # 解码成 png 看结果 ffmpeg -i main.h265 -c:v libx265 -frames:v 1 out.png逻辑说明-c:v copy不做转码只做容器到裸流的封装转换能成功说明iloc和hvcC解析没问题。如果报Could not find codec parameters多半是hvcC没读对或者iloc偏移算错了。-map 0:v:0显式指定第一条视频流避免 ffmpeg 自动选到缩略图。参数说明-f hevc指定输出格式为裸 HEVC 流。如果你的文件是 AVC 编码的 HEIFitem_type是avc1把hevc换成h264。注意ffmpeg对 HEIF 的支持依赖编译时的--enable-heif有些发行版默认没开遇到Unknown input format要自己编。3.3 用 pillow-heif 做快速验证如果只是想确认文件能不能解不想碰底层from pillow_heif import register_heif_opener from PIL import Image register_heif_opener() img Image.open(sample.heic) print(img.size, img.mode) img.save(out.png)逻辑说明register_heif_opener把 HEIF 解码器注册进 Pillow之后Image.open就能直接读。img.size返回(width, height)img.mode通常是RGB或RGBA。如果报cannot identify image file说明文件头不是标准 HEIF或者ftyp品牌不被支持。参数说明pillow-heif底层用的是libheif它自己会处理iloc和hvcC。这个方案适合做服务端批量转码不适合需要精细控制解码参数的场景。生产环境要注意libheif的版本老版本对 10bit HEVC 支持不好会直接崩。4. 缩略图、辅助图和网格图HEIF 里那些容易被忽略的 item4.1 thmb 引用与缩略图提取带缩略图的 HEIF 文件里iref会有一条thmb记录from_item_ID是缩略图to_item_ID是主图。缩略图本身也是完整的 HEVC 码流有自己的hvcC但通常和主图共用ipco里的配置。提取时不能直接拿主图的hvcC去解缩略图因为分辨率不同SPS 里的参数不一样。我一般先遍历iinf对每个hvc1item 读它的ispe图像空间属性在ipco里拿到宽高。宽高最大的那个是主图小的可能是缩略图。再结合iref确认关系。如果ispe缺失可以用hvcC里的 SPS 解析宽高但麻烦得多。4.2 auxl 辅助图与 alpha 通道iPhone 拍的 HEIC 可能带 alpha 通道alpha 数据存在单独的 item 里通过auxl引用关联到主图。auxl的from_item_ID是辅助图to_item_ID是主图。辅助图的item_type也是hvc1但它是单通道的 HEVC 码流解码出来是灰度图当作 alpha 用。处理顺序是先解主图拿到 RGB再解辅助图拿到 alpha最后合并成 RGBA。如果顺序反了或者把辅助图当主图解会得到一张灰度图。判断哪个是辅助图看iref里有没有auxl指向它。有些文件还有auxCbox 描述辅助图类型比如urn:mpeg:hevc:2015:auxid:1表示 alpha。4.3 grid 网格图多图拼接的 HEIF网格图grid是把多张 HEVC 图拼成一张大图常见于全景照片。griditem 本身没有码流它的数据在iloc里指向一个grid描述结构包含行数、列数和每个子图的item_ID。解析时要先读grid描述再按行列顺序解每个子图最后拼起来。常见做法是读griditem 的iloc拿到描述数据解析出rows_minus_one、columns_minus_one和output_width、output_height。然后按item_ID列表逐个解子图每个子图有自己的ispe。拼接时注意子图可能有重叠按output_width裁剪。如果子图数量对不上行列乘积说明文件损坏。5. 避坑与排查HEIF 解析里那些让人摔键盘的瞬间5.1 现象ffmpeg 报 moov box not found原因ffmpeg的 HEIF 解复用器期望moovbox但 HEIF 用的是metabox。这是ffmpeg对 ISO_IEC_23008-12_2017 支持不完整导致的不是文件问题。解决升级ffmpeg到较新版本或者改用libheif的heif-convert工具。如果必须用ffmpeg先用mp4box把meta转成moov但会丢失 item 引用关系不推荐。5.2 现象解码出来颜色发绿或发紫原因HEVC 码流的色彩空间是 YCbCr解码后没做色彩转换或者hvcC里的colour_primaries、transfer_characteristics、matrix_coefficients没读对。iPhone 拍的 HEIC 常用 BT.2020 或 Display P3直接当 BT.709 处理就会偏色。解决读hvcC里的 VUI 参数拿到色彩空间信息在解码后做对应的矩阵变换。libheif会自动处理自己写解码器就要手动做。如果只是验证用pillow-heif省事。5.3 现象iloc 读出来的偏移量指向文件末尾之外原因base_offset没加或者offset_size读错了。iloc的 version 0 用 16 位偏移version 1 用 32 位version 2 用 64 位。如果文件是 version 1 但你按 version 0 读偏移量会少 16 位指向错误位置。解决先读iloc的 version 字段再按对应版本解析。offset_size和length_size各占 4 bit取 0 表示该字段不出现此时偏移量为 0 或继承上一个 extent。用struct动态解析不要硬编码。5.4 现象缩略图解出来是花屏原因缩略图的hvcC和主图不同但代码里复用了主图的hvcC。缩略图的 SPS 里分辨率、profile 可能不一样用错配置解码器会输出乱码。解决每个 item 单独读它的hvcC。ipco里可能有多个hvcC通过ipma关联到具体 item。ipma是iprp下的 box记录每个 item 用哪些属性。先读ipma找到 item 对应的hvcC索引再取对应的hvcC。5.5 现象多 extent item 只解出第一段原因iloc里一个 item 可以有多个 extent代码只读了第一个 extent 就停了。多 extent 常见于文件被分段写入或编辑过的场景。解决遍历iloc里该 item 的所有 extent按顺序拼接数据。每个 extent 的extent_offset和extent_length都要读base_offset加到每个 extent 上。拼接后再交给解码器。6. 进阶用 libheif 的 C API 做可控解码与元数据提取如果你要在生产环境做 HEIF 转码pillow-heif太黑盒ffmpeg支持又不好我一般直接上libheif的 C API。它能让你控制解码线程数、输出色彩空间、是否忽略辅助图还能读 EXIF 和 XMP 元数据。先装开发库# Debian/Ubuntu apt install libheif-dev libde265-dev # macOS brew install libheif下面是一个最小解码示例读主图并输出 RGBA#include libheif/heif.h #include stdio.h #include stdlib.h int main(int argc, char **argv) { struct heif_context *ctx heif_context_alloc(); struct heif_error err heif_context_read_from_file(ctx, argv[1], NULL); if (err.code ! heif_error_Ok) { fprintf(stderr, read failed: %s\n, err.message); return 1; } // 拿主图句柄自动处理 pitm struct heif_image_handle *handle; err heif_context_get_primary_image_handle(ctx, handle); if (err.code ! heif_error_Ok) { fprintf(stderr, get primary failed: %s\n, err.message); return 1; } // 解码为 RGBAchroma 用 4:4:4 避免色度抽样损失 struct heif_image *img; struct heif_decoding_options *opts heif_decoding_options_alloc(); opts-convert_hdr_to_8bit 1; // HDR 转 8bit避免输出 10bit err heif_decode_image(handle, img, heif_colorspace_RGB, heif_chroma_444, opts); if (err.code ! heif_error_Ok) { fprintf(stderr, decode failed: %s\n, err.message); return 1; } int w heif_image_get_width(img, heif_channel_interleaved); int h heif_image_get_height(img, heif_channel_interleaved); printf(decoded %dx%d\n, w, h); // 读 EXIF int exif_size heif_image_handle_get_raw_metadata_size(handle, Exif); if (exif_size 0) { uint8_t *exif malloc(exif_size); heif_image_handle_get_raw_metadata(handle, Exif, exif, exif_size); printf(exif bytes: %d\n, exif_size); free(exif); } heif_image_release(img); heif_image_handle_release(handle); heif_context_free(ctx); return 0; }编译命令gcc -o heif_decode heif_decode.c $(pkg-config --cflags --libs libheif)逻辑说明heif_context_get_primary_image_handle内部会读pitm自动选主图不用自己遍历iinf。heif_decode_image的第三个参数指定输出色彩空间heif_colorspace_RGB加heif_chroma_444能避免色度抽样导致的边缘模糊。opts-convert_hdr_to_8bit处理 10bit HEVC不设的话输出是 16bit per channel后续处理要改。参数说明heif_decoding_options里还有ignore_transformations设 1 会忽略irot和imir变换输出未旋转的原始图。heif_image_handle_get_raw_metadata_size的第二个参数是元数据类型Exif和mime是常见值。EXIF 数据前面有 4 字节偏移解析时要跳过。验证方法编译后跑一张 iPhone 拍的 HEIC看输出的宽高和系统相册显示的是否一致。如果宽高反了说明irot没处理libheif默认会应用旋转变换输出的是旋转后的图。如果颜色偏暗检查convert_hdr_to_8bit是否设了HDR 转 SDR 需要 tone mappinglibheif用的是简单线性缩放效果一般生产环境建议自己接libplacebo做 tone mapping。我自己的习惯是任何 HEIF 相关的库先拿一张带缩略图、带 alpha、带旋转的「三带」文件跑一遍能过再上生产。这个习惯帮我省了至少三次线上事故。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑