访问 SkiaSharp 位图像素位

“将 SkiaSharp 位图保存到文件”一文所述,位图通常以压缩格式(如 JPEG 或 PNG)存储。 相比之下,存储在内存中的 SkiaSharp 位图不会压缩, 而是存储为连续的像素系列。 这种未压缩的格式有助于将位图传输到显示图面。

SkiaSharp 位图占用的内存块以非常简单的方式进行组织:它从左到右,从第一行像素开始,然后继续第二行。 对于全色位图,每个像素由四个字节组成,这意味着位图所需的总内存空间是其宽度和高度的四倍。

本文介绍如何通过访问位图的像素内存块或间接访问这些像素。 在某些情况下,程序可能想要分析图像的像素,并构造某种类型的直方图。 更常见的是,应用程序可以通过以算法方式创建构成位图的像素来构造唯一图像:

像素位示例

技术

SkiaSharp 提供了几种用于访问位图像素位的技术。 选择哪一种技术通常要权衡编码的便利性(与维护和调试方便相关)与性能。 在大多数情况下,你将使用以下方法之一和 SKBitmap 的属性来访问位图的像素:

  • GetPixelSetPixel 方法允许获取或设置单个像素的颜色。
  • Pixels 属性获取整个位图的像素颜色数组,或设置颜色数组。
  • GetPixels 返回位图使用的像素内存的地址。
  • SetPixels 替换位图使用的像素内存的地址。

可以将前两种技术视为“高级”,而将后两种技术视为“低级别”。有另一些方法和属性可以使用,但这些是最有价值的。

为便于查看这些技术之间的性能差异,示例应用程序包含一个名为“渐变位图”的页,该页会创建一个位图,其中的像素结合了红色和蓝色色调以创建渐变效果。 该程序会创建此位图的八个不同副本,所有这些副本都使用不同的方法来设置位图像素。 这八个位图中的每一个都是以单独的方法创建的,这些方法还会设置技术的简短文本描述,并计算设置所有像素所需的时间。 每个方法循环访问像素设置逻辑 100 次,以便更好地估计性能。

SetPixel 方法

如果只需要设置或获取多个单独的像素,SetPixelGetPixel 是理想的方法。 对于这两种方法中的每一种,请指定整数列和行。 无论像素格式如何,这两种方法都允许你获取或设置像素作为 SKColor 值:

bitmap.SetPixel(col, row, color);

SKColor color = bitmap.GetPixel(col, row);

col 参数的范围必须介于 0 到小于位图的 Width 属性的值,而 row 的范围必须介于从 0 到小于 Height 属性的值。

下面是使用 SetPixel 方法设置位图内容的渐变位图 中的方法。 位图为 256 x 256 像素,for 循环用值范围进行硬编码:

public class GradientBitmapPage : ContentPage
{
    const int REPS = 100;

    Stopwatch stopwatch = new Stopwatch();
    ···
    SKBitmap FillBitmapSetPixel(out string description, out int milliseconds)
    {
        description = "SetPixel";
        SKBitmap bitmap = new SKBitmap(256, 256);

        stopwatch.Restart();

        for (int rep = 0; rep < REPS; rep++)
            for (int row = 0; row < 256; row++)
                for (int col = 0; col < 256; col++)
                {
                    bitmap.SetPixel(col, row, new SKColor((byte)col, 0, (byte)row));
                }

        milliseconds = (int)stopwatch.ElapsedMilliseconds;
        return bitmap;
    }
    ···
}

每个像素的颜色设置的红色分量等于位图列,蓝色分量等于行。 生成的位图在左上角为黑色,右上角为红色,左下角为蓝色,右下角有渐变。

SetPixel 方法被调用了 65,536 次,无论这个方法可能有多高效,如果有替代方案的话,通常不建议进行这么多 API 调用。 幸运的是,有几种替代方法。

Pixel 属性

SKBitmap 定义 Pixels 属性,该属性为整个位图返回 SKColor 值的数组。 还可以使用 Pixels 设置位图的颜色值数组:

SKColor[] pixels = bitmap.Pixels;

bitmap.Pixels = pixels;

