Laravelでななめに撮影された文書のずれを整える(Perspective transform)

さてさて、このところ公開している記事では<video><canvas>などのタグを使って画像や映像を処理する方法を紹介しています。

これらの記事は「リアルとコンピュータの融合」というテーマに沿って書かれているのですが、実はもうひとつ隠れたテーマがあったりします。それは、「業務の効率化」です。

特に最近は人材不足がニュースなどで取り沙汰されていますが、できるだけプログラムであらかじめ決められた処理が(完全自動化まではいかなくとも)簡素化できれば時代の流れに沿った情報を提供できるのでは?と考えてたんですね。

そして、この流れで考えたときに1つやりたいことがあったのを思い出しました。それが今回タイトルにある、

画像の中でななめになっている文書や写真をぴったり真っ直ぐに変換する

という機能です。

例えば、こんな画像やPDFをみたことがないでしょうか。

「明らかにずれてます・・・😅」

もしくは、コピーしたときの本が分厚かったりして次のように立体的に文書がずれてしまっている場合。

「できれば画像の中に入って真正面から読みたい・・・😫」

ということで、今回はこういった画像をLaravel(PHP)で「まっすぐピッタリ」に修正する機能を実装してみたいと思います。

開発環境: Ubuntu 18.04(ウェブ環境:nginx + php-fpm)、Laravel 5.8、Vue 2.6

やりたいこと

今回実装する内容は次のとおりです。

  1. ブラウザから画像を選択
  2. 選択された画像をcanvasに表示
  3. ずれた文書や写真の角4つをクリックで選択
  4. 真っ直ぐな画像を作成

では実際にやっていきましょう!

準備

今回画像のずれ・ゆがみを整えるためにImageMagickを利用しますので、以下のコマンドでパッケージをインストールします。

sudo apt install php-imagick

インストールが完了したら、php-fpm(もしくは環境によってはapache / httpd)を再起動しておいてください。

sudo systemctl restart php7.2-fpm

phpinfo()で確認して以下のように「imagick」が表示されていたらインストールは成功です。

Laravel側の作業

では、ここから実際にLaravelで作業をしていきましょう。

ルートをつくる

routes/web.phpに以下3つのルートを追加します。

Route::get('perspective_transform/upload', 'PerspectiveTransformController@upload');
Route::get('perspective_transform/download', 'PerspectiveTransformController@download');
Route::post('perspective_transform/transform', 'PerspectiveTransformController@transform');

内容は次のとおりです。

  • upload() ・・・ 画像を選択してアップロードする
  • download() ・・・ ずれを修正した画像をダウンロードする
  • transform() ・・・ ImageMagickを使ってずれを修正する

コントローラーをつくる

続いてコントローラーです。以下のコマンドで専用のPerspectiveTransformControllerを作成しましょう。

php artisan make:controller PerspectiveTransformController

app/Http/Controllers/PerspectiveTransformController.phpが作成されるので中身を以下のように変更します。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class PerspectiveTransformController extends Controller
{
    public function upload() {

        return view('perspective_transform');

    }

    public function download() {

        return response()->download($this->getImageFilePath());

    }

    public function transform(Request $request) {

        $imagick = new \Imagick();
        $imagick->readImage($request->image);
        $image_width = $imagick->getImageWidth();
        $image_height = $imagick->getImageHeight();

        // 割合から実際のX,Y座標を取得する
        $from_points = [];

        foreach($request->selected_points as $point) {

            $from_points[] = [
                'x' => intval($image_width * $point['x']),
                'y' => intval($image_height * $point['y']),
            ];

        }

        // ずれを修正するための座標を作成(移動元 → 移動後)
        $distortion_points = [

            /*  左上  */
            $from_points[0]['x'], $from_points[0]['y'],   // 移動元(x,y)
            0, 0,   // 移動先(x,y)

            /*  右上  */
            $from_points[1]['x'], $from_points[1]['y'],   // 移動元(x,y)
            $image_width, 0,    // 移動先(x,y)

            /*  右下  */
            $from_points[2]['x'], $from_points[2]['y'],   // 移動元(x,y)
            $image_width, $image_height,  // 移動先(x,y)

            /*  左下  */
            $from_points[3]['x'], $from_points[3]['y'],   // 移動元(x,y)
            0, $image_height    // 移動先(x,y)

        ];

        $result = false;

        try {

            // 画像の修正
            $imagick->distortImage(\Imagick::DISTORTION_PERSPECTIVE, $distortion_points, false);

            // 画像の保存
            $imagick->writeImage($this->getImageFilePath());

            $result = true;

        } catch (\Exception $e) {}

        return ['result' => $result];

    }

    private function getImageFilePath() {

        return storage_path('app/perspective_transform.png');

    }
}

