このシリーズは全 4 記事で構成されています。
- ① なぜポインターが必要か / unsafe の基本 /
*と&演算子 - ②
->演算子 /stackalloc/fixed/sizeof - ③ 本記事 — ビットマップ画像処理の高速化
- ④ 実践例:C++ DLL との相互運用
前回(①)で述べたように、C# でポインターを使う主な理由の一つはパフォーマンス最適化です。本記事では「ビットマップ画像のピクセル処理」を題材に、安全な API との速度差と、ポインターを使った実装方法を解説します。
なぜ画像処理でポインターが有効か
System.Drawing.Bitmap には GetPixel(x, y) / SetPixel(x, y, color) というメソッドがあります。これらは安全で使いやすいですが、1 ピクセルごとに内部でロックや境界チェックが発生するため、全ピクセルをループで処理すると非常に低速です。
一方、Bitmap.LockBits でビットマップのメモリをロックしてポインターを取得すれば、生のバイト配列に直接アクセスできます。ロックや境界チェックがなくなるため、同じ処理が数十倍速くなることがあります。
| 方法 | 特徴 |
|---|---|
GetPixel / SetPixel |
安全・簡単・低速(1 ピクセルごとにオーバーヘッド) |
LockBits + ポインター |
unsafe・高速(メモリに直接アクセス) |
LockBits + Span<byte> |
unsafe 不要・高速(.NET 6 以降で推奨) |
準備:プロジェクトの設定
System.Drawing は Windows 固有のため、.csproj に次を追加します。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Drawing.Common" Version="9.0.0" />
</ItemGroup>
</Project>
LockBits の基本
Bitmap.LockBits は BitmapData を返します。BitmapData.Scan0 が先頭ピクセルへのポインターです。
using System.Drawing;
using System.Drawing.Imaging;
// ビットマップをロックして BitmapData を取得
Bitmap bmp = new Bitmap("input.png");
BitmapData data = bmp.LockBits(
new Rectangle(0, 0, bmp.Width, bmp.Height),
ImageLockMode.ReadWrite,
PixelFormat.Format32bppArgb // 1ピクセル = 4バイト (B, G, R, A の順)
);
// 先頭アドレスを取得
// data.Scan0 は IntPtr 型(ポインターのラッパー)
IntPtr ptr = data.Scan0;
// 処理後は必ずアンロック
bmp.UnlockBits(data);
Format32bppArgb は 1 ピクセルが 4 バイト(Blue, Green, Red, Alpha の順)で格納されます。
グレースケール変換
GetPixel / SetPixel 版(遅い)
static void ToGrayscaleSlow(Bitmap bmp)
{
for (int y = 0; y < bmp.Height; y++)
{
for (int x = 0; x < bmp.Width; x++)
{
Color c = bmp.GetPixel(x, y);
// 輝度 = 0.299R + 0.587G + 0.114B(ITU-R BT.601)
int luminance = (int)(c.R * 0.299 + c.G * 0.587 + c.B * 0.114);
bmp.SetPixel(x, y, Color.FromArgb(c.A, luminance, luminance, luminance));
}
}
}
ポインター版(速い)
static unsafe void ToGrayscaleFast(Bitmap bmp)
{
BitmapData data = bmp.LockBits(
new Rectangle(0, 0, bmp.Width, bmp.Height),
ImageLockMode.ReadWrite,
PixelFormat.Format32bppArgb
);
try
{
byte* ptr = (byte*)data.Scan0.ToPointer();
int stride = data.Stride; // 1行のバイト数(パディング込み)
for (int y = 0; y < bmp.Height; y++)
{
byte* row = ptr + y * stride;
for (int x = 0; x < bmp.Width; x++)
{
// 各ピクセルは B, G, R, A の順
byte b = row[x * 4 + 0];
byte g = row[x * 4 + 1];
byte r = row[x * 4 + 2];
// A はそのまま
byte luminance = (byte)(r * 0.299 + g * 0.587 + b * 0.114);
row[x * 4 + 0] = luminance; // B
row[x * 4 + 1] = luminance; // G
row[x * 4 + 2] = luminance; // R
}
}
}
finally
{
// 例外が発生してもアンロックを保証
bmp.UnlockBits(data);
}
}
stride(ストライド)は 1 行のバイト数です。幅 × チャンネル数にアラインメントのパディングが加わるため、bmp.Width * 4 と一致しない場合があります。stride を使って行の先頭アドレスを計算するのが重要なポイントです。
明度反転(ネガティブ変換)
static unsafe void InvertColors(Bitmap bmp)
{
BitmapData data = bmp.LockBits(
new Rectangle(0, 0, bmp.Width, bmp.Height),
ImageLockMode.ReadWrite,
PixelFormat.Format32bppArgb
);
try
{
byte* ptr = (byte*)data.Scan0.ToPointer();
int byteCount = Math.Abs(data.Stride) * bmp.Height;
for (int i = 0; i < byteCount; i += 4)
{
ptr[i + 0] = (byte)(255 - ptr[i + 0]); // B
ptr[i + 1] = (byte)(255 - ptr[i + 1]); // G
ptr[i + 2] = (byte)(255 - ptr[i + 2]); // R
// ptr[i + 3] はアルファ値。変更しない
}
}
finally
{
bmp.UnlockBits(data);
}
}
行ごとのループを使わず、全バイトを 1 本のループで処理しています。パディングのないフォーマットかつ Stride が正のときだけ使えるシンプルな書き方です。
利用例:ファイルを読み込んで変換して保存
using System.Drawing;
// グレースケール変換
using var bmp1 = new Bitmap("input.png");
ToGrayscaleFast(bmp1);
bmp1.Save("output_gray.png");
// 明度反転
using var bmp2 = new Bitmap("input.png");
InvertColors(bmp2);
bmp2.Save("output_invert.png");
Console.WriteLine("変換完了");
Span<byte> を使った安全版(.NET 6 以降)
ポインター演算(byte*)を使わずに Span<byte> で同等の処理を書けます。unsafe コンテキスト自体は引き続き必要ですが、添字アクセスに範囲チェックが入るためより安全です。
using System.Runtime.InteropServices;
static unsafe void ToGrayscaleSpan(Bitmap bmp)
{
BitmapData data = bmp.LockBits(
new Rectangle(0, 0, bmp.Width, bmp.Height),
ImageLockMode.ReadWrite,
PixelFormat.Format32bppArgb
);
try
{
// IntPtr → Span<byte>
int byteCount = Math.Abs(data.Stride) * bmp.Height;
Span<byte> pixels = new Span<byte>(data.Scan0.ToPointer(), byteCount);
for (int i = 0; i < pixels.Length; i += 4)
{
byte b = pixels[i + 0];
byte g = pixels[i + 1];
byte r = pixels[i + 2];
byte lum = (byte)(r * 0.299 + g * 0.587 + b * 0.114);
pixels[i + 0] = lum;
pixels[i + 1] = lum;
pixels[i + 2] = lum;
}
}
finally
{
bmp.UnlockBits(data);
}
}
Span<byte> を使うことで、ポインター演算(ptr++ や *(ptr + i) など)なしに安全なインデックスアクセスが可能になります。new Span<byte>(void*, int) コンストラクターと IntPtr.ToPointer() はどちらも unsafe コンテキストが必要なため、メソッドには unsafe が必要です。
まとめ
| アプローチ | 速度 | unsafe 要否 | 主な用途 |
|---|---|---|---|
GetPixel / SetPixel |
低速 | 不要 | 数ピクセルの部分変更 |
LockBits + byte* ポインター |
高速 | 必要 | 全ピクセル処理(高速化優先) |
LockBits + Span<byte> |
高速 | 必要 | 全ピクセル処理(ポインター演算なし) |
画像処理のようにメモリを連続アクセスするユースケースでは、ポインターによる直接操作は数十倍のパフォーマンス差を生むことがあります。ただし LockBits / UnlockBits のペアを確実に呼ぶこと(try/finally 必須)、ストライドを正確に使うことが実装上の重要な注意点です。
次回(④)は、C++ で実装された DLL を P/Invoke で呼び出す際にポインターをどう扱うかを解説します。