像素从第一行开始,从左到右排列在数组中,然后是第二行,以此类推。 数组中的颜色总数等于位图宽度和高度的乘积。

尽管这个属性看起来效率很高,但请注意,像素是从位图复制到数组中,然后再从数组复制回位图的,并且像素值会在 SKColor 值之间进行转换。

下面是使用 Pixels 属性设置位图的 GradientBitmapPage 类中的方法。 该方法分配所需大小的 SKColor 数组,但它可能已使用 Pixels 属性创建该数组:

SKBitmap FillBitmapPixelsProp(out string description, out int milliseconds)
{
    description = "Pixels property";
    SKBitmap bitmap = new SKBitmap(256, 256);

    stopwatch.Restart();

    SKColor[] pixels = new SKColor[256 * 256];

    for (int rep = 0; rep < REPS; rep++)
        for (int row = 0; row < 256; row++)
            for (int col = 0; col < 256; col++)
            {
                pixels[256 * row + col] = new SKColor((byte)col, 0, (byte)row);
            }

    bitmap.Pixels = pixels;

    milliseconds = (int)stopwatch.ElapsedMilliseconds;
    return bitmap;
}

请注意,需要从 rowcol 变量中计算 pixels 数组的索引。 行乘以每行的像素数(在本例中为 256),然后加上列。

SKBitmap 还定义了类似的 Bytes 属性,该属性返回整个位图的字节数组,但对于全色位图较为繁琐。

GetPixels 指针

访问位图像素的最强大技术可能是 GetPixels,不要与 GetPixel 方法或 Pixels 属性混淆。 你会立即注意到与 GetPixels 的一个区别,即它返回了 C# 编程中不太常见的内容:

IntPtr pixelsAddr = bitmap.GetPixels();

.NET IntPtr 类型表示指针。 它被称为 IntPtr,因为它是程序运行所在机器的本地处理器上整数的长度,通常长度为 32 位或 64 位。 GetPixels 返回的 IntPtr 是位图对象用来存储其像素的实际内存块的地址。

可以使用 ToPointer 方法将 IntPtr 转换为 C# 指针类型。 C# 指针语法与 C 和 C++ 相同:

byte* ptr = (byte*)pixelsAddr.ToPointer();

ptr 变量属于字节指针类型。 此 ptr 变量允许访问用于存储位图像素的内存的各个字节。 使用此类代码从此内存中读取字节或将字节写入内存:

byte pixelComponent = *ptr;

*ptr = pixelComponent;

在此上下文中,星号是 C# 间接运算符,用于引用 ptr 指向的内存的内容。 最初,ptr 指向位图第一行第一个像素的第一个字节,但你可以在 ptr 变量上执行算术运算,以将其移动到位图内的其他位置。

一个缺点是,只能在用 unsafe 关键字标记的代码块中使用此 ptr 变量。 此外,必须将程序集标记为允许不安全块。 这在项目的属性中完成。

在 C# 中,指针的运用赋予了编程者强大的能力,但也带来了相当高的风险。 需要小心,不要访问超出指针所引用范围的内存。 这就是为什么指针使用与单词 "unsafe" 相关联的原因。

下面是使用 GetPixels 方法的 GradientBitmapPage 类中的方法。 请注意使用字节指针包含所有代码的 unsafe 块:

SKBitmap FillBitmapBytePtr(out string description, out int milliseconds)
{
    description = "GetPixels byte ptr";
    SKBitmap bitmap = new SKBitmap(256, 256);

    stopwatch.Restart();

    IntPtr pixelsAddr = bitmap.GetPixels();

    unsafe
    {
        for (int rep = 0; rep < REPS; rep++)
        {
            byte* ptr = (byte*)pixelsAddr.ToPointer();

            for (int row = 0; row < 256; row++)
                for (int col = 0; col < 256; col++)
                {
                    *ptr++ = (byte)(col);   // red
                    *ptr++ = 0;             // green
                    *ptr++ = (byte)(row);   // blue
                    *ptr++ = 0xFF;          // alpha
                }
        }
    }

    milliseconds = (int)stopwatch.ElapsedMilliseconds;
    return bitmap;
}

