大阪市中央区 システムソフトウェア開発会社

営業時間:平日09:15〜18:15
MENU

C#で画像変換(二値化)

著者:北本 敦
公開日:2023/09/30
最終更新日:2023/10/01
カテゴリー:技術情報
タグ:

北本です。

以前、自分で使用するためにちょっとした画像変換ツールを作ったことがあります。
そのツールの目的は、スキャンされた楽譜などを印刷する際にインクを節約したり視認性を良くしたりすることでした。白黒に二値化された画像なら問題ないのですが、そうでない場合、インクが乗っていない部分の紙面が真っ白というわけではなく、薄いグレーだったり、年季の入ったものなら黄ばんだ色だったりしているわけで、そのまま印刷すると少々無駄にプリンターのインクを消費してしまいます。そういった部分をRGB(255,255,255)の完全な白色にしてしまうことで、インクを節約するとともに視認性も良くしたかったわけです。
かつてはペイントソフトで1ページずつ地道に加工してそれをやっていたのですが、プログラミングによってこのような作業の手間を大きく取り除くことができました。

今回は、そのツールの実装で行った手法を簡略化して紹介します。

Windowsフォームアプリケーションで以下のようなコントロールを配置した画面を作成します。

仕様は、buttonExecuteを押下すると、textBoxSourceFileで指定された画像ファイルについて、輝度がnumericUpDownBlackMaxBrightnessの値より大きいピクセルをRGB(255,255,255)の白色に、それ以外の箇所をRGB(0,0,0)の黒色に変換した画像をtextBoxOutputDirectoryで指定したディレクトリに同名のJPGファイルとして保存するものとします。

buttonExecuteを押下時の処理のひとつ目の実装例です。

private void buttonExecute_Click(object sender, EventArgs e)
{
    Stopwatch sw = new Stopwatch();

    sw.Start();

    string sourceFilePath = textBoxSourceFile.Text;
    string outputDirectory = textBoxOutputDirectory.Text;
    int maxBlackBrightness = (int)numericUpDownBlackMaxBrightness.Value;

    if (!File.Exists(sourceFilePath))
    {
        MessageBox.Show("変換元ファイルが存在しません。");
        return;
    }

    if (!Directory.Exists(outputDirectory))
    {
        MessageBox.Show("出力先フォルダが存在しません。");
        return;
    }

    Bitmap bmp = new Bitmap(sourceFilePath);

    // 二値化処理 start
    for (int y = 0; y < bmp.Height; y++)
    {
        for (int x = 0; x < bmp.Width; x++)
        {
            Color color = bmp.GetPixel(x, y);

            // 輝度を求め、黒の輝度上限より大きい場合は白(RGB(255,255,255))、そうでない場合は黒(RGB(255,255,255))にする
            int brightness = (int)(color.R * 0.29891 + color.G * 0.58661 + color.B * 0.11448);
            
            if (brightness > maxBlackBrightness)
            {
                brightness = 255;
            }
            else
            {
                brightness = 0;
            }
            bmp.SetPixel(x, y, Color.FromArgb(0, brightness, brightness, brightness));
        }
    }
    // 二値化処理 end

    string outputPath = Path.Combine(outputDirectory, Path.GetFileNameWithoutExtension(sourceFilePath) + ".jpg");
    bmp.Save(outputPath, ImageFormat.Jpeg);
    bmp.Dispose();

    sw.Stop();
    MessageBox.Show($"画像の変換が完了しました。(処理時間:{sw.Elapsed})");
}

Bitmap.GetPixelでピクセルの色を取得し、そこから「R × 0.29891 + G × 0.58661+ B × 0.11448」という計算式で輝度を求めています。輝度についてはいろいろな求め方がありますが、ここではNTSC加重平均法(検索しても日本語のサイトばかり出てくるので日本独自の呼称かも)と呼ばれている方法を使用しています。人間の目は緑を明るく青を暗く感じるので、Gに高い係数、Bに低い係数を掛けることで、単純に平均を求めた場合よりも自然な見え方になるというものです。求めた輝度が「黒の輝度上限」よりも大きい場合は白(RGB(255,255,255))、そうでない場合は黒(RGB(255,255,255))を、Bitmap.SetPixelによってセットしています。

