Open10

ブラウザのJavaScriptだけで名刺検出やってみる

菊池紘菊池紘

背景

今のスマホアプリ開発は、FlutterやReactNativeのようなマルチプラットフォームフレームワークの時代ではあります。
が、将来的にはPWAの時代が来ると思っているので、ブラウザでなんでもできるようになっておきたい。

やること

リアカメラで撮影している映像を、リアルタイムに解析し、名刺が見つかればその部分を抽出する。

ということをやります。
画像処理的には矩形検出というか、そういうやつです。
もっと今風に機械学習を用いるなら物体認識タスクとかになるんでしょうか。

菊池紘菊池紘

検出方法

完全にこちらの記事の丸パクリです。
https://dev.classmethod.jp/articles/avfoundation-opencv-findcontours/

違うのはiOSのAPIではなくてJavaScriptのAPIでなんとかすること。

  1. MediaDevices.getUserMedia() でビデオの入力ストリームを得る
  2. <video> 要素に入力ストリームを流して画像サイズ等を得る
  3. <canvas> 要素に加工した映像を反映
    1. グレースケール処理
    2. 2値化処理
    3. 輪郭検出
    4. 射影変換(未完)

最初は全部自力でやろうとしてたんですが、実装も大変だし、ナイーブに実装できても処理に時間がかかりすぎるので、OpenCV.jsを使用することにしました。

https://docs.opencv.org/4.x/d5/d10/tutorial_js_root.html

菊池紘菊池紘

MediaDevices.getUserMedia() でビデオの入力ストリームを得る

難しいことは何もない。
MDNのドキュメントとかにあるようにすればよかった。

https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getUserMedia

PCでコードを書いてそのまま出力を試し見したりしたいので、 exact にはしなかった。

    const stream = await navigator.mediaDevices.getUserMedia({
      video: {
        facingMode: "environment",
      },
    });
菊池紘菊池紘

<video> 要素に入力ストリームを流して画像サイズ等を得る

最初の詰まりポイント。

canvasにdrawImageしても真っ黒なまま

ただ <video> 要素を作って srcObject にストリームを突っ込んでも、後のcanvasへの転記を行なっても真っ黒にしかならない。
-> play() メソッドを呼び出して再生を始める必要がある。

上記を解決してもまだ問題がある。

Macで動くがiOSで動かない

Macでは期待した通りにcanvasに最新のvideoの様子が反映されるのに、iOSだとページを開いた時の一瞬の様子したcanvasにコピーされない。
-> playsinline = true が必要

というわけで、解決するためにはこうする必要があった

動作する設定(video要素を画面に出さない版)

const video = document.createElement("video");
video.playsinline = true;
video.srcObject = stream;
video.onloadedmetadata = () => {
  video.play();
  const width = video.videoWidth;
  const height = video.videoHeight;
  // この後の処理
};

動作する設定(video要素をHTMLに配置して見えるようにする版)

<video id="video" autoplay playsinline />
const video = document.getElementById("video");
video.srcObject = stream;
video.onloadedmetadata = () => {
  const width = video.videoWidth;
  const height = video.videoHeight;
  // この後の処理
};
菊池紘菊池紘

<canvas> 要素に加工した映像を反映

drawImage() で反映すればいい。
ただ、OpenCV.jsの cv.imread() の引数にはimage要素かcanvas要素しか渡せませんでした(video要素は直接突っ込めない)。
そのため、一度videoの内容をcanvasに書き写してからOpenCVに食わせることにします。

const video = document.getElementById("video");
video.srcObject = stream;
video.onloadedmetadata = () => {
  // 後程OpenCVで使用するので、表示しないcanvasを用意してそこにvideoの内容を書き写しておく
  const buffer = document.createElement("canvas");
  const bufferCtx = buffer.getContext("2d");

  // 表示用のcanvasも用意
  const canvas = document.getElementById("canvas");

  canvas.width = buffer.width = video.videoWidth;
  canvas.height = buffer.height = video.videoHeight;

  // この後毎フレームごとに呼ばれる関数
  const tick = () => {
    bufferCtx.drawImage(video, 0, 0);
    const src = cv.imread(buffer);
    cv.imshow(canvas, src);
    // OpenCV.jsはemscriptenでできていて、C++の世界のオブジェクトは自動的に破棄されないため、データ構造を使ったら自分で破棄する必要がある
    src.delete();

    requestAnimationFrame(tick);
  };
  tick();
};
菊池紘菊池紘