ptr 变量先从 ToPointer 方法获取时,它将指向位图第一行最左侧像素的第一个字节。 设置 rowcolfor 循环,以便在设置每个像素的每个字节后,++ 运算符可以递增 ptr。 对于其他 99 次遍历像素的循环,需要将 ptr 设置回位图的开头。

每个像素是四个字节的内存,因此必须单独设置每个字节。 此处的代码假定字节的顺序为红色、绿色、蓝色和 alpha,这与 SKColorType.Rgba8888 颜色类型一致。 你可能会记得,这是 iOS 和 Android 的默认颜色类型,但不适用于通用 Windows 平台。 默认情况下,UWP 会创建颜色类型为 SKColorType.Bgra8888 的位图。 因此,期望在该平台上看到一些不同的结果!

可以将从 ToPointer 返回的值强制转换为 uint 指针,而不是 byte 指针。 这允许在一个语句中访问整个像素。 将该指针与 ++ 运算符结合使用,会使指针增加四个字节,以指向下一个像素:

public class GradientBitmapPage : ContentPage
{
    ···
    SKBitmap FillBitmapUintPtr(out string description, out int milliseconds)
    {
        description = "GetPixels uint ptr";
        SKBitmap bitmap = new SKBitmap(256, 256);

        stopwatch.Restart();

        IntPtr pixelsAddr = bitmap.GetPixels();

        unsafe
        {
            for (int rep = 0; rep < REPS; rep++)
            {
                uint* ptr = (uint*)pixelsAddr.ToPointer();

                for (int row = 0; row < 256; row++)
                    for (int col = 0; col < 256; col++)
                    {
                        *ptr++ = MakePixel((byte)col, 0, (byte)row, 0xFF);
                    }
            }
        }

        milliseconds = (int)stopwatch.ElapsedMilliseconds;
        return bitmap;
    }
    ···
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    uint MakePixel(byte red, byte green, byte blue, byte alpha) =>
            (uint)((alpha << 24) | (blue << 16) | (green << 8) | red);
    ···
}

该像素是使用 MakePixel 方法设置的,它使用红色、绿色、蓝色和 alpha 分量构造整数像素。 请记住,SKColorType.Rgba8888 格式具有如下所示的像素字节排序:

RR GG BB AA

但对应于这些字节的整数为:

AABBGGRR

整数的最小有效字节首先根据 little-endian 体系结构进行存储。 此 MakePixel 方法不适用于颜色类型为 Bgra8888 的位图。

MakePixel 方法被标记为 MethodImplOptions.AggressiveInlining 选项,以鼓励编译器避免将其作为一个单独的方法,而是将代码编译到调用该方法的位置。 这应该可以提高性能。

有趣的是,SKColor 结构定义了一个从 SKColor 到无符号整数的显式转换,这意味着可以创建 SKColor 值,并使用转换为 uint 来代替 MakePixel

SKBitmap FillBitmapUintPtrColor(out string description, out int milliseconds)
{
    description = "GetPixels SKColor";
    SKBitmap bitmap = new SKBitmap(256, 256);

    stopwatch.Restart();

    IntPtr pixelsAddr = bitmap.GetPixels();

    unsafe
    {
        for (int rep = 0; rep < REPS; rep++)
        {
            uint* ptr = (uint*)pixelsAddr.ToPointer();

            for (int row = 0; row < 256; row++)
                for (int col = 0; col < 256; col++)
                {
                    *ptr++ = (uint)new SKColor((byte)col, 0, (byte)row);
                }
        }
    }

    milliseconds = (int)stopwatch.ElapsedMilliseconds;
    return bitmap;
}

唯一的问题是:SKColor 值的整数格式是 SKColorType.Rgba8888 颜色类型还是 SKColorType.Bgra8888 颜色类型,或者完全是其他内容? 这个问题的答案不久将公布。

SetPixels 方法

SKBitmap 还定义了一个名为 SetPixels 的方法,你调用的方法如下所示:

bitmap.SetPixels(intPtr);