一番重要なtransform()で実行していることは次のとおりです。

  1. ImageMagickで送信されてきた画像を読み込む
  2. ずれを修正するための角4つは割合で送信されてくるので実際のX, Y座標を計算
  3. 移動元と移動後のXY座標を配列に格納
  4. それらのデータを使ってずれを修正
  5. 画像を保存

※ なお、このコードは「送信された画像の横幅と高さを基準にしてずれを整えて」います。

つまりイメージは次のようになります。

そのため、上の画像のように元画像とずれたコンテンツの比率が同じであればいいですが、そうでない場合は縦横比がおかしくなってしまいます。

例えば、横長のエリアを選択した場合でも以下のように縦に引き伸ばされた画像になります。(元画像がたて長のため)

もし、これを解消したい場合は「ちなみに:その1」をご覧ください。(私はそれほど数学が得意ではないので簡易バージョンですが、少しはマシな切り出しができていると思います)

ビューをつくる

では最後にコントローラーのupload()で呼び出されるビューを作成しましょう。/resources/views/perspective_transform.phpを作成して以下の内容を保存してください。

<html>
<body>
    <div id="app">
        <h5>画像を選択してください</h5>
        <input type="file" accept="image/*" @change="onImageChange">
        <hr>
        <div style="float:right;">
            <div v-if="status=='select_points'">
                以下の順で修正後の角4つをクリックしてください。
                <ul>
                    <li>&#x2196;左上</li>
                    <li>&#x2197;右上</li>
                    <li>&#x2198;右下</li>
                    <li>&#x2199;左下</li>
                </ul>
            </div>
            <div v-if="status=='prepared'">
                角4つの選択が完了しました。
                <ul>
                    <li v-for="point in selectedPoints">
                        (x, y)=(
                        <span v-text="point.x"></span>,
                        <span v-text="point.y"></span>
                        )
                    </li>
                </ul>
                <button type="button" @click="reselect">やり直す</button>
                <button type="button" @click="transform">送信する</button>
            </div>
        </div>
        <canvas ref="canvas" @click="onImageClick"></canvas>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.0/axios.min.js"></script>
    <script>

        new Vue({
            el: '#app',
            data: {
                file: null,
                status: 'upload',
                selectedPoints: [],
                imageData: null
            },
            methods: {
                onImageChange(e) {

                    this.selectedPoints = [];
                    this.status = 'select_points';
                    this.file = e.target.files[0];
                    this.renderImage();

                },
                onImageClick(e) {

                    if(this.selectedPoints.length < 4) {

                        const x = Math.round(e.offsetX / this.canvas.width * 1000) / 1000;
                        const y = Math.round(e.offsetY / this.canvas.height * 1000) / 1000;
                        const position = {
                            x: x,
                            y: y
                        };
                        this.selectedPoints.push(position);
                        this.drawPoint(position);

                    }

                },
                drawPoint(position) {

                    const r = parseInt(this.canvas.height / 50);
                    const x = Math.round(position.x * this.canvas.width);
                    const y = Math.round(position.y * this.canvas.height);
                    this.context.beginPath();
                    this.context.arc(x, y, r, 0, 2 * Math.PI);
                    this.context.fillStyle = 'red';
                    this.context.fill();

                    if(this.selectedPoints.length === 4) {

                        this.status = 'prepared';

                    }

                },
                transform() {

                    const url = '/perspective_transform/transform';
                    let formData = new FormData();

                    for(let i = 0 ; i < this.selectedPoints.length ; i++) {

                        let x = this.selectedPoints[i]['x'];
                        let y = this.selectedPoints[i]['y'];
                        formData.append('selected_points['+ i +'][x]', x);
                        formData.append('selected_points['+ i +'][y]', y);

                    }

                    formData.append('image', this.imageData);
                    axios.post(url, formData)
                        .then((response) => {

                            if(response.data.result) {

                                location.href = '/perspective_transform/download';

                            }

                        });

                },
                reselect() {

                    this.status = 'select_points';
                    this.selectedPoints = [];
                    this.renderImage();

                },
                renderImage() {

                    let reader = new FileReader();
                    reader.readAsDataURL(this.file);
                    reader.onload = (e) => {

                        let img = new Image();
                        img.onload = () => {

                            // キャンバスに描画
                            const width = img.width;
                            const height = img.height;

                            this.canvas.width = width;
                            this.canvas.height = height;
                            this.context.drawImage(img, 0, 0, width, height);

                            // 画像データを格納
                            this.context.canvas.toBlob((blob) => {

                                this.imageData = blob;

                            }, this.file.type, 1);

                        };
                        img.src = e.target.result;

                    };

                }
            },
            computed: {
                canvas() {

                    return this.$refs['canvas'];

                },
                context() {

                    return this.canvas.getContext('2d');

                }
            }
        });

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

