午後わてんのブログ

ベランダ菜園とWindows用アプリ作成(WPFとC#)

局所的平均値をしきい値として2値化、局所範囲ピクセルの合計は差分計算で高速化

2値化するときに使うしきい値の計算を、注目ピクセルの周囲のピクセルの値を使ってする
f:id:gogowaten:20200421150206p:plain
範囲は1指定なら3x3ピクセル、2なら5x5、3なら7x7とするようにして
計算は平均値にしてみた

範囲1で注目ピクセルの座標が(1,1)のとき
f:id:gogowaten:20200421150724p:plain
3x3ピクセルの平均値は162.2 = (140+210+210+50+130+200+90+190+240)/9=162.22222
注目ピクセルの130は、平均値162.2未満なので0にする

右に移動して、注目ピクセル(2,1)
f:id:gogowaten:20200421151431p:plain
平均値は136.6 = (50+130+200+90+190+240+60+30+240)/9=136.66667
注目ピクセルの190は平均値136.6以上なので255にする

範囲を2(周囲5x5)に固定してC#で書いてみると

/// <summary>
/// 2値化、注目ピクセルの周囲5x5ピクセルの平均をしきい値にして2値化
/// </summary>
/// <param name="source">PixelFormats.Gray8専用</param>
/// <returns></returns>
private BitmapSource LocalArea5x5Threshold(BitmapSource source)
{
    //Bitmapから配列作成
    int w = source.PixelWidth;
    int h = source.PixelHeight;
    int stride = w;
    byte[] pixels = new byte[h * stride];
    source.CopyPixels(pixels, stride, 0);
    //2値化用配列
    byte[] result = new byte[pixels.Length];

    for (int y = 0; y < h; y++)
    {
        for (int x = 0; x < w; x++)
        {
            //注目ピクセルの上下左右2ピクセル(5x5)範囲の値合計
            long total = 0;
            int count = 0;
            for (int i = -2; i <= 2; i++)
            {
                for (int j = -2; j <= 2; j++)
                {
                    int yy = y + i;
                    int xx = x + j;
                    //画像の外になる座標は無視する
                    if (yy >= 0 && yy < h && xx >= 0 && xx < w)
                    {
                        total += pixels[yy * stride + xx];
                        count++;
                    }
                }
            }
            //平均値をしきい値にして2値化
            double threshold = total / (double)count;
            int p = y * stride + x;
            if (pixels[p] < threshold)
                result[p] = 0;
            else
                result[p] = 255;
        }
    }
    return BitmapSource.Create(w, h, 96, 96, PixelFormats.Gray8, null, result, stride);
}

//画像の外になる座標は無視する
参照範囲2で注目ピクセルの座標が(1, 0)のときの参照範囲
f:id:gogowaten:20200421152030p:plain
マイナス座標(画像の外)になるところが出るので、平均値を求めるときの加算対象にしないようにしたけど、画像の外か内なのかの判定が大変

画像処理してみる
f:id:gogowaten:20200421154714p:plain
右下の消費期限の写りが良くない画像を処理すると

f:id:gogowaten:20200421154926p:plain
2020/10/27って見えた、でもみやすさで言ったらどちらも、あんまり変わんないかな

範囲を1にして処理
f:id:gogowaten:20200421155506p:plain
こちらのほうが見やすくなった

範囲5
f:id:gogowaten:20200421155629p:plain
結構変わる

画像全体で決めるしきい値の場合
大津の2値化だと
f:id:gogowaten:20200421185028p:plain

Kittlerの2値化だと
f:id:gogowaten:20200421185111p:plain

手動でしきい値20
f:id:gogowaten:20200421185220p:plain
西暦と月が見えるところだと、日にち部分が真っ黒

しきい値77
f:id:gogowaten:20200421185332p:plain
日にちが見えるところだと他が真っ黒
これだと画像全体で決めるしきい値よりも、今回のような注目ピクセルの周りのピクセル(局所範囲)から計算する、しきい値のほうがいいように見えるけど、使いみちが違うんだと思う
今回のだと減色パレットの作成には使えなさそうなんだよねえ、それでも結果は面白い

さっきは範囲が固定だったのを範囲指定をできるようにしたのがこれ

/// <summary>
/// 2値化、注目ピクセルの指定範囲の平均をしきい値にして2値化
/// </summary>
/// <param name="pixels">PixelFormats.Gray8専用</param>
/// <param name="width"></param>
/// <param name="height"></param>
/// <param name="stride"></param>
/// <param name="near">範囲を1以上で指定、1なら上下左右1ピクセルで3x3マスの範囲になる、2なら5x5</param>
/// <returns></returns>
private BitmapSource LocalThreshold(byte[] pixels, int width, int height, int stride, int near)
{
    //局所範囲のピクセル数
    int count;
    //2値に置き換えた用
    byte[] result = new byte[pixels.Length];
    //2値化
    for (int y = 0; y < height; y++)
    {
        for (int x = 0; x < width; x++)
        {
            long total = 0;
            count = 0;
            for (int i = -near; i <= near; i++)
            {
                int yy = y + i;
                if (yy >= 0 && yy < height)//y座標有効判定
                {
                    for (int j = -near; j <= near; j++)
                    {
                        int xx = x + j;
                        if (xx >= 0 && xx < width)//x座標有効判定
                        {
                            total += pixels[(y + i) * stride + x + j];
                            count++;
                        }
                    }
                }
            }

            //局所範囲の平均値をしきい値にして2値化
            double threshold = total / (double)count;
            int p = (y * stride) + x;//注目ピクセルのインデックス
            if (pixels[p] < threshold)
                result[p] = 0;
            else
                result[p] = 255;
        }
    }
    return MakeBitmapSource(result, width, height, stride);
}

private BitmapSource MakeBitmapSource(byte[] pixels, int width, int height, int stride)
{
    return BitmapSource.Create(width, height, 96, 96, PixelFormats.Gray8, null, pixels, stride);
}

範囲が広くなると計算量がものすごく増える、1指定なら3x3で9ピクセルだったのが、5だと(5*2+1)2=121ピクセル、10だと(10*2+1)2=441!!!!

処理の高速化
範囲2のときでの処理の流れ
注目ピクセルの座標が(2,2)のときの参照範囲
f:id:gogowaten:20200421160823p:plain
こうなっていて、次のピクセルの(3,2)の参照範囲は
f:id:gogowaten:20200421161040p:plain
こう
右に1個ずれるだけだから重複しているところが多い
なので
注目ピクセルの座標が(2,2)の直前で
f:id:gogowaten:20200421162551p:plain
5x5の右端1列を除いた部分を足し算したのをAオレンジとして
f:id:gogowaten:20200421162805p:plain
5x5の左右の1列をそれぞれ足し算、それぞれB灰色C水色として
f:id:gogowaten:20200421162926p:plain
しきい値計算のときにA+CD赤として、これは5x5の合計値になるので25で割って平均値、これで2値化して
f:id:gogowaten:20200421163525p:plain
D-BしたのをAに入れて次の注目ピクセルの計算に持ち越す、これを繰り返していく
次の注目ピクセルの座標は(3,2)
f:id:gogowaten:20200421163847p:plain
持ち越したAオレンジがあるので、新たに足し算するピクセル右端1列の5個だけでよくなる

f:id:gogowaten:20200421170409p:plain

処理時間の比較
製作と計測環境

  • CPU AMD Ryzen 5 2400G(4コア8スレッド)
  • MEM DDR4-2666
  • Window 10 Home 64bit
  • Visual Studio 2019 Community .NET Core 3.1 WPF C#

f:id:gogowaten:20200421171634p:plain
1632x1224ピクセルの画像
範囲2(5x5)を指定
f:id:gogowaten:20200421174055p:plain
約3倍の差、0.122/0.039=3.1282051

範囲5(11x11ピクセル)を指定
f:id:gogowaten:20200421174205p:plain
約10倍の差、0.555/0.055=10.090909

範囲10(21x21=441ピクセル!)を指定
f:id:gogowaten:20200421174317p:plain
約16倍の差、1.862/0.113=16.477876
大差だけど思ったほどではないかなあ、それに差分計算では画像の周縁部は無視しているので、範囲に指定した数値のピクセル幅分が真っ黒になる
真っ黒にならないようにして、一部をマルチスレッドにしたのが

/// <summary>
/// 指定座標の周縁部の平均値を返す
/// </summary>
/// <param name="pixels"></param>
/// <param name="x">指定座標x</param>
/// <param name="y">指定座標y</param>
/// <param name="near">周縁部の広さ、1以上を指定、1なら上下左右1マスづつで3x3の範囲、2指定は5x5</param>
/// <param name="width">画像の横ピクセル数</param>
/// <param name="height">画像の縦ピクセル数</param>
/// <param name="stride"></param>
/// <returns></returns>
private double AroundAverage(byte[] pixels, int x, int y, int near, int width, int height, int stride)
{
    long total = 0;
    int effectiveNumber = 0;
    for (int i = -near; i <= near; i++)
    {
        int yy = y + i;
        if (yy >= 0 && yy < height)//y座標有効判定
        {
            for (int j = -near; j <= near; j++)
            {
                int xx = x + j;
                if (xx >= 0 && xx < width)//x座標有効判定
                {
                    total += pixels[(y + i) * stride + x + j];
                    effectiveNumber++;
                }
            }
        }
    }
    return total / (double)effectiveNumber;
}

//指定範囲を2値化する
private void SetBinaryArea(int xBegin, int xEnd, int yBegin, int yEnd, byte[] pixels, int near, int width, int height, int stride, byte[] result)
{
    Parallel.For(yBegin, yEnd, y =>
    {
        for (int x = xBegin; x < xEnd; x++)
        {
            //局所範囲の平均値をしきい値にする
            double threshold = AroundAverage(pixels, x, y, near, width, height, stride);
            //しきい値で2値化                   
            int p = (y * stride) + x;//注目ピクセルのインデックス
            if (pixels[p] < threshold)
                result[p] = 0;
            else
                result[p] = 255;
        }
    });
}



private BitmapSource LocalThreshold差分計算Multi改(byte[] pixels, int width, int height, int stride, int near)
{
    //局所範囲のピクセル数
    int localAreaLength = (near * 2 + 1) * (near * 2 + 1);
    //2値に置き換えた用
    byte[] result = new byte[pixels.Length];

    //中央部の2値化
    Parallel.For(near, height - near, y =>
    {
        long motikosiTotal = 0;
        for (int my = -near; my <= near; my++)
        {
            for (int mx = 0; mx < near * 2; mx++)
            {
                motikosiTotal += pixels[(y + my) * stride + mx];
            }
        }

        for (int x = near; x < width - near; x++)
        {
            long totalNew = 0;
            long totalOld = 0;
            for (int yy = -near; yy <= near; yy++)
            {
                totalNew += pixels[(y + yy) * stride + x + near];
                totalOld += pixels[(y + yy) * stride + x - near];
            }
            long totalAll = motikosiTotal + totalNew;

            //局所範囲の平均値をしきい値にする
            double threshold = totalAll / (double)localAreaLength;

            //しきい値で2値化                   
            int p = (y * stride) + x;//注目ピクセルのインデックス
            if (pixels[p] < threshold)
                result[p] = 0;
            else
                result[p] = 255;

            //
            motikosiTotal = totalAll - totalOld;
        }
    });

    //画像周縁部の2値化
    //左側           
    SetBinaryArea(0, near, 0, height, pixels, near, width, height, stride, result);
    //右側           
    SetBinaryArea(width - near, width, 0, height, pixels, near, width, height, stride, result);
    //上側
    SetBinaryArea(0, width, 0, near, pixels, near, width, height, stride, result);
    //下側
    SetBinaryArea(0, width, height - near, height, pixels, near, width, height, stride, result);

    return MakeBitmapSource(result, width, height, stride);
}

画像周縁部の処理が大変、もっといい方法ないかなあ
f:id:gogowaten:20200421180102p:plain
高速化はマルチスレッド化よりも差分計算にしたのが大きいねえ
範囲20((20*2+1)2=1681ピクセル)でも
f:id:gogowaten:20200421180429p:plain
7秒かかっていたのが
f:id:gogowaten:20200421180321p:plain
0.087秒で処理できるようになった


範囲1結果
f:id:gogowaten:20200421183856p:plain

範囲2
f:id:gogowaten:20200421183741p:plain

範囲5
f:id:gogowaten:20200421183918p:plain

範囲10
f:id:gogowaten:20200421183944p:plain

範囲20
f:id:gogowaten:20200421184007p:plain


今回のアプリ
f:id:gogowaten:20200421192038p:plain
[20200421_局所平均しきい値で2値化.zip]
↑は画像表示なしのときにボタンを押すとエラーになっていたので
20200421_局所平均しきい値で2値化ver1.1.zip
(2020/05/29追記)

github.com



関連記事
続きの記事は1ヶ月後

gogowaten.hatenablog.com

次のWPF記事は2週間後

gogowaten.hatenablog.com

前回のWPF記事は3日前

gogowaten.hatenablog.com

前回の画像2値化アプリは5日前
gogowaten.hatenablog.com