请记得,GetPixels 会获取 IntPtr,它引用了位图用于存储像素的内存块。 SetPixels 调用将该内存块替换为指定为 SetPixels 参数的 IntPtr 引用的内存块。 然后,位图释放以前使用的内存块。 下次调用 GetPixels 时,它将获取以 SetPixels 设置的内存块。

乍一看,SetPixels 似乎并没有比 GetPixels 提供更强的功能和性能,同时还不太方便。 使用 GetPixels,可以获取位图的内存块并访问它。 使用 SetPixels,可以分配和访问某些内存,然后将该内存块设置为位图内存块。

但是,使用 SetPixels 具有独特的语法优势:它允许你使用数组访问位图像素位。 下面是 GradientBitmapPage 中演示此技术的方法。 该方法首先定义一个多维字节数组,该数组对应于位图像素的字节。 第一个维度是行,第二个维度是列,第三个维度与每个像素的四个组成部分相关联:

SKBitmap FillBitmapByteBuffer(out string description, out int milliseconds)
{
    description = "SetPixels byte buffer";
    SKBitmap bitmap = new SKBitmap(256, 256);

    stopwatch.Restart();

    byte[,,] buffer = new byte[256, 256, 4];

    for (int rep = 0; rep < REPS; rep++)
        for (int row = 0; row < 256; row++)
            for (int col = 0; col < 256; col++)
            {
                buffer[row, col, 0] = (byte)col;   // red
                buffer[row, col, 1] = 0;           // green
                buffer[row, col, 2] = (byte)row;   // blue
                buffer[row, col, 3] = 0xFF;        // alpha
            }

    unsafe
    {
        fixed (byte* ptr = buffer)
        {
            bitmap.SetPixels((IntPtr)ptr);
        }
    }

    milliseconds = (int)stopwatch.ElapsedMilliseconds;
    return bitmap;
}

然后,在数组填充像素后,使用 unsafe 块和 fixed 语句获取指向此数组的字节指针。 然后,可以将该字节指针强制转换为要传递给 SetPixelsIntPtr

创建的数组不必是字节数组。 它可以是整数数组,其中行和列只有两个维度:

SKBitmap FillBitmapUintBuffer(out string description, out int milliseconds)
{
    description = "SetPixels uint buffer";
    SKBitmap bitmap = new SKBitmap(256, 256);

    stopwatch.Restart();

    uint[,] buffer = new uint[256, 256];

    for (int rep = 0; rep < REPS; rep++)
        for (int row = 0; row < 256; row++)
            for (int col = 0; col < 256; col++)
            {
                buffer[row, col] = MakePixel((byte)col, 0, (byte)row, 0xFF);
            }

    unsafe
    {
        fixed (uint* ptr = buffer)
        {
            bitmap.SetPixels((IntPtr)ptr);
        }
    }

    milliseconds = (int)stopwatch.ElapsedMilliseconds;
    return bitmap;
}

MakePixel 方法再次用于将颜色组件合并为 32 位像素。

为了完整性,下面使用相同的代码,但是将 SKColor 值转换为无符号整数:

SKBitmap FillBitmapUintBufferColor(out string description, out int milliseconds)
{
    description = "SetPixels SKColor";
    SKBitmap bitmap = new SKBitmap(256, 256);

    stopwatch.Restart();

    uint[,] buffer = new uint[256, 256];

    for (int rep = 0; rep < REPS; rep++)
        for (int row = 0; row < 256; row++)
            for (int col = 0; col < 256; col++)
            {
                buffer[row, col] = (uint)new SKColor((byte)col, 0, (byte)row);
            }

    unsafe
    {
        fixed (uint* ptr = buffer)
        {
            bitmap.SetPixels((IntPtr)ptr);
        }
    }

    milliseconds = (int)stopwatch.ElapsedMilliseconds;
    return bitmap;
}

比较技术

渐变颜色页的构造函数调用上面显示的所有八种方法,并保存结果:

public class GradientBitmapPage : ContentPage
{
    ···
    string[] descriptions = new string[8];
    SKBitmap[] bitmaps = new SKBitmap[8];
    int[] elapsedTimes = new int[8];