この中で重要な各メソッドを1つずつ見ていきましょう。

onImageChange(), renderImage()

画像が選択された時に実行されるメソッドです。各パラメータを初期化し、さらに選択された画像をキャンバスに描画します。

onImageClick(), drawPoint()

ずれを修正するための4つ角を指定するためのメソッドです。実際には画像が描画されたキャンバスがクリックされたときに呼ばれ、クリックされた場所には以下のように赤丸が描画されます。

transform()

選択された画像と指定された4つ角をコントローラーのtransform()Ajaxで送信するメソッドです。もし処理が成功したらdownload()にリダイレクトし、ダウンロードが自動的に開始されます。

テストしてみる

では、これで準備は完了です。
実際に以下の画像を使って案内板の部分をぴったりと画像におさめてみましょう。

※ ちなみにこれは、先日神戸のストリートピアノを弾きにいったときの写真です。(2019.07.31までの期間限定)

まず画像を選択してキャンバスに表示させます。

つぎに案内板の4つ角を選択します。

「送信する」ボタンをクリック。

すると自動的に画像がダウンロードされます。

そして、ダウンロードされた画像がこちらです↓↓↓

ピッタリ画像の中に掲示板がおさまりました。
成功です!

お疲れ様でした。

ソースコードをダウンロードする

今回実際に開発したソースコード一式を以下からダウンロードすることができます。

※ ただし、パッケージのインストールなどはご自身で行っていただく必要があります。

Laravelでななめに撮影された文書のずれを整える

ちなみに:その1

記事中でも書きましたが、紹介したコードでは縦と横の比率が違っているとダウンロードする画像は歪んでしまいます。そこでもし選択した4つ角を基準として画像を切り出したい場合は以下のコード(2ヵ所)をPerspectiveTransformControllertransform()に追加してください。

public function transform(Request $request) {

    // 省略

    // 割合から実際のX,Y座標を取得する
    // 省略

    // 修正後の画像サイズを取得
    $selected_width = sqrt(
        pow($from_points[1]['x'] - $from_points[0]['x'], 2) +
        pow($from_points[1]['y'] - $from_points[0]['y'], 2)
    );
    $selected_height = sqrt(
        pow($from_points[3]['x'] - $from_points[0]['x'], 2) +
        pow($from_points[3]['y'] - $from_points[0]['y'], 2)
    );

    if($selected_width > $selected_height) {  // 横長の場合

        $selected_height_ratio = $selected_height / $selected_width;
        $image_height = intval($image_width * $selected_height_ratio);

    } else {    // 縦長の場合

        $selected_width_ratio = $selected_width / $selected_height;
        $image_width = intval($image_height * $selected_width_ratio);

    }

    // ずれを修正するための座標を作成(移動元 → 移動後)
    // 省略

    try {

        // 画像の修正
        // 省略

        // 対象部分だけ切り出し
        $imagick->cropImage($image_width, $image_height, 0, 0);

        // 画像の保存
        // 省略

    } catch (\Exception $e) {}

    return ['result' => $result];

}

もし分かりにくい場合は「ソースコードをダウンロードする」からダウンロードできるファイルの中には完全版が入っていますので、そちらをご参照ください(ただし、追加コードはコメントアウトされています)

ちなみに:その2

実はImageMagickには、脆弱性が多いことが報告されています。そのため、よりセキュアなウェブサイトを目指している場合、ImageMagickはあまり適していない場合もあります。

もしImageMagickの脆弱性について興味がある方は、Qiitaにあるこちらの記事がおすすめです。

また、どうやらPythonなどでよく利用されるOpenCVがPHPでも利用できるようですので、こちらを検討してもいいかもしれません。私もそのうち時間ができたら試してみたいと思います。

おわりに

ということで、今回は画像内のずれを修正してきれいに画像内におさまるようにしてみました。

この機能があればいちいち画像ソフトで編集する必要はなくなりますし、作業時間も短縮できると思います。

また、ブラウザを使ってできるのでPCやスマホ関係なしに実行が可能です。

ぜひ皆さんも今回の記事を参考にしてみてくださいね。

ではでは〜!

この記事が役立ちましたらシェアお願いします😊✨