Node.jsで画像処理入門(二値化編)

## 動機
友人が画像の二値化をしたいと呟いているのを見て、興味を持ち調べた結果、試しに実装してみたくなりました。

## 参考
こちらの記事を参考にさせて頂きました。
環境構築はこちらの記事を参考にしてください。
また、`canvas_saver.js`をそのまま使用させていただきました。当該ファイルのソースはリンク先をご参照ください。
[Node.jsでCanvas(ImageData)を使った簡単な画像処理](https://qiita.com/redshoga/items/d5afef65081b7fdf60cc)

輝度に関しての計算式はこちらのを使わせて頂きました。
（https://ofo.jp/osakana/cgtips/hsb.phtml）

# 前提知識
まずは画像をモノクロにすることを考えます。
あるピクセルの_RGB_の値がそれぞれ`r`, `g`, `b`の時、そのピクセルの変換後の値はどのように計算するのが適当でしょうか？

簡単に思い浮かぶ実装として、$ \frac{r+g+b}{3} $というものが考えられます。
しかし、これでは黄色も水色も同じ濃さとして出力されてしまいます。

この問題は、人間の視覚特性に起因します。人間の目は緑色を明るく感じ、青色を暗く感じます。そこで、その差を考慮して補正を掛けた **輝度** という値を採用します。
$$ luminance = 0.298912\times r + 0.58661\times g + 0.114478\times b $$
で計算されますが、正直3桁以上あってもしょうがないので、ここでは
$$ 0.299\times R + 0.587\times G + 0.114\times B $$
を使います。


# 実装

## 判別分析法
画像の二値化で最も簡単なのは、輝度が基準の値以上なら白、そうでなければ黒を出力するものです。式としては

```math
f(x) = \left\{
\begin{array}{ll}
255 & (x \geq 128) \\
0 & (x \lt 128)
\end{array}
\right.
```

となりますが、この最適な閾値は画像によって異なります。そこで、分離度と呼ばれる値が最も大きくなるように閾値を定めます。

まず、黒い画素の数を$n_1$、平均を$\mu_1$、分散を$\sigma_1^2$とします。白い画素について$n_2$、$\mu_2$、$\sigma_2^2$、全ての画素について$n$、$\mu$、$\sigma^2$を同様に定めます。

この時、分離度は

```math
\sigma_i^2 = \frac{n_1\sigma_1^2+n_2\sigma_2^2}{n}
```
```math
\sigma_b^2 = 
\frac{n_1(\mu_1-\mu)^2+n_2(\mu_2-\mu)^2}{n}
```

と置いた時に

```math
\frac{\sigma_b^2}{\sigma_i^2}
```

とすることで算出できます。これを最大化するには

```math
n_1n_2(\mu_1-\mu_2)^2
```

を最大化すれば良いので、閾値を256通り探索してあげます。

javascriptを用いて実装して見ると、

```javascript
// Node.js標準装備のファイルの読み書きするやつ
var fs = require('fs');

// 別途用意した画像を保存してくれるやつ
var canvas_saver = require('./canvas_saver.js');

// node-canvas
var Canvas = require('canvas'),
    Image = Canvas.Image;

function threshold(data, c_){
        c = c_ || 0;
        h = data.height;
        w = data.width;
        var src = new Array(h*w*4).fill(255);
        for(var i=0; i<h*w*4; ++i) src[i] = data.data[i];
        var lum = [0.298912, 0.586611, 0.114478];       //輝度計算用の係数
        var ltoc = new Array(256).fill(0);      //luminance -> count
        for(var y=0; y<h; ++y){
                for(var x=0; x<w; ++x){
                        var i = (y*w+x)*4;
                        ++ltoc[parseInt(src[i]*lum[0] + src[i+1]*lum[1] + src[i+2]*lum[2])];
                }
        }

        var now_cnt = 0, now_sum = 0, all_cnt = 0, all_sum = 0, max_t = -1, best = 127;
        for(var i=0; i<256; ++i){
                all_cnt += ltoc[i];
                all_sum += ltoc[i] * i;
        }
        //判別分析法を使用
        for(var i=0; i<256; ++i){
                now_cnt += ltoc[i];
                now_sum += ltoc[i] * i;
                dm = now_sum/now_cnt - (all_sum-now_sum)/(all_cnt-now_cnt);//delta_m
                var t = now_cnt*(all_cnt-now_cnt)*dm*dm;
                if(max_t < t){
                        max_t = t;
                        best = i;
                }
        }
        for(var y=0; y<h; ++y){
                for(var x=0; x<w; ++x){
                        var i = (y*w+x)*4;
                        if(src[i]*lum[0] + src[i+1]*lum[1] + src[i+2]*lum[2] < best-c)
                                data.data[i] = data.data[i+1] = data.data[i+2] = 0;
                        else
                                data.data[i] = data.data[i+1] = data.data[i+2] = 255;
                }
        }

        return data;
}

fs.readFile(__dirname + '/image.png', function(err, data){
    if (err) throw err;

    // データをcanvasのcontextに設定
    var img = new Image;
    img.src = data;
    // 2019年10月追記: いつの間にかcanvasの生成方法が変わったらしいです
    //var canvas = new Canvas(img.width, img.height);
    var canvas = Canvas.createCanvas(img.width, img.height);
    var ctx = canvas.getContext('2d');
    ctx.drawImage(img, 0, 0, img.width, img.height);

    // RGBの画素値の配列を取得
    var imagedata = ctx.getImageData(0, 0, img.width, img.height);

    threshold(imagedata, 0);

    // 加工したデータをセット
    ctx.putImageData(imagedata, 0, 0);

    // データを保存
    canvas_saver.save(canvas, "output.png", function(){
        console.log("保存しました");
    });

});
```

のようになります。割と高速に動作します。


## 適応的閾値処理
先ほど紹介した手法とは異なり、**適応的閾値処理**は線画を抽出するような処理を行います。
これの良い点は、「画像の半分が影になっていたのでその部分が真っ黒になってしまった」等という結果を防げることです。後は好みですかね。
処理としては、画像にフィルタ処理をすることで「周囲$(2n+1)^2$ピクセルの平均より一定以上暗ければ黒、さもなくば白」という処理をするだけですが、先ほどの手法と違い、最適なパラメータはプログラマが設定する必要があります。
また、先ほどのものよりも処理が重いです。アルゴリズムとしては並列処理がしやすいので知識がある人はGPUに投げて上げてもいいかもしれません。
以下にNode.jsで記述したコードを示します。

```javascript
// Node.js標準装備のファイルの読み書きするやつ
var fs = require('fs');

// 別途用意した画像を保存してくれるやつ
var canvas_saver = require('./canvas_saver.js');

// node-canvas
var Canvas = require('canvas'),
    Image = Canvas.Image;

//注意: sizeは奇数, cは白黒の度合いを決める(0付近の実数)
function threshold_adaption(data, size_, c_){
        size = size_ || 7;
        c = c_ || 2;
        d = parseInt((size-1)/2);
        h = data.height;
        w = data.width;
        var src = new Array(h*w*4).fill(255);
        for(var i=0; i<h*w*4; ++i) src[i] = data.data[i];
        n = size*size;
        var lum = [0.298912, 0.586611, 0.114478];       //輝度計算用の係数
        for(var y=0; y<h; ++y){
                for(var x=0; x<w; ++x){
                        var t = 0;
                        for(var y1=y-d; y1<=y+d; ++y1){
                                for(var x1=x-d; x1<=x+d; ++x1){
                                        var i = (y1*w+x1)*4
                                        t += 0<=x1 && x1<w && 0<=y1 && y1<h ? src[i++]*lum[0] + src[i++]*lum[1] + src[i++]*lum[2] : 0;
                                }
                        }
                        var i = (y*w+x)*4;
                        if(src[i]*lum[0] + src[i+1]*lum[1] + src[i+2]*lum[2] < t/n - c)
                                data.data[i] = data.data[i+1] = data.data[i+2] = 0;
                        else
                                data.data[i] = data.data[i+1] = data.data[i+2] = 255;
                }
        }
        return data;
}

fs.readFile(__dirname + '/image.png', function(err, data){
    if (err) throw err;

    // データをcanvasのcontextに設定
    var img = new Image;
    img.src = data;
    // 2019年10月追記: いつの間にかcanvasの生成方法が変わったらしいです
    //var canvas = new Canvas(img.width, img.height);
    var canvas = Canvas.createCanvas(img.width, img.height);
    var ctx = canvas.getContext('2d');
    ctx.drawImage(img, 0, 0, img.width, img.height);

    // RGBの画素値の配列を取得
    var imagedata = ctx.getImageData(0, 0, img.width, img.height);

    threshold_adaption(imagedata, 13, 5);

    // 加工したデータをセット
    ctx.putImageData(imagedata, 0, 0);

    // データを保存
    canvas_saver.save(canvas, "output.png", function(){
        console.log("保存しました");
    });

});
```

`size`は「周囲$(2n+1)^2$ピクセルの平均より一定以上暗ければ黒、さもなくば白」の$(2n+1)$の値です。$n$を入力にすれば良かった気がします。
`c_`は、「一定以上」の基準値ですね。ノイズの乗り方などを見て決めてください。

コピペしやすいよう、コード全体を貼らせていただきましたが、差分は`threshold`関数のみです。
また、それ以外の部分は参考でも示しました[こちら](https://qiita.com/redshoga/items/d5afef65081b7fdf60cc)のコードを使用させていただいています。

注意点として、初心者の方が互換性の問題で悩むことが無いよう、デフォルト引数などは**少し古い記法**をしています。
このままでは`c_`には0を入れられないなど問題発生する可能性がありますので、適宜修正してください。

アルゴリズムに詳しい人であれば、このコードを見て「効率が悪い」と感じるかもしれません。
実際、累積和をうまく使うと計算時間を短縮することができます。しかし今回は分かりやすさを優先しました。

# あとがき
python等の行列演算が簡単にできる言語を使用したほうが確実に楽だと思いました。
JSで簡単な画像処理をしたい、もしくは単純に画像処理に触れてみたいという方の参考になれば幸いです。

因みに、Qiita初投稿です。もしも間違いがありましたらマサカリ投げてください。