以下の画像を、黒の輝度上限を125に設定して変換を実行してみます。
変換前

変換後

このように、仕様通りの画像変換ができるのですが、GetPixelやSetPixelが遅い処理であるためパフォーマンスに問題があります。


私の環境では変換に2.163秒程度かかりました。この程度の画像処理であれば大した問題にはなりませんが、複雑な変換を行ったり、大量の画像を処理したりするとなると大きく響いてきます。

では、改善した実装例を紹介します。先程のコードの「// 二値化処理 start」~「// 二値化処理 end」間を以下のコードに置き換えます。

// 1ピクセル当たりのバイト数を取得
PixelFormat pixelFormat = bmp.PixelFormat;
int pixelSize = Image.GetPixelFormatSize(pixelFormat) / 8;
if (pixelSize != 3 && pixelSize != 4)
{
    MessageBox.Show("1ピクセルが3バイトか4バイト以外の画像形式には対応していません。");
    return;
}

BitmapData bmpData = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadWrite, pixelFormat);

// Strideはピクセル1行あたりのバイト数。
// トップダウン形式(最も上の行から順にデータが格納されている)の場合は正の値、
// ボトムアップ形式(最も下の行から順にデータが格納されている)の場合は負の値になる。
if (bmpData.Stride < 0)
{
    MessageBox.Show("ボトムアップ形式の画像には対応していません。");
    bmp.UnlockBits(bmpData);
    return;
}

try
{
    // ピクセルデータをバイト列として取得
    IntPtr ptr = bmpData.Scan0;
    byte[] pixels = new byte[bmpData.Stride * bmp.Height];
    System.Runtime.InteropServices.Marshal.Copy(ptr, pixels, 0, pixels.Length);

    for (int y = 0; y < bmpData.Height; y++)
    {
        for (int x = 0; x < bmpData.Width; x++)
        {
            // ピクセル(x, y)のデータのバイト列での位置を求める
            int pos = bmpData.Stride * y + pixelSize * x;

            byte r = pixels[pos + 2]; // Rの値を取得
            byte g = pixels[pos + 1]; // Gの値を取得
            byte b = pixels[pos];     // Bの値を取得

            // 輝度を求め、黒の輝度上限より大きい場合は白(RGB(255,255,255))、そうでない場合は黒(RGB(255,255,255))にする
            int brightness = (int)(r * 0.29891 + g * 0.58661 + b * 0.11448);

            if (brightness > maxBlackBrightness)
            {
                brightness = 255;
            }
            else
            {
                brightness = 0;
            }

            pixels[pos + 2] = (byte)brightness; // Rの値をセット
            pixels[pos + 1] = (byte)brightness; // Gの値をセット
            pixels[pos]     = (byte)brightness; // Bの値をセット
        }
    }
    // 変更を反映
    System.Runtime.InteropServices.Marshal.Copy(pixels, 0, ptr, pixels.Length);
}
catch (Exception ex)
{
    MessageBox.Show("画像の変換でエラーが発生しました。" + Environment.NewLine + ex.Message);
    return;
}
finally
{
    bmp.UnlockBits(bmpData);
}

Bitmap.LockBitsを使用して、画像データをバイト列として展開して、読み書きを行っています。各ピクセルへは、1ピクセル当たりのバイト数や1行当たりのバイト数をからバイト列での位置を求めてアクセスしています。


変換結果は全く同じものになりますが、こちらの実装方法だと私の環境では変換が0.036秒程度で済みました。GetPixelやSetPixelを用いた実装例に比べると文字通り桁違いの速さです。

ちなみに、実際に私が使用しているツールでは、ドラッグ&ドロップで変換元画像(複数でも可)を指定するようにしていたり、今回、ソースを紹介したものよりも、簡単に操作ができるようにしています。
また、「if (brightness > maxBlackBrightness)」のelseで0をセットするのを無くして、完全に二値化するのではなく、上限以下の黒は元の輝度を残すようにしています(下掲画像はそのように変換した場合のもの)。その方が、若干アンチエイリアスがかかったような見た目になり視認性が良いかもしれません。

参考リンク
DOBON.NET 画像のカラーバランスを補正して表示する
https://dobon.net/vb/dotnet/graphics/colorbalance.html

    上に戻る