一张 HDR 照片
怎么变成一段 HDR 视频
想法是从一个矛盾开始的
矛盾是这样的:手机拍的 HDR 照片,在手机屏上高光透亮、明暗层次都在;可一想搬到客厅那台 HDR 电视的大屏上看,就尴尬了——电视明明播 HDR 视频(HDR10)毫无压力,却偏偏认不出这种带增益图的 HDR 照片,要么当成普通 SDR 图显示、HDR 效果当场全丢,要么干脆打不开。设备有 HDR 显示的本事,却因为「照片」这个格式用不上。
电视点不亮 HDR 照片
同一台 HDR 电视,播 HDR10 视频毫无压力,可它的相册 / 图片浏览根本不认带增益图的 HDR 照片——只能当普通 SDR 图显示,或者直接打不开。屏幕有 HDR 能力,却因为「照片」这个格式用不上。
增益图里就存着 HDR
手机存的其实是「一张 SDR 图 + 一张增益图(Gain Map)」。HEIC 里是 Apple 的 tmap,UltraHDR JPEG 里是 hdrgm: 命名空间,本质都是「逐像素的 log2 提亮量」。两层合起来才是完整 HDR。
HDR 视频兼容性最好
静态 HDR 格式(JXL)电视基本不认,但 H.265 HDR10 的 MOV 在电视、QuickTime、剪辑软件里都能直接放。把一张 HDR 照片做成静止 HDR 视频,就能直接用上电视本来就支持的 HDR 视频解码。
把流程拆成五个能单独测的模块
核心是一个 C++ 引擎 hdr2jxl,外面套一层 Flask + WKWebView 的本地界面。引擎按「一件事一个文件」拆开,每个环节都能单独喂数据、单独验证,避免一条长函数从头错到尾。
格式检测 + 解码
heic_reader / jpeg_reader:认出格式,拆出 base 层、增益图、元数据
增益图 → 线性 HDR
gainmap:按 log2 公式把 SDR 提亮成线性光
线性 → PQ
SMPTE ST 2084 绝对亮度编码,并写入 HDR10 色彩标签
JXL / HEVC
jxl_writer 出静态图;video_writer 调 ffmpeg 出视频
导出中间层
image_writer:base 层 + 增益图灰度,供核对
读取层只负责「拆」
判断是 HEIC 还是 JPEG,把 base 图、增益图、元数据(gamma、hdrCapacityMin/Max、offset)原样吐出来,不做任何亮度计算。读取出错就早退,不让坏数据流进算法。
合并层是唯一的「数学中心」
所有亮度相关的公式都集中在 gainmap.cpp:增益合并、sRGB↔线性、PQ↔线性、HLG↔线性。后面所有 bug 几乎都在这里改,集中放好处巨大。
写出层只负责「编码 + 容器」
JXL 走 libjxl,写入 PQ / HDR 色彩信息;视频走 ffmpeg,补齐 HDR10 的编码与容器标签。写出层不重新调亮度,只把已经算好的线性 HDR 编进文件。
主程序只做编排
Main.cpp 解析参数、串起读取→合并→写出,并且无论成不成功都导出 base 层和增益图。这条「总是导出旁证」的设计,是后面定位过曝 bug 的关键。
增益图合并:一条指数公式
增益图的值是 0–1 的归一化数,代表「在最小和最大提亮量之间插到哪」。真正的提亮是 2 的指数次方——这点决定了后面所有调参都得在 log2 域里做。
实现上逐像素跑这条公式,刻意不把结果裁剪到 1.0 以内——超过 1.0 的部分正是 HDR 高光,裁掉就退回 SDR 了。算完线性 HDR,再用 PQ(SMPTE ST 2084)曲线编码进 16-bit,交给 JXL 或 ffmpeg。
// gainmap.cpp — 合并的核心循环(节选) float log2Gain = gain * (capMax - capMin) + capMin; // 映射到 log2 提亮量 float factor = std::pow(2.0f, log2Gain); // 指数 → 线性增益因子 float hdr = base * factor + offsetHdr; hdrImage[idx] = std::max(0.0f, hdr); // 只保非负,不裁上限(保住高光)
难的不是算,是先把增益图找出来
三家厂商把增益图藏在三个不同的地方。读取层要足够健壮:标准路径走不通时要有退路,否则一张被压过的图就让整个转换失败。
tmap 辅助图
遍历 HEIF 容器里的 item,按 fourcc 't','m','a','p' 找到增益图项,单独解码成单通道亮度图。位深可能是 8/10/12/16 位,按最大值归一化到 0–1。
XMP + 内嵌 JPEG
从 hdrgm: 命名空间正则提取 gamma、capacity、offset;增益图本身是文件尾部第二张内嵌 JPEG,靠 Container:Item 长度定位。
双阶段定位
优先用 MPF 段里的绝对偏移直接跳到增益图;MPF 缺失或被改写时,退回全文件扫描 JPEG 起始标记(0xFFD8)找第二张图。两条路都留着。
- 症状
- 增益图像素数和主图对不上,直接套用就错位甚至崩溃。
- 原因
- 为省空间,厂商常把增益图存成主图的 1/2 或 1/4 分辨率。
- 修复
- 读取层先把增益图双线性放大到主图尺寸再交给合并层,让后续逐像素公式能直接一一对齐。
静态图怎么变成 HDR10 视频
没有自己写 H.265 编码器,而是复用已经算好的线性 HDR:先落一张临时 JXL,再用 ffmpeg 把这张图「循环」成一段静止视频,并打上完整的 HDR10 颜色标签。
先出一张临时 HDR 图
用和静态导出完全相同的 JXL 写出逻辑生成临时文件,保证视频和图片的亮度处理一字不差。
ffmpeg 循环成视频
-loop 1 把单帧重复成指定时长和帧率,编成 H.265 main10,像素格式 p010le。
打满 HDR10 颜色三件套
color_primaries bt2020 + color_trc smpte2084 + colorspace bt2020_ncl,少一个播放器就不认 HDR。
硬件优先,软件兜底
先试 Apple hevc_videotoolbox 硬件编码;失败就自动回退到 libx265 软件编码,慢但稳。
拆开看:HDR 其实是两层叠出来的
还是这张 OPPO UltraHDR 原图,导出它内部的两个中间层就能看清 HDR 是怎么合成出来的——SDR base 层是普通屏幕能显示的部分(参考白封顶、招牌已经有点发灰),增益图是一张灰度图,记录每个像素还要往上提多少 stop:灰度越亮提得越多。两层按指数公式 base × 2g 相乘,灯箱、招牌就被抬到 SDR 顶不住的亮度,得到下面那段 HDR 视频。
注意增益图里发亮的区域,正好对应成品里冲破 SDR 上限的高光——这就是「base 决定底色、增益图决定哪里该更亮」。
把这两层按 203 nits 锚定 + PQ 编码合并,再循环成视频,就是整条流水线跑通后的成品:
真正的 HDR10(HEVC / PQ / BT.2020)视频。在支持 HDR 的屏幕上,灯箱和高光会冲破 SDR 上限真正「亮」起来;这里是浏览器压回 SDR 后的预览。下一节就从这段视频回看——同一条算法稍微写错一步,画面会变成什么样。
真正的算法,是被导出结果逼出来的
- 症状
- 导出的 JXL / 视频整体过亮,本该是参考白的区域亮得发光。
- 原因
- PQ 是绝对亮度曲线,线性 1.0 = 10000 nits。直接把 sRGB 满亮度 1.0 喂进 PQ,等于把「纸面白」当成了 10000 nits 的超亮高光。
- 修复
- 合并前先把 base 层锚定到 SDR 参考白:乘以
sdrWhiteNits / 10000。203 nits 来自 ITU-R BT.2408 / libultrahdr 推荐值,让「SDR 1.0」对应到 PQ 的 203 nits 而不是满量程。
// 改前:sRGB 1.0 直接进 PQ → 被当成 10000 nits,全图过曝 float baseR = baseImage[idx]; // 改后:先把 SDR 参考白锚到 203 nits 的绝对亮度位置 const float k = sdrWhiteNits / 10000.0f; // 203 / 10000 float baseR = baseImage[idx] * k;
同一张 OPPO UltraHDR 原图,分别用「漏掉锚定」的旧算法和修好的算法转成 HDR 视频,差别一眼可见——左边整屏发光、招牌和路面全糊成一片白,右边夜景层次正常、只有灯箱该亮的地方亮:
注:两段都是真正的 HDR10(HEVC / PQ / BT.2020)视频。在 HDR 屏上差距更夸张;这里看到的已是浏览器把 HDR 压回 SDR 后的效果,过曝那条仍然惨白。
- 症状
- 增益强度 = 1(完整 HDR)正常,可一拖到 0(纯 SDR)画面又爆掉。
- 原因
- 强度为 0 时为了省事走了一条快捷分支,忘了同样乘
sdrWhiteNits/10000,于是 sRGB 1.0 又变回 10000 nits。两条代码路径不一致。 - 修复
- 让所有强度分支共用同一个
k = sdrWhiteNits/10000系数,保证 0、1 和中间值落在同一套亮度坐标里。
- 症状
- 增益强度取 0.5 这类中间值时,高光过渡浑浊,不像「一半 HDR」。
- 原因
- 一开始在像素值上线性插值((1−s)·SDR + s·HDR)。但提亮是 2 的指数关系,对指数结果做线性混合会破坏曝光的几何级数。
- 修复
- 把插值挪到 log2 域:直接缩放指数
log2Gain × gainStrength,再做2^。强度 0 = 不提亮,强度 1 = 完整提亮,中间是平滑的曝光过渡。
// 在指数上插值,而不是在像素上插值 float log2Gain = gain * (capMax - capMin) + capMin; log2Gain *= gainStrength; // ← 关键:缩放的是指数 float factor = std::pow(2.0f, log2Gain);
- 症状
- ffmpeg 编出来的 MOV,QuickTime 要么打不开,要么当成普通 SDR 播放。
- 原因
- QuickTime 只认
hvc1标签的 HEVC(不认默认的hev1),而且必须带齐 HDR10 的颜色元数据才会进 HDR 通路。 - 修复
- 编码时显式加
-tag:v hvc1、用 10-bitp010le,并补全 BT.2020 / ST.2084 / BT.2020-ncl 三件套。
# 让 QuickTime 认得、且走 HDR10 通路的关键参数 ffmpeg -loop 1 -i tmp.jxl \ -c:v hevc_videotoolbox -profile:v main10 -pix_fmt p010le \ -tag:v hvc1 \ -color_primaries bt2020 -color_trc smpte2084 -colorspace bt2020_ncl ...
- 症状
- JXL 文件能写出来,但解码时颜色错乱 / 花屏。
- 原因
- basic_info 里声明的位深,必须和实际像素的数据类型严格对应;混用 16-bit 声明 + float 数据会让解码器按错误布局读取。
- 修复
- 分成两条干净的路径:16-bit 走整型量化(
uint16),32-bit 走浮点(JXL_TYPE_FLOAT),声明与数据一一对应。
203 nits:从过曝现象追到标准锚点
代码里有一行毫不起眼的常量:k = sdrWhiteNits / 10000。它解决的不是「画面再压暗一点」这种主观调色问题,而是一个坐标转换问题:SDR base 层是相对参考白编码,PQ 输出却要求绝对亮度。203 nits 就是把这两套坐标接起来的参考白锚点。
问题不是亮度不够,而是坐标系错了
SDR base 层里的 1.0 表示「到达 SDR 参考白」,它本身不是一个固定物理亮度;PQ(SMPTE ST 2084)则完全相反,编码值直接对应显示亮度,满刻度对应 10000 nits。如果把 SDR 的 1.0 原样送进 PQ,就等于把普通白纸声明成 10000 nits 的极端高光,结果当然会整屏过曝。正确做法是:合并出的线性 HDR 在进入 PQ 前,先乘一个系数,把 SDR 参考白落到 HDR 系统里的漫反射白位置。
// gainmap.cpp — 进 PQ 前的曝光锚定 const float baseExposureCompensation = sdrWhiteNits / 10000.0f; // 203/10000 ≈ 0.0203 // 所有强度路径都必须乘它:gainStrength=0 是 SDR 参考白, // gainStrength=1 是完整 HDR;两者都要落在同一套绝对亮度坐标里
从症状反推出缺失变量
最早暴露出来的症状是「算法公式看起来没错,但导出视频整体白得不正常」。继续打印亮度统计后可以看到,原本应该只是参考白的区域被推到了很高的 PQ 亮度。于是问题从「增益图是不是太强」变成了「base 层进 PQ 前有没有先定参考白」。用不同系数临时测试时,0.02 附近会回到合理范围,0.05 又开始偏亮;这不是为了靠肉眼定最终值,而是在确认:这里确实缺了一个 Lwhite / 10000 的绝对亮度换算。
用标准把变量定下来
确认缺的是 Lwhite 之后,再去查「SDR 参考白进入 HDR/PQ 时应该是多少 nits」,资料就不再是泛泛而读,而是直接回答工程问题。几份文档和实现交叉指向同一个答案:
HDR 参考白 = 203 cd/m²
ITU 给 HDR 制作定义的「漫反射白 / 图文白」标准亮度是 203 cd/m²。它不是峰值,而是普通白纸、字幕、图文内容在 HDR 画面里的基准位置。
PQ 是绝对亮度曲线
定义 PQ 编码与亮度的对应关系,满刻度为 10000 nits。这条曲线解释了「为什么必须有 k」:不锚定,SDR 的 1.0 就会被当成一万尼特。
PQ / HLG / BT.2020 体系
把 PQ、HLG 传递函数和 BT.2020 色域绑成一套 HDR 体系,JXL / HEVC 输出打的颜色标签都来自这里。
增益图实现也用 203
UltraHDR 的官方增益图合并实现也把 SDR 参考白放在 203 nits。和上游实现对齐,能减少不同设备、不同软件之间的亮度漂移。
203 是「白纸」,不是「最亮」
这一步最容易误解:203 nits 不是画面的峰值亮度,而是 漫反射白(diffuse / paper white)。它描述的是白纸、白墙、字幕这类普通白在 HDR 系统里的位置。HDR 真正要保留的是 203 之上的余量:灯箱、镜面反射、太阳和其他高光可以继续冲到上千 nits,甚至接近 PQ 的 10000 nits 上限。增益图的作用,正是把这些超过漫反射白的区域撑起来。所以把 SDR 白锚到 203,等于给高光留出 headroom;如果把 203 当成峰值,整张图会被整体压到亮度上限,高光反而没有余量。换算到信号上,203 nits 对应 75% 的 HLG 信号、约 58% 的 PQ 信号,这也是它在 HDR 工作流里反复出现的原因。
问题暴露阶段
- 画面过曝,但增益图公式本身没有明显错误
- 亮度统计显示 SDR 白被推到过高 PQ 亮度
- 临时系数只能说明缺了映射,不能当最终答案
- 如果不区分参考白和峰值,解释会越来越乱
- 不同图片、不同屏幕下很难保持一致
标准化之后
- 203 = BT.2408 定义的 HDR 漫反射参考白
k = 203/10000,一个有出处的常量- base 层、增益层和 PQ 编码落在同一亮度坐标
- 明确 203 之上的空间专门留给高光
- 与 libultrahdr 上游一致,行为可预期
三种形态,同一个内核
引擎 hdr2jxl 写好之后,能力就固定了。真正变化的是外层的交互方式——从命令行到网页再到 Mac App,每一层都只解决一个问题:上一层,谁还用不了。内核一行没改,受众却一级一级扩大。
命令行内核
交互式问路径,或一串 -g -e -w -v -d -f 参数。引擎能力 100%,但门槛也 100%:得会用终端、记得住参数、还要先装好 Homebrew 那套库。基本只有作者自己用得动。
浏览器界面
同一个内核外面包一层 Flask,浏览器打开就是图形界面。拖拽上传、可视化调参、实时预览。会用浏览器就能用——这一跳把「会背命令」的门槛彻底拆掉。
双击即用
用 py2app 把 Python、Flask、WKWebView 和所有 dylib 打进一个 .app:独立窗口、原生文件选择/保存面板、零依赖。DMG 拖进 Applications 就能用,全程不碰终端。
关键的一跳:命令行 → 网页,易用性提升在哪
这一跳收益最大。同样的转换能力,交互方式换了,「能用」的人一下子从「会终端的少数」变成「会浏览器的所有人」。具体提升可以拆成五个维度:
命令行的摩擦
- 参数靠记:
-g 0.8 -e 0 -w 203 -v -d 5 -f 30一长串 - 输入靠手打完整文件路径,错一个字符就失败
- 转换前看不到这张图是不是 HDR、增益图在不在
- 调一次参数就重跑一次,还得另开看图器看效果
- 成功还是失败,只能从一屏终端日志里找
网页消除了它
- 滑块 + 按钮,参数自带范围约束,填不出非法值
- 拖拽上传,文件名和路径自动带过去
- 上传即解析:相机、ISO、HDR 格式、增益图容量一目了然
- 实时预览 + 原图/成品左右对比,所见即所得
- 进度条反馈 + 一键下载到本地,不用盯日志
把「命令」变成「界面」
CLI 的每个 flag 在网页里都对应一个控件:-g 变增益滑块、-w 变 203/100 nits 切换、-v -d -f 变视频时长帧率输入。用户不再需要知道参数叫什么,只需要知道自己想要什么效果。
把「盲转」变成「先看后转」
命令行是把文件丢进去碰运气;网页在上传瞬间就解析 EXIF 和 HDR 元数据并显示出来,用户在转换前就知道这张图有没有增益图、是哪家的 HDR 格式,再决定参数。
把「重跑」变成「实时反馈」
调参从「改命令 → 回车 → 等 → 另开软件看 → 不满意再来」压缩成「拖滑块 → 预览里直接看到原图和成品的差别」。反馈回路从几十秒缩短到几乎即时。
./start_webui.sh、装 Python 和 Homebrew、再手动打开浏览器。Mac App 这一层把这些环境依赖也一并消灭——双击图标,独立窗口直接弹出,对完全不懂技术的人也成立。这个项目教会的三件事
把整个过程压缩成三句话,换个图像处理项目也能套用。
再谈 HDR
PQ 是绝对亮度曲线,任何 SDR 数据进 PQ 前都得先映射到参考白(203 nits),否则必然过曝。
要在指数域里调
增益是 2 的指数次方,强度、插值、曝光补偿都该在 log2 域操作,在像素值上线性混合一定走样。
用结果反推 bug
每次转换都吐出 base 层、增益图和亮度统计。画面不对时,靠这些中间结果几分钟就能定位是哪一步算错。