Corredor

ウェブ、プログラミングの勉強メモ。

OpenCV.js : JavaScript で実装・ブラウザオンリーで OpenCV を使う

Python や Java で利用することが多い OpenCV。ビルドやインストールが面倒臭いのがタマにキズなのだが、今回 OpenCV.js という JavaScript 版を見つけたので紹介。

公式のガイド

公式のガイドは以下にある。

ハッキリ言って滅茶苦茶分かりづらい…。どうやったら使えるのか、何をどう実装したらいいのかが分かりにくく、また一見したサンプルコードがそのまま動かせなかったりするので、順々に説明していく。

ライブラリファイル

公式的には JavaScript 版もビルドして手元で作れ、ってことみたいで、npm とかで配布されていない。

しかし、公式で「どうしてもビルドできないならコレ使えば?」というビルド済のファイルが公開されているので、コレを使うことにする。

自分は今回、OpenCV v3.4.0 を利用したのだが、以下のように URL 中にバージョン番号があるので、コレを書き換えてやればそれぞれ必要なファイルがダウンロードできる。

OpenCV.js の基本的な操作だけならこのファイルだけで良いのだが、顔認識を行うために特徴分類器の XML ファイルを読み込む場合は、次の utils.js が必要になる。

で、この utils.js を使って読み込む、特徴分類器ファイルも落としておく。今回は人の顔の正面を特定したいので、haarcascade_frontalface_default.xml をダウンロードしておく。

特徴分類器の概要は以下を参照。

ということで、最低限 opencv.js、後はやりたいことに応じて utils.js.xml ファイルを用意しておけば良い。

実装サンプル・デモ

コレから使ってみた感触を紹介するのだが、先に動作デモとサンプルコードの全量をお伝えしておく。

github.com

以下は単純に opencv.js のみを使って、ウェブカメラの映像をキャプチャしてグレースケールに変換し canvas 要素に描画している例。

neos21.github.io

以下は opencv.js に加えて utils.jshaarcascade_frontalface_default.xml を利用して実現した、顔認識のサンプル。

neos21.github.io

顔認識までやってみせると、かなり OpenCV っぽいかなと思う。

動作の感覚だが、グレースケール変換の方は 30fps できちんと動いた。しかし、顔認識の方は結構ガクガク。重たいのねー。

読み込み方・初期処理

まずは基本的な使い方を紹介。

opencv.js は結構ファイルが重たいので、script 要素で読み込む時に onload 属性を付けて読み込み完了を確認しておくと良い。読み込めると、window.cv が使えるようになる。

cv.onRuntimeInitialized に初期化関数を代入するようなサンプルコードを見かけたのだが、WASM 版じゃないと効かないのかな。指定しても無駄だった。そこで、window.onload イベントのタイミングで各種初期化をしてやることにした。

今回のサンプルでは video 要素に描画しているウェブカメラの映像を利用したりするので、DOM 要素が出揃っている状態で OpenCV の初期処理をしてやらないといけない。

これらのタイミングをハンドリングするためには、body 要素の末尾で、次の順番で実装 (読み込み) してやると良い。

<!-- はじめに初期化処理などを実装しておく -->
<script>

// Video Settings
const width  = 640;
const height = 480;
const fps = 30;

// Globals
let videoCapture = null;

// Elements
const videoElem  = document.getElementById('video');
const canvasElem = document.getElementById('canvas');

/** On OpenCV.js Loaded */
function onCvLoaded() {
  cv.onRuntimeInitialized = onReady;  // ← 動かない
}

/** On Ready */
function onReady() {
  // Set Element Size
  videoElem.width  = canvasElem.width  = width;
  videoElem.height = canvasElem.height = height;
  
  // Start Video Capture
  videoCapture = new cv.VideoCapture(videoElem);
};

/** On Window Loaded */
window.addEventListener('load', () => {
  onReady();
});

</script>

<!-- それから OpenCV.js を読み込み onload 属性で読み込み完了を確認する -->
<script src="./opencv.js" onload="onCvLoaded();"></script>

</body>
</html>

videoElemcanvasElem など、グローバルで DOM 要素の参照を取得しておくので、body 要素の末尾でやるのが良い。

window.onload の実体は onReady() 関数で、ココで要素のサイズを指定しつつ、VideoCapture を準備している。

ウェブカメラのキャプチャ開始

ウェブカメラのキャプチャを開始するためのスタートボタンを用意し、次のような関数を実行してやる。

// Globals
let stream = null;
let isStreaming = false;
let matSrc = null;
let matDst = null;

/** On Start */
function onStart() {
  navigator.mediaDevices.getUserMedia({
    video: true,
    audio: false
  })
    .then((_stream) => {
      stream = videoElem.srcObject = _stream;
      videoElem.play();
      
      matSrc = new cv.Mat(height, width, cv.CV_8UC4);  // For Video Capture
      matDst = new cv.Mat(height, width, cv.CV_8UC1);  // For Canvas Preview
      
      // Start Process Video
      setTimeout(processVideo, 0);
      
      isStreaming = true;
    })
    .catch((error) => {
      console.error('On Start : Error', error);
    });
}

ウェブカメラのキャプチャは navigator.mediaDevices.getUserMedia() を使う。

取得した _stream は、キャプチャ停止処理のためにグローバル変数 stream に退避させておく他、video 要素の srcObject に割り当てる。コレにより、video 要素がプレビュー表示されるようになる。

matSrc はキャプチャ映像をそのまま取得するための Mat、matDst はグレースケールに変換した後のイメージを保持しておくための Mat だ。

setTimeout()processVideo() 関数を実行しているが、コレが「映像をキャプチャしてグレースケール変換して表示する」といった一連の動作を行うための関数である。内容は後述。

キャプチャ停止ボタン

キャプチャを停止する場合は、次のような関数を用意してやれば良いだろう。それぞれ null チェックをしておくと安心かも。

/** On Stop */
function onStop() {
  videoElem.pause();
  videoElem.srcObject = null;
  
  stream.getVideoTracks()[0].stop();
  
  isStreaming = false;
}

映像のキャプチャ処理

映像をキャプチャしてグレースケール変換する部分。

/** Process Video */
function processVideo() {
  // キャプチャ中でない場合は Mat を破棄して終了する
  if(!isStreaming) {
    matSrc.delete();
    matDst.delete();
    return;
  }
  
  const begin = Date.now();
  videoCapture.read(matSrc);  // Capture Video Image To Mat Src
  cv.cvtColor(matSrc, matDst, cv.COLOR_RGBA2GRAY);  // Convert Colour To Grey
  cv.imshow('canvas', matDst);  // Set Element ID
  
  // Loop
  const delay = 1000 / fps - (Date.now() - begin);
  setTimeout(processVideo, delay);
}

VideoCapture#read(mat) を使い、引数に渡した mat にウェブカメラのコマ画像を取得させる。

コレをよしなに変換し、cv.imshow() 関数で描画している。第1引数の文字列は getElementById() で指定する要素の ID になるので、

<canvas id="canvas"></canvas>

というように、グレースケールに変換した画像を出力する canvas 要素に、id 属性を付与しておくこと。

このキャプチャ処理を 30fps 感覚で動かすために、関数の末尾で setTimeout() を使って、自分自身を再度遅延実行している。ほとんど setInterval() 的な動作になるワケだが、OpenCV.js の処理が終わらない間に再度 processVideo() が呼ばれるようなことがないように制御しているワケ。

顔認識を行う場合は

顔認識を行う場合は、XML ファイルを読み込む必要があるのだが、そのまますんなりとは読み込めないので、utils.js を使用する。

<div id="error-message">エラーがある場合はココに書き込まれる</div>

<script>
  // 自前の処理…
</script>


<!-- utils.js を読み込む -->
<script src="./utils.js"></script>

<!-- 最後に OpenCV.js を読み込む -->
<script src="./opencv.js" onload="onCvLoaded();"></script>

utils.js を使って XML ファイルを読み込むには、次のように実装する。

// Globals
const faceCascadeFile = './haarcascade_frontalface_default.xml';

/** On Ready */
function onReady() {
  // Video Capture などなど…
  videoCapture = new cv.VideoCapture(videoElem);
  
  // XML ファイルを XHR で読み込んでおく
  const utils = new Utils('error-message');
  utils.createFileFromUrl(faceCascadeFile, faceCascadeFile, () => {
    console.log('Face Cascade File Loaded');
  });
};

まずは new Utils()utils.js をインスタンス化するのだが、第1引数に ID 名の文字列を渡しておく。エラーが発生した場合は、この ID の要素にエラーメッセージが書き込まれる。内部的には getElementById()innerHTML を使っているので、存在しない DOM 要素だとエラーメッセージ出力時にエラーが発生してしまうことに注意。

utils をインスタンス化したら、createFileFromUrl() で XML ファイルを読み込んでおく。第1・第2引数には、同じ読み込みたい XML ファイルのパスを指定すれば良い。内部的には XMLHttpRequest で読み込んでいるので、ローカルだと CORS 制限に引っかかって Ajax が動作しないと思う。そのため、

$ npx http-server

なんかで簡易サーバを立てて実行してやると良い。

第3引数のコールバック関数で、XML ファイルを Ajax 読み込みできたことを確認できるようになっている。

utils.jscreateFileFromUrl() 関数の中身を見ると、cv.FS_createDataFile() 関数を使って XML ファイルの中身をキャッシュしている様子。

ファイルを読み込んだら、カメラの起動時なんかに、「XML を読み込む」という処理が動作するようになる。

// Globals
let videoCapture = null;
let stream = null;
let isStreaming = false;
let matSrc  = null;
let matDst  = null;
let matGrey = null;
let faces = null;
let classifier = null;

/** On Start */
function onStart() {
  navigator.mediaDevices.getUserMedia({
    video: true,
    audio: false
  })
    .then((_stream) => {
      stream = videoElem.srcObject = _stream;
      videoElem.play();
      
      matSrc  = new cv.Mat(height, width, cv.CV_8UC4);  // For Video Capture
      matDst  = new cv.Mat(height, width, cv.CV_8UC4);  // For Canvas Preview
      matGrey = new cv.Mat();
      faces = new cv.RectVector();
      classifier = new cv.CascadeClassifier();
      // Load Pre-Trained Classifiers
      classifier.load(faceCascadeFile);
      
      // Start Process Video
      setTimeout(processVideo, 0);
      
      isStreaming = true;
    })
    .catch((error) => {
      console.error('On Start : Error', error);
    });
}

classifier.load() 部分がそれ。utils.createFileFromUrl() をやらずに classifier.load() を実行しても、うまく動作しない。

あとは普通の OpenCV とほぼ同じ API なので、以下のような感じで顔認識した結果を四角い枠で囲んでやると良い。

/** Process Video */
function processVideo() {
  if(!isStreaming) {
    matSrc.delete();
    matDst.delete();
    matGrey.delete();
    faces.delete();
    classifier.delete();
    return;
  }
  
  const begin = Date.now();
  
  videoCapture.read(matSrc);  // Capture Video Image To Mat Src
  matSrc.copyTo(matDst);  // Copy Src To Dst
  cv.cvtColor(matDst, matGrey, cv.COLOR_RGBA2GRAY, 0);  // Get Grey Image
  classifier.detectMultiScale(matGrey, faces, 1.1, 3, 0);  // Detect Faces
  
  // Draw Faces Rectangle
  for(let i = 0; i < faces.size(); ++i) {
    const face = faces.get(i);
    const point1 = new cv.Point(face.x, face.y);
    const point2 = new cv.Point(face.x + face.width, face.y + face.height);
    cv.rectangle(matDst, point1, point2, [255, 0, 0, 255]);
  }
  
  cv.imshow('canvas', matDst);  // Set Element ID
  
  // Loop
  const delay = 1000 / fps - (Date.now() - begin);
  setTimeout(processVideo, delay);
}

VideoCapture#read()matSrc にコマ画像を取得し、copyTo() を使って matDst に同じ内容を貼り付ける。matDst にはこのあと、認識した顔を示す四角枠を描画していく。

顔認識はグレースケールの画像を利用するので、グレースケールに変換した画像を matGrey に置き、classifier.detectMultiScale() で顔認識処理を行う。

結果は引数の faces に格納されるので、コレを取り出して cv.rectangle() で四角枠を描き込んでいく流れ。

今回はココまで

…ということで、今回はココまで。

良いところ

  • JavaScript オンリーで OpenCV が使えて、ブラウザ上で動作するので環境構築・利用が楽チン
  • JS 版特有のちょっとした違いはあるが、ほとんどの API がネイティブの OpenCV と同様に使えるので、他の言語で OpenCV 経験があれば JS 版も実装しやすいかと

良くないところ

  • 動作速度が遅めか
  • XML ファイルを読み込む際に XHR を使う必要があり、完全なローカルでの動作は CORS エラーになるため困難
  • JS 版特有の仕様に関するドキュメント・文献が少ない

環境構築がほぼ要らないので手軽さはダントツだが、JS 版特有の調整が必要で、文献も少なめだ。

上手く活用できる場面に遭遇したら、すぐに使えるよう、キャッチアップだけしておくとしよう。

参考文献