    SKCanvasView canvasView;

    public GradientBitmapPage ()
    {
        Title = "Gradient Bitmap";

        bitmaps[0] = FillBitmapSetPixel(out descriptions[0], out elapsedTimes[0]);
        bitmaps[1] = FillBitmapPixelsProp(out descriptions[1], out elapsedTimes[1]);
        bitmaps[2] = FillBitmapBytePtr(out descriptions[2], out elapsedTimes[2]);
        bitmaps[4] = FillBitmapUintPtr(out descriptions[4], out elapsedTimes[4]);
        bitmaps[6] = FillBitmapUintPtrColor(out descriptions[6], out elapsedTimes[6]);
        bitmaps[3] = FillBitmapByteBuffer(out descriptions[3], out elapsedTimes[3]);
        bitmaps[5] = FillBitmapUintBuffer(out descriptions[5], out elapsedTimes[5]);
        bitmaps[7] = FillBitmapUintBufferColor(out descriptions[7], out elapsedTimes[7]);

        canvasView = new SKCanvasView();
        canvasView.PaintSurface += OnCanvasViewPaintSurface;
        Content = canvasView;
    }
    ···
}

构造函数最后通过创建一个 SKCanvasView 来显示生成的位图。 PaintSurface 处理程序将其图面划分为八个矩形,并调用 Display 来显示每个矩形:

public class GradientBitmapPage : ContentPage
{
    ···
    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        int width = info.Width;
        int height = info.Height;

        canvas.Clear();

        Display(canvas, 0, new SKRect(0, 0, width / 2, height / 4));
        Display(canvas, 1, new SKRect(width / 2, 0, width, height / 4));
        Display(canvas, 2, new SKRect(0, height / 4, width / 2, 2 * height / 4));
        Display(canvas, 3, new SKRect(width / 2, height / 4, width, 2 * height / 4));
        Display(canvas, 4, new SKRect(0, 2 * height / 4, width / 2, 3 * height / 4));
        Display(canvas, 5, new SKRect(width / 2, 2 * height / 4, width, 3 * height / 4));
        Display(canvas, 6, new SKRect(0, 3 * height / 4, width / 2, height));
        Display(canvas, 7, new SKRect(width / 2, 3 * height / 4, width, height));
    }

    void Display(SKCanvas canvas, int index, SKRect rect)
    {
        string text = String.Format("{0}: {1:F1} msec", descriptions[index],
                                    (double)elapsedTimes[index] / REPS);

        SKRect bounds = new SKRect();

        using (SKPaint textPaint = new SKPaint())
        {
            textPaint.TextSize = (float)(12 * canvasView.CanvasSize.Width / canvasView.Width);
            textPaint.TextAlign = SKTextAlign.Center;
            textPaint.MeasureText("Tly", ref bounds);

            canvas.DrawText(text, new SKPoint(rect.MidX, rect.Bottom - bounds.Bottom), textPaint);
            rect.Bottom -= bounds.Height;
            canvas.DrawBitmap(bitmaps[index], rect, BitmapStretch.Uniform);
        }
    }
}

为了让编译器优化代码,此页面在发布模式下运行。 下面是在 MacBook Pro、Nexus 5 Android 手机以及运行 Windows 10 的 Surface Pro 3 上的 iPhone 8 模拟器上运行的页面。 由于硬件差异,请避免比较设备之间的执行时间,而是查看每个设备上的相对时间:

渐变位图

下表合并了执行时间(以毫秒为单位):

API 数据类型 iOS Android UWP
SetPixel 3.17 10.77 3.49
像素 0.32 1.23 0.07
GetPixels byte 0.09 0.24 0.10
uint 0.06 0.26 0.05
SKColor 0.29 0.99 0.07
SetPixels byte 1.33 6.78 0.11
uint 0.14 0.69 0.06
SKColor 0.35 1.93 0.10

按预期方式调用 SetPixel 65,536 次是设置位图像素的最低效方法。 填充 SKColor 数组并设置 Pixels 属性要好得多,甚至与一些 GetPixelsSetPixels 技术相比也更为优越。 使用 uint 像素值进行工作通常比分别设置 byte 组件更快,将 SKColor 值转换为无符号整数会给这个过程增加一些开销。

