将彩色位图转为灰度位图,是图像处理的常用算法。本文将介绍 Bgr24彩色位图转为Gray8灰度位图的算法,除了会给出标量算法外,还会给出向量算法。且这些算法是跨平台的,同一份源代码,能在 X86(Sse、Avx等指令集)及Arm(AdvSimd指令集)等架构上运行,且均享有SIMD硬件加速。
修订历史
.NET 7.0
的基准测试结果,且各章节补充了内容。对于彩色转灰度,由于人眼对红绿蓝三种颜色的敏感程度不同,在灰度转换时,每个颜色分配的权重也是不同的。有一个很著名的心理学公式:
Gray = R*0.299 + G*0.587 + B*0.114
该公式含有浮点数,而浮点数运算一般比较慢。
于是在具体实现时,需要做一些优化。可以将小数转为定点整数,这样便能将除法转为移位。整数计算比浮点型快,移位运算和加减法比乘除法快,于是取得了比较好的效果。
但是这种方法也会带来一定的精度损失,故应根据实际情况,来选择定点整数的精度位数。
Bgr24是一种打包(packed)像素格式。“Bgr”是指它有3个颜色通道,且颜色通道顺序为 B(蓝色)、G(绿色)、R(红色)。“24”是指像素的总位数为24,即3个8位字节。这3个字节,正好平均分配给每一个颜色通道,每个通道均为8位(1字节)。于是该格式也被称呼为 Bgr888、B8G8R8 等。
而对于 Gray8,“Gray”表示它只有1个颜色通道——灰度(Grayscale)。“8”是指像素的总位数为8,即用1个8位字节来表示灰度值。在介绍像素数据的存储布局时,一般用字母“Y”来代表灰度像素。
对于 Bgr24彩色转为Gray8灰度,数据是这样转化的。
位置: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 ...
源图: B0 G0 R0 B1 G1 R1 B2 G2 R2 B2 G3 R3 B4 G4 R4 B5 G5 R5 B6 G6 R6 B7 G7 R7 ...
目标: Y0 Y1 Y2 Y3 Y4 Y5 Y6 Y7 ...
每3个字节(B、G、R),给转成了1个灰度字节。
了解了 彩色转灰度的计算公式,以及像素格式后,便可以编写彩色转灰度的算法了。这里使用了16位精度(shiftPoint为16),源代码如下。
public static unsafe void ScalarDo(BitmapData src, BitmapData dst) {
const int cbPixel = 3; // Bgr24
const int shiftPoint = 16;
const int mulPoint = 1 << shiftPoint; // 0x10000
const int mulRed = (int)(0.299 * mulPoint + 0.5); // 19595
const int mulGreen = (int)(0.587 * mulPoint + 0.5); // 38470
const int mulBlue = mulPoint - mulRed - mulGreen; // 7471
int width = src.Width;
int height = src.Height;
int strideSrc = src.Stride;
int strideDst = dst.Stride;
byte* pRow = (byte*)src.Scan0.ToPointer();
byte* qRow = (byte*)dst.Scan0.ToPointer();
for (int i = 0; i < height; i++) {
byte* p = pRow;
byte* q = qRow;
for (int j = 0; j < width; j++) {
*q = (byte)((p[2] * mulRed + p[1] * mulGreen + p[0] * mulBlue) >> shiftPoint);
p += cbPixel; // Bgr24
q += 1; // Gray8
}
pRow += strideSrc;
qRow += strideDst;
}
}
使用 BenchmarkDotNet 进行基准测试。
可以事先分配好数据。源代码如下。
private static readonly Random _random = new Random(1);
private BitmapData _sourceBitmapData = null;
private BitmapData _destinationBitmapData = null;
private BitmapData _expectedBitmapData = null;
[Params(1024, 2048, 4096)]
public int Width {
get; set; }
public int Height {
get; set; }
~Bgr24ToGrayBgr24Benchmark() {
Dispose(false);
}
public void Dispose() {
Dispose(true);
GC.SuppressFinalize(this);
}
private void Dispose(bool disposing) {
if (_disposed) return;
_disposed = true;
if (disposing) {
Cleanup();
}
}
private BitmapData AllocBitmapData(int width, int height, PixelFormat format) {
const int strideAlign = 4;
if (width <= 0) throw new ArgumentOutOfRangeException($"The width({
width}) need > 0!");
if (height <= 0) throw new ArgumentOutOfRangeException($"The width({
height}) need > 0!");
int stride = 0;
switch (format) {
case PixelFormat.Format8bppIndexed:
stride = width * 1;
break;
case PixelFormat.Format24bppRgb:
stride = width * 3;
break;
}
if (stride <= 0) throw new ArgumentOutOfRangeException($"Invalid pixel format({
format})!");
if (0 != (stride % strideAlign)) {
stride = stride - (stride % strideAlign) + strideAlign;
}
BitmapData bitmapData = new BitmapData();
bitmapData.Width = width;
bitmapData.Height = height;
bitmapData.PixelFormat = format;
bitmapData.Stride = stride;
bitmapData.Scan0 = Marshal.AllocHGlobal(stride * height);
return bitmapData;
}
private void FreeBitmapData(BitmapData bitmapData) {
if (null == bitmapData) return;
if (IntPtr.Zero == bitmapData.Scan0) return;
Marshal.FreeHGlobal(bitmapData.Scan0);
bitmapData.Scan0 = IntPtr.Zero;
}
[GlobalCleanup]
public void Cleanup() {
FreeBitmapData(_sourceBitmapData); _sourceBitmapData = null;
FreeBitmapData(_destinationBitmapData); _destinationBitmapData = null;
FreeBitmapData(_expectedBitmapData); _expectedBitmapData = null;
}
[GlobalSetup]
public void Setup() {
Height = Width;
// Create.
Cleanup();
_sourceBitmapData = AllocBitmapData(Width, Height, PixelFormat.Format24bppRgb);
_destinationBitmapData = AllocBitmapData(Width, Height, PixelFormat.Format8bppIndexed);
_expectedBitmapData = AllocBitmapData(Width, Height, PixelFormat.Format8bppIndexed);
RandomFillBitmapData(_sourceBitmapData, _random);
}
使用这些已分配好的数据,能很容易写出ScalarDo 的基准测试代码。
[Benchmark(Baseline = true)]
public void Scalar() {
ScalarDo(_sourceBitmapData, _destinationBitmapData);
}
24位像素的标量算法很简单,但是它的向量算法要复杂的多。
这是因为向量大小一般是 16或32字节这样的2的整数幂,而24位像素是3个字节一组,无法整除。这就给地址计算、数据处理等方面,带来很大的难题。
很多人探索过这个问题,其中komrad36给出了效率高的办法“RGB2Y”,并将源码公布在 github上。
RGB2Y的Sse版算法,是一次性处理 12个像素的,步骤比较复杂。这里简单说明一下:先使用9条加载指令加载不同偏移的的数据,然后对这9个向量分别用不同的系数做乘法,且通过多条 shuffle指令对元素进行换位,再通过移位、与或非 等指令的配合,将12个灰度值给算出来。最后每隔12字节存储一次,因为向量里前部的12个字节正好是有效的12个灰度值,而后部的4个字节会被下次循环给填充。为了避免“最后4个字节”写入时的内存越界问题,对于最右侧的像素,回退为标量算法来处理。
若想了解细节,可阅读 Imageshop的文章《SSE图像算法优化系列一:一段BGR2Y的SIMD代码解析。》。该文章详细介绍了该算法。
24位像素的向量化算法为什么这么复杂?这是因为向量大小无法被3整除,且还需要处理“每3个字节处理后缩紧为1个字节”的水平方向移动等。得花很多心思去考虑向量内各元素如何重排,使其符合计算公式所需。且对于Bgr这种有3个颜色通道的数据来说,将数据重排为公式所需有时是很困难的,于是这时需要折衷将公式进行变形,降低数据重排的开销。
而且,komrad36还在github上提供了基于Avx系列指令集的算法,性能有了进一步提升。是一次性处理 10个像素,即30个字节。读取了2次,利用 _mm256_mulhrs_epi16
指令的特点,同时对2组数据进行计算。随后进过一系列的256位Avx运算,算出10个灰度值,并存储在1个128位向量里。最后将结果向量里的前10字节保存到目标图像里。虽然一次只处理10个像素,比Sse版算法的12个像素要少,但由于精心挑选了更有效的指令,且利用了256位是128位的2倍长度的优势,进一步提升了性能。具体步骤比较复杂,可去github上看 RGB2Y 的源代码。本文的完整源码里,将komrad36的这2个算法均翻译为 C#语言的了,也可以通过它来查看。
此时可以注意到一个情况,RGB2Y的Avx版算法虽然用了256位的Avx指令集进行了主体运算,但在最后保存时还是使用了Sse的128位向量。这明显没有充分发挥Avx的256位运算的优势。而且128位是16字节,仅保存前10个字节,也表示运算不够充分。
这是有苦衷的。因为要使24位像素满足计算公式的要求,得做复杂数据重排操作。而高效率数据重排操作,是很依赖数据特点的,以及需要权衡更契合的 换位类指令、乘法类指令等细节。牵一发而动全身,导致这些办法很难扩展到更宽的向量类型。
上面的算法比较繁琐,我们希望有更好的办法。希望它能具备这些特点——
回想一下,Bgr24是一种打包像素格式。每3个字节为1个像素,连续存放在内存中。
根据上面经验,打包像素很难处理。因为向量指令一般是按垂直方向处理数据,而打包的像素存在水平方向的3元素组。
既然难点在于“水平方向的3元素组”,那就优先针对它进行处理——将打包(packed)布局的像素,转为平面(planar)布局的像素。随后就能方便的用向量指令来处理了。
而且可以观察到:
打包布局像素转为平面布局像素,这种运算叫做 解交织(De-Interleave)。
于是对于24位转8位灰度,可以使用这种办法: 每次从源位图读取3个向量,进行3-元素组的解交织运算,得到 R,G,B 平面数据。随后使用向量化的乘法与加法,来计算灰度值。结果是存储了各个灰度值的1个向量,于是可将它存储到目标位图。
对于3-元素组的解交织,可以使用 shuffle 类别的指令来实现。例如对于 128位向量,X86架构可以使用 SSSE3 的 _mm_shuffle_epi8
指令,它对应 .NET 中的 Ssse3.Shuffle
方法。源代码如下。
static readonly Vector128<byte> YGroup3Unzip_Shuffle_Byte_X_Part0 = Vector128.Create((sbyte)0, 3, 6, 9, 12, 15, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1).AsByte();
static readonly Vector128<byte> YGroup3Unzip_Shuffle_Byte_X_Part1 = Vector128.Create((sbyte)-1, -1, -1, -1, -1, -1, 2, 5, 8, 11