Inverse Tone Mapping · 实战项目

把普通画面还原成 HDR
——一套逆色调映射系统的思路与打磨

从一张 SDR 图片或一段 SDR 视频出发,自动判断"哪里该亮、该多亮", 在物理正确的色彩空间里重建高动态范围画面,输出符合行业标准的 HDR 文件。 本页记录系统的核心思路,以及一路上踩过的坑与评测驱动的优化全过程。

Rec.2020 + PQ (ST 2084) BT.2408 参考白 203nit YOLOE 开放词汇检测 GPU(MPS) 加速 JPEG XL / HDR10
01 · 问题

我们到底在解决什么

绝大多数现存影像都是 SDR(标准动态范围):白墙、台灯、天空在 SDR 里被压到同一个"白"—— 亮度信息在编码时就丢了,而 HDR 显示器能呈现的亮度是它的几十倍。

逆色调映射(ITM)的任务,是从被压扁的 SDR 反推回合理的高动态范围: 让光源真正发光、高光刺眼、暗部留层次,而不是把整张图无脑调亮。

🎯

难点一 · 哪里该亮

SDR 里一盏 1000nit 的灯和一面 200nit 的白墙像素值可能完全一样(都削顶到 255)。光靠亮度无法区分,必须理解画面内容。

📐

难点二 · 该多亮

提多了肤色/白墙过曝刺眼,提少了又没有 HDR 该有的通透。需要在物理正确的亮度刻度上精确标定。

🚫

难点三 · 别露馅

提亮区与未提亮区之间若有硬边界、色彩断层、块状伪影,观感比 SDR 还差。平滑过渡是底线。

02 · 架构

一帧画面要经过的处理管线

核心原则:所有增强都在"线性光"域完成。SDR 是 gamma 编码的,直接在编码值上运算会污染颜色—— 必须先解码到线性光,处理完再编码回 HDR 的 PQ 曲线。

IN
SDR 输入
sRGB / Rec.709 gamma
01
线性化
sRGB EOTF → 线性光
02
区域检测
自发光/反射/普通
03
差分 ITM
三类各自扩展
04
融合+护栏
软过渡+保真限制
05
色域+PQ
Rec.2020 / ST 2084
OUT
HDR 输出
JPEG XL / HDR10

三类区域,三种策略

把每个像素分成三类,因为它们需要完全不同的处理——这是"哪里该亮"的核心答案。

Type I · 自发光

灯、火焰、屏幕、太阳。该最亮,凸性曲线把削顶白扩展到接近显示峰值(~800nit)。靠 YOLOE 检测 + 近削顶白核兜底。

Type II · 材质反射

玻璃、金属、水面的镜面高光。中等亮(~500nit),保留材质感与色彩体积,边缘需平滑防硬块。

Type III · 普通区域

占画面 90%+。温和:漫反射白轻抬到 ~250nit,近削顶高光给"肩部"抬到 ~450nit,其余基本保持原片。

02b · 处理前后

同一张图,处理前 → 处理后

左为输入的 SDR 原图,右为系统重建的 真 HDR 输出(Rec.2020 PQ 的 HDR AVIF)。 在支持 HDR 的浏览器 + HDR 屏幕(如 macOS 上的 Safari / Chrome,配 XDR / HDR 显示器)上, 右图的窗户、灯、高光会真正刺眼地亮起来,超出左图 SDR 的白。

若左右看着差不多,说明当前屏幕或浏览器未启用 HDR—— 换 HDR 屏 + Safari/Chrome 再看,或参考评测章节的 nit 热力图。
SDR 原图处理前 · SDR
HDR 输出处理后 · HDR ✨
办公室(白天):窗外天光、天花板灯被识别为高光,扩展到 ~700nit;地面反光、屏幕中等提亮,桌面/墙壁等漫反射保持自然——这正是"哪里该亮"的判断结果。
SDR 原图处理前 · SDR
HDR 输出处理后 · HDR ✨
含光源场景:真正的光源与高光被精准点亮到 700nit+,其余区域不被无脑调亮。
SDR 原图处理前 · SDR
HDR 输出处理后 · HDR ✨
夜景街道:天桥灯、车灯、路面反光被点亮,但按"折中"理念克制中间调——不像手机那样把整片夜空无脑提亮,峰值约 335nit,保留夜的氛围。
03 · 基础

