【OpenCV.js】ななめに写った文書をピッタリさせる

こんにちは❗フリーランス・エンジニアの 九保すこひ です。

さてさて、少し前に私にとって少し前に驚くべき事実を知ることになりました。

それは・・・・・

画像処理パッケージ「OpenCV」がブラウザでも使える

というものです。

OpenCVとは、Pythonなどでは定番の画像処理パッケージのことで、PHPで言うところのGDのような存在です。

しかし、GDと比べるとより専門的な処理ができるので、数学的な知識が多少必要な分、できることもより多くなっていたりします。

ということで、今回はこのJavaScript版のOpenCVを使って、「ななめに写ってしまった文書」を自動的に修正して画像にピッタリになるように修正してみたいと思います。

つまり、こういうことですね。
↓↓↓

これが自動で・・・

ピッタリ❗

では、実際にやってみましょう。

「ブラウザだけで
OpenCVってすごいですね👍」

開発環境: OpenCV.js 4.5

準備する

OpenCV.jsにはCDNが用意されているので直接読み込むのがシンプルでいいです。ただ、ファイルサイズが大きいので、読み込みに時間がかかる場合があります。

そのため、もしサイトに設置し使いたい場合は、以下のページから「opencv-(バージョン番号)-docs.zip」をダウンロードし、展開した中にあるopencv.js/js/opencv.jsとして使ってください。

📝ダウンロードURL: https://github.com/opencv/opencv/releases

以下はバージョン4.5.2の場合です。

※ なお、後でServiceWorkerによる高速化を紹介していますが、その場合は上記の手順で実装してください。

歪みを修正する部分をつくる

では、いきなりメイン「歪んだ文書をピッタリさせる」部分です。

<html>
<head>
    <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
</head>
<body>
    <div id="app" class="p-3">
        <div v-if="!initialized">
            opencvを準備中です...しばらくお待ちください。
        </div>
        <div v-else>
            <input type="file" accept="image/*" @change="onFileChange">
            <div class="pt-3" v-if="imageData">
                <img id="image" class="float-left" :src="imageData" v-if="imageData">
                <canvas id="outputCanvas"></canvas>
            </div>
            <div class="pt-3 clear-both" v-if="imageData">
                <button type="button" class="bg-indigo-500 text-indigo-50 p-2 rounded mb-3" @click="transform">歪みを修正する</button>
            </div>
        </div>
    </div>
    <script src="https://unpkg.com/vue@3.0.2/dist/vue.global.prod.js"></script>
    <script src="https://docs.opencv.org/4.5.2/opencv.js"></script>
    <script>

        Vue.createApp({
            data() {
                return {
                    initialized: false,
                    imageData: null,
                    MIN_CONTOURS_SCALE: 20, // 最低元画像比率
                    THRESHOLD: 170, // モノクロのしきい値
                }
            },
            methods: {
                onFileChange(e) {

                    const files = e.target.files;

                    if(files.length > 0) {

                        const file = files[0];
                        const reader = new FileReader();
                        reader.onload = (e) => {

                            this.imageData = e.target.result;

                        };
                        reader.readAsDataURL(file);

                    }

                },

                // opencv
                transform() {

                    const imageElement = document.querySelector('#image');
                    const im = cv.imread(imageElement);
                    const pts = this.getContoursPoints(im);

                    if(pts) {

                        const transformedIm = this.getTransformedImage(im, pts);
                        cv.imshow('outputCanvas', transformedIm);
                        console.log('Done!');

                    } else {

                        console.log('Failed...');

                    }

                    im.delete();

                },
                getContoursPoints(im) {

                    // Image area
                    const imRectArea = im.cols * im.rows // 元画像の面積

                    // Grayscale
                    let im_gray = new cv.Mat();
                    cv.cvtColor(im, im_gray, cv.COLOR_RGBA2GRAY);

                    // Threshold
                    let threshold_im = new cv.Mat();
                    cv.threshold(im_gray, threshold_im, this.THRESHOLD, 255, cv.THRESH_BINARY);

                    // Contours
                    let contours = new cv.MatVector();
                    let hierarchy = new cv.Mat();
                    cv.findContours(threshold_im, contours, hierarchy, cv.RETR_CCOMP, cv.CHAIN_APPROX_SIMPLE);
                    hierarchy.delete();

                    let pts = null;
                    let maxCntArea = 0

                    for(let i = 0; i < contours.size(); ++i) {

                        let cnt = contours.get(i);
                        const cntArea = cv.contourArea(cnt)
                        const maxRectScale = parseInt(cntArea / imRectArea * 100) // 元画像と比べてどれぐらいの大きさか(%)

                        if(maxRectScale >= this.MIN_CONTOURS_SCALE) { // 元画像との比率でフィルター

                            if(cntArea > maxCntArea) { // より大きいものをキープ

                                let approx = new cv.Mat();
                                const epsilon = 0.02 * cv.arcLength(cnt, true)
                                cv.approxPolyDP(cnt, approx, epsilon, true)

                                if(approx.size().height === 4) { // 四角形ならキープ

                                    maxCntArea = cntArea;
                                    pts = approx // 切り抜くべき四角形の座標(4点)

                                }

                            }

                        }

                    }

                    contours.delete();
                    im_gray.delete();
                    threshold_im.delete();
                    pts.convertTo(pts, cv.CV_32FC2);

                    return pts;

                },

                getTransformedImage(im, fromPts) {

                    let transformedIm = new cv.Mat();
                    const rows = im.rows;
                    const cols = im.cols;
                    let dsize = new cv.Size(cols, rows);
                    const toPts = cv.matFromArray(4, 1, cv.CV_32FC2, [
                        cols, 0, 0, 0, 0, rows, cols, rows
                    ]);
                    const M = cv.getPerspectiveTransform(fromPts, toPts); // 変形の行列
                    cv.warpPerspective(im, transformedIm, M, dsize);

                    fromPts.delete();
                    toPts.delete();
                    return transformedIm;

                }

            },
            mounted() {

                cv['onRuntimeInitialized'] = () => {

                    this.initialized = true;

                };

            }
        }).mount('#app');

    </script>
</body>
</html>

この中で文書の歪みを修正している手順は次のとおりです。

  1. 画像をモノクロに変換
  2. しきい値を使ってより文書の部分を明確にする
  3. 全ての輪郭を取得し「形が四角形で最大のもの(=つまり文書)」を取得する
  4. 取得した位置情報から「遠近法ワープ」で画像を変形
  5. 完成!

つまり画像でいうとこうなります。

1.元画像

2.モノクロへ変換

3.しきい値を適用

4.輪郭から位置を取得

5.遠近法ワープで元画像の歪みを修正

なお、今回は「A4縦サイズ」を想定していますがパラメータを使って横向きや他のサイズへ対応することもできるでしょう。

デモページを用意しました

今回のコードを少しだけ改変したものをデモページとして公開することにしました。ぜひOpenCV.jsを試してみてください。

📝 デモページ: ななめに写った文書をピッタリにするサンプル

ちなみに:ServiceWokerによる高速化

デモページを見ていただくとお気づきかもしれませんが、OpenCV.js本体のファイルサイズが大きいので、少し待たないといけません。

次回からはブラウザのキャッシュが効いてくれるのでそれほど気にする必要はありませんが、キャッシュは完全ではありませんし、ユーザービリティとしてあまりいいとは言えませんよね。

そこで、今回はせっかくですので「Service Worker」を使って事前に/js/opencv.jsをキャッシュするようにしてみます。

例えば、流れとしては以下のようになります。

  1. ユーザーがログインする
  2. ログインした時点で /js/opencv.js をキャッシュする(バックグランドで独立して動きます)
  3. /js/opencv.js が必要なページへ移動
  4. すでにファイルはキャッシュされているので高速でページ表示できる!

つまり、キャッシュ・ファイルをローカルに保存するので高速化できるというわけですね。(HTMLなどもキャッシュできるので、オフラインでも操作できるようになります👍)

では、実際のコードです。
まず、Service Worker本体です。

/service-worker.js

const CACHE_NAME = 'opencv-cache-v1';

self.addEventListener('install', function(e) {

    e.waitUntil(
        caches.open(CACHE_NAME).then(cache => {

            return cache.addAll([
                '/js/opencv.js'
            ]);

        })
    );

});

self.addEventListener('fetch', function(e) {
    e.respondWith(
        caches.match(e.request).then(response => {

                if(response) {
                    return response;
                }
                return fetch(e.request);

        })
    );
});

※ なお、Service Workerファイルが存在するフォルダ以下で有効になります。そのため、/js/service-worker.jsに設置としてしまうと、サイト全体で有効にならなくなるため、今回はルートフォルダに設置しています。

そして、このService Workerを登録するコードです。
これは事前に/js/service-worker.jsをキャッシュしたいページと、実際に/js/service-worker.jsを使うページで実行するといいでしょう。

<html>
<body>
    Service Worker を準備中
    <script>

        window.onload = e => {

            if('serviceWorker' in navigator) {

                navigator.serviceWorker.register('/service-worker.js')
                    .then(
                        registration => {

                            console.log('ServiceWorker registration successful', registration.scope);

                        },
                        err => {

                            console.log('ServiceWorker registration failed: ', err);

                        }
                    );

            }

        }

    </script>
</body>
</html>

これでopencv.jsService Worker経由で読み込まれるので、次回からは高速でページ表示ができます👍

おまけ:Python版