比较各种渐变也很有趣:每个平台的顶部行相同,并按预期显示渐变。 这意味着 SetPixel 方法和 Pixels 属性能够正确地根据颜色创建像素,而不受底层像素格式的影响。

iOS 和 Android 屏幕截图的接下来两行也是相同的,这证实了为这些平台的默认 Rgba8888 像素格式正确定义了小的 MakePixel 方法。

iOS 和 Android 屏幕截图的下一行向后,指示通过强制转换 SKColor 值获得的无符号整数采用以下格式:

AARRGGBB

字节按以下顺序排列:

BB GG RR AA

这是 Bgra8888 排序,而不是 Rgba8888 排序。 Brga8888 格式是通用 Windows 平台的默认值,这就是为什么该屏幕截图最后一行的渐变与第一行相同的原因。 但中间两行不正确,因为创建这些位图的代码假定 Rgba8888 排序。

如果要使用相同的代码来访问每个平台上的像素位,可以使用 Rgba8888Bgra8888 格式显式创建 SKBitmap。 如果要将 SKColor 值强制转换为位图像素,请使用 Bgra8888

像素的随机访问

渐变位图页面中的 FillBitmapBytePtrFillBitmapUintPtr 方法受益于 for 循环的设计,这些循环旨在按顺序填充位图,从上到下逐行填充,并且每行从左到右填充。 可以使用递增指针的相同语句设置像素。

有时,必须随机而不是按顺序访问像素。 如果使用 GetPixels 方法,则需要基于行和列计算指针。 这在彩虹正弦页中演示,它创建一个位图,该位图以正弦曲线的一个周期的形式显示彩虹。

使用 HSL(色相、饱和度、亮度)颜色模型最容易创建彩虹的颜色。 SKColor.FromHsl 方法使用色调值创建一个 SKColor 值,该值范围为 0 到 360(例如圆的角度,但从红色、绿色和蓝色到红色)以及饱和度和发光度值(范围为 0 到 100)。 对于彩虹的颜色,饱和度应设置为最多 100,亮度设置为 50 的中点。

彩虹正弦通过遍历位图的行,然后循环访问 360 色调值来创建此图像。 从每个色调值计算也基于正弦值的位图列:

public class RainbowSinePage : ContentPage
{
    SKBitmap bitmap;

    public RainbowSinePage()
    {
        Title = "Rainbow Sine";

        bitmap = new SKBitmap(360 * 3, 1024, SKColorType.Bgra8888, SKAlphaType.Unpremul);

        unsafe
        {
            // Pointer to first pixel of bitmap
            uint* basePtr = (uint*)bitmap.GetPixels().ToPointer();

            // Loop through the rows
            for (int row = 0; row < bitmap.Height; row++)
            {
                // Calculate the sine curve angle and the sine value
                double angle = 2 * Math.PI * row / bitmap.Height;
                double sine = Math.Sin(angle);

                // Loop through the hues
                for (int hue = 0; hue < 360; hue++)
                {
                    // Calculate the column
                    int col = (int)(360 + 360 * sine + hue);

                    // Calculate the address
                    uint* ptr = basePtr + bitmap.Width * row + col;

                    // Store the color value
                    *ptr = (uint)SKColor.FromHsl(hue, 100, 50);
                }
            }
        }

        // Create the SKCanvasView
        SKCanvasView canvasView = new SKCanvasView();
        canvasView.PaintSurface += OnCanvasViewPaintSurface;
        Content = canvasView;
    }

    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear();
        canvas.DrawBitmap(bitmap, info.Rect);
    }
}

请注意,构造函数基于 SKColorType.Bgra8888 格式创建位图:

bitmap = new SKBitmap(360 * 3, 1024, SKColorType.Bgra8888, SKAlphaType.Unpremul);

这使得程序能够毫无顾虑地将 SKColor 值转换为 uint 像素。 虽然它在这个特定的程序中不起作用,但每当使用 SKColor 转换来设置像素时,你也应该指定 SKAlphaType.Unpremul,因为 SKColor 不会将其颜色分量与 alpha 值进行预乘。

