次の方法で共有


SkiaSharp ビットマップ ピクセル ビットへのアクセス

記事「SkiaSharp ビットマップをファイルに保存する」で述べたとおり、多くの場合、ビットマップは圧縮形式のファイル (JPEG、PNG など) に格納されます。 それに対し、SkiaSharp ビットマップのメモリへの格納形式は非圧縮です。 単なる一連のピクセル列として、 圧縮せずに格納されるため、表示サーフェイスへの転送が簡単にできます。

SkiaSharp ビットマップを格納したメモリ ブロックの構造は非常に単純なものです。最初は一番上の行のピクセルが左端から右端まで順に並び、その後、2 行目以降が同様に続きます。 フルカラー ビットマップの場合、個々のピクセルは 4 バイトで構成されます。つまり、ビットマップの格納に必要なメモリ領域の合計サイズは、幅と高さの積の 4 倍です。

この記事では、それらのピクセルにアプリケーションからアクセスする方法を、ビットマップのピクセル メモリ ブロックに直接アクセスする場合と、間接的にアクセスする場合の両方について説明します。 たとえば、場合によっては、画像内のピクセルをプログラムで分析してヒストグラムなどを作成することがあります。 また、下の例のように、何らかのアルゴリズムに基づいて独特な画像を生成する処理もよく行われます。

ピクセル ビットのサンプル

各種の手法

SkiaSharp には、ビットマップのピクセル ビットにアクセスする手法の選択肢が数種類あります。 どれを採用するかは、通常、コーディングの利便性 (メンテナンス性とデバッグしやすさに関連) とパフォーマンスとのバランスを考慮して決定されます。 ほとんどの場合、ビットマップのピクセルにアクセスするには、SKBitmap に用意されている以下のメソッドとプロパティのいずれかを使用します。

  • GetPixel メソッドと SetPixel メソッドでは、単独のピクセルの色を取得または設定します。
  • Pixels プロパティでは、ビットマップ全体のピクセル カラー配列を取得または設定します。
  • GetPixels では、ビットマップで使用するピクセル メモリのアドレスを取得します。
  • SetPixels では、ビットマップで使用するピクセル メモリのアドレスを置き換えます。

これらのうち、上の 2 つは "高レベル" の手法、下の 2 つは "低レベル" の手法と考えることができます。メソッドとプロパティはほかにもありますが、これらが最も有用なものです。

手法によるパフォーマンスの違いを比較するには、サンプル アプリケーションの Gradient Bitmap というページを参照してください。このページのデモは、異なる赤と青の濃淡を組み合わせたピクセルでグラデーションのビットマップを作成するものです。 8 とおりの異なる手法でビットマップ内のピクセルを設定し、同じビットマップを 8 回作成します。 各ビットマップは別々のメソッドで作成されており、それらのメソッドには、使用した手法を簡潔にテキスト表示し、すべてのピクセルを設定する処理に費やした時間を計算するコードが含まれています。 的確なパフォーマンス比較ができるよう、どのメソッドでも、ピクセル設定ロジックをループで 100 回繰り返し実行します。

SetPixel メソッド

数個程度のピクセルを個別に設定または取得するだけの場合は、SetPixelGetPixel のメソッドが最適です。 どちらのメソッドに対しても、列と行を整数値の引数で指定します。 これらのメソッドで取得および設定するピクセル値は、実際のピクセル形式に関係なく、常に 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 メソッドから取得した時点では、ビットマップ内にある先頭行の、左端ピクセルの、先頭バイトをポイントしています。 rowcolfor ループでは、個々のピクセルのデータを 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 でこのページを実行した結果です。 ハードウェアの条件が異なるためデバイス間のパフォーマンス比較にはなりませんが、各デバイス上で、手法による所要時間の違いを相対的に比較することができます。

Gradient Bitmap

実行時間をまとめた表を次に示します (単位はミリ秒)。

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 のスクリーンショットを見ると 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);

SKCanvasViewPaintSurface ハンドラーで、ビットマップが表示領域いっぱいに引き伸ばされ、以下のような表示結果になります。

Rainbow Sine

ビットマップからもう 1 つのビットマップへ

画像処理タスクでは、ビットマップに含まれるピクセル群を別のビットマップに転送しながらピクセルに変更を加えるという操作が非常によく行われます。 この手法に関するデモは Color Adjustment ページにあります。 このページは、ビットマップ リソースを 1 つ読み込み、3 つの Slider ビューを使用してイメージを補正するものです。

Color Adjustment

一番上の 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 個のバイト値を取得します。 バイト列の並び順は Rgba8888Bgra8888 のどちらでも問題ありません。 カラー タイプを調べ、それに応じて SKColor 値を作成します。 その後、HSL の各成分を抽出して調整を加え、それらを基にして SKColor 値を再作成します。 バイト列を描画先ビットマップに格納する際は、描画先のカラー タイプが Rgba8888Bgra8888 のどちらであるかに応じて格納順序を変更します。

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 色) に縮小されます。

スクリーンショットには、2 つのモバイル デバイスと 1 つのデスクトップ ウィンドウに表示されたおもちゃのサルのポスタリゼーション画像が示されています。