把普通画面还原成 HDR
——一套逆色调映射系统的思路与打磨
从一张 SDR 图片或一段 SDR 视频出发,自动判断"哪里该亮、该多亮", 在物理正确的色彩空间里重建高动态范围画面,输出符合行业标准的 HDR 文件。 本页记录系统的核心思路,以及一路上踩过的坑与评测驱动的优化全过程。
我们到底在解决什么
绝大多数现存影像都是 SDR(标准动态范围):白墙、台灯、天空在 SDR 里被压到同一个"白"—— 亮度信息在编码时就丢了,而 HDR 显示器能呈现的亮度是它的几十倍。
逆色调映射(ITM)的任务,是从被压扁的 SDR 反推回合理的高动态范围: 让光源真正发光、高光刺眼、暗部留层次,而不是把整张图无脑调亮。
难点一 · 哪里该亮
SDR 里一盏 1000nit 的灯和一面 200nit 的白墙像素值可能完全一样(都削顶到 255)。光靠亮度无法区分,必须理解画面内容。
难点二 · 该多亮
提多了肤色/白墙过曝刺眼,提少了又没有 HDR 该有的通透。需要在物理正确的亮度刻度上精确标定。
难点三 · 别露馅
提亮区与未提亮区之间若有硬边界、色彩断层、块状伪影,观感比 SDR 还差。平滑过渡是底线。
一帧画面要经过的处理管线
核心原则:所有增强都在"线性光"域完成。SDR 是 gamma 编码的,直接在编码值上运算会污染颜色—— 必须先解码到线性光,处理完再编码回 HDR 的 PQ 曲线。
三类区域,三种策略
把每个像素分成三类,因为它们需要完全不同的处理——这是"哪里该亮"的核心答案。
Type I · 自发光
灯、火焰、屏幕、太阳。该最亮,凸性曲线把削顶白扩展到接近显示峰值(~800nit)。靠 YOLOE 检测 + 近削顶白核兜底。
Type II · 材质反射
玻璃、金属、水面的镜面高光。中等亮(~500nit),保留材质感与色彩体积,边缘需平滑防硬块。
Type III · 普通区域
占画面 90%+。温和:漫反射白轻抬到 ~250nit,近削顶高光给"肩部"抬到 ~450nit,其余基本保持原片。
同一张图,处理前 → 处理后
左为输入的 SDR 原图,右为系统重建的 真 HDR 输出(Rec.2020 PQ 的 HDR AVIF)。 在支持 HDR 的浏览器 + HDR 屏幕(如 macOS 上的 Safari / Chrome,配 XDR / HDR 显示器)上, 右图的窗户、灯、高光会真正刺眼地亮起来,超出左图 SDR 的白。
处理前 · SDR
处理后 · HDR ✨
处理前 · SDR
处理后 · HDR ✨
处理前 · SDR
处理后 · HDR ✨几个绕不开的色彩科学概念
BT.2408 参考白 = 203nit
整套系统的"亮度锚点"。行业标准规定:SDR 的漫反射白(纸张、白衬衫)在 HDR 里应对应 203 nit, 而非显示器峰值。高光(光源、镜面)才向上延伸到 1000nit。
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 对比看差距。详见评测章节 ↓
优化历程:每一步都是一个踩坑
按主题串起系统的演进。● 画质/算法 ● 性能 ● 评测驱动
色彩断层与参考白
问题: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) |
scipy 众数滤波:一帧 20 秒
问题:视频渲染奇慢(6.5 小时)。逐阶段 profile 发现真凶是时序平滑里的 scipy.stats.mode,在 5×1080p 上单次 19.87 秒,占每帧 95%+。
修复:区域图只有 3 个标签值,改用 numpy 按标签计数取 argmax——516× 提速,与 scipy 输出 100% 一致。
把 ITM 整条搬到 MPS GPU
问题:profile 显示逆色调映射占每帧 82% 耗时(1080p ~700ms),且全跑在 CPU 上。
修复:用 torch 在 Apple Silicon 的 MPS 上重写整条 ITM,逐点数学数值精确复刻;唯一难点是双边滤波在 GPU 上仍要 140ms——换成引导滤波(纯 box-filter,O(1),~2ms)后整体 10.7× 提速,峰值亮度与 CPU 版逐 nit 一致。
硬件编码 + 可选分辨率
用 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 直接以正确亮度查看。
用手机原生 HDR 当"标准答案"
调参数最怕"自我感觉良好"。于是搭了一套有真值的评测闭环:手机 gainmap 照片本身同时含 SDR 和 HDR,正好当 ground truth。
诊断 → 标定 → 复测
问题不在"亮度幅度"而在"覆盖"和"封顶"。确认走折中路线(漫反射白 ~250nit、高光 ~800nit、峰值维持 1000nit)后,三处联动调整:
- Type III 给近削顶高光加"肩部"——没被检测为光源的亮天空/窗户也抬到 HDR 高光;
- Type I/II 提高扩展幅度,让真光源够到 700–800nit;
- 放开融合护栏(2.2→4.2×),不再把扩展截断。
| 指标 | 优化前 | 折中标定后 |
|---|---|---|
| 高光 recall(命中手机高光比例) | 0.05 | 0.15 (3×) |
| 重建峰值 | ~350 nit | 700+ nit (2×) |
| 白天场景高光 IoU | 0.01 | 0.36–0.39 |
| precision(不误增高光) | 1.00 | 1.00 |
注:夜景下手机会把整片夜空/路面都激进提亮,我们按折中只提真光源——recall 仍偏低,但这是主动选择的理念,不是缺陷。
越激进,越要防"露馅"
把高光提得更猛后,新问题浮现:提亮区与未提亮区之间出现硬边界 / 块状伪影。 根因有二——反射检测在亮纹理区碎成椒盐麻点(每块都被提亮,边界全是硬边);陡峭的高光曲线还放大了 SDR 里的 JPEG 块伪影。
两道修复
① 区域去碎块
分类后做"开运算去碎点 → 闭运算合并 → 剔除小连通域",让 Type I/II 成为连贯的块而非麻点。
② 增益场边缘感知平滑
借鉴 gainmap 思想:在 ITM 最后把"提亮倍率场"用引导滤波平滑,以 SDR 感知亮度为导向——真实边缘(窗框、灯沿)处倍率保持锐利,平坦区内的碎块/JPEG 块倍率被抹平,从源头消除提亮硬边界。