タブレット/スマホのカメラを使ってのOCRをAngularとTesseract.jsで実装

## 概要
中小IT企業に勤めています。製造業様向けにタブレットを用いた品質管理システムを作成するとして、材料のコード、ロットNoを手入力ではなくOCRで読み取ることはできるか調べてほしい、とのことで調査した結果です。AngularとTesseract.jsを使ってサンプルを作成しました。

![sample01.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261708/53810cdc-8289-7e5c-6d42-2a37d64f758d.gif)


## 環境
- windows 10 Home
- wsl2
- docker desktop 4.6.1
- node.js 14
- 動作確認した端末
    - iphone se 
    - iPad(第7世代) 


## 主要フレームワーク、ライブラリ
- angular 13
- Tesseract.js
    [Tesseract.js \| Pure Javascript OCR for 100 Languages!](https://tesseract.projectnaptha.com/)
    
## 参考にさせていただいた記事、サイト
- [Webの技術だけで作るQRコードリーダー - Qiita](https://qiita.com/kan_dai/items/4331aae12f5f2d3ad18d)
    実装方法、また、当記事の構成、こちらの記事を参考にさせていただきました。
- [ブラウザでTesseract.js使ってOCRしてみた - Qiita](https://qiita.com/keito1024/items/dff19c95ca7c9b6c5d0c)
- [Jeromewu - Tesseract.js Angular App - StackBlitz](https://stackblitz.com/github/jeromewu/tesseract.js-angular-app)
- [Angular-cli ng serveでhttps - Qiita](https://qiita.com/cilly/items/6dfcf15600b9e3d5e414)
    後に出るブラウザで端末のカメラを取得する`MediaDevices.getUserMedia()`を働かせるはhttpではなくhttpsサーバの必要があります。
    Angularのテストサーバをhttpsで立ち上げる方法について参考にさせて頂きました。

## コード

全てのコードは以下で公開しております。
[GitHub - YamaDash82/angular-tesseract-ocr: AngularフレームワークとTesseract.jsライブラリを用いてOCRを実装](https://github.com/YamaDash82/angular-tesseract-ocr)


### 準備
Angularプロジェクトフォルダにて、Tesseract.jsをインストールします。


```bash
npm install --save tesseract
```


### テンプレート
video要素にてタブレットのカメラの映像を取り込んでいます。`hidden`にし、カメラの映像はvideo要素では表示させません。
ふたつあるcanvas要素のうち1つ目のcanvas(以下canvas1)に、カメラの映像を表示させます。
カメラの映像の一部分をocr取込み対象としたく、また取込み対象となる領域に枠線を表示させたかったためこのvideoを不可視にし、canvas1に埋め込んで表示させるという方法をとりました。
canvas2はOCRライブラリに渡す画像を埋め込みます。`読込`ボタンタップ時に、canvas1から読み込み対象となる部分を切り取り、canvas2にはめ込みます。


```html
<p>AngularでOCR</p>
<p>{{consoleText}}</p>
<video 
  #targetVideo 
  playsinline 
  class="reader-video"
  hidden
></video>
<!--canvas1-->
<canvas
  #previewCanvas
>
</canvas>
<!--canvas2-->
<canvas
  #captured
>
</canvas>
<button (click)="recognize()" class="ocr-button">読込</button>
```

### コンポーネント

#### メンバ定義


```typescript:app.component.ts(抜粋)
　//コンポーネント側で要素を参照するので@ViewChildを利用する。
  //video要素
  @ViewChild('targetVideo') videoVc !: ElementRef;
  videoEmt!: HTMLVideoElement;
    
  //カメラ映像と取込領域の枠線を表示するcanvas要素
  @ViewChild('previewCanvas') canvasVc !: ElementRef;
  canvasEmt!: HTMLCanvasElement;
  canvasCtx!: CanvasRenderingContext2D | null;
  
  //OCR読み取り対象となる画像を埋め込むcanvas要素
  @ViewChild('captured') capturedVc !: ElementRef;
  capturedEmt!: HTMLCanvasElement;

  //カメラ映像の中から切り取るサイズ
  targetSize =  { width: 192, height: 96 };
　
  //カメラ映像中に、切り取る領域に枠線を書く際の座標。
  //後で計算した座標を代入する。
  rectPoints: { x: number, y:number }[] = [
    {x: 0, y:0}, 
    {x: 0, y:0}, 
    {x: 0, y:0}, 
    {x: 0, y:0}
  ];
    
  //後述のsetInterval関数を用いて、指定時間ごとに、カメラ映像の取込、canvas1への書き込みを繰り返す処理の識別子を格納する。
  //clearintervalにて繰り返し処理を止める際に使用する。
  private intervalId!: NodeJS.Timeout;
  
  //処理状況や、エラー情報を一時格納する変数
  consoleText = "";
```
#### カメラ映像の取込
`video`タグに`navigator.mediaDevices.getUserMedia()`メソッドを用いて取得した端末のカメラ映像を連結させます。



```typescript:app.component.ts(抜粋)
  async ngAfterViewInit(): Promise<void> {
    this.videoEmt = this.videoVc.nativeElement;    

    //...中略
    
    await ((): Promise<void> => {
      return new Promise((resolve, reject) => {
        navigator.mediaDevices.getUserMedia({
          audio: false,
          video: { facingMode: "environment"}
        }).then(stream => {
          this.videoEmt.srcObject = stream;
          this.videoEmt.setAttribute("playsinline", "true");
          return resolve();
        }).catch(err => {
          return reject(err);
        });
      })
    })();
  }
  //...
}
```


### カメラ映像と取込領域の枠線を描画するcanvasの設定
video要素の映像のサイズが変わった時(タブレットの縦横が変わった時を想定)、canvas1のサイズをカメラ映像のサイズに再設定し、取込領域の枠線を描画する座標を再計算します。
要素のサイズが決まった後に処理をする必要があるので、`ngOnInit`ではなく、`ngAfterViewChecked`にて処理します。


```typescript:app.component.ts(抜粋)
  async ngAfterViewChecked(): Promise<void> {
    const settingVideoWidth = Math.floor(this.videoEmt.videoWidth / 2);
    const settingVideoHeight = Math.floor(this.videoEmt.videoHeight / 2);
    
    //要素のvideo要素のサイズ変更があった際、映像を表示するcanvasのサイズの再設定、取込領域の枠線の座標の再計算を行う。
    if(this.canvasEmt.width !== settingVideoWidth || this.canvasEmt.height !== settingVideoHeight) {
      this.canvasEmt.width = settingVideoWidth ;
      this.canvasEmt.height = settingVideoHeight;    
      
      //カメラ映像から切り取る部分の座標を取得する。
      //左上
      this.rectPoints[0].x = Math.floor((settingVideoWidth - this.targetSize.width) / 2);
      this.rectPoints[0].y = Math.floor((settingVideoHeight - this.targetSize.height) / 2);

      //右上
      this.rectPoints[1].x = Math.floor(settingVideoWidth / 2) + Math.floor(this.targetSize.width / 2);
      this.rectPoints[1].y = this.rectPoints[0].y;
      
      //右下
      this.rectPoints[2].x = this.rectPoints[1].x;
      this.rectPoints[2].y =  Math.floor(settingVideoHeight / 2) + Math.floor(this.targetSize.height / 2);
      
      //左下
      this.rectPoints[3].x = this.rectPoints[0].x;
      this.rectPoints[3].y = this.rectPoints[2].y;
      
      //カメラ映像をcanvas1に表示させる。
      this.takeVideo();
    }
  }
```

### カメラ映像と取込領域の枠線の描画
video要素を介して取得したカメラの画像を0.2秒おきにcanvas要素に書き込んでいます。あわせて、画像中に線を描画し、OCR取込領域を示しています。
[canvas に図形を描く - Web API \| MDN](https://developer.mozilla.org/ja/docs/Web/API/Canvas_API/Tutorial/Drawing_shapes)


```typescript:app.component.ts(抜粋)
  async takeVideo() {
    try {
      this.intervalId = setInterval(() => {
        //カメラ映像を再生する。
        this.videoEmt.play();
        
        //カメラ映像をcanvas1に描画する。
        this.canvasCtx?.drawImage(this.videoEmt, 0, 0, this.canvasEmt.width, this.canvasEmt.height);
        
        //video再生画像中に、OCRで取り込む範囲の線を描画する。
        if(this.canvasCtx) {
          
          this.canvasCtx.beginPath();
          
          this.canvasCtx.moveTo(this.rectPoints[0].x, this.rectPoints[0].y);
          this.canvasCtx.lineTo(this.rectPoints[1].x, this.rectPoints[1].y);
          this.canvasCtx.lineTo(this.rectPoints[2].x, this.rectPoints[2].y);
          this.canvasCtx.lineTo(this.rectPoints[3].x, this.rectPoints[3].y);
          this.canvasCtx.closePath();
          
          this.canvasCtx.strokeStyle = "gray";
          this.canvasCtx.lineWidth = 5;
  
          this.canvasCtx.stroke();
        }
      //200ms毎に繰り返す。
      }, 200);
    } catch(err) {
      setTimeout(() => {
        this.consoleText = err instanceof Error ? err.message : "takeVideoでエラー発生";
      });
    }
  }
```

### OCR実行
OCR実行部分です。
以下の順に処理しています。
1. カメラ映像からcanvas1へ描画する繰り返し処理を停止する
3. カメラ映像を表示しているcanvas1からOCR対象領域を切り取りcanvas2に描画する
    [drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)－Canvasリファレンス](http://www.htmq.com/canvas/drawImage_s.shtml)
5. canvas2から画像を取得する
6. OCRライブラリに渡し、結果を得る
7. 取得した結果をalertメソッドにて表示する
    
```typescript:app.component.ts(抜粋)
  async recognize() {
    //canvasへの描画を止める。
    clearInterval(this.intervalId);
    this.videoEmt.pause();
    
    //カメラ映像からOCRにかける領域を切り取る。
    const capCtx = this.capturedEmt.getContext('2d');

    capCtx?.drawImage(this.canvasEmt, 
      this.rectPoints[0].x, 
      this.rectPoints[0].y, 
      this.targetSize.width, 
      this.targetSize.height, 
      0, 0, 
      this.targetSize.width, 
      this.targetSize.height
    );

    //canvas2から読み込む画像を取得する。
    const image = this.capturedEmt.toDataURL();

    //ocr実行
    try {
      const worker = Tesseract.createWorker();
      await worker.load();
      //複数言語連ねるときは+で連結する。
      //https://github.com/naptha/tesseract.js/blob/master/docs/api.md#worker-load-language
      await worker.loadLanguage('jpn');
      await worker.initialize('jpn');

      const recongnized = await worker.recognize(image);
      await worker.terminate();
      
      alert(recongnized.data.text);
    } catch(err) {
      this.consoleText = err instanceof Error ? err.message : "認識処理中にエラーが発生しました。";
    }

    this.consoleText = "";

    this.takeVideo();
  }
```


## 感想
OCRの精度はそこそこでした。
印刷物の文字を認識させると、たいてい誤字を含みます。軽い照明の反射でも読み取れないこともありました。
しかしタブレットでOCRが実装できた時は、自分はなんだか偉業を達成したような気になりました。

しかし、ほとんどは既存のフレームワーク、ライブラリ、有志の記事の助けにより成り立っております。


以上です。