もともと今回の記事は、「AWS LambdaでPythonを実行する」というのがテーマだったんですが、LambdaOpenCVを使うには自分でOpenCVを同梱しないといけないということが分かり「うーん…」となって、方向転換をしました。

(当然と言えば当然ですが、有名パッケージだから大丈夫だろうなんて考えでした…💦)

しかし、せっかくPython版もつくったのにもったいないなということで「おまけ」として公開することにしました。(ボツコード供養 🙏✨)

準備する

今回はPythonコードの中以下のパッケージを使います。
もしインストールされていない場合は先にインストールしておいてください。

  • opencv-python 4.5
  • numpy 1.19

インストールはこちら

pip3 install opencv-python

そして、

pip3 install numpy

※おそらく上のコマンドで依存パッケージとしてインストールされると思いますが念のため。

歪みを修正する部分をつくる(Python)

では、以下のようなコマンドで画像から文書の位置を取得し、歪みを修正するコードを書いていきます。

python3 transform_document.py (元画像のパス) (修正した画像のパス)

実際のコードはこちら

#!/usr/bin/env python3
# command example: python3 transform_document.py ./images/original/document.jpg ./images/transformed/document.jpg

import cv2
import numpy as np
import sys

MIN_CONTOURS_SCALE = 20 # 最低元画像比率
THRESHOLD = 170 # モノクロのしきい値

if len(sys.argv) - 1 != 2:
    print('You need to TWO arguments at least!')
    sys.exit()

INPUT_IMAGE_PATH = sys.argv[1]
OUTPUT_IMAGE_PATH = sys.argv[2]

def get_contours_points(im):
    rows, cols = im.shape[:2] # 元画像の縦、横を取得
    im_rect_area = cols * rows # 元画像の面積
    im_gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
    ret, thresh = cv2.threshold(im_gray, THRESHOLD, 255, 0)
    contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    pts = None
    max_cnt_area = 0
    for cnt in contours:
        cnt_area = cv2.contourArea(cnt)
        max_rect_scale = int(cnt_area / im_rect_area * 100) # 元画像と比べてどれぐらいの大きさか(%)
        if max_rect_scale < MIN_CONTOURS_SCALE: # 元画像との比率でフィルター continue if cnt_area > max_cnt_area: # より大きいものをキープ
            epsilon = 0.02 * cv2.arcLength(cnt, True)
            approx = cv2.approxPolyDP(cnt, epsilon, True)
            if len(approx) == 4: # 四角形ならキープ
                max_cnt_area = cnt_area
                pts = approx # 切り抜くべき四角形の座標(4点)
    return pts

def get_transformed_image(im, from_pts):
    rows, cols = im.shape[:2]
    to_pts = np.float32([[cols,0], [0,0], [0,rows], [cols,rows]])
    M = cv2.getPerspectiveTransform(from_pts, to_pts) # 変形の行列
    return cv2.warpPerspective(im, M, (cols, rows))

im = cv2.imread(INPUT_IMAGE_PATH)
pts = get_contours_points(im)

if pts is not None:
    transformed_im = get_transformed_image(im, np.float32(pts))
    cv2.imwrite(OUTPUT_IMAGE_PATH, transformed_im)
    print('Done!')
else:
    print('Failed...')

企業様へのご提案

今回の技術を使えば、サーバーやAWSなどクラウド環境がなくても写真の歪みを修正できます。(つまりコストを減らすことができます)

また、文書の撮影にスキャナーを購入する必要はなく、手持ちのスマートフォンで問題ありません。

※ なお、Google Cloud Vision API などを使えば日本語の読み取り機能も追加することができます。

ぜひ、興味がございましたらご連絡ください。😊✨

おわりに

ということで、今回はブラウザでOpenCV.jsを使って遠近法ワープを実装してみました。

なお、「おまけ」でも書きましたがLambdaPythonパッケージを使う場合は同梱してアップしないといけないというのは想定外でした 😫

・・・ただ、そうなると「Lambdaってどんな使い方してるんだろう❓❓」という疑問も浮かんできました。

Pythonを使うメリットの一つに「パッケージが豊富」というのが私の中にはあって、それがデフォルトで使えないとなると・・・というカンジです。(もしかすると「パッケージを同梱するなんて当然でしょ!」みたいなカンジなのかもしれません)

とにかく、そんなことがあったので今回は逆に「クラウドなんて不要です👍」をテーマに記事を書いてみました。

ぜひ皆さんも試してみてくださいね。

ではでは〜❗

「A5ランクの肉、
うまかったな〜🍖」

開発のご依頼お待ちしております 😊✨ お問い合わせ
また、こちらもお待ちしております。
  • 実案件の開発サポート: 詳細
  • ツイッターのフォロー: 詳細
  • 投げ銭のご支援: 詳細
どうぞよろしくお願いいたします!
このエントリーをはてなブックマークに追加       follow us in feedly