OpenCV.jsをChrome拡張で動かしてみた

## はじめに
OpenCVにはJavaScriptから動かせるWebAssembly版があります。
JavaScriptで動かせるということはブラウザで動きますし、拡張機能でも使えます。

ただ、拡張機能はセキュリティにおける制限が色々あり、知らないと動かせるようになるまで結構苦労します。（苦労しました）
拡張機能とOpenCVの組み合わせの記事は殆ど見かけませんでしたので、どうせならと記事にしました。

## サンプル
本記事で説明しているソースコードをプッシュしています。
最小限のコードなので、こっちを見る方が早いかもしれません。

https://github.com/Freedom645/opencvjs-chrome-extension-sample

## OpenCV.jsを用意
まずは、OpenCVのモジュールそのものを用意します。

とは、言っても公式のチュートリアルページを見ると「ビルド済みのファイルがドキュメントにあるから、ダウンロードしてね」と言ってます。

> You can get a copy of opencv.js from opencv-{VERSION_NUMBER}-docs.zip in each release, or simply download the prebuilt script from the online documentations at "https://docs.opencv.org/{VERSION_NUMBER}/opencv.js" (For example, https://docs.opencv.org/4.5.0/opencv.js. Use 4.x if you want the latest build).

https://docs.opencv.org/4.5.5/d0/d84/tutorial_js_usage.html

ビルドは結構メンドクサイので、ありがたくいただきましょう。
```powershell
> curl https://docs.opencv.org/4.5.5/opencv.js -o opencv.js
```


## 拡張機能内で読み込むも、使えない
早速、ダウンロードしたopencv.jsを拡張機能上（アクションポップアップ上）で読み込もうとすると以下のエラーが出て使えません。
```
Uncaught EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src 'self'".
```

実は、opencv.jsではeval式が使用されていますが、Chrome拡張（Manifest V3から？）ではセキュリティの観点からeval式の使用が禁じられています。その為、単純に拡張機能で読み込もうとするとセキュリティポリシー違反となり実行できません。

### ではどうするか
処理をSandbox化して、iframeを通してやり取りする必要があります。

具体的には下記の手順でiframeで切り離すことでOpenCVの機能が使えるようになります。

1. OpenCVの処理をSandbox化したページに分ける
1. 画像処理が必要なページにiframeを通して読み込む
1. iframeとpostMessageでやり取りして、画像処理を実装


https://developer.chrome.com/docs/extensions/mv3/sandboxingEval/

## 実装してみる
フォルダ構成はこんな感じです。`sandbox/sandbox.html`がいわゆるSandbox化したページになり、`index.html`から呼び出して`sample.png`を画像処理するようにしてみます。
```
root/
　├ sandbox/
　│　├ opencv.js
　│　├ sandbox.html
　│　└ sandbox.js
　├ index.html
　├ index.js
　├ manifest.json
　└ sample.png
```

右下の実行ボタンで、グレースケール化するような処理をしてみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311823/9137fc50-40c1-4ffe-74c6-6209cc70bf3b.png)


### 1.OpenCVの処理をSandbox化
OpenCVの処理を配置する用のhtmlを作ります。
基本的に受け取った画像データを処理するだけなので、htmlではスクリプトだけ読み込んで終わりです。
```sandbox.html
<script src="opencv.js"></script>
<script src="sandbox.js"></script>
```
OpenCVの処理を記述するjsファイルです。
画像データは文字列（base64）でやり取りする必要があるので、入出力時に変換してます。
```sandbox.js
window.addEventListener("message", (event) => {
  // 受け取ったbase64を読み込み
  const srcImg = document.createElement("img");
  srcImg.src = event.data;

  srcImg.onload = () => {
    // imgタグからMatに読み込んで色空間変換
    const src = cv.imread(srcImg);
    cv.cvtColor(src, src, cv.COLOR_RGBA2GRAY, 0);

    // Canvaに描画
    const outputCanvas = document.createElement("canvas");
    cv.imshow(outputCanvas, src);
    src.delete();

    // base64に変換して返信
    event.source.postMessage({ result: outputCanvas.toDataURL("image/png") }, event.origin);
  };
});
```
`manifest.json`にセキュリティポリシーの設定とsanboxのページを指定します。
```manifest.json
{
  "manifest_version": 3,
  ...,
  "content_security_policy": {
    "sandbox": "sandbox allow-scripts; script-src 'self' 'unsafe-eval';"
  },
  "sandbox": {
    "pages": [
      "sandbox.html"
    ]
  }
}
```

