SkiaSharp ビットマップ ピクセル ビットへのアクセス
記事「SkiaSharp ビットマップをファイルに保存する」で述べたとおり、多くの場合、ビットマップは圧縮形式のファイル (JPEG、PNG など) に格納されます。 それに対し、SkiaSharp ビットマップのメモリへの格納形式は非圧縮です。 単なる一連のピクセル列として、 圧縮せずに格納されるため、表示サーフェイスへの転送が簡単にできます。
SkiaSharp ビットマップを格納したメモリ ブロックの構造は非常に単純なものです。最初は一番上の行のピクセルが左端から右端まで順に並び、その後、2 行目以降が同様に続きます。 フルカラー ビットマップの場合、個々のピクセルは 4 バイトで構成されます。つまり、ビットマップの格納に必要なメモリ領域の合計サイズは、幅と高さの積の 4 倍です。
この記事では、それらのピクセルにアプリケーションからアクセスする方法を、ビットマップのピクセル メモリ ブロックに直接アクセスする場合と、間接的にアクセスする場合の両方について説明します。 たとえば、場合によっては、画像内のピクセルをプログラムで分析してヒストグラムなどを作成することがあります。 また、下の例のように、何らかのアルゴリズムに基づいて独特な画像を生成する処理もよく行われます。
各種の手法
SkiaSharp には、ビットマップのピクセル ビットにアクセスする手法の選択肢が数種類あります。 どれを採用するかは、通常、コーディングの利便性 (メンテナンス性とデバッグしやすさに関連) とパフォーマンスとのバランスを考慮して決定されます。 ほとんどの場合、ビットマップのピクセルにアクセスするには、SKBitmap
に用意されている以下のメソッドとプロパティのいずれかを使用します。
GetPixel
メソッドとSetPixel
メソッドでは、単独のピクセルの色を取得または設定します。Pixels
プロパティでは、ビットマップ全体のピクセル カラー配列を取得または設定します。GetPixels
では、ビットマップで使用するピクセル メモリのアドレスを取得します。SetPixels
では、ビットマップで使用するピクセル メモリのアドレスを置き換えます。
これらのうち、上の 2 つは "高レベル" の手法、下の 2 つは "低レベル" の手法と考えることができます。メソッドとプロパティはほかにもありますが、これらが最も有用なものです。
手法によるパフォーマンスの違いを比較するには、サンプル アプリケーションの Gradient Bitmap というページを参照してください。このページのデモは、異なる赤と青の濃淡を組み合わせたピクセルでグラデーションのビットマップを作成するものです。 8 とおりの異なる手法でビットマップ内のピクセルを設定し、同じビットマップを 8 回作成します。 各ビットマップは別々のメソッドで作成されており、それらのメソッドには、使用した手法を簡潔にテキスト表示し、すべてのピクセルを設定する処理に費やした時間を計算するコードが含まれています。 的確なパフォーマンス比較ができるよう、どのメソッドでも、ピクセル設定ロジックをループで 100 回繰り返し実行します。
SetPixel メソッド
数個程度のピクセルを個別に設定または取得するだけの場合は、SetPixel
と GetPixel
のメソッドが最適です。 どちらのメソッドに対しても、列と行を整数値の引数で指定します。 これらのメソッドで取得および設定するピクセル値は、実際のピクセル形式に関係なく、常に SKColor
型です。
bitmap.SetPixel(col, row, color);
SKColor color = bitmap.GetPixel(col, row);
引数 col
の値の下限は 0 で、上限はビットマップの Width
プロパティから 1 を引いた値です。同様に、row
の値の下限は 0 で、上限は Height
プロパティから 1 を引いた値です。
Gradient Bitmap の、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 呼び出しをこのように多数繰り返すのはあまり良い方法ではありません。別の手段があればそちらを検討すべきです。 幸い、この場合は異なる選択肢が複数あります。
Pixels プロパティ
SKBitmap
クラスには、Pixels
プロパティ (そのビットマップ全体を表す SKColor
値の配列を返す) が定義されています。 また、ビットマップのカラー値の配列は、次のように Pixels
を使用して設定することもできます。
SKColor[] pixels = bitmap.Pixels;
bitmap.Pixels = pixels;
この配列の要素は、一番上の行の左端にあるピクセルから始まって右端まで、次に 2 行目の左端から右端へ、という順に並んでいます。 配列に含まれるカラー要素の総数は、ビットマップの幅と高さの積と同じです。
このプロパティは一見効率が良さそうに思われますが、実際には、ビットマップのピクセルから配列へのコピー、配列からビットマップへのコピーが発生し、ピクセルと SKColor
値との変換が相互に行われることに注意する必要があります。
次に示す GradientBitmapPage
クラス内のメソッドは、Pixels
プロパティを使用してビットマップを設定するものです。 必要なサイズの 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;
}
pixels
配列のインデックス値は、row
変数と col
変数から計算で求める必要があります。 行位置に 1 行のピクセル数 (この場合は 256) を掛け、そこに列位置を足すとインデックス値が得られます。
SKBitmap
には、これに似た、ビットマップ全体のバイト配列を返す Bytes
プロパティが定義されていますが、フルカラー ビットマップの場合は扱いが面倒です。
GetPixels ポインター
ビットマップ ピクセルへの最も強力な可能性を持つアクセス手法は GetPixels
です。これは、GetPixel
メソッドや Pixels
プロパティとは異なります。 GetPixels
の戻り値は、C# のプログラミングであまり一般的には使われないものであり、その意味で、他の手法とは明白な違いがあります。
IntPtr pixelsAddr = bitmap.GetPixels();
.NET の IntPtr
型はポインターを表します。 この IntPtr
という型名は、実際にプログラムを実行しているコンピューターのネイティブ プロセッサに適した長さの整数 (通常は 32 ビットまたは 64 ビット) を扱うものであることを意味します。 GetPixels
から返される IntPtr
は、ビットマップ オブジェクトでピクセルの格納に使用されている実際のメモリ ブロックのアドレスです。
IntPtr
を C# ポインター型に変換するには、ToPointer
メソッドを使用します。 C# ポインターの構文は、C や C++ と同じです。
byte* ptr = (byte*)pixelsAddr.ToPointer();
ptr
変数は "バイト ポインタ" 型です。 この ptr
変数を使用すると、ビットマップでピクセルの格納に使用されているメモリ内の個々のバイトにアクセスできます。 メモリ内の 1 バイトに対して読み取り、書き込みを実行するには、次のようなコードを使用します。
byte pixelComponent = *ptr;
*ptr = pixelComponent;
このコンテキストで使用されているアスタリスク (*) は、C# の "間接演算子"、つまり、ptr
によってポイントされているメモリの内容を参照するものです。 初期状態では、ptr
はビットマップ内にある先頭行の、先頭ピクセルの、先頭バイトを指しています。ptr
変数に対して算術演算を実行すると、位置を動かし、ビットマップ内の他の場所をポイントすることができます。
短所としては、この ptr
変数は unsafe
キーワードでマークされたコード ブロック内でしか使用できないことが挙げられます。 さらに、アセンブリにも、安全でないブロックを許可するフラグを設定する必要があります。 この設定はプロジェクトのプロパティで行います。
C# のポインターは非常に強力ですが、使用することは非常に危険でもあります。 そのポインターで参照すべきである範囲を超えた想定外のメモリに決してアクセスしないよう、注意する必要があります。 ポインターの使用が "unsafe" (安全でない) という用語に関連付けられているのは、こうした理由があるからです。
GradientBitmapPage
クラス内で GetPixels
メソッドを使用するメソッドを次に示します。 バイト ポインターを使用するコード全体が 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
ループでは、個々のピクセルのデータを 1 バイト設定するたびに、ptr
の値を ++
演算子でインクリメントします。 その後、ピクセル群の設定操作をあと 99 回ループ処理で繰り返すために、ptr
をビットマップの先頭に戻す必要があります。
1 個のピクセルは 4 バイトのメモリで表されており、各バイト値は個別に設定する必要があります。 このコードは、バイト列の並び方が赤、緑、青、アルファの順、つまり SKColorType.Rgba8888
カラー タイプと同様である前提で記述されています。 これは iOS と Android における既定のカラー タイプですが、Universal Windows Platform (UWP) では異なります。 UWP の場合、ビットマップは既定では SKColorType.Bgra8888
カラー タイプで作成されます。 したがって、UWP プラットフォームで得られる結果は多少異なるものになります。
ToPointer
から返された値は、byte
ポインターではなく uint
ポインターにキャストすることも可能です。 その場合は 1 文のステートメントで 1 個のピクセル全体にアクセスできます。 そのポインター値を ++
演算子でインクリメントすると、位置が 4 バイト分移動して次のピクセルがポイントされます。
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
メソッドで実行します。このメソッドでは、赤、緑、青、アルファの成分を組み合わせて整数のピクセル値を構成しています。 ここでの注意点は、SKColorType.Rgba8888
形式のピクセルが以下のバイト順で構成されていることと整数値の関係です。
RR GG BB AA
上のコードでは、バイト列を以下のように並べて整数値を構成しています。
AABBGGRR
これは、リトルエンディアン アーキテクチャのコンピューターでは整数値が最下位バイトから順に格納されるためです。 ビットマップのカラー タイプが Bgra8888
である場合には、この MakePixel
メソッドは正しく動作しません。
この MakePixel
メソッドは MethodImplOptions.AggressiveInlining
オプションでフラグ付けされています。これは、実際に別のメソッドとしてコンパイルするのではなく呼び出されている場所にコードを展開することを、コンパイラに対して推奨するオプションです。 そのようにコンパイルすると性能向上が期待できます。
興味深いことに、SKColor
構造体には、SKColor
から符号なし整数への明示的な変換が定義されています。これは、MakePixel
を使用しなくても、SKColor
値を作成して uint
に変換できることを意味します。
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;
}
1 つだけ疑問が残るのは、SKColor
値の整数形式のバイト順が SKColorType.Rgba8888
カラー タイプなのか、SKColorType.Bgra8888
カラー タイプなのか、どちらでもない別の構成なのかということです。 この疑問の答えは、まもなく明らかになります。
SetPixels メソッド
SKBitmap
には、SetPixels
というメソッドも定義されています。呼び出し方は次のとおりです。
bitmap.SetPixels(intPtr);
前出の GetPixels
は、ビットマップのピクセルの格納に使用されているメモリ ブロックへの IntPtr
を取得するメソッドでした。 SetPixels
呼び出しは、ビットマップで使用するメモリ ブロックを、SetPixels
引数で指定した IntPtr
の参照先メモリ ブロックに切り替えます。 それまでビットマップで使用されていたメモリ ブロックは解放されます。 したがって、この後に GetPixels
を呼び出すと、SetPixels
で設定したメモリ ブロックへのポインターが得られます。
一見、この SetPixels
の働きは不便そうであり、GetPixels
のような強力さやパフォーマンスが得られるとは思えないかもしれません。 GetPixels
ではビットマップのメモリ ブロックを取得してアクセスできるのに対し、 SetPixels
を使用するときは、メモリを割り当ててアクセスし、それをビットマップのメモリ ブロックとして設定することになります。
しかし、SetPixels
には、配列でビットマップ ピクセルのビット群にアクセスできるという、構文上の明確な利点があります。 GradientBitmapPage
内の、この手法のデモを実行するメソッドを次に示します。 このメソッドでは、まず、ビットマップ ピクセルのバイト群に対応する多次元のバイト配列を定義します。 第 1 の次元は行、第 2 の次元は列です。そして第 3 の次元は、個々のピクセルに含まれる 4 つの成分に対応しています。
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
ステートメントで、この配列を参照するバイト ポインターを取得します。 このバイト ポインターは、IntPtr
にキャストして SetPixels
に渡すことができます。
作成する配列は、バイト配列である必要はありません。 次のように、行と列の 2 次元で構成される整数配列として扱うこともできます。
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;
}
手法の比較
Gradient Color ページのコンストラクターでは、上記 8 つのメソッドをすべて呼び出し、結果を保存しています。
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
ハンドラーは、サーフェイスを 8 つの四角形に分割し、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 上の iPhone 8 シミュレーター、Nexus 5 Android スマートフォン、および Windows 10 の Surface Pro 3 でこのページを実行した結果です。 ハードウェアの条件が異なるためデバイス間のパフォーマンス比較にはなりませんが、各デバイス上で、手法による所要時間の違いを相対的に比較することができます。
実行時間をまとめた表を次に示します (単位はミリ秒)。
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 のスクリーンショットを見ると 2 段目も同じ結果であり、これらのプラットフォームの既定の Rgba8888
ピクセル形式に対しては、シンプルな MakePixel
メソッドの定義が正しく働いていることを確認できます。
iOS と Android のスクリーンショットの最下段では、赤と青が反転しています。これは、SKColor
値をキャストして得られる符号なし整数の構成が次の形式であったことを示しています。
AARRGGBB
つまり、バイト列の並び順は次のようになっています。
BB GG RR AA
これは、Rgba8888
ではなく Bgra8888
の順序に相当します。 Brga8888
は Universal Windows Platform (UWP) における既定の形式なので、UWP のスクリーンショットでは、最下段のグラデーションが最上段と同じです。 しかし、中間の 2 段は、Rgba8888
の順序を前提としたコードでビットマップが作成されているため、正しい結果になっていません。
各プラットフォームで同じコードを使用してピクセルのビット群にアクセスするには、明示的に Rgba8888
形式または Bgra8888
形式で SKBitmap
を作成します。 SKColor
値をキャストでビットマップのピクセルに変換する場合は、Bgra8888
を使用します。
ピクセルへのランダム アクセス
Gradient Bitmap ページの FillBitmapBytePtr
メソッドと FillBitmapUintPtr
メソッドでは、ビットマップ内のピクセルを、一番上の行から下の行に向かって、各行内を左端から右端に向かって順に設定していくことを前提に、簡単な for
ループを使用していました。 1 つのステートメントで、ポインターをインクリメントするのと同時にピクセルの値を設定することができました。
しかし、端から順ではなくランダムな位置のピクセルにアクセスすることが必要な場合もあります。 GetPixels
アプローチを使用する場合は、行位置と列位置からポインターの場所を計算する必要があります。 Rainbow Sine ページはそのようなデモの一種で、正弦曲線の 1 サイクルを描く虹模様のビットマップを作成するものです。
虹を構成するカラーは、HSL (色相、彩度、明度) カラー モデルを使用して作成するのが最も簡単です。 SKColor.FromHsl
メソッドは、色相値 (0 から 360 の範囲。角度が 360 度回転すると元に戻るように、数字を増やすと赤、緑、青を経て赤に戻る) と、彩度および明度の値 (それぞれ 0 から 100 の範囲) に基づいて SKColor
値を作成します。 虹のカラーを出すには、彩度を上限の 100 に、明度を中間の 50 に設定しておきます。
Rainbow Sine では、ビットマップの最上行から最下行までピクセルを描くループを、色相値 0 から 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
変換を使用してピクセルを設定する際には、SKColor
のカラー成分はアルファ値乗算済みの値ではないことを示す SKAlphaType.Unpremul
も指定する必要があります。
次に、ビットマップの先頭ピクセルへのポインターを 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
ハンドラーで、ビットマップが表示領域いっぱいに引き伸ばされ、以下のような表示結果になります。
ビットマップからもう 1 つのビットマップへ
画像処理タスクでは、ビットマップに含まれるピクセル群を別のビットマップに転送しながらピクセルに変更を加えるという操作が非常によく行われます。 この手法に関するデモは Color Adjustment ページにあります。 このページは、ビットマップ リソースを 1 つ読み込み、3 つの Slider
ビューを使用してイメージを補正するものです。
一番上の Slider
では、各ピクセルの色相値に 0 から 360 の値を足した後、その結果をモジュロ演算子で 0 から 360 の範囲内に収める計算が行われます。これにより、スペクトルに沿ってカラーがシフトする効果が得られます (UWP のスクリーンショットを参照)。 2 番目の Slider
では、彩度に適用する乗法係数を 0.5 から 2 の範囲で指定できます。3 番目の Slider
では、同様の操作を明度について実行できます (Android のスクリーンショットを参照)。
このプログラム内には 2 つのビットマップが保持されています。調整前の取得元ビットマップは srcBitmap
、調整後の描画先ビットマップは dstBitmap
です。 Slider
が操作されるたびに、このプログラムは dstBitmap
内の全ピクセルを新たに再計算します。 ユーザーが試行錯誤する際には Slider
ビューのすばやい操作が行われるため、できる限り処理パフォーマンスを高めることが求められます。 このため、取得元ビットマップと描画先ビットマップの両方について、GetPixels
メソッドによるアクセス方法を採用しています。
Color Adjustment ページでは、取得元および描画先ビットマップのカラー形式を制御していません。 その代わり、SKColorType.Rgba8888
形式の場合と SKColorType.Bgra8888
形式の場合を区別し、わずかに異なるロジックで対応しています。 取得元と描画先の形式が異なっていても、このプログラムは正しく機能します。
取得元のピクセルを描画先に転送する重要な TransferPixels
メソッドについては後で説明しますが、まずは、それ以外の部分のコードを次に示します。 コンストラクターは、srcBitmap
と同じ内容を dstBitmap
に設定します。 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
指定されています。 まず、それぞれのビットマップ内にあるピクセルのビット列を指すバイト ポインターを取得し、次に、すべての行と列をループで処理します。 取得元ビットマップから、1 個のピクセルにつき 4 個のバイト値を取得します。 バイト列の並び順は 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
ループをカラー タイプ別に複数用意する方法も考えられます。
ポスタリゼーション
ピクセルのビット群にアクセスするその他の一般的なジョブとしては、ポスタリゼーションがあります。 これは、ビットマップ内のピクセルにエンコードされているカラーの数を減らし、限られたカラー パレットで手描きしたポスターのような効果を出す処理です。
Posterize ページでは、サルの画像を使用してこのプロセスのデモを実行します。
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 との AND 演算には、カラー成分それぞれの上位 3 ビットだけを維持し、下位 5 ビットを 0 に設定する作用があります。 その結果、ビットマップに含まれるカラーの情報量は 224 (16,777,216 色) から 29 (512 色) に縮小されます。