canvasで輪郭を検出してアートっぽくする


前回の[canvasでクロマキー合成っぽいことしてみる](http://qiita.com/keiskimu/items/a4fc9ebb338906b9813e)の派生。
映像の中から輪郭を検出して色を変えてみる。

**完成物**
![demo](http://keiskimu.com/Qiita/outline/demo.gif)


## やること
1. 映像を取り込む
2. canvasに描画する
3. 描画されたcontextを走査し、隣接する色の差によって色を塗り分ける
4. 色の塗り方を変える

_※[前回](http://qiita.com/keiskimu/items/a4fc9ebb338906b9813e)と同じ処理の説明は割愛_

## htmlファイルを用意

```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Language" content="ja">
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0,minimal-ui">
<title>Outline</title>
</head>
<body>

    <div>
        <input id="color" type="color" value="#ff0000" />
        <input id="distance" type="number" value="10" />
    </div>

    <div>
        <video id="video" autoplay></video>
        <canvas id="canvas" width="480" height="360"></canvas>
    </div>

    <script type="text/javascript" src="./script.js"></script>

</body>
</html>
```

`script.js`に処理を書いていく。

## 省略
- [映像を取り込む](http://qiita.com/keiskimu/items/a4fc9ebb338906b9813e#%E6%98%A0%E5%83%8F%E3%82%92%E5%8F%96%E3%82%8A%E8%BE%BC%E3%82%80)
- [canvasに描画する](http://qiita.com/keiskimu/items/a4fc9ebb338906b9813e#canvas%E3%81%AB%E6%8F%8F%E7%94%BB%E3%81%99%E3%82%8B)


## 描画されたcontextを走査し、隣接する色の差によって色を塗り分ける

[ユークリッド距離を求める関数](http://qiita.com/keiskimu/items/a4fc9ebb338906b9813e#%E6%8F%8F%E7%94%BB%E3%81%95%E3%82%8C%E3%81%9Fcontext%E3%81%AE%E6%8C%87%E5%AE%9A%E3%81%95%E3%82%8C%E3%81%9F%E8%89%B2%E3%81%AE%E9%83%A8%E5%88%86%E3%82%92%E9%80%8F%E9%81%8E%E3%81%AB%E3%81%99%E3%82%8B)を使い、今回は隣接するピクセルの色が遠いかどうかを判定する。
隣り合う色のユークリッド距離の差が大きければ、映像上そこは境界であると認識できる。

左上から順に走査していき、垂直方向の境界線は右隣の色、水平方向の境界線は下の色と比較をして検出する。

```javascript:アウトライン処理
var outlineColor = {r: 255, g: 0, b: 0},
    colorDistance = 10;

// アウトライン処理
var outline = function () {
    var imageData = context.getImageData(0, 0, canvas.width, canvas.height),
        data = imageData.data;

    // dataはUint8ClampedArray
    // 長さはcanvasの width * height * 4(r,g,b,a)
    // 先頭から、一番左上のピクセルのr,g,b,aの値が順に入っており、
    // 右隣のピクセルのr,g,b,aの値が続く
    // n から n+4 までが1つのピクセルの情報となる

    for (var i = 0, l = data.length; i < l; i += 4) {

        // この条件の時、currentは右端の色、nextは1px下の段の左端の色になるので透明にしてスキップする
        if ((i / 4 + 1) % canvas.width === 0) {
            data[i + 3] = 0;
            continue;
        }

        var currentIndex = i,
            nextIndex = currentIndex + 4,
            underIndex = currentIndex + (canvas.width * 4),

            // チェックするピクセルの色
            current = {
                r: data[currentIndex],
                g: data[currentIndex + 1],
                b: data[currentIndex + 2]
            },
            // 右隣の色
            next = {
                r: data[nextIndex],
                g: data[nextIndex + 1],
                b: data[nextIndex + 2]
            },
            // 下の色
            under = {
                r: data[underIndex],
                g: data[underIndex + 1],
                b: data[underIndex + 2]
            };

        // 現在のピクセルと右隣、下の色の三次元空間上の距離を閾値と比較する
        // 閾値より大きい（色が遠い）場合、境界線とみなしそのピクセルをoutlineColorに変更
        // 閾値より小さい（色が近い）場合、そのピクセルを消す
        if (getColorDistance(current, next) > colorDistance || getColorDistance(current, under) > colorDistance) {
            data[i] = outlineColor.r;
            data[i + 1] = outlineColor.g;
            data[i + 2] = outlineColor.b;
        } else {
            // alpha値を0にすることで見えなくする
            data[i + 3] = 0;
        }
    }

    // 書き換えたdataをimageDataにもどし、描画する
    imageData.data = data;
    context.putImageData(imageData, 0, 0);
};
```

ここまでで映像内の輪郭を検出して単色で塗り、その他の部分を透明にすることができる。

## 色の塗り方を変える

単色塗りだとおもしろくないので、指定された色からその色の[補色](https://ja.wikipedia.org/wiki/%E8%A3%9C%E8%89%B2)にグラデーションをかけるようにする。

補色の求め方はAdobeぱいせんを参考にした。
参考：[Adobe Illustrator/カラーの調整#カラーの反転色または補色への変更](https://helpx.adobe.com/jp/illustrator/using/adjusting-colors.html#WS714a382cdf7d304e7e07d0100196cbc5f-6288a)

```javascript:補色を求める関数
// 補色の計算
var getComplementaryColor = function (rgb) {
    var max = Math.max(rgb.r, rgb.g, rgb.b),
        min = Math.min(rgb.r, rgb.g, rgb.b),
        sum = max + min;
    return {
        r: sum - rgb.r,
        g: sum - rgb.g,
        b: sum - rgb.b
    };
};
```

指定色とその補色をもとに、contextの上から一段ずつ色を変えるように計算する。

アウトライン処理部分を次のように修正。

```javascript:グラデーションをで塗るアウトライン処理
var outlineColor = {r: 255, g: 0, b: 0},
    complementaryColor = {r: 0, g: 255, b: 255},
    colorDistance = 10;

// アウトライン処理
var outline = function () {
    var imageData = context.getImageData(0, 0, canvas.width, canvas.height),
        data = imageData.data;

    // dataはUint8ClampedArray
    // 長さはcanvasの width * height * 4(r,g,b,a)
    // 先頭から、一番左上のピクセルのr,g,b,aの値が順に入っており、
    // 右隣のピクセルのr,g,b,aの値が続く
    // n から n+4 までが1つのピクセルの情報となる

    var currentOutlineColor = outlineColor;

    for (var i = 0, l = data.length; i < l; i += 4) {

        // この条件の時、currentは右端の色、nextは1px下の段の左端の色になるので透明にしてスキップする
        if ((i / 4 + 1) % canvas.width === 0) {
            data[i + 3] = 0;
            continue;
        }

        // 段が変わったら色を変える
        // 一段ずつoutlineColorからcomplementaryColorにグラデーションにする
        if ((i / 4) % canvas.width === 0) {
            var row = (i / 4) / canvas.width,
                r = (outlineColor.r - complementaryColor.r) / canvas.height,
                g = (outlineColor.g - complementaryColor.g) / canvas.height,
                b = (outlineColor.b - complementaryColor.b) / canvas.height;

            currentOutlineColor = {
                r: outlineColor.r - (r * row),
                g: outlineColor.g - (g * row),
                b: outlineColor.b - (b * row)
            };
        }

        var currentIndex = i,
            nextIndex = currentIndex + 4,
            underIndex = currentIndex + (canvas.width * 4),

            // チェックするピクセルの色
            current = {
                r: data[currentIndex],
                g: data[currentIndex + 1],
                b: data[currentIndex + 2]
            },
            // 右隣の色
            next = {
                r: data[nextIndex],
                g: data[nextIndex + 1],
                b: data[nextIndex + 2]
            },
            // 下の色
            under = {
                r: data[underIndex],
                g: data[underIndex + 1],
                b: data[underIndex + 2]
            };

        // 現在のピクセルと右隣、下の色の三次元空間上の距離を閾値と比較する
        // 閾値より大きい（色が遠い）場合、境界線とみなしそのピクセルをcurrentOutlineColorに変更
        // 閾値より小さい（色が近い）場合、そのピクセルを消す
        if (getColorDistance(current, next) > colorDistance || getColorDistance(current, under) > colorDistance) {
            data[i] = currentOutlineColor.r;
            data[i + 1] = currentOutlineColor.g;
            data[i + 2] = currentOutlineColor.b;
        } else {
            // alpha値を0にすることで見えなくする
            data[i + 3] = 0;
        }
    }

    // 書き換えたdataをimageDataにもどし、描画する
    imageData.data = data;
    context.putImageData(imageData, 0, 0);
};
```

ここまでのコードをまとめると↓のようになる。

## コード全体像
```javascript:script.js
(function () {

    var video = document.getElementById('video');
    // videoは非表示にしておく
    video.style.display = 'none';

    var canvas = document.getElementById('canvas');
    // そのまま表示すると鏡像にならないので反転させておく
    canvas.style.transform = 'rotateY(180deg)';

    var context = canvas.getContext('2d');

    navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia;
    navigator.getUserMedia({video: true, audio: false}, function (stream) {
        video.src = URL.createObjectURL(stream);
        draw();
    }, function () {});

    // videoの映像をcanvasに描画する
    var draw = function () {
        context.drawImage(video, 0, 0, canvas.width, canvas.height);
        // ここでアウトライン処理をする
        outline();
        requestAnimationFrame(draw);
    };

    // 境界線とする閾値
    var outlineColor = {r: 255, g: 0, b: 0},
        complementaryColor = {r: 0, g: 255, b: 255},
        colorDistance = 10;

    // アウトライン処理
    var outline = function () {
        var imageData = context.getImageData(0, 0, canvas.width, canvas.height),
            data = imageData.data;

        // dataはUint8ClampedArray
        // 長さはcanvasの width * height * 4(r,g,b,a)
        // 先頭から、一番左上のピクセルのr,g,b,aの値が順に入っており、
        // 右隣のピクセルのr,g,b,aの値が続く
        // n から n+4 までが1つのピクセルの情報となる

        var currentOutlineColor = outlineColor;

        for (var i = 0, l = data.length; i < l; i += 4) {

            // この条件の時、currentは右端の色、nextは1px下の段の左端の色になるので透明にしてスキップする
            if ((i / 4 + 1) % canvas.width === 0) {
                data[i + 3] = 0;
                continue;
            }

            // 段が変わったら色を変える
            // 一段ずつoutlineColorからcomplementaryColorにグラデーションにする
            if ((i / 4) % canvas.width === 0) {
                var row = (i / 4) / canvas.width,
                    r = (outlineColor.r - complementaryColor.r) / canvas.height,
                    g = (outlineColor.g - complementaryColor.g) / canvas.height,
                    b = (outlineColor.b - complementaryColor.b) / canvas.height;

                currentOutlineColor = {
                    r: outlineColor.r - (r * row),
                    g: outlineColor.g - (g * row),
                    b: outlineColor.b - (b * row)
                };
            }

            var currentIndex = i,
                nextIndex = currentIndex + 4,
                underIndex = currentIndex + (canvas.width * 4),
                // チェックするピクセルの色
                current = {
                    r: data[currentIndex],
                    g: data[currentIndex + 1],
                    b: data[currentIndex + 2]
                },
                // 右隣の色
                next = {
                    r: data[nextIndex],
                    g: data[nextIndex + 1],
                    b: data[nextIndex + 2]
                },
                // 下の色
                under = {
                    r: data[underIndex],
                    g: data[underIndex + 1],
                    b: data[underIndex + 2]
                };

            // 現在のピクセルと右隣、下の色の三次元空間上の距離を閾値と比較する
            // 閾値より大きい（色が遠い）場合、境界線とみなしそのピクセルをcurrentOutlineColorに変更
            // 閾値より小さい（色が近い）場合、そのピクセルを消す
            if (getColorDistance(current, next) > colorDistance || getColorDistance(current, under) > colorDistance) {
                data[i] = currentOutlineColor.r;
                data[i + 1] = currentOutlineColor.g;
                data[i + 2] = currentOutlineColor.b;
            } else {
                // alpha値を0にすることで見えなくする
                data[i + 3] = 0;
            }
        }

        // 書き換えたdataをimageDataにもどし、描画する
        imageData.data = data;
        context.putImageData(imageData, 0, 0);
    };

    // r,g,bというkeyを持ったobjectが第一引数と第二引数に渡される想定
    var getColorDistance = function (rgb1, rgb2) {
        // 三次元空間の距離が返る
        return Math.sqrt(
            Math.pow((rgb1.r - rgb2.r), 2) +
            Math.pow((rgb1.g - rgb2.g), 2) +
            Math.pow((rgb1.b - rgb2.b), 2)
        );
    };

    var color = document.getElementById('color');
    color.addEventListener('change', function () {
        // フォームの値は16進カラーコードなのでrgb値に変換する
        outlineColor = color2rgb(this.value);
        complementaryColor = getComplementaryColor(outlineColor);
    });

    var color2rgb = function (color) {
        color = color.replace(/^#/, '');
        return {
            r: parseInt(color.substr(0, 2), 16),
            g: parseInt(color.substr(2, 2), 16),
            b: parseInt(color.substr(4, 2), 16)
        };
    };

    // 補色の計算
    var getComplementaryColor = function (rgb) {
        var max = Math.max(rgb.r, rgb.g, rgb.b),
            min = Math.min(rgb.r, rgb.g, rgb.b),
            sum = max + min;
        return {
            r: sum - rgb.r,
            g: sum - rgb.g,
            b: sum - rgb.b
        };
    };

    var distance = document.getElementById('distance');
    distance.style.textAlign = 'right';
    distance.addEventListener('change', function () {
        colorDistance = this.value;
    });

})();
```

ええでええで。

## demo
https://secure3521.sakura.ne.jp/keiskimu.com/Qiita/outline/

_※chrome推奨_

デザイナーからみたらアートでもなんでもないんだろうけどな！！！！！