### 2.画像処理が必要なページにiframeを通して読み込む
今回はindex.html上で処理をしたいので、iframeタグで`sandbox.html`を読み込みます。
なお、iframeは表示させる必要がないので、iframeタグには`display: none`を設定します。
```index.html
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
</head>

<body>
  <iframe id="sandbox" src="./sandbox/sandbox.html" style="display: none;"></iframe>
  <img id="input" width="300px">
  <img id="output" width="300px">
  <input type="button" id="execute" value="実行"></input>
  <script src="index.js"></script>
</body>

</html>
```

### 3.iframeとpostMessageでやり取りして、画像処理を実行

sanbox化したiframeには、`.contentWindow.postMessage(base64, "*");`でbase64を送信します。
iframeからの返信には`window.addEventListener("message", (event) =>{})`で結果を受け取ります。
（~~JSってどうやったら綺麗に書けますか~~）
```index.js
const input = document.getElementById("input");
input.src = "sample.png";

document.getElementById("execute").onclick = (e) => {
  const canvas = document.createElement("canvas");
  canvas.width = input.width;
  canvas.height = input.height;
  canvas.getContext("2d").drawImage(input, 0, 0, canvas.width, canvas.height);
  const base64 = canvas.toDataURL("image/png");

  document.getElementById("sandbox").contentWindow.postMessage(base64, "*");
};

window.addEventListener("message", (event) => {
  // 結果受け取り
  document.getElementById("output").src = event.data.result;
});
```

## 動かしてみる
拡張機能の導入は端折ります。（一応、GitHubのREADME.mdに記載してあります）

拡張機能のアイコンを押してポップアップを表示し、「実行」ボタンをクリック
<img width="70%" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311823/673370ca-047a-83e8-eb0c-60817348511f.png">
見事、拡張機能のポップアップ上でグレースケール化できました。
<img width="70%" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311823/d2cacc80-888c-519b-85ed-fefed8c3fc36.png">

## 実装上での注意点
注意点を記載しておきます。役に立てれば幸いです。

### スクリプトのロードに時間がかかる
opencv.jsをscriptタグで読み込む際は、ローカルとは言えど8MBほどあるため、ほんの少しロードに時間がかかります。
人間が退屈になるようなレベルではありませんが、特定の場面においては意外にもこれが原因で画像処理が走らないこともあります。

例えば、jsファイルのトップレベルに記載して`cv.imread(xxxx)`を実行しようとすると、`cv.imread is not a function`が出たりします。

一応、回避方法はあるのですが、今回みたいにiframeの外からイベント発火される場合は、ちゃんとした非同期処理を実装する必要があります。
https://stackoverflow.com/questions/56671436/cv-mat-is-not-a-constructor-opencv

`cv.xxxx is not a yyyyyy`が出たらこれを疑いましょう。

### デストラクタがない
opencvjsでは、言語の仕様上デストラクタが実装されていません。
従って、`cv.Mat`や`cv.MatVector`のインスタンスを自分で作成した場合は、自分で削除する必要があります。

```javascript
const src = cv.imread(imgTag)
const dst = new cv.Mat();

// 何かの処理

src.delete();
dst.delete();
```

最近の言語は優秀なGCのおかげでメモリ解放を忘れがちですが、解放し忘れたり、タイミングを間違えるとメモリリークの原因になります。（ループ周りで量産しがち）

### ドキュメントが不十分
opencvjsの公式ドキュメント（チュートリアル）は網羅しきれていません。
サンプル付きで解説しているのがありますが、それ以外で使いたい関数があると途端に敷居が高くなります。

では、どうやって使い方を探すかと言うと、GitHubでコードを検索するのがおすすめです。
例えば、`cv.inRange`で2値化をしたい場合は、下記みたいに調べると色々ヒットします。
https://github.com/search?l=JavaScript&q=cv.inRange&type=Code

サンプルがあるのとないのとでは実装のしやすさがかなり変わるので、是非試してみてください。

## おわりに
ここまで拡張機能の開発も、OpenCVの開発も初めてだったにも関わらずなんだかんだいけてよかったです。
拡張機能とOpenCVを組み合わせて作成したという記事は見当たらなかったので、誰かの役に立てれば嬉しい限りです。

ちなみに、今開発しているのは再生しているYouTubeから画面を取得して、ゲームのちょっとしたデータを解析する機能を作っています。
結構形になってきてはいますが、誤認識があったり、ポップアップ上での動作しかできなかったりと、ユーザビリティが著しく悪いです（笑）
公開できるレベルになればまた記事を書こうかと思います。