🎄

OpenCVとTypeScriptの組み合わせで快適に開発するためにJupyter Notebookを使う

2023/12/24に公開

この記事は、 Luup Advent Calendar の24日目の記事です。

こんにちは、フリーランスでソフトウェアエンジニアをしているfuji44です。
最近は、LuupのServerチームでバックエンドエンジニアをしています。

今回は、OpenCVとTypeScriptの組み合わせで快適に開発するためにJupyter Notebookを使う方法についてまとめてみました。

はじめに

画像解析する際にOpenCVとJupyter Notebookを使い、ビジュアライズしながら試行するのは非常に便利です。
この組み合わせではPythonでコーディングすることが多いと思います。OpenCVだけで検索しても出てくるのはPythonでコーディングされたものがほとんどです。

Luupではバックエンドの主だったコードはTypeScriptで記述されているため、TypeScriptで同じことがしたい!ということでやってみました。
応用で、OCRもやります。

ここで書いたコードは、こちらで公開しています。

https://github.com/fuji44/playground-typescript-opencv-notebooks

前提

作業環境は以下のとおりです。

  • Debian 11 (bullseye)
  • Node.js 20.3.1
  • npm 9.6.7
  • Python 3.9.2
  • jupyter core 4.7.1
  • jupyter-notebook 6.2.0

Jupyter NotebookでTypeScriptを実行する

Jupyter Notebookは使用するカーネルを切り替えることで様々な言語を使って記述できるようになっています。Jupyterのアーキテクチャについて興味のある方は以下のページをご覧ください。

https://docs.jupyter.org/en/latest/projects/architecture/content-architecture.html

つまり、TypeScript用のカーネルを使えばよいということで、探してみるといくつかあるみたいです。今回はtslabを使ってみようと思います。

https://github.com/yunabe/tslab

まず、tslabをインストールします。

$ npm install -g tslab
....
$ tslab install
Running python3 /usr/local/share/npm-global/lib/node_modules/tslab/python/install.py --tslab=tslab
Installing TypeScript kernel spec
Installing JavaScript kernel spec
$ tslab --version
tslab 1.0.21

Jupyterのカーネルとしてtslabが認識されていることを確認します。

$ jupyter kernelspec list
Available kernels:
  jslab      /home/node/.local/share/jupyter/kernels/jslab
  tslab      /home/node/.local/share/jupyter/kernels/tslab
  python3    /usr/share/jupyter/kernels/python3

次に、Notebook サーバーを起動します。

$ jupyter notebook
[I 01:46:45.914 NotebookApp] Serving notebooks from local directory: /workspaces/sample-node-typescript-opencv
[I 01:46:45.914 NotebookApp] Jupyter Notebook 6.2.0 is running at:
[I 01:46:45.914 NotebookApp] http://localhost:8888/?token=7f61e3425b775165bd12ac3e9f9e5908b038f2402f5c026e
[I 01:46:45.914 NotebookApp]  or http://127.0.0.1:8888/?token=7f61e3425b775165bd12ac3e9f9e5908b038f2402f5c026e
[I 01:46:45.914 NotebookApp] Use Control-C to stop this server and shut down all kernels (twice to skip confirmation).
[C 01:46:45.921 NotebookApp] 
    
    To access the notebook, open this file in a browser:
        file:///home/node/.local/share/jupyter/runtime/nbserver-10364-open.html
    Or copy and paste one of these URLs:
        http://localhost:8888/?token=7f61e3425b775165bd12ac3e9f9e5908b038f2402f5c026e
     or http://127.0.0.1:8888/?token=7f61e3425b775165bd12ac3e9f9e5908b038f2402f5c026e

起動が出来たらコンソールに表示されたURLをブラウザでアクセスします。
TypeScriptのnotebookを作成して実行できることを確認します。

NotebookでTypeScriptを使いhello,world

実行できますね。

OpenCVで処理した画像を表示する

次は、OpenCVを使って処理した画像を出力してみます。
まず、必要なnpm packageをインストール。

npm i @techstark/opencv-js jimp tslab

OpenCVには公式のJavaScriptバインディングであるOpenCV.jsがあります。ただ、npm packageは提供されていないので、サードパーティーのものを利用します。今回は、TypeScriptサポートもしていてメンテナンスされていそうな@techstark/opencv-jsを使用します。
OpenCV.jsだけでは画像ファイルを読み込むことができないため、Jimpを合わせて使用します。
Notebook上で画像を出力するために、tslabのnpm packageもインストールします。

これで、準備ができたのでコードを書いていきます。

まずは、インポート。

import { display } from "tslab"
import Jimp from "jimp"
import cv from "@techstark/opencv-js"

次に、画像表示用のユーティリティー関数。

function toImage(imageMat: cv.Mat): Jimp {
  return new Jimp({
    width: imageMat.cols,
    height: imageMat.rows,
    data: Buffer.from(imageMat.data)
  })
}
async function print(imageMat: cv.Mat) {
  const invoke = async () => {
    const outputImage = toImage(imageMat)
    display.png((await outputImage.getBufferAsync(outputImage.getMIME())))
  }
  if (imageMat.type() === cv.CV_8UC1) {
    cv.cvtColor(imageMat, imageMat, cv.COLOR_GRAY2RGBA, 0)
    await invoke()
    cv.cvtColor(imageMat, imageMat, cv.COLOR_RGBA2GRAY, 0)
    return
  }
  await invoke()
}

