iPhone 拍摄的 HDR 照片存储在一个 HEIC 文件中,文件中和 HDR 相关的主要有三部分:

  • Baseline Image
  • Gainmap Image
  • Metadata 中的 Headroom

本文中,将会使用两种方法处理 HEIC 文件,一种是使用 Apple 的 Core Image 框架读取,另一种则是结合文档和 Python 手动计算。

文档方面,最直接的是 Apple 官方的文档,介绍了在不使用 Apple 框架的情况下,如何提取和使用这些数据:

https://developer.apple.com/documentation/appkit/applying-apple-hdr-effect-to-your-photos

Headroom

Apple 通常使用 Headroom 来表示 HDR 与 SDR 间的亮度差距,是 Apple 的 HDR 生态中最核心的参数,它们的显示器也经常用 Headroom 来描述 HDR 能力,比如 iPhone 最高有 8x 的 Headroom,代表 HDR 图像的亮度可以达到 SDR 的 8 倍。

照片中的 Headroom 有三种获取方式:

  1. XMP:HDRGainMapHeadroom 直接获得
  2. EXIF 中的两个 MakerNote 计算后得到。
  • MakerNotes:HDRHeadroom(tag 0x0021
  • MakerNotes:HDRGain(tag 0x0030
  1. Core Image 读取(Swift)

方式 1 和 2 最好用 Exiftool,方法 3 使用 Swift。

// ReadHeadroom.swift
import CoreImage

let fileURL = URL(fileURLWithPath: "image.heic")

// 1. 加载图像并开启 HDR 扩展选项
guard let image = CIImage(contentsOf: fileURL, options: [.expandToHDR: true]) else {
    fatalError("无法加载图像")
}

// 2. 读取 contentHeadroom 属性
print("HDR Headroom: \(image.contentHeadroom)")

以 Core Image 的结果为基准,方法 1 与之最接近(但会少几位有效数字),方法 2 则是 Apple 文档推荐的,不使用 Apple 框架时的做法,有一点点偏差。

Baseline Image

Apple 的文档里一般叫 SDR RGB。

一个 8 Bit 的基准图像,范围理论上为 0-1 之间。对 Apple 来说位于 Display P3 空间,使用 sRGB 传递函数,颜色空间使用 ICC 标记,可以用各种支持 HEIF 的库提取,主要有以下几种:

  • 关闭 .expandToHDR 选项通过 Core Image 读取,可以选颜色空间,比如 extendedLinearDisplayP3 或者 displayP3,也可以选不同的格式,比如 RGBAhRGBA8
  • 通过 ImageIO 读取,得到的是 8 Bit 图像。
  • 通过 pillow-heif 提取,得到的也是 8 Bit 图像。

从 iPhone 拍摄照片中提取的 Baseline Image

formatRGBAhRGBAf 时,Core Image 解码后的图像也会有超过 1.0 或小于 0.0 的值,表示其也有一定的 HDR 信息?这对于之后的 HDR 处理会有影响,稍后会提到。

除此之外,当 formatRGBA8 时,Core Image 解码后的图像范围在 0-255 之间,且大部分像素值与使用 pillow-heif 提取的图像一致,在高亮和暗部的地方会存在差异,也许是 Core Image 存在一些色调压缩的处理,ImageIO 读取的结果与 pillow-heif 也存在一定的偏差,更像是解码器的处理。

不同方法提取的 baseline image 对比

Gainmap Image

增益图,记录了如何结合 Baseline Image 计算出 HDR 图像。本身也是一个图像,范围在 0-1 之间,长宽均为 Baseline Image 的一半,灰度图,使用了 Rec.709 传递函数。

通过 Core Image 可以提取该图,之后可以保存成无压缩的 TIFF 方便 Python 处理。

// ExtractGainmap.swift
import CoreImage

let fileURL = URL(fileURLWithPath: "image.HEIC")

// 1. 指定 auxiliaryHDRGainMap 选项提取 Gain Map
let options: [CIImageOption: Any] = [
    .auxiliaryHDRGainMap: true,
    .applyOrientationProperty: true
]

guard let gainMapCIImage = CIImage(contentsOf: fileURL, options: options) else {
    fatalError("未找到 Gain Map 信息")
}

// 2. 将 CIImage 渲染为 CGImage
let context = CIContext()
let colorSpace = CGColorSpace(name: CGColorSpace.extendedLinearDisplayP3)!

let gainMapCGImage = context.createCGImage(
    gainMapCIImage, 
    from: gainMapCIImage.extent, 
    format: .RGBA8, 
    colorSpace: colorSpace
)

Apple 文档也说明了不依赖其框架时的做法。

Get the existing HDR gain map from the image’s auxiliary data using the urn:com:apple:photo:2020:aux:hdrgainmap image data type. The gain map is untagged and formatted as linear data. It’s encoded using the Rec.709 transfer function and is 1/4 the resolution of the original image.

比如 Python 中,可以用 pillow-heif 提取,结果与 Core Image 得到的是完全一致的。

# extract_gainmap.py
import pillow_heif
from PIL import Image

heif_file = pillow_heif.read_heif("input.heic")

# 提取 HDR Gainmap 的辅助图像 ID
gain_map_urn = "urn:com:apple:photo:2020:aux:hdrgainmap"
if gain_map_urn in heif_file.info.get("aux", {}):
    gain_map_id = heif_file.info["aux"][gain_map_urn][0]
    aux_image = heif_file.get_aux_image(gain_map_id)
    
    # 构建 PIL 图像
    gain_map = Image.frombytes(
        aux_image.mode, 
        aux_image.size, 
        aux_image.data, 
        "raw", 
        aux_image.mode, 
        aux_image.stride
    )

从 iPhone 拍摄照片中提取的 Gainmap

转换到 HDR

根据文档记录的如何手动进行 HDR 转换,和别的双层 HDR 图像格式类似。

操作之前,先进行如下步骤:

  1. 调整 Gainmap 的尺寸,使其与原图匹配。
  2. 使用 Rec.709 传递函数线性化 Gainmap 图像。
  3. 使用对应的传递函数线性化 SDR RGB 图像,如果通过 Core Image 读取,可以指定 extendedLinearDisplayP3 等线性的空间。

然后应用如下公式进行转换:

hdr_rgb = sdr_rgb * (1.0 + (headroom - 1.0) * gainmap)

此时得到的是一个线性的 HDR 图像,其中 1.0 表示参考白亮度,该公式在两个图层均为 1.0 的情况下输出为 headroom,确保了峰值不会超过 headroom 值。

也可以通过 Core Image 直接转换,按照 Apple 的说法,转换后的最大值不应该超过 Headroom,但 Core Image 的结果却显著超过,也许和之前提到的 Baseline Image 的范围有关。

// ConvertToHDR.swift
import CoreImage

let fileURL = URL(fileURLWithPath: "image.heic")

// 提取并应用 HDR 参数
let hdrOptions: [CIImageOption: Any] = [.expandToHDR: true]
guard let hdrCIImage = CIImage(contentsOf: fileURL, options: hdrOptions) else {
    fatalError("图像加载失败")
}

let context = CIContext()
let colorSpace = CGColorSpace(name: CGColorSpace.displayP3_PQ)!

// 渲染为高动态范围格式 (RGBAh)
let hdrCGImage = context.createCGImage(
    hdrCIImage, 
    from: hdrCIImage.extent, 
    format: .RGBAh, 
    colorSpace: colorSpace
)

下图是使用 Core Image 直接得到的 AVIF 图像,转换用到的脚本可以在后面的章节下载。

Core Image 转换得到的 HDR 图像

以 Core Image 的转换结果为基准,手动转换后的结果平均下来约有 1% 的差距,主要就是出现在高亮部分的截断上。下图是手动提取,手动转换得到的 HDR 图像,完全没有用到 Apple 的接口。

全手动转换得到的 HDR 图像

如果使用 Core Image 提取的三个部分,再手动的进行转换,虽然高亮部分不会截断,但结果也会存在一些偏差,尚不清楚原因。下图是先用 Swift 提取三个部分,再手动转换为 HDR 图像的结果。

半手动转换得到的 HDR 图像

高压缩的 HDR 图像

之前,朋友试图将这种 iPhone 拍摄的 HDR 照片转换为单层的 HDR 图像,并使用 AVIF 来获得更高的压缩率,那个项目 中使用的就是纯手动的解析与转换方法,与原图片存在一定的差距。

现在有了 Swift,我们可以全程调用 Apple 框架来进行读取和转换,包括 AVIF 或 HEIC 的输出,在 macOS 上看结果是完全一致的,我暂时将这个小脚本放在对象存储上供大家测试。

点击 下载

swift encoding-apple-heic-beta.swift

不过现在回头思考一下,单层的纯 HDR 格式在压缩率上能取得显著的进步吗?

  • 双层:一个 8bit 的全分辨率 rgb,一个 8bit 的 1/4 分辨率灰度。
  • 单层:一个 10bit 的全分辨率 rgb。

注意:iOS 26 的 HDR 截图

在 iOS 26 中,Apple 增加了对 HDR 截图的支持,虽然也是 HEIC,也是双层结构,但手动解析的办法和 iPhone 拍摄的 HDR 照片不一样,通过 Apple 框架读取是没问题的。