プリザンターでQRコードを読み取る_OpenCV.js

[OSSのノーコード・ローコード開発ツール「プリザンター」 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/pleasanter)への参加記事です。

プリザンターにて、QRコードを読み込むスクリプトをつくってみました！
OpenCVをJavaScriptで使える**OpenCV.js**を使用します。


# スクリプトを使う準備
## 環境
- windows11 IIS
- SQLServer
- プリザンター ver1.4.9.2

## 使用ライブラリ
- [OpenCV.js](https://github.com/opencv/opencv/releases)
- `opencv-4.10.0-docs.zip`にある`opencv.js`を使用
- もしくは[https://docs.opencv.org/4.10.0/opencv.js](https://docs.opencv.org/4.10.0/opencv.js) を使う（バージョン番号を変える）
- 他には[jsQR](https://github.com/cozmo/jsQR)というのも有名なようです

## jsファイルの読み込み
プリザンターの管理画面から追加。
`body script bottom`として以下を入れる。
`main.js`：処理する内容
`opencv.js`：上記でダウンロードしたもの
「新規作成」・「編集」の画面で使いたいのでチェック

```html
<script src="/scripts/extentions/opencv/opencv.js"></script>
<script src="/scripts/extentions/opencv/main.js"></script>
```

# カメラ表示用htmlの追加
プリザンターの拡張htmlとかで入れておいたらいいと思いますが、今回はhtmlファイルをmain.jsから読み込むようにしています。

```camera.html
<!DOCTYPE html>
<html>
<button id="videoButton">onoff</button>
<video id="videoInput"
       autoplay="true"
       style="display: none"></video>
<canvas id="canvasOutput"
        style="display: none"></canvas>

</html>
```

# jsの追加

<details>
<summary>main.jsの全体</summary>

```main.js
let video;
let canvas;
let targetElement = document.getElementById("Issues_IssueIdField");
fetch("/scripts/extentions/opencv/camera.html")
  .then((response) => response.text())
  .then((data) => {
    targetElement.insertAdjacentHTML("beforebegin", data);
    document.getElementById("videoButton").addEventListener("click", videoLoad);
    video = document.getElementById("videoInput");
    video.addEventListener("loadeddata", qrReader);
    canvas = document.getElementById("canvasOutput");
    canvas.onmouseover = () => (canvas.style.cursor = "pointer");
    canvas.onmouseout = () => (canvas.style.cursor = "default");
    canvas.addEventListener("click", qrClick);
    document.addEventListener("visibilitychange", visibilityChange);
  });

function videoLoad() {
  // onになってたら停止
  if (video.srcObject !== null) {
    video.srcObject.getTracks().forEach((track) => track.stop());
    video.srcObject = null;
    video.style.display = "none";
    canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height);
    canvas.style.display = "none";
    canvas.textContent = "";
    console.log("videoを停止");
    return;
  }
  // カメラを起動
  const constraints = {
    video: { width: 320, height: 240, facingMode: "environment" },
    audio: false,
  };
  navigator.mediaDevices
    .getUserMedia(constraints)
    .then((stream) => {
      video.srcObject = stream;
      video.style.display = "inline";
      canvas.style.display = "inline";
      console.log("videoを開始");
    })
    .catch(function (err) {
      console.log(err.name + ": " + err.message);
      alert("カメラを許可しないと使えません");
    });
}

let frame;
let gray;
let points;
let straight_qrcode;
// videoのオンオフで呼び出される
function qrReader() {
  if (video.srcObject == null) {
    frame.delete();
    gray.delete();
    points.delete();
    straight_qrcode.delete();
    return;
  }
  let FPS = 30;
  let begin = Date.now();

  let cap = new cv.VideoCapture(video);
  video.height = video.videoHeight;
  video.width = video.videoWidth;
  frame = new cv.Mat(video.videoHeight, video.videoWidth, cv.CV_8UC4);
  cap.read(frame);
  // グレー
  gray = new cv.Mat();
  cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
  // // QRコードのオブジェクト
  // let qrDecoder = new cv.QRCodeDetector();
  let qrDecoder = new cv.QRCodeDetectorAruco();
  // バーコードの検出
  // let qrDecoder = new cv.barcode_BarcodeDetector();
  points = new cv.Mat();
  straight_qrcode = new cv.Mat();
  // QRの検出とデコード
  let qrCodeString = qrDecoder.detectAndDecode(gray, points, straight_qrcode);
  if (qrCodeString) {
    console.log(qrCodeString);
    // qrコードに枠線を引く
    let n = points.total();
    for (let i = 0; i < n; i++) {
      let start = new cv.Point(
        points.data32F[i * 2],
        points.data32F[i * 2 + 1]
      );
      let end = new cv.Point(
        points.data32F[(i * 2 + 2) % (n * 2)],
        points.data32F[(i * 2 + 3) % (n * 2)]
      );
      cv.line(frame, start, end, [0, 255, 0, 255], 3);
      console.log(`線を描画: (${start.x}, ${start.y}) -> (${end.x}, ${end.y})`);
    }
    canvas.textContent = qrCodeString;
  } else {
    // console.log("qrなし");
  }
  // cv.line(frame, { x: 10, y: 20 }, { x: 100, y: 300 }, [255, 0, 255, 255], 3);
  // 画像内にQR文字列を入れる
  // canvas.textContent = "abcdefg";
  // canvas.textContent = "https://qiita.com/advent-calendar/2024/pleasanter";
  cv.putText(
    frame,
    canvas.textContent,
    { x: 10, y: 50 },
    cv.FONT_HERSHEY_SIMPLEX,
    0.5,
    [255, 0, 255, 255],
    2
  );
  // canvasへの表示
  cv.imshow("canvasOutput", frame);

  let delay = 1000 / FPS - (Date.now() - begin);
  setTimeout(qrReader, delay);
}
// qr読み込んだ画像をクリックした時の処理
// ページを開くかタイトルへ反映させて、カメラをオフ
function qrClick(e) {
  let qrText = e.target.textContent;
  console.log("qr読み込み:" + qrText);
  if (qrText.startsWith("https")) {
    window.open(qrText, "_blank");
  } else {
    document.getElementById("Issues_Title").value = qrText;
    videoLoad();
  }
}
// ページを離れた時にカメラをオフにする
function visibilityChange() {
  if (document.visibilityState === "hidden" && video.srcObject !== null) {
    videoLoad();
  }
}

```

</details>


## 読み込んだ時の処理
イベント追加など。
htmlファイルからカメラ表示を追加。

```main.js
let video;
let canvas;
let targetElement = document.getElementById("Issues_IssueIdField");
fetch("/scripts/extentions/opencv/camera.html")
  .then((response) => response.text())
  .then((data) => {
    targetElement.insertAdjacentHTML("beforebegin", data);
    document.getElementById("videoButton").addEventListener("click", videoLoad);
    video = document.getElementById("videoInput");
    video.addEventListener("loadeddata", qrReader);
    canvas = document.getElementById("canvasOutput");
    canvas.onmouseover = () => (canvas.style.cursor = "pointer");
    canvas.onmouseout = () => (canvas.style.cursor = "default");
    canvas.addEventListener("click", qrClick);
    document.addEventListener("visibilitychange", visibilityChange);
  });

```

## ビデオを開始・終了する処理
[ビデオ使用方法のチュートリアル](https://docs.opencv.org/4.10.0/dd/d00/tutorial_js_video_display.html)
`navigator.mediaDevices.getUserMedia`はhttpsかlocalhostでないと使えないので注意。
スマホからも試したかったので、自己証明書でhttpsにしました。

```main.js
function videoLoad() {
  // onになってたら停止
  if (video.srcObject !== null) {
    video.srcObject.getTracks().forEach((track) => track.stop());
    video.srcObject = null;
    video.style.display = "none";
    canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height);
    canvas.style.display = "none";
    canvas.textContent = "";
    console.log("videoを停止");
    return;
  }
  // カメラを起動
  const constraints = {
    video: { width: 320, height: 240, facingMode: "environment" },
    audio: false,
  };
  navigator.mediaDevices
    .getUserMedia(constraints)
    .then((stream) => {
      video.srcObject = stream;
      video.style.display = "inline";
      canvas.style.display = "inline";
      console.log("videoを開始");
    })
    .catch(function (err) {
      console.log(err.name + ": " + err.message);
      alert("カメラを許可しないと使えません");
    });
}
```

## QRコード取得の処理
QRコードがあったら枠を囲って、その文字列を画面に表示します。
文字は一度読んだらフォーカス外れても消えないようにしています。

```main.js
let frame;
let gray;
let points;
let straight_qrcode;
// videoのオンオフで呼び出される
function qrReader() {
  if (video.srcObject == null) {
    frame.delete();
    gray.delete();
    points.delete();
    straight_qrcode.delete();
    return;
  }
  let FPS = 30;
  let begin = Date.now();

  let cap = new cv.VideoCapture(video);
  video.height = video.videoHeight;
  video.width = video.videoWidth;
  frame = new cv.Mat(video.videoHeight, video.videoWidth, cv.CV_8UC4);
  cap.read(frame);
  // グレー
  gray = new cv.Mat();
  cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
  // // QRコードのオブジェクト
  // let qrDecoder = new cv.QRCodeDetector();
  let qrDecoder = new cv.QRCodeDetectorAruco();
  // バーコードの検出
  // let qrDecoder = new cv.barcode_BarcodeDetector();
  points = new cv.Mat();
  straight_qrcode = new cv.Mat();
  // QRの検出とデコード
  let qrCodeString = qrDecoder.detectAndDecode(gray, points, straight_qrcode);
  if (qrCodeString) {
    console.log(qrCodeString);
    // qrコードに枠線を引く
    let n = points.total();
    for (let i = 0; i < n; i++) {
      let start = new cv.Point(
        points.data32F[i * 2],
        points.data32F[i * 2 + 1]
      );
      let end = new cv.Point(
        points.data32F[(i * 2 + 2) % (n * 2)],
        points.data32F[(i * 2 + 3) % (n * 2)]
      );
      cv.line(frame, start, end, [0, 255, 0, 255], 3);
      console.log(`線を描画: (${start.x}, ${start.y}) -> (${end.x}, ${end.y})`);
    }
    canvas.textContent = qrCodeString;
  } else {
    // console.log("qrなし");
  }
  // cv.line(frame, { x: 10, y: 20 }, { x: 100, y: 300 }, [255, 0, 255, 255], 3);
  // 画像内にQR文字列を入れる
  // canvas.textContent = "abcdefg";
  // canvas.textContent = "https://qiita.com/advent-calendar/2024/pleasanter";
  cv.putText(
    frame,
    canvas.textContent,
    { x: 10, y: 50 },
    cv.FONT_HERSHEY_SIMPLEX,
    0.5,
    [255, 0, 255, 255],
    2
  );
  // canvasへの表示
  cv.imshow("canvasOutput", frame);

  let delay = 1000 / FPS - (Date.now() - begin);
  setTimeout(qrReader, delay);
}
```

## canvasをクリックした時の処理
読み込んだQRの内容をタイトルへ反映させます。
もしくはページを開く。

```main.js
// qr読み込んだ画像をクリックした時の処理
// ページを開くかタイトルへ反映させて、カメラをオフ
function qrClick(e) {
  let qrText = e.target.textContent;
  console.log("qr読み込み:" + qrText);
  if (qrText.startsWith("https")) {
    window.open(qrText, "_blank");
  } else {
    document.getElementById("Issues_Title").value = qrText;
    videoLoad();
  }
}

```

## ページを離れた時の処理
ページを離れたらカメラをオフにします。

```main.js
function visibilityChange() {
  if (document.visibilityState === "hidden" && video.srcObject !== null) {
    videoLoad();
  }
}
```

# 完成！
QRコードを読み取れるようになりました！
左がカメラそのままで、右がopencv.jsで加工されたもの。
これはページ飛んでますけど、プリザンターへのデータ反映とか色々組み合わせられますね！

![qrのgif3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2383487/cd3f136d-04d7-d966-04a2-03635c6858ee.gif)



# その他
## QRCodeDetectorAruco
`cv.QRCodeDetector()`よりも`cv.QRCodeDetectorAruco()`の方が、速くて検出精度もいいようです。
これを変えただけでも`[Violation]'setTimeout' handler took xxms`が出なくなるくらいのはやさになりました。
[こちらの方](https://www.codevace.com/py-opencv-qrcode-detection/)が（Python・OpenCV）で検証されていました。

## バーコードの読み取り
`  let qrDecoder = new cv.QRCodeDetectorAruco();`の所を、`new cv.barcode_BarcodeDetector();`にすればバーコードでも読み取れるようです。
`detectAndDecode`の内容は同じように使えます。

## httpsにする
自己証明書でのhttpsとしました。
[こちら](https://tech.robotpayment.co.jp/entry/2022/05/12/092516)や[こちら](https://kb.eway-crm.com/en/faq-1/server/how-to-create-self-signed-certificate-on-local-ip-address)を参考にpowershellで作成。
これでスマホからでもカメラ使えますが、ブラウザの上の警告表示はでます。

```powershell
New-SelfSignedCertificate -Subject localip -TextExtension @("2.5.29.17={text}DNS=hostnameee&IPAddress=192.168.xxx.xxx") -NotAfter (Get-Date).AddYears(10) -CertStoreLocation cert:\LocalMachine\My
```

## videoとcanvasのサイズ
videoとcanvasのサイズを合わせないと、pcで出来ていてもスマホでサイズ/比率がおかしくなったりします。
`video: { width: 320, height: 240}`で指定してサイズを合わせておきます。
```js
  video.height = video.videoHeight;
  video.width = video.videoWidth;
  let frame = new cv.Mat(video.videoHeight, video.videoWidth, cv.CV_8UC4);
```
ブラウザとかスマホ機種によったらサイズおかしくなるかもしれない。
[こちら](https://adiary.adiary.jp/0420)も参考。

# 参考
- https://docs.opencv.org/4.10.0/d0/d84/tutorial_js_usage.html
- https://neos21.net/blog/2020/07/03-02.html
- https://qiita.com/stoyosawa/items/e2173907809d409bf74c