最後に、画像ファイルを読み取ってそのままの画像とグレースケールした画像を表示します。

const image = await Jimp.read("../images/apple.jpg")
const imageMat = cv.matFromImageData(image.bitmap)
await print(imageMat)

const workImage = new cv.Mat()
cv.cvtColor(imageMat, workImage, cv.COLOR_RGBA2GRAY, 0)
await print(workImage)

記述が出来たら、ステップを順番に実行。

OpenCV.jsで画像を出力

これで、OpenCV+TypeScriptでもビジュアライズしながら試行できるようになりました!

応用: 表の画像から文字を抽出する

主題としてはこれで終わりなのですがこれだけだと面白くないので、表の画像からOCRでセルの値を読み取ってみます。
OCRにはtesseract.jsを使用します。

npm i tesseract.js

インポートを追加します。

 import { display } from "tslab"
 import Jimp from "jimp"
 import cv from "@techstark/opencv-js"
+import { createWorker } from "tesseract.js"

読み込む画像ファイルを変更して表示してみます。
画像は、うどん県統計情報コーナーのExcelファイルを変換して作成しました。

-const image = await Jimp.read("../images/apple.jpg")
+const image = await Jimp.read("../images/udonjouhou_data1-1.png")
 const imageMat = cv.matFromImageData(image.bitmap)
 await print(imageMat)
-
-const workImage = new cv.Mat()
-cv.cvtColor(imageMat, workImage, cv.COLOR_RGBA2GRAY, 0)
-await print(workImage)

表画像を出力

ここから画像解析処理です。画像解析処理については、話の本筋から外れるため、詳細な解説は省略させていただきます。ご理解いただければ幸いです。

まずは、前処理を行います。はじめはグレースケール。

const workImage = imageMat.clone()
// grayscale
cv.cvtColor(workImage, workImage, cv.COLOR_RGBA2GRAY, 0)
await print(workImage)

グレースケールした表画像を出力

次に、二値化。

// threshold
const thresh = 220
cv.threshold(
  workImage,
  workImage,
  thresh,
  255,
  cv.THRESH_BINARY
)
await print(workImage)

二値化した表画像を出力

スキャナーで取り込んだ画像のようにノイズがあるわけではないので、前処理はこのくらいにします。
次に、表のセルの座標を取得するために、輪郭を検出します。

// Contour detection
const contours = new cv.MatVector()
const hierarchy = new cv.Mat()
cv.findContours(
  workImage,
  contours,
  hierarchy,
  cv.RETR_TREE,
  cv.CHAIN_APPROX_SIMPLE
)

const overrideImageMat = imageMat.clone()
const rects: cv.Rect[] = []
for (let i = 0; i < contours.size(); i++) {
  // 輪郭の面積をもとに、セルらしい面積のものだけを処理する
  // 実務では、もう少し条件を考えたほうが良い
  const area = cv.contourArea(contours.get(i))
  if (area < 1000 || area > 35000) {
    continue
  }

  const rect = cv.boundingRect(contours.get(i))
  rects.push(rect)

  const color = new cv.Scalar(
    Math.random() * 255,
    Math.random() * 255,
    Math.random() * 255,
    255
  )
  cv.drawContours(overrideImageMat, contours, i, color, 3)
}

await print(overrideImageMat)

輪郭検出した表画像を出力

検出したセルの座標情報をもとにOCRを実行していきます。

const worker = await createWorker("jpn")
try {
  const ocrData: { text: string, image: cv.Mat }[] = []
  for (const rect of rects) {
    const cellImageMat = imageMat.roi(rect).clone()
    const cellImage = toImage(cellImageMat)
    const result = await worker.recognize(await cellImage.getBufferAsync(cellImage.getMIME()))

    ocrData.push({
      text: result.data.text,
      image: cellImageMat
    })
  }

  for (let i = 0; i < 5; i++) {
    const ocrDatum = ocrData[i]
    await print(ocrDatum.image)
    console.log(ocrDatum.text)
  }
} finally {
  worker.terminate()
}

OCR対象の画像とOCR結果を出力

無事、文字を抽出することが出来ました。

おわりに

以上、OpenCVとTypeScriptの組み合わせで快適に開発するためにJupyter Notebookを使う方法についてでした。

PythonでOpenCVを使う場合は、何も考えずにNotebookで検証して実際のコードに落とし込むという作業をしていました。はじめはNotebookでTypeScriptを実行できないと思っていたのでNotebookなしで開発していたのですがなかなかつらかったです。試行結果の確認が即座にできるのはやっぱりありがたいのだなぁとしみじみと感じました。

OpenCV.jsについての記事自体も少ないので、ここで書いた内容が誰かの役に立てばうれしく思います。

参考文献

Luup Developers Blog

Discussion