几个绕不开的色彩科学概念

BT.2408 参考白 = 203nit

整套系统的"亮度锚点"。行业标准规定:SDR 的漫反射白(纸张、白衬衫)在 HDR 里应对应 203 nit, 而非显示器峰值。高光(光源、镜面)才向上延伸到 1000nit。

早期有个严重 bug:把 SDR 白直接编码到峰值 1000nit,整张图过曝约 5 倍。修正后才有了正确的亮度基准。

PQ 曲线 + Rec.2020

PQ(SMPTE ST 2084)是 HDR 传输函数,把 0–10000nit 绝对亮度编码进有限码值,暗部分配更多精度(符合人眼)。 Rec.2020是更宽色域。两者组合即 HDR10 / HDR 静图标准。

线性光 1.0(=203nit)→ ×(203/10000) 进入 PQ 绝对亮度域 → 编码。

Gainmap:手机 HDR 的秘密,也是我们的"真值"

iPhone / Android 的 HDR 照片其实是 SDR 基础层 + 增益图(gainmap):增益图是逐像素的"该亮多少"灰度图。 这给了一个绝佳评测思路——把手机 HDR 拆成 SDR,用我们的系统重建,再和手机原生 HDR 对比看差距。详见评测章节 ↓

04 · 打磨

优化历程:每一步都是一个踩坑

按主题串起系统的演进。 画质/算法 性能  评测驱动

画质正确性

色彩断层与参考白

问题:HDR 输出大片色彩断层(posterization);整体过曝。

修复:把全管线 LAB/HSV/双边/CLAHE 从 uint8(256 级)升级到 float32 / uint16,消除量化断层;引入 BT.2408 参考白,SDR 白从错误的 1000nit 锚回 203nit。

检测能力

从 COCO 封闭集到 YOLOE 开放词汇

问题:原模型(YOLOv8n / COCO-80)根本没有"台灯/吊灯/烛火/霓虹/荧光灯"这些类别——核心自发光物体永远检不出。

修复:换成 YOLOE 开放词汇 + 分割模型,用文本提示(58 条,8 大类)直接描述光源,还输出像素级掩码替代矩形框。

检测能力 · 案例

"手电筒/条形灯不亮"——五层故障叠加

两个真实片源里光源死活提不亮,逐层排查发现 五个独立问题叠在一起

故障修复
提示词没有 flashlight / fluorescent tube补齐提示词
置信度暗场景光源置信度仅 0.05–0.23 被滤掉三层证据融合:弱语义 × 框内光度确认
白核兜底强光白衣物被误判成光源收紧到硬削顶 V≥0.99 & S≤0.15
亮度门控调色"平"的暖灯差一线被拒门控按场景白相对化
高光曲线渐近 Reinhard 永远到不了设定峰值凸性曲线 + 场景自适应白锚点(EMA)
教训:一个"效果不对"的现象,背后可能是检测、分类、色调映射、时序四个环节同时出问题。逐级 profile、单帧诊断,比拍脑袋调参数可靠得多。
性能 · 头号瓶颈

scipy 众数滤波:一帧 20 秒

问题:视频渲染奇慢(6.5 小时)。逐阶段 profile 发现真凶是时序平滑里的 scipy.stats.mode,在 5×1080p 上单次 19.87 秒,占每帧 95%+。

修复:区域图只有 3 个标签值,改用 numpy 按标签计数取 argmax——516× 提速,与 scipy 输出 100% 一致。

19.9s 0.04s
众数滤波单次
516×
加速比
性能 · GPU 化

把 ITM 整条搬到 MPS GPU

问题:profile 显示逆色调映射占每帧 82% 耗时(1080p ~700ms),且全跑在 CPU 上。

修复:用 torch 在 Apple Silicon 的 MPS 上重写整条 ITM,逐点数学数值精确复刻;唯一难点是双边滤波在 GPU 上仍要 140ms——换成引导滤波(纯 box-filter,O(1),~2ms)后整体 10.7× 提速,峰值亮度与 CPU 版逐 nit 一致。

712ms 67ms
1080p ITM 单帧
10.7×
加速比
±1nit
与 CPU 差异
性能 · 编码与分辨率

硬件编码 + 可选分辨率

