访问 SkiaSharp 位图像素位
如“将 SkiaSharp 位图保存到文件”一文所述,位图通常以压缩格式(如 JPEG 或 PNG)存储。 相比之下,存储在内存中的 SkiaSharp 位图不会压缩, 而是存储为连续的像素系列。 这种未压缩的格式有助于将位图传输到显示图面。
SkiaSharp 位图占用的内存块以非常简单的方式进行组织:它从左到右,从第一行像素开始,然后继续第二行。 对于全色位图,每个像素由四个字节组成,这意味着位图所需的总内存空间是其宽度和高度的四倍。
本文介绍如何通过访问位图的像素内存块或间接访问这些像素。 在某些情况下,程序可能想要分析图像的像素,并构造某种类型的直方图。 更常见的是,应用程序可以通过以算法方式创建构成位图的像素来构造唯一图像:
技术
SkiaSharp 提供了几种用于访问位图像素位的技术。 选择哪一种技术通常要权衡编码的便利性(与维护和调试方便相关)与性能。 在大多数情况下,你将使用以下方法之一和 SKBitmap
的属性来访问位图的像素:
GetPixel
和SetPixel
方法允许获取或设置单个像素的颜色。Pixels
属性获取整个位图的像素颜色数组,或设置颜色数组。GetPixels
返回位图使用的像素内存的地址。SetPixels
替换位图使用的像素内存的地址。
可以将前两种技术视为“高级”,而将后两种技术视为“低级别”。有另一些方法和属性可以使用,但这些是最有价值的。
为便于查看这些技术之间的性能差异,示例应用程序包含一个名为“渐变位图”的页,该页会创建一个位图,其中的像素结合了红色和蓝色色调以创建渐变效果。 该程序会创建此位图的八个不同副本,所有这些副本都使用不同的方法来设置位图像素。 这八个位图中的每一个都是以单独的方法创建的,这些方法还会设置技术的简短文本描述,并计算设置所有像素所需的时间。 每个方法循环访问像素设置逻辑 100 次,以便更好地估计性能。
SetPixel 方法
如果只需要设置或获取多个单独的像素,SetPixel
和 GetPixel
是理想的方法。 对于这两种方法中的每一种,请指定整数列和行。 无论像素格式如何,这两种方法都允许你获取或设置像素作为 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;
}
请注意,需要从 row
和 col
变量中计算 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
方法获取时,它将指向位图第一行最左侧像素的第一个字节。 设置 row
和 col
的 for
循环,以便在设置每个像素的每个字节后,++
运算符可以递增 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
语句获取指向此数组的字节指针。 然后,可以将该字节指针强制转换为要传递给 SetPixels
的 IntPtr
。
创建的数组不必是字节数组。 它可以是整数数组,其中行和列只有两个维度:
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
属性要好得多,甚至与一些 GetPixels
和 SetPixels
技术相比也更为优越。 使用 uint
像素值进行工作通常比分别设置 byte
组件更快,将 SKColor
值转换为无符号整数会给这个过程增加一些开销。
比较各种渐变也很有趣:每个平台的顶部行相同,并按预期显示渐变。 这意味着 SetPixel
方法和 Pixels
属性能够正确地根据颜色创建像素,而不受底层像素格式的影响。
iOS 和 Android 屏幕截图的接下来两行也是相同的,这证实了为这些平台的默认 Rgba8888
像素格式正确定义了小的 MakePixel
方法。
iOS 和 Android 屏幕截图的下一行向后,指示通过强制转换 SKColor
值获得的无符号整数采用以下格式:
AARRGGBB
字节按以下顺序排列:
BB GG RR AA
这是 Bgra8888
排序,而不是 Rgba8888
排序。 Brga8888
格式是通用 Windows 平台的默认值,这就是为什么该屏幕截图最后一行的渐变与第一行相同的原因。 但中间两行不正确,因为创建这些位图的代码假定 Rgba8888
排序。
如果要使用相同的代码来访问每个平台上的像素位,可以使用 Rgba8888
或 Bgra8888
格式显式创建 SKBitmap
。 如果要将 SKColor
值强制转换为位图像素,请使用 Bgra8888
。
像素的随机访问
在渐变位图页面中的 FillBitmapBytePtr
和 FillBitmapUintPtr
方法受益于 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);
在 SKCanvasView
的 PaintSurface
处理程序中,将拉伸位图以填充显示区域:
从一个位图到另一位图
许多图像处理任务涉及修改像素,因为它们从一个位图传输到另一个位图。 颜色调整页中演示了此方法。 该页面加载其中一个位图资源,然后允许你使用三个 Slider
视图修改图像:
对于每个像素颜色,第一个 Slider
将值从 0 添加到色调 360,但随后使用模态运算符将结果保持在 0 到 360 之间,从而有效地沿光谱移动颜色(如 UWP 屏幕截图所示)。 第二个 Slider
允许你选择一个介于 0.5 和 2 之间的乘数因子来应用于饱和度,第三个 Slider
对亮度执行相同操作,如 Android 屏幕截图所示。
程序维护两个位图,一个是名为 srcBitmap
的原始源位图,另一个是名为 dstBitmap
的调整后目标位图。 每次移动 Slider
时,程序都会计算 dstBitmap
中的所有新像素。 当然,用户会通过非常快速地移动 Slider
视图来进行实验,因此你需要尽可能实现最佳性能。 这涉及到源位图和目标位图的 GetPixels
方法。
颜色调整页不控制源位图和目标位图的颜色格式。 相反,它包含针对 SKColorType.Rgba8888
和 SKColorType.Bgra8888
格式的略有不同的逻辑。 源和目标可以是不同的格式,程序仍将有效。
除了将像素从源位图传输到目标位图的关键 TransferPixels
方法之外,以下是完整的程序。 构造函数将 dstBitmap
设置为等于 srcBitmap
。 PaintSurface
处理程序显示 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
。 它首先获取指向两个位图的像素位的字节指针,然后循环访问所有行和列。 从源位图中,该方法为每个像素获取四个字节。 这些可以是 Rgba8888
或 Bgra8888
顺序。 检查颜色类型允许创建 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 种: