まだ未完成なので公開していませんが、以前、Yapps 用に写真から顔認識を行い、顔の部分をモザイク加工する「顔モザイク加工」という Web アプリを作ってました。
顔認識には opencv.js を利用しています。opencv.js は wasm によってブラウザ上での仮想環境を実現しており、js ファイルを読み込むだけで JavaScript から opencv の機能が利用可能です。
久々に開発を再開する気になったので、復習も兼ねて実現方法だけでも思い出しながらここにまとめておこうかなと思います。

opencv.js の用意

公式リファレンスに従ってやるなら自前でコンパイルして opencv.js を用意するのですが、面倒なので OpenCV の ドキュメントのサンプルで読み込まれているファイルを拝借しました。
https://docs.opencv.org/3.4.0/opencv.js

Web アプリとして実際に運用する際、HTML で上記の URL に直リンしていまうと opencv のサーバに負荷をかけてしまうかもしれないので、ダウンロードして自分のサーバに置いておいたほうがいいでしょう。

opencv.js のバージョンですが、本来なら最新版を使いたいところですが、どうも opencv.js と組み合わせて使う utils.js が対応していないのかうまくいかなかったので、少し古いバージョン 3.4 を利用しています。

utils.js の用意

で、その utils.js は何に使うかというと、サーバの同じディレクトリに置かれた特徴分類器の xml 形式の学習データファイルを読み込むために使います。今回だと、顔を認識するための学習データであるhaarcascade_frontalface_default.xmlがこれに該当します。顔以外にも他にも色々な特徴分類器があります。

utils.js も opencv.js と同様に opencv のドキュメントからダウンロードできるので、ここから入手しました。
https://docs.opencv.org/3.4.0/utils.js

顔認識をする

必要な手順は以下の通り。

  1. opencv.js の読み込み(完了するまで待つ)
  2. cv の initialize が完了する待つ
  3. 特徴分類器ファイル(xml 形式)を読み込み(完了するまで待つ)
  4. 入力画像を読み込み(完了するまで待つ)
  5. 読み込まれた画像をキャンバスに書き出しておき、cv.imread()でキャンバスから cv に読み込ませる。このとき、 utils.js のcreateFileUrl()で URL を生成し、その URL から load
  6. 画像をcv.cvtColor()でグレースケール化する
  7. 特徴分類器のdetectMultScale()で顔検出
  8. 検出した顔領域を赤枠で囲み、検出結果表示用キャンバスに書き出し

完了するまで待つ、完了するまで待つ、完了するまで待つ…と書いてありますが、別に読み込みに時間がかかるわけではありません。ただ、完了前に実行してしまうと失敗するので念の為。

今回使うキャンバスは以下の3つ。

  • canvas_input(入力画像表示用; 最大 1000x1000 にリサイズ)
  • canvas_output(検出結果表示用; 最大 1000x1000 にリサイズ)
  • virtual_canvas(仮想キャンバス、ダウンロード用; サイズは入力画像と同じ)

ページに表示する関係上、ブラウザ上での画像表示は最大 1000x1000 としています。検出結果のダウンロードにも対応しており、ダウンロード時は元のサイズにリサイズされます。

ファイルの配置

必要なファイルはここに示す通り。

スクリーンショット 2022-12-08 16.08.11

  • index.html:Html ページ(内容は後述)

  • faces-detect.js:opencv.js を用いた顔検出、赤枠の表示など(内容は後述)

  • haarcascade_frontalface_default.xml:特徴分類器ファイル; faces-detect.js で使用

  • opencv.js:opencv そのもの; faces-detect.js で使用

  • utils.js:haarcascade_frontalface_default.xml を読み込むために使う; faces-detect.js で使用

特徴分類器ファイルhaarcascade_frontalface_default.xmlは opencv のリポジトリで手に入ります。
opencv/data/haarcascades at master · kipr/opencv · GitHub
これをダウンロードしておき、faces-detect.jsと同じ場所においておきます。

ソースコード : faces-detect.js

faces-detect.js では、opencv.js の読み込みと、画像ファイル読み込み時に顔検出まで行います。

// cvが読み込まれたときに実行
// cvのinitialize待ち
function onCvLoaded() {
    console.log('on OpenCV.js Loaded', cv);

    cv.onRuntimeInitialized = onCVReady();
}

// cvがInitializeされたときに実行
// DOMContentLoaded待ち
function onCVReady() {
    console.log("onCVReady");

    window.addEventListener('DOMContentLoaded', function(){
        document.getElementById("download").onclick = (event) => {
            let canvas = document.getElementById("virtual_canvas");

            let link = document.createElement("a");
            link.href = canvas.toDataURL("image/png");
            link.download = "test.png";
            link.click();
        }

        loadCascade();
    });
}

// DOMContentLoaded完了後
// 特徴分類器の読み込み
function loadCascade() {
    // cascadeファイルの読み込み
    let faceCascade = new cv.CascadeClassifier();

    // ファイル入力
    let fileInput = document.getElementById('fileInput');

    // 学習済みデータの読み込み
    // xmlファイルを読み込むので、utilsで読み込んでからcascadeを読み込む必要あり
    faceCascadeFile = './haarcascade_frontalface_default.xml';
    const utils = new Utils('error-message');
    utils.createFileFromUrl(faceCascadeFile, faceCascadeFile, () => {
        faceCascade.load(faceCascadeFile);
    });

    // ファイル読み込み完了時の動作
    fileInput.onchange = (e) => {
        onCascadeFileLoaded(cv, e, faceCascade);
    };
}

// 学習済みデータ読込完了後
// 入力画像の読み込みを行う
function onCascadeFileLoaded(cv, e, faceCascade) {
    // 画像読み込み準備
    const image = new Image();
    image.src = URL.createObjectURL(e.target.files[0]);

    image.onload = ()  => {
        detect(cv, image, faceCascade);
    }
}

// 入力画像の読み込み完了後
// 入力画像より顔検出を行い、顔領域を赤枠で囲む
function detect(cv, image, faceCascade) {
    // 画像をcanvas_inputキャンバスに読み込み
    drawMap(image)

    // 読み込み完了後:
    // canvas_inputキャンバスからopencvに読み込み
    let cvImage = cv.imread("canvas_input");
    let img_width = cvImage.cols;
    let img_height = cvImage.rows;

    // グレースケール化
    let gray = new cv.Mat();
    cv.cvtColor(cvImage, gray, cv.COLOR_RGBA2GRAY, 0);

    // 顔検出
    let faces = new cv.RectVector();
    let msize = new cv.Size(0, 0);
    faceCascade.detectMultiScale(gray, faces, 1.1, 3, 0, msize, msize);

    // 検出した領域に赤枠を表示
    for (let i = 0; i < faces.size(); ++i) {
        let point1 = new cv.Point(faces.get(i).x, faces.get(i).y);
        let point2 = new cv.Point(faces.get(i).x + faces.get(i).width, faces.get(i).y + faces.get(i).height);
        cv.rectangle(cvImage, point1, point2, [255, 0, 0, 255], 2);
    }

    // 顔検出結果をcanvas_outputキャンバスに表示
    // output用のキャンバスも同じサイズにする
    canvas_output = document.querySelector('#canvas_output');
    ctx_output = canvas_output.getContext('2d');
    canvas_output.width = img_width;
    canvas_output.height = img_height;
    canvas_output.style.width = img_width + "px";
    canvas_output.style.height = img_height + "px";

    cv.imshow("canvas_output", cvImage);

    // 仮想キャンバスにも適用(ダウンロード用; サイズは元画像と同じ)
    let virtualImage = cv.imread("virtual_canvas");
    virtual_canvas = document.querySelector('#virtual_canvas');
    for (let i = 0; i < faces.size(); ++i) {
        let point1 = new cv.Point(faces.get(i).x * (virtual_canvas.width / img_width), faces.get(i).y * (virtual_canvas.height / img_height));
        let point2 = new cv.Point((faces.get(i).x + faces.get(i).width) * (virtual_canvas.width / img_width), (faces.get(i).y + faces.get(i).height) * (virtual_canvas.height / img_height));
        cv.rectangle(virtualImage, point1, point2, [255, 0, 0, 255]);
    }
    cv.imshow("virtual_canvas", virtualImage);

    // メモリ解放
    cvImage.delete();
    virtualImage.delete();
    gray.delete();
    faces.delete();
    faceCascade.delete();
}

// 入力画像の描画処理
function drawMap(image) {
    // 仮想キャンバスに画像を描画(画像サイズはそのまま)
    virtual_canvas = document.querySelector('#virtual_canvas');
    virtual_ctx = virtual_canvas.getContext('2d');
    virtual_canvas.width = image.width;
    virtual_canvas.height = image.height;
    virtual_ctx.drawImage(image, 0, 0, image.width, image.height);

    /*--------------------------------------------------*/

    // 表示用キャンバスに画像を描画(画像サイズは縮小)
    canvas_input = document.querySelector('#canvas_input');
    scroller_inner = document.querySelector('#canvas-scroller-input-inner');

    // リサイズ処理(最大: x=1000, y=1000)
    ctx = canvas_input.getContext('2d');
    img = image;
    let width = img.width, height = img.height;
    const max_width = 1000, max_height = 1000;

    if (width > max_width) {
        if (img.width > img.height) {
            height = max_width * img.height / img.width;
            width = max_width;
        } else {
            width = max_height * img.width / img.height;
            height = max_height;
        }
    }
    if (height > max_height) {
        if (img.width > img.height) {
            height = max_width * img.height / img.width;
            width = max_width;
        } else {
            width = max_height * img.width / img.height;
            height = max_height;
        }
    }

    console.log("after width:" + width + ", height:" + height);
    canvas_input.width = width;
    canvas_input.height = height;
    canvas_input.style.width = width;
    canvas_input.style.height = height;
    ctx.canvas.width = width;
    ctx.canvas.height = height;
    ctx.drawImage(image, 0, 0, width, height);
}

基本的にはコメントに書いたとおり。一番下のdrawMap()を除き、上から順に実行されます。

  1. onCvLoaded()で cv の初期化を待ち
  2. onCVReady()で Html 要素の読み込みが完了するまで待ち
  3. loadCascade()で特徴分類器ファイルの読み込み&完了待ち
  4. ユーザが画像を選択したら、onCascadeFileLoaded()で入力画像が読み込み&完了待ち
  5. detect()で顔検出し、赤枠で囲む。完了したら、cancas_outputに検出結果を書き出し
    1. このとき、drawMap()で入力画像をcanvas_inputに書き出し、さらにvirtual_canvasにダウンロード用画像を書き出す
  6. 全部完了したら各データの解放

最後にデータの解放が最後に必要な点が要注意。

ソースコード:index.html

<html>
    <head>
        <meta charset="utf8">
        <title>顔認識 - YotioSoft CodeSpace</title>

        <link rel="stylesheet" href="/style.css">

        <script src="./faces-detect.js"></script>
    </head>

    <body>
        <div class="contect">
            <div class="title">
                顔認識
            </div>

            <div class="summary">
                opencv.js を用いて画像から顔を認識し、赤枠で囲みます。
            </div>

            <div class="app-area" id="app-area-id">
                <div>
                    <input type="file" id="fileInput" name="file" />
                </div>
                <canvas id="virtual_canvas" style="display: none;"></canvas>

                <canvas id="canvas_input"></canvas>
                <canvas id="canvas_output"></canvas>

                <button id="download">download</button>
            </div>
        </div>
    </body>

    <script src="./opencv.js" onload="onCvLoaded();"></script>
    <script src="./utils.js"></script>
</html>

Html 側では要素の配置くらいしかしていません。一つ重要なポイントは、opencv.jsの読み込みが完了したらonCvLoaded()を呼び出すという点くらいでしょうか。

サンプルページ

以上のコードが動いているページがこちら。
https://code.yotiosoft.com/faces-detect/

実際に動かしてみると、こんな感じ。

スクリーンショット 2022-12-08 16.08.11

※画像は Pixabay より

一部検出されていなかったり顔じゃない部分が顔として検出されていたりしますが、まあまあな精度だと思います。
ただし、この特徴分類器はマスクをつけた顔には対応していませんのでその点はご注意。

モザイクをかける

次に、検出した顔にモザイクをかけます。
opencv にはモザイクをかける機能は(多分)用意されていないので、

  1. 顔領域を切り抜き
  2. 顔領域だけ一旦縮小
  3. 顔領域を元のサイズに拡大し、元画像にはめ込み

という手順でモザイクをかけていきます。

まず、モザイク用の関数がこちら。

// モザイクをかける
function mosaic(img, x, y, w, h) {
    // モザイクをかける領域の切り抜き
    let roi = img.roi(new cv.Rect(x, y, w, h));

    // 画像の縮小(5x5に)
    let dst = new cv.Mat();
    let dsize = new cv.Size(5, 5);
    cv.resize(roi, dst, dsize, 0, 0, cv.INTER_AREA);

    // 画像の拡大(元のサイズに)
    let dst2 = new cv.Mat();
    let dsize2 = new cv.Size(w, h);
    cv.resize(dst, dst2, dsize2, 0, 0, cv.INTER_CUBIC);

    // モザイクを元画像に貼り付け
    dst2.copyTo(img.roi(new cv.Rect(x, y, w, h)));

    // 画像の解放
    roi.delete();
    dst.delete();
    dst2.delete();
}

引数は画像imgと、モザイクをかける位置を示すx, y, w, hの5つ。imgにモザイク後の画像が上書きされます。

そして、先程faces-detect.jsで赤枠で囲っていた部分をこれに置き換え。

function detect(cv, image, faceCascade) {

    

    // 検出した領域にモザイクを表示
    for (let i = 0; i < faces.size(); ++i) {
        mosaic(cvImage, faces.get(i).x, faces.get(i).y, faces.get(i).width, faces.get(i).height);
    }

    

    // 仮想キャンバスにも適用(ダウンロード用; サイズは元画像と同じ)
    let virtualImage = cv.imread("virtual_canvas");
    virtual_canvas = document.querySelector('#virtual_canvas');
    for (let i = 0; i < faces.size(); ++i) {
        mosaic(virtualImage, faces.get(i).x * (virtual_canvas.width / img_width), faces.get(i).y * (virtual_canvas.width / img_width),
                    faces.get(i).width * (virtual_canvas.width / img_width), faces.get(i).height * (virtual_canvas.width / img_width));
    }
    cv.imshow("virtual_canvas", virtualImage);

    
}

サンプルページ

以上のコードが動いているページがこちら。
https://code.yotiosoft.com/faces-mosaic/

実際に動かしてみると、こんな感じ。
スクリーンショット 2022-12-08 16.08.11

先程赤枠で囲まれていた部分にモザイクがかかっていることが確認できます。これで完成です。

おわりに

従来なら裏で動的にバックエンドを動かしてやるような処理ですが、opencv.js ならここまでのことがバックエンドサーバなしで完結できるところが嬉しいですね。当然 GitHub Pages でも動かせます。
今回作ったものは、近いうちに Yapps に Web アプリ「顔モザイク加工」として公開予定です。