グレースケール処理

2値化するための前処理。
cv.cvtColor() で色空間を変えてやるだけ。これは簡単

const src = cv.imread(offscreen);
const dst = new cv.Mat();
cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY, 0);
菊池紘菊池紘

2値化処理

これで名刺とそうでない部分の境界線を明確にします。
大津のアルゴリズムを使って、画面の明るさヒストグラムに応じて適当な輝度で白と黒を分けます。

https://ja.wikipedia.org/wiki/大津の二値化法

cv.threshold(dst, dst, 0, 255, cv.THRESH_OTSU);
菊池紘菊池紘

輪郭検出

つまりポイント

方針としては

  1. cv.findContours() で輪郭を出し
  2. その中から cv.contourArea() である程度のサイズのある輪郭を選択
  3. さらに輪郭を cv.arcLength()cv.approxPolyDP() で4角形に近似できるものを探す

という感じ。

          let contours = new cv.MatVector();
          let hierarchy = new cv.Mat();
          // 輪郭を全部見つけ出す
          cv.findContours(dst, contours, hierarchy, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_TC89_L1);
          for (let i = 0; i < contours.size(); i++) {
            // ある程度のサイズ以上の輪郭のみ処理
            const area = cv.contourArea(contours.get(i), false);
            if (area > 15000) {
              let approx = new cv.Mat();
              // cv.Matは行列で、幅1, 高さ4のものが4頂点に近似できた範囲になる
              cv.approxPolyDP(contours.get(i), approx, 0.01 * cv.arcLength(contours.get(i), true), true);
              if (approx.size().width === 1 && approx.size().height === 4) {
                // 四角形に近似できる領域は赤で輪郭線描画
                cv.drawContours(dst, contours, i, new cv.Scalar(255, 0, 0, 255), 4, cv.LINE_8, hierarchy, 100);
              } else {
                // それ以外の輪郭は緑で描画
                cv.drawContours(dst, contours, i, new cv.Scalar(0, 255, 0, 255), 1, cv.LINE_8, hierarchy, 100);
              }
              approx.delete();
            }
          }
菊池紘菊池紘

供養

自力で画像処理実装してみたけどやっぱりきつかったやつ。
基本的に ctx.getImageData()data を書き換えていく実装にしてある。

グレースケール化

CIE XYZ色空間のY値を採用すると良いらしい。
https://qiita.com/yoya/items/96c36b069e74398796f3#cie-xyz-の-y

そのためにはガンマ補正をする必要がある。
ガンマの値はどうやら 2.0 とか 2.2 とからしい。要調査。

function grayscale(imageContainer) {
  const brightnessArray = new Array(imageContainer.data.length / 4);
  for (let pixelIdx = 0; pixelIdx < imageContainer.data.length; pixelIdx+= 4) {
    const r = imageContainer.data[pixelIdx + 0];
    const g = imageContainer.data[pixelIdx + 1];
    const b = imageContainer.data[pixelIdx + 2];
    const a = imageContainer.data[pixelIdx + 3];
    
    const r_dash = reverceGammaRevice(r);
    const g_dash = reverceGammaRevice(g);
    const b_dash = reverceGammaRevice(b);
    
    const gray_dash = 0.2126*r_dash + 0.7152*g_dash + 0.0722*b_dash;
    const gray = gammaRevice(gray_dash);
    imageContainer.data[pixelIdx + 0] = imageContainer.data[pixelIdx + 1] = imageContainer.data[pixelIdx + 2] = gray;
    brightnessArray[pixelIdx / 4] = gray;
  }
  return brightnessArray;
}

const GAMMA = 2.2;
// ガンマ補正
function gammaRevice(v) {
  return ~~(255 * Math.pow(v/255, 1/GAMMA));
}
// 逆ガンマ補正
function reverceGammaRevice(v) {
  return ~~(255 * Math.pow(v/255, GAMMA))
}

2値化

前述のグレースケール化したときに作った輝度値の配列を使う。

大津のアルゴリズムを自前で実装した。
最大化すればいい式はこちらから拝借。
https://imagingsolution.blog.fc2.com/blog-entry-113.html

