[C#] Bgr24彩色位图转为Gray8灰度位图的跨平台SIMD硬件加速向量算法(第2版: 增加512位向量算法、RGB2Y算法的测试对比)

文章目录

  • 一、标量算法
    • 1.1 算法原理
      • 1.1.1 彩色转灰度的计算公式
      • 1.1.2像素格式说明
    • 1.2 算法实现
    • 1.3 基准测试代码
  • 二、向量算法
    • 2.1 算法思路
      • 2.1.1 难点说明
      • 2.1.2 前人的经验(RGB2Y)
      • 2.1.3 更好的办法
    • 2.2 算法实现
      • 2.2.1 怎样处理非整数倍数据
    • 2.3 基准测试代码
    • 2.4 128位向量的算法
    • 2.5 512位向量的算法
  • 三、基准测试结果
    • 3.1 X86 架构
      • 3.1.1 X86 架构上`.NET 7.0`程序的测试结果
      • 3.1.2 X86 架构上`.NET 8.0`程序的测试结果
    • 3.2 Arm 架构
      • 3.2.1 Arm 架构上`.NET 7.0`程序的测试结果
      • 3.2.2 Arm 架构上`.NET 8.0`程序的测试结果
    • 3.3 VC++编译RGB2Y并测试
      • 3.1.1 单线程时的测试
      • 3.1.2 多线程时的测试
  • 四、结语
  • 附录

将彩色位图转为灰度位图,是图像处理的常用算法。本文将介绍 Bgr24彩色位图转为Gray8灰度位图的算法,除了会给出标量算法外,还会给出向量算法。且这些算法是跨平台的,同一份源代码,能在 X86(Sse、Avx等指令集)及Arm(AdvSimd指令集)等架构上运行,且均享有SIMD硬件加速。

修订历史

  • 2024-11-19 v1:发表第1版。
  • 2024-12-15 v2:增加 512位向量算法、RGB2Y算法的测试对比,还增加了 .NET 7.0 的基准测试结果,且各章节补充了内容。

一、标量算法

1.1 算法原理

1.1.1 彩色转灰度的计算公式

对于彩色转灰度,由于人眼对红绿蓝三种颜色的敏感程度不同,在灰度转换时,每个颜色分配的权重也是不同的。有一个很著名的心理学公式:

Gray = R*0.299 + G*0.587 + B*0.114

该公式含有浮点数,而浮点数运算一般比较慢。

于是在具体实现时,需要做一些优化。可以将小数转为定点整数,这样便能将除法转为移位。整数计算比浮点型快,移位运算和加减法比乘除法快,于是取得了比较好的效果。

但是这种方法也会带来一定的精度损失,故应根据实际情况,来选择定点整数的精度位数。

1.1.2像素格式说明

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个灰度字节。

1.2 算法实现

了解了 彩色转灰度的计算公式,以及像素格式后,便可以编写彩色转灰度的算法了。这里使用了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;
    }
}

1.3 基准测试代码

使用 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);
}

二、向量算法

2.1 算法思路

2.1.1 难点说明

24位像素的标量算法很简单,但是它的向量算法要复杂的多。

这是因为向量大小一般是 16或32字节这样的2的整数幂,而24位像素是3个字节一组,无法整除。这就给地址计算、数据处理等方面,带来很大的难题。

2.1.2 前人的经验(RGB2Y)

很多人探索过这个问题,其中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位像素满足计算公式的要求,得做复杂数据重排操作。而高效率数据重排操作,是很依赖数据特点的,以及需要权衡更契合的 换位类指令、乘法类指令等细节。牵一发而动全身,导致这些办法很难扩展到更宽的向量类型。

2.1.3 更好的办法

上面的算法比较繁琐,我们希望有更好的办法。希望它能具备这些特点——

  • 能充分占满向量宽度,而不是 10或12字节那样没有占满向量宽度。
  • 能适应各种向量宽度。不仅能适应 128位向量,且能适应 256位(Avx2)、512位(Avx512)。
  • 代码可读性更高,便于理解与维护。

回想一下,Bgr24是一种打包像素格式。每3个字节为1个像素,连续存放在内存中。

根据上面经验,打包像素很难处理。因为向量指令一般是按垂直方向处理数据,而打包的像素存在水平方向的3元素组。

既然难点在于“水平方向的3元素组”,那就优先针对它进行处理——将打包(packed)布局的像素,转为平面(planar)布局的像素。随后就能方便的用向量指令来处理了。

而且可以观察到:

  • 若使用1个128位向量来读取,会读取16个字节。其中前面5个像素有效,最后1个字节无法整除。
  • 若使用2个128位向量来读取,会读取32个字节。其中前面10个像素有效,最后2个字节无法整除。
  • 若使用3个128位向量来读取,会读取48个字节。其中16个像素有效,正好整除!

打包布局像素转为平面布局像素,这种运算叫做 解交织(De-Interleave)。

于是对于24位转8位灰度,可以使用这种办法: 每次从源位图读取3个向量,进行3-元素组的解交织运算,得到 R,G,B 平面数据。随后使用向量化的乘法与加法,来计算灰度值。结果是存储了各个灰度值的1个向量,于是可将它存储到目标位图。

  • 例如 Sse指令集使用的是128位向量,此时1个向量为16字节。每次从源位图读取3个向量,就是读取了48字节,即16个RGB像素。最后将1个向量存储到目标位图,就是写入了16字节,即16个灰度像素。

对于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

你可能感兴趣的:(VectorTraits,算法,c#,.net,图像处理,SIMD)