4. HDR视频的色调映射基础

任务说明

本案例的目标是:给定一张HDR格式图像(或视频帧),通过全局色调映射算子将其动态范围压缩至SDR显示范围(0~255),并输出可正常显示的LDR图像。你需要理解全局映射与局部映射的本质区别,并实现两种经典全局算子:线性缩放与对数映射,同时观察不同算子对亮度层次的影响。

核心原理

HDR图像的像素值通常以浮点形式存储,亮度范围可达0~10^6 cd/m²,而SDR显示设备仅能呈现0~100 cd/m²左右的亮度。色调映射(Tone Mapping)的核心任务是将HDR亮度域压缩到SDR亮度域,同时尽量保留视觉对比度与细节。全局色调映射算子对图像中所有像素使用同一个映射函数,计算简单、实时性好,但可能在极端高动态范围场景下丢失局部细节。本案例聚焦两种基础全局算子:

  • 线性缩放:直接乘以缩放系数,简单但会压缩整体对比度,暗部细节易丢失。
  • 对数映射:利用人眼对亮度感知近似对数的特性,将高动态范围压缩到低动态范围,能更好地保留暗部层次。

代码实现

以下Python代码使用OpenCV读取HDR图像(格式为.hdr或.exr),实现两种全局色调映射算子,并输出对比结果。代码中包含了完整的图像读取、映射函数定义、结果保存与可视化流程。

import cv2
import numpy as np

def linear_scale(hdr_img, scale=0.18):
    """
    线性缩放色调映射
    :param hdr_img: HDR图像 (float32, 范围0~1或更大)
    :param scale: 缩放系数,将最大亮度映射到1.0
    :return: LDR图像 (uint8, 0~255)
    """
    # 将HDR亮度归一化到0~1
    max_val = np.max(hdr_img)
    normalized = hdr_img / max_val
    # 线性缩放
    mapped = normalized * scale
    # 裁剪到0~1并转为8bit
    mapped = np.clip(mapped, 0, 1)
    return (mapped * 255).astype(np.uint8)

def log_mapping(hdr_img, mu=5000):
    """
    对数映射色调映射 (基于 Reinhard 对数算子)
    :param hdr_img: HDR图像 (float32)
    :param mu: 对数映射参数,控制压缩强度
    :return: LDR图像 (uint8, 0~255)
    """
    # 计算亮度 (使用灰度近似)
    if len(hdr_img.shape) == 3:
        luminance = cv2.cvtColor(hdr_img, cv2.COLOR_BGR2GRAY)
    else:
        luminance = hdr_img
    # 对数映射公式: Ld = log(1 + mu * L) / log(1 + mu)
    # 先归一化到0~1
    max_lum = np.max(luminance)
    if max_lum < 1e-6:
        max_lum = 1.0
    norm_lum = luminance / max_lum
    mapped_lum = np.log(1 + mu * norm_lum) / np.log(1 + mu)
    # 将映射后的亮度应用到RGB通道
    if len(hdr_img.shape) == 3:
        # 保持色相,调整亮度
        hsv = cv2.cvtColor(hdr_img, cv2.COLOR_BGR2HSV)
        hsv[:, :, 2] = mapped_lum  # V通道替换为映射后亮度
        result = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
    else:
        result = mapped_lum
    # 裁剪并转为8bit
    result = np.clip(result, 0, 1)
    return (result * 255).astype(np.uint8)

# 主程序
if __name__ == "__main__":
    # 读取HDR图像 (请替换为实际路径)
    hdr_path = "input.hdr"
    hdr_img = cv2.imread(hdr_path, cv2.IMREAD_UNCHANGED)
    if hdr_img is None:
        print("错误:无法读取HDR图像,请检查文件路径")
        exit(1)

    # 确保为float32类型
    if hdr_img.dtype != np.float32:
        hdr_img = hdr_img.astype(np.float32)

    # 应用两种映射
    ldr_linear = linear_scale(hdr_img, scale=0.18)
    ldr_log = log_mapping(hdr_img, mu=5000)

    # 保存结果
    cv2.imwrite("output_linear.png", ldr_linear)
    cv2.imwrite("output_log.png", ldr_log)

    # 显示对比 (可选)
    # cv2.imshow("Linear", ldr_linear)
    # cv2.imshow("Log", ldr_log)
    # cv2.waitKey(0)
    # cv2.destroyAllWindows()

    print("处理完成,结果已保存为 output_linear.png 和 output_log.png")

落地与验证

运行环境:需要安装Python 3.7+、OpenCV(pip install opencv-python opencv-contrib-python)以及NumPy。HDR测试图像可从OpenCV官方示例库或HDR图像数据库(如EMoR数据库)获取,也可使用以下命令生成一张简单的合成HDR图像用于测试:

# 生成合成HDR图像 (在Python中执行)
import cv2
import numpy as np
hdr = np.zeros((256, 256, 3), dtype=np.float32)
# 创建从暗到亮的渐变
for i in range(256):
    hdr[i, :, :] = (i / 255.0) ** 2 * 1000  # 亮度范围0~1000
cv2.imwrite("input.hdr", hdr)

验证方法:运行主程序后,检查输出图像是否满足以下判定标准:

  1. 像素范围正确:输出图像为8位PNG,像素值在0~255之间,无溢出或全黑/全白。
  2. 亮度层次可见:在合成渐变图像中,线性缩放结果应呈现从黑到白的均匀渐变(但暗部层次可能被压缩);对数映射结果应能区分更多暗部细节,整体亮度分布更接近人眼感知。
  3. 对比观察:将两张输出图像并排查看,对数映射结果应比线性缩放结果保留更多暗部信息,但整体对比度可能略低。

判定标准:若两张输出图像均能正常显示且无异常色偏,且对数映射结果在暗部区域的灰度层次明显多于线性缩放结果,则本案例实现通过。

关键观测点

全局色调映射算子对比与验证流程 HDR输入 float32, 0~10⁶ 线性缩放 Ld = L / Lmax × scale 对数映射 Ld = log(1+μL)/log(1+μ) LDR输出 uint8, 0~255 LDR输出 uint8, 0~255 验证观测点 ① 像素范围0~255 ② 暗部层次保留 ③ 无异常色偏 ④ 对比度差异 关键差异:线性缩放均匀压缩所有亮度,对数映射对暗部给予更多编码范围 暗部 亮部 线性:均匀分布 对数:暗部区间更宽

上图展示了本案例的实现流程与验证要点。通过对比两种算子的输出,可以直观理解全局色调映射中映射函数形状对亮度分布的影响——对数映射将更多编码空间分配给暗部区域,因此能保留更多暗部细节,而线性映射则保持均匀的亮度间隔。