承接上文内容,纯干货,别看漏一个字。
BT.601、709、2020标准下的YUV转RGB
上回我们已经把解码数据以short类型传递到gpu显存环境当中,下一步就是要正确的转换城rgb形式,翻查libyuv的最新源码就能找到BT.601(full / limited) BT.709(full / limited)BT.2020(full / limited)的转换公式系数。(关于色域的三个标准有不明白的,可以翻查HDR专栏的文章,具体标准可以到这下载 https://www.itu.int/rec/R-REC-BT/en)
// BT.601 limited range YUV to RGB reference
static void YUVToRGBReference(int y, int u, int v, int* r, int* g, int* b) {
*r = RoundToByte((y - 16) * 1.164 - (v - 128) * -1.596);
*g = RoundToByte((y - 16) * 1.164 - (u - 128) * 0.391 - (v - 128) * 0.813);
*b = RoundToByte((y - 16) * 1.164 - (u - 128) * -2.018);
}
// BT.601 full range YUV to RGB reference (aka JPEG)
static void YUVJToRGBReference(int y, int u, int v, int* r, int* g, int* b) {
*r = RoundToByte(y - (v - 128) * -1.40200);
*g = RoundToByte(y - (u - 128) * 0.34414 - (v - 128) * 0.71414);
*b = RoundToByte(y - (u - 128) * -1.77200);
}
// BT.709 limited range YUV to RGB reference
static void YUVHToRGBReference(int y, int u, int v, int* r, int* g, int* b) {
*r = RoundToByte((y - 16) * 1.164 - (v - 128) * -1.793);
*g = RoundToByte((y - 16) * 1.164 - (u - 128) * 0.213 - (v - 128) * 0.533);
*b = RoundToByte((y - 16) * 1.164 - (u - 128) * -2.112);
}
// BT.709 full range YUV to RGB reference
static void YUVFToRGBReference(int y, int u, int v, int* r, int* g, int* b) {
*r = RoundToByte(y - (v - 128) * -1.5748);
*g = RoundToByte(y - (u - 128) * 0.18732 - (v - 128) * 0.46812);
*b = RoundToByte(y - (u - 128) * -1.8556);
}
// BT.2020 limited range YUV to RGB reference
static void YUVUToRGBReference(int y, int u, int v, int* r, int* g, int* b) {
*r = RoundToByte((y - 16) * 1.164384 - (v - 128) * -1.67867);
*g = RoundToByte((y - 16) * 1.164384 - (u - 128) * 0.187326 -
(v - 128) * 0.65042);
*b = RoundToByte((y - 16) * 1.164384 - (u - 128) * -2.14177);
}
// BT.2020 full range YUV to RGB reference
static void YUVVToRGBReference(int y, int u, int v, int* r, int* g, int* b) {
*r = RoundToByte(y + (v - 128) * 1.474600);
*g = RoundToByte(y - (u - 128) * 0.164553 - (v - 128) * 0.571353);
*b = RoundToByte(y + (u - 128) * 1.881400);
}
libyuv里面的转换公式
根据转换公式,此时的GLSL. fragment shader应该是这样的:
#version 320 es
precision highp float;
uniform int bitMark; // 双字节(16bit)存储10位数据的 位掩码个数
uniform lowp float imgWidth;
uniform lowp float imgHeight;
uniform highp usampler2D tex_unsigned_y; // GL_R16UI、GL_RED_INTEGER、GL_UNSIGNED_SHORT
uniform highp usampler2D tex_unsigned_uv;// GL_RG16UI、GL_RG_INTEGER、GL_UNSIGNED_SHORT
in vec2 vTextureCoord;
out vec4 _FragColor;
highp vec3 YuvConvertRGB_BT2020(highp uvec3 yuv, int normalize) {
highp vec3 rgb;
highp int y = highp int(yuv.x);
highp int u = highp int(yuv.y);
highp int v = highp int(yuv.z);
// [64, 960]
float r = float(y - 64) * 1.164384 - float(v - 512) * -1.67867;
float g = float(y - 64) * 1.164384 - float(u - 512) * 0.187326 - float(v - 512) * 0.65042;
float b = float(y - 64) * 1.164384 - float(u - 512) * -2.14177;
rgb.r = r;
rgb.g = g;
rgb.b = b;
if (normalize == 1) {
rgb.r = r / 1024.0;
rgb.g = g / 1024.0;
rgb.b = b / 1024.0;
}// 归一化处理,10位数据共有1024个色阶
return rgb;
}
void main() {
float samplerPosX = vTextureCoord.x * imgWidth;
float samplerPosY = vTextureCoord.y * imgHeight;
highp uint unsigned_y = texelFetch(tex_unsigned_y, ivec2(int(samplerPosX), int(samplerPosY)), 0).x;
highp uint unsigned_u = texelFetch(tex_unsigned_uv, ivec2(int(samplerPosX / 2.0), int(samplerPosY / 2.0)), 0).r;
highp uint unsigned_v = texelFetch(tex_unsigned_uv, ivec2(int(samplerPosX / 2.0), int(samplerPosY / 2.0)), 0).g;
highp uvec3 yuv10bit = uvec3(unsigned_y >> bitMark, unsigned_u >> bitMark, unsigned_v >> bitMark);
//highp vec3 rgb10bit = YuvConvertRGB_BT601(yuv10bit, 1);
//highp vec3 rgb10bit = YuvConvertRGB_BT709(yuv10bit, 1);
highp vec3 rgb10bit = YuvConvertRGB_BT2020(yuv10bit, 1);
_FragColor = vec4(fragColor, 1.0);
}
这里有几处地方要提醒:
1、bitMark,双字节(16bit)存储10位数据的位掩码数。HEVCProfileMain10HDR10 和 非8bit数据位深的一些讨论。_Mr_Zzr的博客-优快云博客
在 HEVCProfileMain10HDR10 和 非8bit数据位深的讨论文章当中,已经分析过双字节(16bit)存储10位数据的存储格式,其中需要右移正确的位掩码数才能得到需要的色值。
2、usampler2D tex_unsigned_uv;的格式组合是用GL_RG16UI、GL_RG_INTEGER、GL_UNSIGNED_SHORT,在texelFetch提取纹素的时候,记得uv的宽高要跟原大小保持一直,即Y分量是1920*1088,那UV分量就是960*544了。
3、YuvConvertRGB_BT2020会根据输入参数,进行归一化处理,10bit数据单个色光有1024个值,即取值范围是[0,1024],1 / 1024 = 0.0009765625,数数多少个小数位。所以还是要时刻留意着运算过程中的精度问题。
4、顺带说一句:10bit imited的Y取值范围[64,960]
Gamma矫正、Tone Mapping处理
按照以上直接输出光栅化渲染,你会发现这显示的内容和原8bit的数据比较起来,看着更暗淡,好像似蒙上了一层灰一样的糟糕。
左侧直接光栅化,右侧8bit效果
为啥会这样?主要是是10bit数据直接归一化后,按照8bit原数据渲染,精度都丢失了。换一种说法就是:原本[0,1024]的数据,只使用了[0~255]的部分,[256~1024]全丢失了。
那可咋整?此时我们需要色调映射 Tone mapping
色调映射(Tone Mapping)是一个将HDR的图像或者视频转换成SDR的图像,并在SDR显示设备上正确显示的技术。目前HDR的内容还不是很多,HDR和SDR混合的内容会长期存在,而且能够支持HDR显示的屏幕还没全民推广,要让HDR的内容能够有一个正确的显示,色调映射是一个经常用到的技术。
在介绍Tone Mapping之前,还需要铺垫一个知识:Gamma校正
当我们计算出场景中所有像素的最终颜色以后,我们就必须把它们显示在监视器上。过去,大多数监视器是阴极射线管显示器(CRT)。这些监视器有一个物理特性就是两倍的输入电压产生的不是两倍的亮度。输入电压产生约为输入电压的2.2次幂的亮度,这叫做Gamma效应。
人类所感知的亮度恰好和CRT所显示出来相似的指数关系非常匹配。为了更好的理解所有含义,请看下面的图片:
因为人眼看到颜色的亮度更倾向于顶部的灰阶,监视器使用的也是一种指数关系(电压的2.2次幂),所以物理亮度通过监视器能够被映射到顶部的非线性亮度;因此看起来效果不错。监视器的这个非线性映射的确可以让亮度在我们眼中看起来更好,但当渲染图像时,会产生一个问题:我们在应用中配置的亮度和颜色是基于监视器所看到的,这样所有的配置实际上是非线性的亮度/颜色配置。请看下图:
有关Gamma矫正的详细内容,请参阅 LearnOpenGL的这篇文章
正因为人眼对于物理世界的感知是非线性的,即对于中等亮度和暗部区间的敏感程度远高于高亮度区间,于是就有了SDR领域的Gamma曲线。我们倾向于用更多的比特数来表示中等亮度或者暗部的区域,以提高压缩率,取得更贴近人眼的效果。
在HDR时代,Gamma曲线已经不能满足最大亮度的需求。基于人眼的对比敏感度函数(Contrast Sensitivity Function,CSF),在SMPTE ST 2084标准中规定了EOTF曲线,就是感知量化(PQ)曲线。亮度范围可从最暗0.00005 nits到最亮10000 nits。PQ曲线最早是由Dolby公司开发的,并且在ST 2084中进行了标准化。
混合对数伽马(HLG)是另外一个重要的HDR传输曲线,由BBC和NHK公司开发。这个曲线与PQ曲线不同,HLG规定的是OETF曲线,因为在低亮度区域基本与Gamma曲线重合,所以提供了与SDR显示设备很好的兼容性,在广播电视系统里有着广泛的应用。HLG曲线最早在ARIB STD-B67中进行了标准化,后面也进入了ITU-R BT.2100。
有关PQ曲线和HLG曲线的详细内容,请参阅 之前我搬运的 HDR in Mind。本文使用的感知量化(PQ)曲线的方法。
好了,看到这可能很多同学又吐槽,我这说的是啥玩意呢?这是越说越懵逼了。我这就用人说的话总结一下:
颜色值(数字信号 / 电信号 / Electrical signal)转到硬件设备显示(模拟信号 / 光信号 / Optical signal)这一过程的转换,并不是线性关系;所以如果直接基于数字信号的图像处理(即我们写的运算代码)并不能正确的反应的硬件设备上。基于此,才有了EOTF(电光转换)和 OETF(光电转换)在图像处理前,先利用eotf转换成线性比例,ToneMapping图像处理完之后,再逆一次eotf,最终输出到硬件设备
此时你再去看 LearnOpenGL的这篇文章 和 HDR in Mind。我相信你会有不一样的收获。
FFmpeg 命令行 HDR 转 SDR
理论说了一大堆,那到底该如何做呢?还是先看看万能的ffmpeg是如何处理这一块的吧。
ffmpeg -i planet_hdr.MP4 -vf zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p planet_ff_hdr2sdr.MP4
一行行来拆解命令行内容,最主要的是 -vf video filter的一大串命令。
zscale=t=linear:npl=100 => 利用zscale模块的转换函数linear,输入参数npl=100
format=gbrpf32le => 转换格式gbr浮点32 little end
zscale=p=bt709 => 利用zscale模块的设置色域bt709
tonemap=tonemap=hable:desat=0 => 指定tonemapping转换算法hable,输入参数desat=0
zscale=t=bt709:m=bt709:r=tv => 利用zscale模块的转换函数bt709,range=tv. limited
format=yuv420p => 转换格式yuv420p
zscale转换模块是ffmpeg内部引用第三方库zimg,官方介绍地址:FFmpeg Filters Documentation想利用zscale,必须确认ffmpeg编译是--enable-libzimg,zimg库源码:GitHub - sekrit-twc/zimg: Scaling, colorspace conversion, and dithering library
从命令行的流程可以印证刚刚的总结。