C#でBitmapとbyte配列を相互変換する方法|MemoryStream・LockBits・Marshal.Copyの使い分け

はじめに

C#で画像を扱っていると、「Bitmapをbyte配列に変換したい」「byte配列からBitmapを復元したい」という場面がよくあります。

ただし、c# bitmap byteで調べたときに注意したいのは、ひとことでbyte配列といっても、次の2種類があることです。

1つ目は、PNG、JPEG、BMPなどの画像ファイル形式としてのbyte配列です。これは画像をファイル保存したり、DBに格納したり、APIで送受信したりする場合に使います。

2つ目は、Bitmapのピクセル値をそのまま並べた生ピクセルデータとしてのbyte配列です。これは画像処理、画素値の変更、フィルター処理、高速な画像解析などで使います。

この2つを混同すると、「byte配列からBitmapを作成できない」「画像が崩れる」「色がおかしい」「Parameter is not validが出る」といった問題が起きます。

この記事では、C#でBitmapbyte[]を相互変換する方法を、MemoryStreamLockBitsMarshal.Copyの使い分けが分かるように整理します。

1. [c# bitmap byte]の検索意図とこの記事で解決できること

1-1. Bitmapをbyte配列に変換したいユーザーの主な目的

C#でBitmapをbyte配列に変換したい目的は、大きく分けると次のようになります。

画像をデータベースに保存したい、Web APIで画像を送信したい、Base64文字列に変換したい、ファイルに保存せずメモリ上で画像を扱いたい、画像処理のために各ピクセルの値を直接操作したい、といったケースです。

ここで重要なのは、「保存・送信したい」のか「ピクセルを操作したい」のかで、使うべき変換方法が変わることです。

画像をPNGやJPEGとして扱いたいならMemoryStreamを使います。一方で、ピクセルのRGB値やARGB値を直接扱いたいならLockBitsMarshal.Copyを使います。

1-2. byte配列からBitmapを復元したいケース

byte配列からBitmapを復元するケースもよくあります。

たとえば、データベースに保存していた画像データを画面に表示したい場合、APIから受け取った画像のバイナリをBitmapとして読み込みたい場合、ファイルから読み込んだbyte[]を画像として扱いたい場合などです。

この場合、byte配列の中身がPNGやJPEGなどの画像ファイル形式であれば、MemoryStream経由でBitmapを作成できます。

一方、byte配列の中身が生ピクセルデータの場合は、new Bitmap(new MemoryStream(bytes))のような方法では復元できません。生ピクセルデータには画像形式のヘッダー情報が含まれていないため、幅、高さ、PixelFormat、Strideなどの情報を別途指定してBitmapへ書き戻す必要があります。

1-3. MemoryStream・LockBits・Marshal.Copyのどれを使うべきか迷う理由

C#でBitmapとbyte配列の変換方法を調べると、MemoryStreamを使うコードと、LockBitsMarshal.Copyを使うコードが混在しています。

どちらも「Bitmapをbyte配列に変換する」コードに見えますが、実際には目的が違います。

MemoryStreamは、BitmapをPNG、JPEG、BMPなどの画像ファイル形式としてbyte配列に変換します。

LockBitsは、Bitmapのピクセル領域をロックして、画像の生データへアクセスするために使います。Microsoftのドキュメントでも、LockBitsPixelFormatWidthHeightScan0などを使ってBitmapのピクセルデータへアクセスする例で説明されています。Microsoft Learn

Marshal.Copyは、BitmapData.Scan0が指すアンマネージメモリと、C#のマネージド配列であるbyte[]の間でデータをコピーするために使います。Microsoft Learn

つまり、MemoryStreamLockBitsMarshal.Copyは競合する方法ではなく、用途が異なる道具です。

1-4. 最初に結論:画像ファイルのbyte配列か、生ピクセルのbyte配列かで方法が変わる

最初に結論をまとめると、次のようになります。

画像を保存、送信、DB格納、Base64化したい場合は、MemoryStreamを使ってPNGやJPEGなどの画像形式のbyte配列に変換します。

画像処理やピクセル単位の操作をしたい場合は、LockBitsでBitmapのピクセル領域をロックし、Marshal.Copybyte[]へコピーします。

byte配列からBitmapへ戻す場合も同じです。PNGやJPEGのbyte配列ならMemoryStreamからBitmapを作成します。生ピクセルのbyte配列なら、幅、高さ、PixelFormat、Strideを考慮してBitmapへ書き戻します。

2. C#で扱うBitmapとbyte配列の基本

2-1. Bitmapとは何か

Bitmapは、C#で画像を扱うための代表的なクラスです。System.Drawing.Bitmapを使うと、画像ファイルの読み込み、描画、保存、ピクセル操作などができます。

たとえば、次のように画像ファイルからBitmapを作成できます。

C#
using System.Drawing;

using var bitmap = new Bitmap("sample.png");

Bitmapは見た目には1枚の画像ですが、内部的には画像の幅、高さ、ピクセル形式、色データなどを持っています。

2-2. byte配列とは何か

byte[]は、0〜255の値を持つバイトの配列です。

画像に限らず、ファイル、通信データ、暗号化データ、音声データなど、バイナリデータを扱うときによく使われます。

画像の場合、byte[]には大きく分けて2つの意味があります。

1つは、PNGやJPEGなどの画像ファイルそのものをbyte配列にしたものです。

もう1つは、画像の各ピクセルの値を並べた生データです。

この違いを理解していないと、Bitmapとbyte配列の変換でつまずきやすくなります。

2-3. 画像ファイルとしてのbyte配列とピクセルデータとしてのbyte配列の違い

画像ファイルとしてのbyte配列には、画像形式を判別するための情報、圧縮情報、メタデータ、幅、高さなどが含まれます。

たとえばPNG形式のbyte配列であれば、そのbyte配列自体がPNGファイルの中身です。そのため、MemoryStreamに入れてBitmapとして読み込むことができます。

一方、生ピクセルデータとしてのbyte配列は、各ピクセルの色成分を並べたものです。たとえば32bpp ARGB形式なら、1ピクセルあたり4バイトを使います。

この場合、byte配列だけを見ても画像の幅や高さは分かりません。そのため、Bitmapへ戻すには、幅、高さ、PixelFormat、Strideなどの情報が必要になります。

2-4. PixelFormat・Stride・Scan0を理解しておくべき理由

LockBitsを使う場合、特に重要なのがPixelFormatStrideScan0です。

PixelFormatは、1ピクセルをどのような形式で表すかを示します。たとえばFormat24bppRgbなら1ピクセルあたり3バイト、Format32bppArgbなら1ピクセルあたり4バイトです。

Strideは、画像の1行分のバイト数です。ただし、単純に「幅 × 1ピクセルあたりのバイト数」とは限りません。BitmapのStrideは4バイト境界に丸められるため、行末に余白バイトが入ることがあります。また、Strideの符号によって画像のメモリ上の向きが変わる場合があります。Microsoft Learn

Scan0は、ロックされたBitmapデータの先頭を指すポインターです。C#の通常の配列ではなくアンマネージメモリを指すため、Marshal.Copyを使ってbyte[]へコピーします。

2-5. System.Drawing.Commonを使う際の注意点

System.Drawing.Commonを使う場合は、実行環境に注意が必要です。

.NET 6以降、System.Drawing.CommonはWindows固有のライブラリとして扱われ、Windows以外の環境ではサポートされません。Microsoftはクロスプラットフォーム用途ではSkiaSharp、ImageSharp、Microsoft.Maui.Graphicsなどへの移行を推奨しています。Microsoft Learn

そのため、Windows FormsやWindows上のバッチ処理であればSystem.Drawing.Bitmapを使いやすいですが、Linuxサーバー、Docker、クラウド環境で動かすアプリでは別ライブラリの利用も検討した方が安全です。

3. MemoryStreamでBitmapをbyte配列に変換する方法

3-1. MemoryStreamを使うべきケース

MemoryStreamを使うべきなのは、Bitmapを画像ファイル形式のbyte配列として扱いたい場合です。

具体的には、次のようなケースです。

画像をPNGやJPEGとして保存したい場合、Web APIで画像を送信したい場合、データベースのBLOB列に格納したい場合、Base64文字列に変換したい場合、ファイルには保存せずメモリ上で画像データを作りたい場合です。

この方法で得られるbyte配列は、画像ファイルと同じ構造です。そのため、あとからFile.WriteAllBytesで保存すれば、そのまま画像ファイルとして開けます。

3-2. BitmapをPNG・JPEG・BMP形式のbyte配列に変換するコード

Bitmapを画像ファイル形式のbyte配列に変換する基本コードは次のとおりです。

C#
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;

public static byte[] BitmapToBytes(Bitmap bitmap, ImageFormat format)
{
if (bitmap == null)
{
throw new ArgumentNullException(nameof(bitmap));
}

using var stream = new MemoryStream();
bitmap.Save(stream, format);
return stream.ToArray();
}

PNG形式に変換する場合は次のように呼び出します。

C#
byte[] pngBytes = BitmapToBytes(bitmap, ImageFormat.Png);

JPEG形式に変換する場合は次のようにします。

C#
byte[] jpgBytes = BitmapToBytes(bitmap, ImageFormat.Jpeg);

BMP形式に変換する場合は次のようにします。

C#
byte[] bmpBytes = BitmapToBytes(bitmap, ImageFormat.Bmp);

この方法では、byte[]の中にPNG、JPEG、BMPなどの画像ファイルとして必要な情報が含まれます。

3-3. ImageFormatを指定する際の注意点

ImageFormatを指定するときは、用途に合った形式を選ぶ必要があります。

PNGは可逆圧縮で、透過情報も扱いやすいため、画質を保ちたい場合に向いています。

JPEGは非可逆圧縮なので、写真のような画像を小さく保存したい場合に向いています。ただし、保存のたびに画質劣化が起きる可能性があり、透過情報も保持できません。

BMPは圧縮されないことが多く、ファイルサイズが大きくなりやすい形式です。扱いは単純ですが、保存・送信・DB格納ではPNGやJPEGの方が使いやすいことが多いです。

迷った場合は、画質重視ならPNG、写真の容量削減ならJPEGを選ぶとよいでしょう。

3-4. byte配列からBitmapを復元するコード

PNGやJPEGなどの画像ファイル形式のbyte配列からBitmapを復元するには、MemoryStreamを使います。

ただし、次のように単純に書くと注意が必要です。

C#
public static Bitmap BytesToBitmapUnsafe(byte[] bytes)
{
using var stream = new MemoryStream(bytes);
return new Bitmap(stream);
}

このコードは、メソッドを抜けるとMemoryStreamが破棄されます。MicrosoftのBitmapコンストラクターの説明では、ストリームからBitmapを作成する場合、そのBitmapの有効期間中はストリームを開いたままにしておく必要があるとされています。Microsoft Learn

そのため、メソッドからBitmapを返す場合は、いったん作成したBitmapを複製して返す方法が安全です。

C#
using System.Drawing;
using System.IO;

public static Bitmap BytesToBitmap(byte[] bytes)
{
if (bytes == null)
{
throw new ArgumentNullException(nameof(bytes));
}

using var stream = new MemoryStream(bytes);
using var tempBitmap = new Bitmap(stream);

return new Bitmap(tempBitmap);
}

このようにnew Bitmap(tempBitmap)で複製して返せば、MemoryStreamを閉じた後でも返却したBitmapを使いやすくなります。

3-5. MemoryStream利用時によくあるエラーと対処法

MemoryStreamでよくあるエラーは、ArgumentException: Parameter is not validです。

これは、byte配列が正しい画像ファイル形式ではない場合によく発生します。

たとえば、次のようなケースです。

PNGやJPEGではなく生ピクセルデータを渡している、byte配列が途中で欠けている、Base64デコードに失敗している、画像ではないファイルのbyte配列を渡している、といった場合です。

対処法としては、まずそのbyte配列が本当に画像ファイル形式か確認します。

C#
File.WriteAllBytes("debug_image.bin", bytes);

保存したファイルの拡張子を.png.jpgにして開けるなら、画像ファイル形式のbyte配列です。開けない場合は、生ピクセルデータか、破損したデータの可能性があります。

4. LockBitsとMarshal.CopyでBitmapをbyte配列に変換する方法

4-1. LockBitsを使うべきケース

LockBitsを使うべきなのは、Bitmapのピクセルデータを直接扱いたい場合です。

たとえば、画像の明るさを変える、RGB値を解析する、特定の色を置換する、グレースケール化する、二値化する、画像認識の前処理を行う、といったケースです。

GetPixelSetPixelでもピクセル操作はできますが、大量のピクセルを扱う場合は処理が遅くなりやすいため、実用的な画像処理ではLockBitsを使うことが多いです。

4-2. BitmapDataからピクセル情報を取得する流れ

LockBitsを使う流れは次のとおりです。

まず、処理対象の範囲をRectangleで指定します。

次に、LockBitsを呼び出してBitmapのピクセル領域をロックします。

すると、BitmapDataが返されます。

BitmapDataには、StrideScan0PixelFormatなどの情報が含まれます。BitmapDataは、Bitmapの属性を表し、LockBitsUnlockBitsで使われるクラスです。Microsoft Learn

その後、Marshal.Copyを使ってScan0からbyte[]へデータをコピーします。

最後に、必ずUnlockBitsを呼び出してロックを解除します。

4-3. Bitmapを生ピクセルのbyte配列に変換するコード

次のコードは、BitmapをFormat32bppArgbにそろえたうえで、生ピクセルデータのbyte配列に変換する例です。

C#
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;

public sealed record RawBitmapBytes(
byte[] Bytes,
int Width,
int Height,
int Stride,
PixelFormat PixelFormat
);

public static RawBitmapBytes BitmapToRawBytes(Bitmap source)
{
if (source == null)
{
throw new ArgumentNullException(nameof(source));
}

using var bitmap = ConvertTo32bppArgb(source);

var rect = new Rectangle(0, 0, bitmap.Width, bitmap.Height);
var pixelFormat = PixelFormat.Format32bppArgb;

BitmapData? bitmapData = null;

try
{
bitmapData = bitmap.LockBits(
rect,
ImageLockMode.ReadOnly,
pixelFormat
);

int byteCount = Math.Abs(bitmapData.Stride) * bitmap.Height;
byte[] bytes = new byte[byteCount];

Marshal.Copy(bitmapData.Scan0, bytes, 0, byteCount);

return new RawBitmapBytes(
bytes,
bitmap.Width,
bitmap.Height,
bitmapData.Stride,
pixelFormat
);
}
finally
{
if (bitmapData != null)
{
bitmap.UnlockBits(bitmapData);
}
}
}

private static Bitmap ConvertTo32bppArgb(Bitmap source)
{
var converted = new Bitmap(
source.Width,
source.Height,
PixelFormat.Format32bppArgb
);

using var graphics = Graphics.FromImage(converted);
graphics.DrawImage(source, new Rectangle(0, 0, source.Width, source.Height));

return converted;
}

このコードでは、PixelFormatをFormat32bppArgbに統一しています。

ピクセル形式を固定しておくと、1ピクセルあたり4バイトとして扱えるため、後続の処理が分かりやすくなります。

Format32bppArgbの場合、基本的には1ピクセルがB、G、R、Aの4バイトで並びます。名前はARGBですが、メモリ上の並びは環境や形式の扱いで混乱しやすいため、実際にピクセル操作する場合は小さな画像で確認してから実装すると安全です。

4-4. byte配列からBitmapへ書き戻すコード

生ピクセルのbyte配列からBitmapを復元するには、幅、高さ、Stride、PixelFormatを使ってBitmapへ書き戻します。

C#
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;

public static Bitmap RawBytesToBitmap(
byte[] bytes,
int width,
int height,
int sourceStride,
PixelFormat pixelFormat
)
{
if (bytes == null)
{
throw new ArgumentNullException(nameof(bytes));
}

if (width <= 0)
{
throw new ArgumentOutOfRangeException(nameof(width));
}

if (height <= 0)
{
throw new ArgumentOutOfRangeException(nameof(height));
}

if (sourceStride == 0)
{
throw new ArgumentOutOfRangeException(nameof(sourceStride));
}

var bitmap = new Bitmap(width, height, pixelFormat);
var rect = new Rectangle(0, 0, width, height);

BitmapData? bitmapData = null;

try
{
bitmapData = bitmap.LockBits(
rect,
ImageLockMode.WriteOnly,
pixelFormat
);

int sourceRowBytes = Math.Abs(sourceStride);
int destinationRowBytes = Math.Abs(bitmapData.Stride);
int requiredBytes = sourceRowBytes * height;

if (bytes.Length < requiredBytes)
{
throw new ArgumentException("byte配列の長さが不足しています。", nameof(bytes));
}

if (sourceRowBytes == destinationRowBytes)
{
int byteCount = destinationRowBytes * height;
Marshal.Copy(bytes, 0, bitmapData.Scan0, byteCount);
}
else
{
int copyRowBytes = Math.Min(sourceRowBytes, destinationRowBytes);

for (int y = 0; y < height; y++)
{
IntPtr destinationPtr = IntPtr.Add(
bitmapData.Scan0,
y * bitmapData.Stride
);

Marshal.Copy(
bytes,
y * sourceRowBytes,
destinationPtr,
copyRowBytes
);
}
}

return bitmap;
}
catch
{
bitmap.Dispose();
throw;
}
finally
{
if (bitmapData != null)
{
bitmap.UnlockBits(bitmapData);
}
}
}

このコードでは、変換元のStrideと、作成したBitmap側のStrideが異なる可能性を考慮しています。

画像の横幅が4バイト境界に合わない場合、1行の末尾に余白が入るため、単純にwidth * bytesPerPixel * heightだけでコピーすると画像が崩れることがあります。

4-5. UnlockBitsを必ず呼び出すべき理由

LockBitsを呼び出したら、必ずUnlockBitsを呼び出す必要があります。

Bitmapのピクセル領域をロックしたままにすると、そのBitmapを他の処理で使えなくなったり、リソースが解放されなかったりする原因になります。

そのため、try-finallyを使って、例外が発生してもUnlockBitsが呼ばれるようにします。

C#
BitmapData? data = null;

try
{
data = bitmap.LockBits(rect, ImageLockMode.ReadWrite, bitmap.PixelFormat);

// ピクセル処理
}
finally
{
if (data != null)
{
bitmap.UnlockBits(data);
}
}

画像処理の途中で例外が起きることは珍しくありません。UnlockBitsを確実に呼ぶ設計にしておくことが重要です。

4-6. Strideを考慮しないと画像が崩れる理由

Strideを無視すると、変換後の画像が斜めに崩れたり、横幅がずれたり、色がおかしくなったりします。

たとえば24bpp RGBでは、1ピクセルあたり3バイトです。横幅が101ピクセルの場合、単純計算では1行あたり303バイトです。

しかし、BitmapのStrideは4バイト境界に丸められるため、実際の1行分のバイト数は304バイトになることがあります。

この余白1バイトを無視して次の行を読み始めると、2行目以降の読み取り位置がずれます。その結果、画像全体が斜めに崩れたように見えます。

LockBitsを使う場合は、必ずbitmapData.Strideを使ってコピーサイズや行の位置を計算します。

5. Marshal.Copyの役割と使い方

5-1. Marshal.Copyとは何か

Marshal.Copyは、アンマネージメモリとマネージド配列の間でデータをコピーするためのメソッドです。

BitmapのLockBitsで取得できるScan0IntPtrです。これはC#の通常のbyte[]ではなく、メモリ上のアドレスを表します。

そのため、Scan0の中身をC#の配列として扱うには、Marshal.Copybyte[]へコピーする必要があります。

反対に、byte[]の内容をBitmapのピクセル領域へ書き戻すときも、Marshal.Copyを使います。

5-2. BitmapData.Scan0からbyte配列へコピーする方法

Bitmapからbyte配列へコピーする基本形は次のとおりです。

C#
int byteCount = Math.Abs(bitmapData.Stride) * bitmap.Height;
byte[] bytes = new byte[byteCount];

Marshal.Copy(bitmapData.Scan0, bytes, 0, byteCount);

第1引数のbitmapData.Scan0はコピー元のポインターです。

第2引数のbytesはコピー先のbyte配列です。

第3引数の0は配列内のコピー開始位置です。

第4引数のbyteCountはコピーするバイト数です。

5-3. byte配列からBitmapData.Scan0へコピーする方法

byte配列からBitmapへ書き戻す場合は、引数の順番が逆になります。

C#
int byteCount = Math.Abs(bitmapData.Stride) * bitmap.Height;

Marshal.Copy(bytes, 0, bitmapData.Scan0, byteCount);

第1引数のbytesがコピー元です。

第3引数のbitmapData.Scan0がコピー先のポインターです。

コピー方向を間違えると、期待した結果にならないため注意してください。

5-4. コピーサイズの計算方法

コピーサイズは、基本的に次の式で計算します。

C#
int byteCount = Math.Abs(bitmapData.Stride) * bitmap.Height;

width * height * bytesPerPixelではなく、Stride * Heightで計算するのがポイントです。

なぜなら、Strideには行末の余白バイトが含まれるからです。

ただし、別のBitmapや外部データから取得したbyte配列を書き戻す場合は、変換元と変換先のStrideが一致するとは限りません。

その場合は、全体を一括コピーするのではなく、1行ずつコピーする方が安全です。

C#
for (int y = 0; y < height; y++)
{
IntPtr destinationPtr = IntPtr.Add(bitmapData.Scan0, y * bitmapData.Stride);

Marshal.Copy(
sourceBytes,
y * sourceRowBytes,
destinationPtr,
copyRowBytes
);
}

5-5. ArgumentExceptionやAccessViolationExceptionを防ぐ注意点

Marshal.Copyを使うときは、コピーサイズを間違えないことが重要です。

コピーサイズがbyte配列の長さを超えていると、ArgumentExceptionが発生する可能性があります。

また、コピー先のメモリ領域を超えて書き込むような処理をすると、AccessViolationExceptionの原因になります。

特に注意するべき点は次のとおりです。

bytes.Lengthが必要なサイズ以上あるか確認すること、PixelFormatを変換元と変換先でそろえること、Strideを考慮すること、UnlockBitsを必ず呼ぶこと、破棄済みのBitmapにアクセスしないことです。

6. MemoryStream・LockBits・Marshal.Copyの使い分け

6-1. 画像を保存・送信・DB格納するならMemoryStream

画像を保存、送信、DB格納するならMemoryStreamを使います。

C#
byte[] bytes = BitmapToBytes(bitmap, ImageFormat.Png);

このbyte配列はPNGファイルとして扱えます。

たとえば、次のようにファイルへ保存できます。

C#
File.WriteAllBytes("output.png", bytes);

また、Base64に変換してJSONで送ることもできます。

C#
string base64 = Convert.ToBase64String(bytes);

DBに保存する場合も、この形式のbyte配列をBLOB列やVARBINARY列に格納するのが一般的です。

6-2. 画像処理・高速なピクセル操作ならLockBits

画像の各ピクセルを直接操作したい場合は、LockBitsを使います。

たとえば、全ピクセルを走査して明るさを変える処理は、GetPixelSetPixelでも書けますが、大きな画像では非常に遅くなりがちです。

LockBitsでまとめてピクセルデータを取得し、byte配列上で処理してから書き戻す方が高速です。

6-3. メモリ間コピーにはMarshal.Copy

Marshal.Copyは、LockBitsと組み合わせて使うことが多いメソッドです。

LockBitsで取得したBitmapData.Scan0はポインターなので、そのままC#の配列として扱えません。

そこで、Marshal.Copyを使って次のようにコピーします。

C#
Marshal.Copy(bitmapData.Scan0, bytes, 0, byteCount);

また、編集したbyte配列をBitmapへ戻すときは次のようにします。

C#
Marshal.Copy(bytes, 0, bitmapData.Scan0, byteCount);

つまり、LockBitsはピクセル領域をロックするためのもの、Marshal.Copyはメモリ間コピーを行うためのものです。

6-4. GetPixel・SetPixelよりLockBitsが向いているケース

GetPixelSetPixelは分かりやすいAPIですが、大量のピクセルを処理する用途には向いていません。

たとえば、1920×1080の画像には約207万ピクセルがあります。

各ピクセルに対してGetPixelSetPixelを呼び出すと、メソッド呼び出しのオーバーヘッドが大きくなります。

一方、LockBitsを使えば、画像データをまとめてbyte配列にコピーして処理できます。

画像全体を走査する処理、フィルター処理、二値化、グレースケール変換、差分検出、リアルタイム画像処理などでは、LockBitsの方が適しています。

6-5. 用途別のおすすめ変換方法

用途別にまとめると、次のようになります。

画像をファイルとして保存したい場合はMemoryStreamを使います。

画像をAPIで送信したい場合もMemoryStreamを使います。

画像をDBに保存したい場合もMemoryStreamを使います。

画像をBase64文字列にしたい場合もMemoryStreamで画像形式のbyte配列にしてから変換します。

画像のピクセルを直接編集したい場合はLockBitsMarshal.Copyを使います。

画像処理を高速化したい場合もLockBitsMarshal.Copyを使います。

外部ライブラリや独自形式から受け取った生ピクセルデータをBitmapにしたい場合は、幅、高さ、PixelFormat、Strideを指定してLockBitsで書き戻します。

7. 実践コード:Bitmapとbyte配列を相互変換するサンプル

7-1. Bitmapから画像ファイル形式のbyte配列へ変換するメソッド

まずは、BitmapをPNGやJPEGなどの画像ファイル形式のbyte配列に変換するメソッドです。

C#
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;

public static byte[] BitmapToImageBytes(Bitmap bitmap, ImageFormat imageFormat)
{
if (bitmap == null)
{
throw new ArgumentNullException(nameof(bitmap));
}

if (imageFormat == null)
{
throw new ArgumentNullException(nameof(imageFormat));
}

using var memoryStream = new MemoryStream();

bitmap.Save(memoryStream, imageFormat);

return memoryStream.ToArray();
}

使用例は次のとおりです。

C#
using var bitmap = new Bitmap("input.png");

byte[] pngBytes = BitmapToImageBytes(bitmap, ImageFormat.Png);
byte[] jpegBytes = BitmapToImageBytes(bitmap, ImageFormat.Jpeg);

7-2. 画像ファイル形式のbyte配列からBitmapへ変換するメソッド

次は、画像ファイル形式のbyte配列からBitmapを復元するメソッドです。

C#
using System;
using System.Drawing;
using System.IO;

public static Bitmap ImageBytesToBitmap(byte[] imageBytes)
{
if (imageBytes == null)
{
throw new ArgumentNullException(nameof(imageBytes));
}

if (imageBytes.Length == 0)
{
throw new ArgumentException("byte配列が空です。", nameof(imageBytes));
}

using var memoryStream = new MemoryStream(imageBytes);
using var temporaryBitmap = new Bitmap(memoryStream);

return new Bitmap(temporaryBitmap);
}

使用例は次のとおりです。

C#
byte[] imageBytes = File.ReadAllBytes("input.png");

using Bitmap bitmap = ImageBytesToBitmap(imageBytes);

bitmap.Save("output.png", ImageFormat.Png);

ポイントは、temporaryBitmapをそのまま返さず、new Bitmap(temporaryBitmap)で複製して返していることです。

これにより、MemoryStreamの破棄後も返却したBitmapを使いやすくなります。

7-3. Bitmapから生ピクセルのbyte配列へ変換するメソッド

次は、Bitmapから生ピクセルデータのbyte配列を取得するメソッドです。

C#
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;

public sealed record RawBitmap(
byte[] Bytes,
int Width,
int Height,
int Stride,
PixelFormat PixelFormat
);

public static RawBitmap BitmapToRawBitmap(Bitmap source)
{
if (source == null)
{
throw new ArgumentNullException(nameof(source));
}

using var bitmap = new Bitmap(
source.Width,
source.Height,
PixelFormat.Format32bppArgb
);

using (var graphics = Graphics.FromImage(bitmap))
{
graphics.DrawImage(source, new Rectangle(0, 0, source.Width, source.Height));
}

var rect = new Rectangle(0, 0, bitmap.Width, bitmap.Height);

BitmapData? data = null;

try
{
data = bitmap.LockBits(
rect,
ImageLockMode.ReadOnly,
PixelFormat.Format32bppArgb
);

int byteCount = Math.Abs(data.Stride) * data.Height;
byte[] bytes = new byte[byteCount];

Marshal.Copy(data.Scan0, bytes, 0, byteCount);

return new RawBitmap(
bytes,
bitmap.Width,
bitmap.Height,
data.Stride,
PixelFormat.Format32bppArgb
);
}
finally
{
if (data != null)
{
bitmap.UnlockBits(data);
}
}
}

このメソッドは、元画像のPixelFormatが何であっても、Format32bppArgbに変換してからbyte配列化します。

ピクセル形式を固定することで、後続処理のバグを減らせます。

7-4. 生ピクセルのbyte配列からBitmapへ変換するメソッド

次は、生ピクセルデータのbyte配列からBitmapを復元するメソッドです。

C#
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;

public static Bitmap RawBitmapToBitmap(RawBitmap raw)
{
if (raw == null)
{
throw new ArgumentNullException(nameof(raw));
}

return RawBytesToBitmap(
raw.Bytes,
raw.Width,
raw.Height,
raw.Stride,
raw.PixelFormat
);
}

public static Bitmap RawBytesToBitmap(
byte[] bytes,
int width,
int height,
int sourceStride,
PixelFormat pixelFormat
)
{
if (bytes == null)
{
throw new ArgumentNullException(nameof(bytes));
}

var bitmap = new Bitmap(width, height, pixelFormat);
var rect = new Rectangle(0, 0, width, height);

BitmapData? data = null;

try
{
data = bitmap.LockBits(
rect,
ImageLockMode.WriteOnly,
pixelFormat
);

int sourceRowBytes = Math.Abs(sourceStride);
int destinationRowBytes = Math.Abs(data.Stride);
int requiredBytes = sourceRowBytes * height;

if (bytes.Length < requiredBytes)
{
throw new ArgumentException("byte配列の長さが不足しています。", nameof(bytes));
}

if (sourceRowBytes == destinationRowBytes)
{
Marshal.Copy(bytes, 0, data.Scan0, destinationRowBytes * height);
}
else
{
int copyRowBytes = Math.Min(sourceRowBytes, destinationRowBytes);

for (int y = 0; y < height; y++)
{
IntPtr destination = IntPtr.Add(data.Scan0, y * data.Stride);

Marshal.Copy(
bytes,
y * sourceRowBytes,
destination,
copyRowBytes
);
}
}

return bitmap;
}
catch
{
bitmap.Dispose();
throw;
}
finally
{
if (data != null)
{
bitmap.UnlockBits(data);
}
}
}

このメソッドでは、変換元のStrideと復元先BitmapのStrideが異なる場合でも、1行ずつコピーすることで画像崩れを防いでいます。

7-5. usingとDisposeを含めた安全な実装例

BitmapやMemoryStreamは、使い終わったら破棄する必要があります。

特にBitmapはGDI+のリソースを持つため、不要になったらDisposeする習慣をつけることが重要です。

C#
using System.Drawing;
using System.Drawing.Imaging;

byte[] pngBytes;

using (var bitmap = new Bitmap("input.png"))
{
pngBytes = BitmapToImageBytes(bitmap, ImageFormat.Png);
}

using (var restoredBitmap = ImageBytesToBitmap(pngBytes))
{
restoredBitmap.Save("restored.png", ImageFormat.Png);
}

usingを使うことで、処理が終わったタイミングで自動的にDisposeされます。

大量の画像を処理する場合は、Dispose漏れがメモリ使用量の増加につながるため、特に注意してください。

8. 変換時によくある失敗と解決方法

8-1. byte配列からBitmapを作成すると「Parameter is not valid」が出る

new Bitmap(memoryStream)Parameter is not validが出る場合、まず疑うべきなのはbyte配列の中身です。

そのbyte配列は、本当にPNGやJPEGなどの画像ファイル形式でしょうか。

生ピクセルデータをMemoryStreamに入れても、Bitmapとして読み込むことはできません。

正しい画像ファイル形式であれば、次のようにファイルへ保存して確認できます。

C#
File.WriteAllBytes("debug.png", bytes);

保存したファイルが画像ビューアーで開けない場合、byte配列が壊れているか、そもそも画像ファイル形式ではない可能性があります。

Base64から復元している場合は、Base64文字列に余計なヘッダーが含まれていないかも確認します。

たとえば、次のような文字列です。

data:image/png;base64,....

この場合、,より前の部分を取り除いてからConvert.FromBase64Stringに渡す必要があります。

8-2. 変換後の画像が真っ黒・色がおかしい

変換後の画像が真っ黒になったり、色がおかしくなったりする場合は、PixelFormatの不一致を疑います。

たとえば、Format24bppRgbとして取得したbyte配列を、Format32bppArgbのBitmapへそのままコピーすると、1ピクセルあたりのバイト数が異なるため、色が崩れます。

また、RGBのつもりで処理していても、実際のメモリ上の並びはBGR系になっていることがあります。

対策としては、処理前にPixelFormatを固定することです。

C#
PixelFormat pixelFormat = PixelFormat.Format32bppArgb;

画像処理では、最初にFormat32bppArgbへ変換してから処理すると、実装が安定しやすくなります。

8-3. 画像の横幅がずれる・斜めに崩れる

画像が斜めに崩れる場合は、Strideを無視している可能性が高いです。

次のような計算だけでコピーしていると危険です。

C#
int byteCount = width * height * 4;

Format32bppArgbでは多くの場合この計算で合うこともありますが、常に安全とは限りません。

LockBitsを使う場合は、次のように計算します。

C#
int byteCount = Math.Abs(bitmapData.Stride) * bitmapData.Height;

また、異なるBitmap間でコピーする場合は、Strideが一致するとは限らないため、1行ずつコピーする実装を検討してください。

8-4. PixelFormatが一致しない

PixelFormatが一致しないと、byte配列の解釈がずれます。

たとえば、次のような違いがあります。

Format24bppRgbは1ピクセルあたり3バイトです。

Format32bppArgbは1ピクセルあたり4バイトです。

Format8bppIndexedは1ピクセルあたり1バイトですが、パレット情報が必要です。

特にIndexed系のPixelFormatは扱いが複雑です。単純なRGB画像として処理したい場合は、Format24bppRgbFormat32bppArgbに変換してから処理する方が安全です。

C#
using var converted = new Bitmap(
source.Width,
source.Height,
PixelFormat.Format32bppArgb
);

PixelFormatを固定することで、1ピクセルあたりのバイト数を明確にできます。

8-5. MemoryStreamを閉じた後にBitmapが使えない

MemoryStreamからBitmapを作成したあと、ストリームを閉じるとBitmap操作時にエラーが出ることがあります。

これは、ストリームからBitmapを作成する場合、そのBitmapの使用中はストリームを開いたままにしておく必要があるためです。Microsoft Learn

メソッドからBitmapを返す場合は、次のように複製してから返すのが安全です。

C#
using var stream = new MemoryStream(bytes);
using var temp = new Bitmap(stream);

return new Bitmap(temp);

この方法なら、streamtempを破棄した後でも、返却したBitmapを使いやすくなります。

8-6. 大きな画像でメモリ使用量が増える

大きな画像を扱うと、メモリ使用量が急増します。

たとえば、Format32bppArgbの画像では1ピクセルあたり4バイトを使います。

4000×3000ピクセルの画像なら、単純なピクセルデータだけで約48MBです。

さらに、元のBitmap、変換後のBitmap、byte配列、MemoryStreamなどを同時に持つと、メモリ使用量はさらに増えます。

対策としては、不要になったBitmapをすぐDisposeする、同時に処理する画像数を減らす、必要以上にBitmapを複製しない、処理単位を分割する、といった方法があります。

9. パフォーマンスを重視したBitmap変換のポイント

9-1. 大量画像を処理する場合の注意点

大量の画像を処理する場合は、変換のたびにBitmapやbyte配列が作られるため、メモリ負荷が大きくなります。

特に、次のような処理には注意が必要です。

画像ファイルをBitmapへ読み込む、Bitmapを別PixelFormatへ変換する、Bitmapをbyte配列へコピーする、byte配列を加工する、加工後にBitmapへ書き戻す、さらに画像形式として保存する、という流れでは、複数の大きなメモリ領域が一時的に必要になります。

不要になったオブジェクトは早めに破棄し、ループ内ではusingを徹底しましょう。

C#
foreach (string filePath in imageFiles)
{
using var bitmap = new Bitmap(filePath);

byte[] bytes = BitmapToImageBytes(bitmap, ImageFormat.Png);

// 保存・送信・DB格納など
}

大量画像を扱う場合は、全画像を一度にメモリへ読み込まず、1枚ずつ処理する設計が基本です。

9-2. LockBitsで高速化できる処理

LockBitsで高速化しやすいのは、画像全体のピクセルを走査する処理です。

たとえば、グレースケール変換、二値化、色の置換、明るさ補正、コントラスト調整、アルファ値の変更、特定色の検出、差分検出などです。

GetPixelSetPixelでは、ピクセルごとにメソッド呼び出しが発生します。

一方、LockBitsでは、ピクセルデータをまとめてbyte配列にコピーし、配列上で連続的に処理できます。

たとえば、Format32bppArgbで簡単なグレースケール変換をする場合は、次のようなイメージになります。

C#
for (int i = 0; i < bytes.Length; i += 4)
{
byte b = bytes[i];
byte g = bytes[i + 1];
byte r = bytes[i + 2];

byte gray = (byte)((r + g + b) / 3);

bytes[i] = gray;
bytes[i + 1] = gray;
bytes[i + 2] = gray;
}

実際にはStrideの余白部分を考慮して、行ごとに処理する方が安全です。

9-3. Marshal.Copyによるコピー回数を減らす考え方

Marshal.Copy自体は便利ですが、コピー回数が増えるほど処理時間とメモリ使用量が増えます。

たとえば、次のような流れではコピーが多くなります。

Bitmapからbyte配列へコピーし、別のbyte配列へ加工結果を作り、さらにBitmapへコピーし、最後にMemoryStreamへ保存する、という流れです。

パフォーマンスを重視する場合は、できるだけ同じbyte配列を再利用する、不要な中間配列を作らない、PixelFormat変換を最小限にする、といった工夫が有効です。

画像を単に保存・送信するだけなら、LockBitsでピクセルデータを取得する必要はありません。MemoryStreamだけで十分です。

逆に、画像処理が目的なら、画像形式への保存は最後の1回だけにする方が効率的です。

9-4. unsafeコードを使うべきかどうか

C#では、unsafeコードを使ってScan0をポインターとして直接操作する方法もあります。

unsafeを使うと、Marshal.Copyでbyte配列へコピーせずにピクセルデータへアクセスできるため、処理によっては高速化できます。

ただし、ポインター操作はバグが起きたときの影響が大きく、範囲外アクセスやメモリ破壊につながる可能性があります。

多くの用途では、まずLockBitsMarshal.Copyで実装し、パフォーマンスが不足する場合に限ってunsafeを検討するのがよいでしょう。

特に業務アプリや保守性を重視するコードでは、可読性と安全性を優先した方が扱いやすくなります。

9-5. メモリリークを防ぐDispose設計

Bitmapを扱うコードでは、Dispose設計が非常に重要です。

BitmapGraphicsMemoryStreamなどは、使い終わったら破棄します。

基本はusingを使うことです。

C#
using var bitmap = new Bitmap("input.png");
using var converted = new Bitmap(bitmap.Width, bitmap.Height);
using var graphics = Graphics.FromImage(converted);

メソッド内でBitmapを作成して呼び出し元へ返す場合は、そのBitmapの所有権が呼び出し元に移ります。

C#
public static Bitmap CreateBitmap(byte[] bytes)
{
using var stream = new MemoryStream(bytes);
using var temp = new Bitmap(stream);

return new Bitmap(temp);
}

この場合、返されたBitmapは呼び出し元がDisposeします。

C#
using var bitmap = CreateBitmap(bytes);

所有権を明確にしておくと、Dispose漏れや二重Disposeを防ぎやすくなります。

まとめ

C#でBitmapとbyte配列を相互変換する場合は、まずbyte配列の種類を見極めることが重要です。

PNG、JPEG、BMPなどの画像ファイル形式としてbyte配列にしたい場合は、MemoryStreamを使います。

C#
using var stream = new MemoryStream();
bitmap.Save(stream, ImageFormat.Png);
byte[] bytes = stream.ToArray();

この方法は、画像の保存、送信、DB格納、Base64変換に向いています。

一方、画像処理やピクセル操作を行いたい場合は、LockBitsMarshal.Copyを使います。

C#
BitmapData data = bitmap.LockBits(rect, ImageLockMode.ReadOnly, pixelFormat);
Marshal.Copy(data.Scan0, bytes, 0, byteCount);
bitmap.UnlockBits(data);

この方法は、RGB値の取得、画素値の変更、フィルター処理、高速な画像解析に向いています。

また、Marshal.Copyを使うときは、Strideを考慮してコピーサイズを計算する必要があります。

C#
int byteCount = Math.Abs(bitmapData.Stride) * bitmap.Height;

width * height * bytesPerPixelだけで計算すると、画像が崩れる原因になることがあります。

さらに、MemoryStreamからBitmapを作成する場合は、ストリームの寿命にも注意が必要です。メソッドからBitmapを返す場合は、new Bitmap(tempBitmap)で複製して返すと安全です。

C#のBitmapとbyte配列の変換では、目的に応じて次のように選ぶのが基本です。

画像ファイルとして扱うならMemoryStream

生ピクセルを扱うならLockBits

ポインターとbyte配列のコピーにはMarshal.Copy

この使い分けを理解しておけば、c# bitmap byteに関する多くの変換処理を安全に実装できます。