用 Apple VideoToolbox 硬件 HEVC 替代 libx265 软编(快一个量级),并用 hevc_metadata 比特流过滤器强制写入正确的 HDR10 标签;新增处理分辨率选项(4K→1080p 约快 4 倍)。

时序稳定性

视频不能逐帧抖:检测记忆 · 光流 · 镜头切换

检测记忆

开放词汇模型对弱光源逐帧抖动,用衰减记忆桥接间歇漏检,避免光源逐帧断闪。

光流跟踪 mask

开"检测间隔"时用 DIS 光流把上一帧区域图跟踪到当前帧,mask 不滞后于运动;ITM 仍逐帧跑,画面不冻结。

镜头切换检测

HSV 直方图相关性突变即判定硬切,清空所有时序状态——否则上一镜头的 mask/曝光锚点会残留到下一镜头。

交付

正确的 HDR 文件格式

图像输出对齐工业实现:JPEG XL,Rec.2020 + D65 + PQ + 绝对渲染意图 + intensity_target。实测硬件编码器会丢 VUI 色彩信息,必须显式注入,否则播放器不切 HDR 模式。可在 macOS 预览 / Safari 直接以正确亮度查看。

05 · 评测驱动

用手机原生 HDR 当"标准答案"

调参数最怕"自我感觉良好"。于是搭了一套有真值的评测闭环:手机 gainmap 照片本身同时含 SDR 和 HDR,正好当 ground truth。

手机原图
HEIC / UltraHDR gainmap
拆分
SDR base + 原生 HDR(真值)
我们重建
SDR base → 我们的 ITM
解码对比
双方 → 绝对 nit 域
指标+热力图
峰值/覆盖/IoU
办公室场景对比
办公室场景 | 左:SDR 基础层 中:手机原生 HDR(真值)热力图 右:我们重建的 HDR。 蓝=暗(<100nit),青绿=中(~200nit),红=亮(~700nit+)。窗户、天花板灯被正确点亮。
第一次跑出来的诊断很扎心:12 张图平均 高光 recall 仅 0.05——漏掉手机 95% 的高光;峰值只有它的 1/5(350 vs 1600nit)。 但 precision 1.00:我们标亮的永远对,只是太胆小。

诊断 → 标定 → 复测

问题不在"亮度幅度"而在"覆盖"和"封顶"。确认走折中路线(漫反射白 ~250nit、高光 ~800nit、峰值维持 1000nit)后,三处联动调整:

指标优化前折中标定后
高光 recall(命中手机高光比例)0.050.15 (3×)
重建峰值~350 nit700+ nit (2×)
白天场景高光 IoU0.010.36–0.39
precision(不误增高光)1.001.00

注:夜景下手机会把整片夜空/路面都激进提亮,我们按折中只提真光源——recall 仍偏低,但这是主动选择的理念,不是缺陷。

06 · 仍在打磨

越激进,越要防"露馅"

把高光提得更猛后,新问题浮现:提亮区与未提亮区之间出现硬边界 / 块状伪影。 根因有二——反射检测在亮纹理区碎成椒盐麻点(每块都被提亮,边界全是硬边);陡峭的高光曲线还放大了 SDR 里的 JPEG 块伪影。

碎块区域图
诊断图:红=Type I,绿=Type II。反射检测在屏幕/窗户上碎成大量绿色小块,正是硬边界的来源。

两道修复

① 区域去碎块

分类后做"开运算去碎点 → 闭运算合并 → 剔除小连通域",让 Type I/II 成为连贯的块而非麻点。

② 增益场边缘感知平滑

借鉴 gainmap 思想:在 ITM 最后把"提亮倍率场"用引导滤波平滑,以 SDR 感知亮度为导向——真实边缘(窗框、灯沿)处倍率保持锐利,平坦区内的碎块/JPEG 块倍率被抹平,从源头消除提亮硬边界。

窗户边界
窗户亮区放大:上为 SDR,下为重建后 nit 热力图。亮窗被提到 ~700nit,过渡随平滑迭代逐步变软——当前正在精修的环节。
这套系统的方法论:不靠主观感觉,而是有真值的量化评测 + 逐级 profile 定位 + 单帧诊断可视化。 每个"看起来不对"的现象,都拆到具体环节、给出可测指标,再针对性优化。