function threthold(imageContainer, brightnessArray) {
  const histgram = brightnessToHistgram(brightnessArray);
  let t = 0;
  let maxSigma = -1;
  for (let i = 0; i < 256; i++) {
    const class1 = histgram.slice(0, i);
    const class2 = histgram.slice(i, 256);
    const m1 = brightnessMean(class1);
    const m2 = brightnessMean(class2);
    const omega1 = pixelSum(class1);
    const omega2 = pixelSum(class2);
    const sigma = omega1 * omega2 * Math.pow(m1-m2, 2);
    if (maxSigma < sigma) {
      maxSigma = sigma;
      t = i;
    }
  }
  
  const monochromeArray = new Array(brightnessArray.length);
  for (let pixelIdx = 0; pixelIdx < brightnessArray.length; pixelIdx++) {
    const g = brightnessArray[pixelIdx];
    let v = 0;
    if (g >= t) {
      v = 255;
    }
    imageContainer.data[pixelIdx*4 + 0] = imageContainer.data[pixelIdx*4 + 1] = imageContainer.data[pixelIdx*4 + 2] = v;
    monochromeArray[pixelIdx] = v;
  }
  return monochromeArray;
}

// 輝度値からヒストグラムを形成
function brightnessToHistgram(brightnessArray) {
  const hist = new Array(256);
  for (let i = 0; i < 256; i++) {
    hist[i] = 0;
  }
  for (let pixel = 0; pixel < brightnessArray.length; pixel++) {
    hist[brightnessArray[pixel]] += 1;
  }
  return hist;
}

// 輝度値の平均
function brightnessMean(list) {
  let count = 0;
  let s = 0;
  for (let i = 0; i < list.length; i++) {
    if (list[i] > 0) {
      s += list[i];
      count++;
    }
  }
  return s / count;
}

// 画素数合計
function pixelSum(list) {
  return list.reduce((prev, value) => prev + value, 0);
}

輪郭検出

OpenCVはSuzuki85なるアルゴリズムを使っているようだけれど、検索しても具体的な解説がヒットしなかったので、大学の授業で使われていそうなアルゴリズムを採用。
https://www.maebashi-it.ac.jp/~odagaki/ImageProc/src/ppt/8.pdf

実装しきれていないので、収縮・膨張処理あたりだけ。

// 膨張・収縮処理をかけてノイズを減らす
function bulr(monochromeArray, width, height, times) {
  return (times > 0)
    ? expansion(bulr(contract(monochromeArray, width, height), width, height, times-1), width, height)
    : monochromeArray;
}

// 収縮処理
function contract(monochromeArray, width, height) {
  const newArray = new Array(monochromeArray.length);
  for (let i = 0; i < monochromeArray.length; i++) {
    const neighbor = neighborIdx(i, width, height).map(i => (i >= 0) ? monochromeArray[i] : 255);
    const hasBlack = neighbor.some(v => v == 0);
    newArray[i] = hasBlack ? 0 : 255;
  }
  return newArray;
}

// 膨張処理
function expansion(monochromeArray, width, height) {
  const newArray = new Array(monochromeArray.length);
  for (let i = 0; i < monochromeArray.length; i++) {
    const neighbor = neighborIdx(i, width, height).map(i => (i >= 0) ? monochromeArray[i] : 0);
    const hasWhite = neighbor.some(v => v > 0);
    newArray[i] = hasWhite ? 255 : 0;
  }
  return newArray;
}

// 8近傍の座標。無効値は-1。左下から反時計回りに並べてある
function neighborIdx(idx, width, height) {
  const line = ~~(idx / width);
  const column = idx - (width * line);
  return [
    // 位置関係はこう
    // [line-1, column-1], [line-1, column], [line-1, column+1],
    // [line, column-1], [line, column], [line, column+1],
    // [line+1, column-1], [line+1, column], [line+1, column+1],
    [line+1, column-1],
    [line+1, column],
    [line+1, column+1],
    [line, column+1],
    [line-1, column+1],
    [line-1, column],
    [line-1, column-1],
    [line, column-1],
  ].map(pos => (pos[0] < 0 || pos[0] >= height || pos[1] < 0 || pos[1] >= width) ? -1 : (width * line + column));
}