然后,构造函数使用 GetPixels 方法获取指向位图第一个像素的指针:

uint* basePtr = (uint*)bitmap.GetPixels().ToPointer();

对于任何特定的行和列,必须将偏移值添加到 basePtr。 此偏移量是位图宽度的行时间加上列:

uint* ptr = basePtr + bitmap.Width * row + col;

SKColor 值使用此指针存储在内存中:

*ptr = (uint)SKColor.FromHsl(hue, 100, 50);

SKCanvasViewPaintSurface处理程序中,将拉伸位图以填充显示区域:

彩虹正弦

从一个位图到另一位图

许多图像处理任务涉及修改像素,因为它们从一个位图传输到另一个位图。 颜色调整页中演示了此方法。 该页面加载其中一个位图资源,然后允许你使用三个 Slider 视图修改图像:

颜色调整

对于每个像素颜色,第一个 Slider 将值从 0 添加到色调 360,但随后使用模态运算符将结果保持在 0 到 360 之间,从而有效地沿光谱移动颜色(如 UWP 屏幕截图所示)。 第二个 Slider 允许你选择一个介于 0.5 和 2 之间的乘数因子来应用于饱和度,第三个 Slider 对亮度执行相同操作,如 Android 屏幕截图所示。

程序维护两个位图,一个是名为 srcBitmap 的原始源位图,另一个是名为 dstBitmap 的调整后目标位图。 每次移动 Slider 时,程序都会计算 dstBitmap 中的所有新像素。 当然,用户会通过非常快速地移动 Slider 视图来进行实验,因此你需要尽可能实现最佳性能。 这涉及到源位图和目标位图的 GetPixels 方法。

颜色调整页不控制源位图和目标位图的颜色格式。 相反,它包含针对 SKColorType.Rgba8888SKColorType.Bgra8888 格式的略有不同的逻辑。 源和目标可以是不同的格式,程序仍将有效。

除了将像素从源位图传输到目标位图的关键 TransferPixels 方法之外,以下是完整的程序。 构造函数将 dstBitmap 设置为等于 srcBitmapPaintSurface 处理程序显示 dstBitmap

public partial class ColorAdjustmentPage : ContentPage
{
    SKBitmap srcBitmap =
        BitmapExtensions.LoadBitmapResource(typeof(FillRectanglePage),
                                            "SkiaSharpFormsDemos.Media.Banana.jpg");
    SKBitmap dstBitmap;

    public ColorAdjustmentPage()
    {
        InitializeComponent();

        dstBitmap = new SKBitmap(srcBitmap.Width, srcBitmap.Height);
        OnSliderValueChanged(null, null);
    }

    void OnSliderValueChanged(object sender, ValueChangedEventArgs args)
    {
        float hueAdjust = (float)hueSlider.Value;
        hueLabel.Text = $"Hue Adjustment: {hueAdjust:F0}";

        float saturationAdjust = (float)Math.Pow(2, saturationSlider.Value);
        saturationLabel.Text = $"Saturation Adjustment: {saturationAdjust:F2}";

        float luminosityAdjust = (float)Math.Pow(2, luminositySlider.Value);
        luminosityLabel.Text = $"Luminosity Adjustment: {luminosityAdjust:F2}";

        TransferPixels(hueAdjust, saturationAdjust, luminosityAdjust);
        canvasView.InvalidateSurface();
    }
    ···
    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear();
        canvas.DrawBitmap(dstBitmap, info.Rect, BitmapStretch.Uniform);
    }
}

Slider 视图的 ValueChanged 处理程序计算调整值并调用 TransferPixels

整个 TransferPixels 方法标记为 unsafe。 它首先获取指向两个位图的像素位的字节指针,然后循环访问所有行和列。 从源位图中,该方法为每个像素获取四个字节。 这些可以是 Rgba8888Bgra8888 顺序。 检查颜色类型允许创建 SKColor 值。 然后提取、调整 HSL 组件,并用于重新创建 SKColor 值。 根据目标位图是 Rgba8888 还是 Bgra8888,字节存储在目标位块中:

public partial class ColorAdjustmentPage : ContentPage
{
    ···
    unsafe void TransferPixels(float hueAdjust, float saturationAdjust, float luminosityAdjust)
    {
        byte* srcPtr = (byte*)srcBitmap.GetPixels().ToPointer();
        byte* dstPtr = (byte*)dstBitmap.GetPixels().ToPointer();

        int width = srcBitmap.Width;       // same for both bitmaps
        int height = srcBitmap.Height;

        SKColorType typeOrg = srcBitmap.ColorType;
        SKColorType typeAdj = dstBitmap.ColorType;

        for (int row = 0; row < height; row++)
        {
            for (int col = 0; col < width; col++)
            {
                // Get color from original bitmap
                byte byte1 = *srcPtr++;         // red or blue
                byte byte2 = *srcPtr++;         // green
                byte byte3 = *srcPtr++;         // blue or red
                byte byte4 = *srcPtr++;         // alpha

                SKColor color = new SKColor();

                if (typeOrg == SKColorType.Rgba8888)
                {
                    color = new SKColor(byte1, byte2, byte3, byte4);
                }
                else if (typeOrg == SKColorType.Bgra8888)
                {
                    color = new SKColor(byte3, byte2, byte1, byte4);
                }

                // Get HSL components
                color.ToHsl(out float hue, out float saturation, out float luminosity);

                // Adjust HSL components based on adjustments
                hue = (hue + hueAdjust) % 360;
                saturation = Math.Max(0, Math.Min(100, saturationAdjust * saturation));
                luminosity = Math.Max(0, Math.Min(100, luminosityAdjust * luminosity));

                // Recreate color from HSL components
                color = SKColor.FromHsl(hue, saturation, luminosity);

                // Store the bytes in the adjusted bitmap
                if (typeAdj == SKColorType.Rgba8888)
                {
                    *dstPtr++ = color.Red;
                    *dstPtr++ = color.Green;
                    *dstPtr++ = color.Blue;
                    *dstPtr++ = color.Alpha;
                }
                else if (typeAdj == SKColorType.Bgra8888)
                {
                    *dstPtr++ = color.Blue;
                    *dstPtr++ = color.Green;
                    *dstPtr++ = color.Red;
                    *dstPtr++ = color.Alpha;
                }
            }
        }
    }
    ···
}

通过为源位图和目标位图的各种颜色类型组合创建单独的方法,并避免对每个像素进行类型检查,该方法的性能可能会得到进一步的提升。 另一个选项是基于颜色类型为 col 变量创建多个 for 循环。

海报化

涉及访问像素位的另一个常见工作是海报化。 位图像素中编码的颜色数量减少,从而使结果类似于使用有限颜色调色板手绘的海报。

海报页面对其中一张猴子图像执行此过程:

public class PosterizePage : ContentPage
{
    SKBitmap bitmap =
        BitmapExtensions.LoadBitmapResource(typeof(FillRectanglePage),
                                            "SkiaSharpFormsDemos.Media.Banana.jpg");
    public PosterizePage()
    {
        Title = "Posterize";

        unsafe
        {
            uint* ptr = (uint*)bitmap.GetPixels().ToPointer();
            int pixelCount = bitmap.Width * bitmap.Height;

            for (int i = 0; i < pixelCount; i++)
            {
                *ptr++ &= 0xE0E0E0FF;
            }
        }

        SKCanvasView canvasView = new SKCanvasView();
        canvasView.PaintSurface += OnCanvasViewPaintSurface;
        Content = canvasView;
    }

    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear();
        canvas.DrawBitmap(bitmap, info.Rect, BitmapStretch.Uniform;
    }
}

构造函数中的代码访问每个像素,执行与值 0xE0E0E0FF 的按位 AND 运算,然后将结果存储回位图。 值 0xE0E0E0FF 保留每个颜色组件的高 3 位,并将低 5 位设置为 0。 位图的颜色从 224 或 16,777,216 种减少到 29 或 512 种:

屏幕截图显示两台移动设备和桌面窗口中玩具猴子的海报图像。