プロトタイプ
射影変換
ホモグラフィ行列推定
Fusetools

Fusetools - 画像投稿フォームの実装例 - 射影変換(ホモグラフィ変換)

More than 1 year has passed since last update.

例えば、名刺管理アプリなどでは、クライアントから解析対象の画像を取得することがありますよね。
その時、カメラの角度によっては、その対象物(本来長方形)が、
台形になり、斜めになり、さらには画像の中央からズレた場所に写っていて、
後続の解析処理がうまく実行できないことがあります。

そんな時に使える、画像投稿用フォームの実装例を紹介します。
開発には、Fusetoolsというプロトタイピングツールを使用します。

サーバでの画像変換処理については、以下に記載しました。(12/15/2017追記)
PIL/pillowとNumpyで射影変換(ホモグラフィ変換)をしてみた / GAE環境でも実行可能

なぜFusetools?

ちなみに、日本で今のところマイナーなFusetoolsを取り上げているのは、
回し者ではなく、ただ純粋におすすめしたいからです。
Fuse開発チームの方曰く、製品品質となることも1つの売りにしているプロトタイプツールで、
実際、自身のスマートフォンWebゲーム開発+PhoneGapのシングルページアプリ開発経験からして、
Fusetoolsの描画性能(Androidでもヌルヌル)と、UIの自由度、
JavaScript V8 Engineによる、Web開発経験の移植可能性の高さは、とても魅力です。
もちろんカメラなど端末の各機能へのアクセスも可能です。

なので、この記事に限らず、自由度が低そうな印象の"プロトタイプツール"の中でも、
「Fusetoolsは頑張ったらできる子なんです」というところを示していければと思います。

この記事を通じて実現できるもの

以下のような一般的な画像投稿のフローを実現します。
画像投稿フロー.png

注) この例では、クライアントアプリ側で、ホモグラフィ変換を行いません。
ホモグラフィ変換に必要なパラメータを自然なUI操作の中で取得するまでを目指します。

ホモグラフィ変換とは

自分なりに一番わかりやすく説明しようとすると、
「ある四角形を、回転させたり引き伸ばしたりして、
他の四角形の形に当てはめるよう変換する処理のこと」をホモグラフィ変換と言います。
例えば、ある対象物を斜めから撮影した画像から、
その対象物を真正面から見た場合の画像へと変換する処理などで用いられます。
(利用例: 名刺管理アプリ, Google翻訳アプリ, 3D空間でのテクスチャマッピング(アフィン変換))

変換前 変換後
IMG_0211.jpg tmpjd2h7L.jpg

(C)Delta Air Lines, lnc.

詳細は、こちらの資料にて丁寧に解説されています。
(アフィン変換という類似変換もありますが、これは平行四辺形 x 拡大/縮小 x 回転まで対応できますが、台形には対応できません。原理/実装はとても似ています。)

画像投稿フローの実装

以下のような、自由変形可能な四角形での領域選択を実装します。

初期画面 写真の取得方法選択 撮影 or
画像選択
画像表示 領域選択
IMG_0396.png IMG_0397.png >>>>>>> IMG_0398.png IMG_0399.png

おそらく、Fusetools上でも何通りか表現方法があると思いますが
ここで紹介するのは、おそらく一番楽に導入できる、
uxファイルというwebでいうHTML+JSのようなファイルで完結する実装方法です。

実装環境

Fusetools 1.4.0

実装例

以下の説明で用いたコードを含む、Fusetoolsサンプルプロジェクトを
Github上に公開しています。

自由に頂点を移動できる領域選択用UIパーツ

my.CropArea.ux
<Panel ux:Class="my.CropArea" Layer="Overlay" Width="100%" Height="100%">
    <object ux:Property="CornerList" />
    <JavaScript>
        var Observable = require("FuseJS/Observable");
        var cornerList = this.CornerList.innerTwoWay();
        var tmpCornerList = Observable();
        var strokePairList = tmpCornerList.map(function(c) {
            var pairIndex = (c.id + 1 <= 3) ? c.id + 1 : 0;
            var strokePair = {start: c, end: tmpCornerList.getAt(pairIndex)};
            return strokePair;
        });
        // マーカー表示用の設定値
        var circleSize = 80;
        var radius = 40;
        var offset = - circleSize / 2;
        var insideCircleSize = 20;
        // ドラッグ対象のマーカーのID
        var selected_id = null;
        var lastX = null, lastY = null, localX = null, localY = null,
            initialX = null, initialY = null;
        function onMoved(args) {
            if (selected_id == null) {
                return;
            }
            if (localX == null && localY == null) {
                // localX,localYは、イベントを発行したエレメント内における発火点のX,Y座標のこと
                localX = -radius + args.localX;
                localY = -radius + args.localY;
                initialX = args.x;
                initialY = args.y;
            }
            lastX = Math.floor(initialX + (args.x - initialX) - localX);
            lastY = Math.floor(initialY + (args.y - initialY) - localY);
            updatePosition(selected_id, lastX, lastY);
        }

        function onDragCorner(args) {
            selected_id = args.data.id;
        }

        function onReleaseCorner(args) {
            // 指を話した時のみ、このux:Classの呼び出し元へと変数の更新を伝播
            updatePosition(selected_id, lastX, lastY, true);
            selected_id = null;
            lastX = null;
            lastY = null;
            localX = null;
            localY = null;
            initialX = null;
            initialY = null;
        }

        function updatePosition(id, x, y, updateParent=false) {
            if (id == null || x == null || y == null) {
                return;
            }
            tmpCornerList.getAt(id).x.value = x;
            tmpCornerList.getAt(id).y.value = y;
            if (updateParent) {
                cornerList.replaceAll(tmpCornerList.toArray());
            }
        }

        var initialCornerList = Observable();
        cornerList.onValueChanged(module, function(v) {
            if (!initialCornerList.value && cornerList.toArray().length > 0) {
                initialCornerList.replaceAll(cornerList.toArray());
                tmpCornerList.replaceAll(cornerList.toArray());
            }
        });

        module.exports = {
            circleSize: circleSize,
            offset: offset,
            insideCircleSize: insideCircleSize,
            initialCornerList: initialCornerList,
            strokePairList: strokePairList,
            onDragCorner: onDragCorner,
            onReleaseCorner: onReleaseCorner,
            onMoved: onMoved,
        }
    </JavaScript>

    <!-- 頂点を指定するためのマーカー表示の雛形 -->
    <Panel ux:InnerClass="CornerPoint" ux:Name="CornerPointElm" Layer="Overlay" X="{x}" Y="{y}">
        <Circle Color="Blue" Width="{circleSize}" Height="{circleSize}" Offset="{offset}, {offset}" HitTestMode="LocalBounds" Opacity="0.3">
            <Pressed>
                <Callback Handler="{onDragCorner}" />
            </Pressed>
        </Circle>
        <Circle Color="Blue" Width="{insideCircleSize}" Height="{insideCircleSize}" Offset="{offset}, {offset}" />
    </Panel>

    <!-- 実際のマーカー表示 -->
    <ClientPanel Moved="{onMoved}" HitTestMode="LocalBoundsAndChildren">
        <Released>
            <Callback Handler="{onReleaseCorner}" />
        </Released>
        <Each Items="{initialCornerList}">
            <Text Value="({x}, {y})" Color="Gray" TextWrapping="Wrap" X="{x}" Y="{y}" Offset="-30,10"/>
                <!-- InnerClass:CornerPointを呼び出し -->
                <CornerPoint />
        </Each>
    </ClientPanel>

    <!-- 各マーカーを結ぶ直線の表示 -->
    <ClientPanel>
        <!-- https://www.fusetools.com/community/forums/bug_reports/curve_dont_draw_line -->
        <Each Items="{strokePairList}">
            <Curve StrokeWidth="3" Width="1" Height="1" StrokeColor="Blue" Style="Straight" Layer="Overlay" X="0" Y="0" Offset="0, 0" Opacity="0.5">
                <CurvePoint At="{start.x}, {start.y}" />
                <CurvePoint At="{end.x}, {end.y}" />
            </Curve>
        </Each>
    </ClientPanel>
</Panel>

備考: "ux:Class"とは何か / ネームスペース指定の必要性
"ux:Class"というのは、HTMLと関連するJavaScriptをまとめたブロック、AngularのModuleみたいなもので、
外部から、その名前を指定することで、そのブロックを丸々インポートできるような仕組みになっています。
Fusetoolsは、これらのClassの再利用(ユーザ間でのシェア)を推奨するのに合わせ、
他のClassとの衝突を避ける為、ネームスペースでの管理も合わせて推奨しています。
ネームスペースは、"ネームスペース名."と指定するのが慣習のようで、仮に上記では、"my."としています。

投稿フォームの実装

上記のUIパーツを組み込み、投稿フォーム全体を作ります。
試しにGoogle Vision API のベストプラクティスに則って、1024x768サイズの画像を投稿するようにしてみます。
この投稿フォームでは、上記の通り定義したmy.CropAreaを以下の形で呼び出して使用しています。

<my.CropArea CornerList="{cornerList}" />
投稿フォーム.ux
<Page>
    <JavaScript File="投稿フォーム.js" />
    <Panel>
        <DockPanel ux:Name="CropPanel" Visibility="Collapsed" Height="100%" Width="100%">
            <Rectangle Color="Black" Width="100%" Height="100%" Layer="Background" />
            <Panel Dock="Top" Height="90%">
                <WhileTrue Value="{showCropPanel}">
                    <Change CropPanel.Visibility="Visible" />
                </WhileTrue>
                <WhileTrue Value="{isImageUploaded}">
                    <Image File="{uploadedImage.path}" StretchMode="Uniform" Width="100%" Height="80%" Placed="{imagePlaced}" />
                </WhileTrue>
                <my.CropArea CornerList="{cornerList}" />
            </Panel>
            <Panel Dock="Top" Height="10%">
                <my.Button Text="<" Clicked="{onCloseCropPanelClicked}"  Layer="Overlay" Width="40" Alignment="Left" />
                <my.Button Text="Upload" Clicked="{onUploadImageClicked}" IsLoading="{isLoading}" Alignment="Center" />
            </Panel>
        </DockPanel>

        <StackPanel ux:Name="ImageUploader" Margin="0,50">
            <Panel ux:Name="PictureAddPanel" Alignment="Center" Width="100%" Height="200" Visibility="Visible">
                <Clicked>
                    <Toggle Target="showPhotoUploadOptions"/>
                </Clicked>
                <Rectangle Layer="Background" CornerRadius="0,0,3,3" Color="#eee" />
                <StackPanel Orientation="Horizontal" Alignment="Center" ItemSpacing="7" Margin="0,13">
                    <Image StretchMode="Uniform" Width="17" Height="17" Opacity="0.3">
                        <MultiDensityImageSource>
                            <FileImageSource File="./Assets/fa-camera@1x.png" Density="1" />
                            <FileImageSource File="./Assets/fa-camera@2x.png" Density="2" />
                            <FileImageSource File="./Assets/fa-camera@4x.png" Density="4" />
                        </MultiDensityImageSource>
                    </Image>
                    <Text FontSize="20" TextColor="0,0,0,0.3" Margin="0,2,0,0">Add a picture</Text>
                </StackPanel>
                <WhileTrue Value="{isImageUploaded}">
                    <Change PictureAddPanel.Opacity="0" Duration="0.5" />
                    <Change PictureAddPanel.Visibility="Collapsed" Duration="0.5" />
                </WhileTrue>
            </Panel>
        </StackPanel>
    </Panel>

    <Panel ux:Name="PopupPanel" ClipToBounds="true" Layer="Overlay" Visibility="Collapsed">
        <WhileTrue ux:Name="showPhotoUploadOptions">
            <Change PopupPanel.Visibility="Visible" Duration="0.5"/>
            <Change DropShadow.Opacity="0.7" Duration="0.5"/>
            <Change PhotoMenuPanel.Offset="0,-20" Duration="0.2" Delay="0.22" DelayBack="0" Easing="CubicOut"/>
        </WhileTrue>

        <Rectangle ux:Name="DropShadow" Color="#222" Opacity="0" Layer="Background">
            <Clicked>
                <Toggle Target="showPhotoUploadOptions"/>
            </Clicked>
        </Rectangle>
        <DockPanel>
            <StackPanel ux:Name="PhotoMenuPanel" Dock="Bottom" Width="90%" Offset="0,200">
                <Rectangle Color="#FDFDFD" CornerRadius="8" Layer="Background" />
                <ButtonEntry Text="Take a photo" Clicked="{onTakePictureClicked}">
                    <Clicked><Toggle Target="showPhotoUploadOptions"/></Clicked>
                </ButtonEntry>
                <Divider />
                <ButtonEntry Text="Select from library" Clicked="{onSelectPictureClicked}">
                    <Clicked><Toggle Target="showPhotoUploadOptions"/></Clicked>
                </ButtonEntry>
                <Divider />
                <ButtonEntry Text="Cancel">
                    <Clicked><Toggle Target="showPhotoUploadOptions"/></Clicked>
                </ButtonEntry>
            </StackPanel>
        </DockPanel>
    </Panel>

</Page>
投稿フォーム.js
var Observable = require("FuseJS/Observable");
var CameraRoll = require("FuseJS/CameraRoll");
var Camera = require("FuseJS/Camera");
var ImageTools = require('FuseJS/ImageTools');

var postUrl = "https://YOUR.API.ENDPOINT";

var cornerList = Observable(
    {id:0, x: Observable(0), y: Observable(0)},
    {id:1, x: Observable(0), y: Observable(0)},
    {id:2, x: Observable(0), y: Observable(0)},
    {id:3, x: Observable(0), y: Observable(0)}
);

var uploadedImage = Observable();
var imageElmInfo = {height: 0, width: 0, x: 0, y: 0};
var isImageUploaded = uploadedImage.map(function(image) {
    if (uploadedImage.value == null) {
        return false;
    }
    return uploadedImage.value.path.length > 0;
});
var showCropPanel = Observable(false);

function onTakePictureClicked() {
    Camera.takePicture(1024, 768)
    .then(function(image) {
        uploadedImage.value = image;
        showCropPanel.value = true;
    }).catch(function(e){
        console.log("Error");
        console.log(e);
    });
}

function onSelectPictureClicked() {
    CameraRoll.getImage()
    .then(function(image) {
        // if ((image.width < 640 && image.height < 480) || (image.width < 480 && image.height < 640)) {
        //     throw new Exception("too small image");
        // }
        var resizeOptions = {
            mode: ImageTools.KEEP_ASPECT,
        };
        if (image.width < image.height) {
            resizeOptions['desiredWidth'] = 768;
            resizeOptions['desiredHeight'] = 1024;
        } else {
            resizeOptions['desiredWidth'] = 1024;
            resizeOptions['desiredHeight'] = 768;
        }
        return ImageTools.resize(image, resizeOptions);
    }).then(function(image) {
        uploadedImage.value = image;
        showCropPanel.value = true;
    }, function(error) {
        console.log(JSON.stringify(error));
    });
}

function onCancelPictureClicked() {
    uploadedImage.value = null;
}

function onUploadImageClicked() {
    if (!isImageUploaded.value) {
        return;
    }

    ImageTools.getBase64FromImage(uploadedImage.value)
    .then(function(b64image) {
        var cornerListPayload = [];
        // この例では、縦長画像のみを想定しています。
        var scale = uploadedImage.value.height / imageElmInfo.height;
        cornerList.forEach(function(c) {
            cornerListPayload.push([
                (c.x.value - imageElmInfo.x) * scale,
                (c.y.value - imageElmInfo.y) * scale
            ]);
        });
        var payload = {
            'img': b64image,
            'corner_list': cornerListPayload,
        };
        return fetch(postUrl, {
            method: "POST",
            headers: new Headers({"Content-type": "application/json"}),
            credentials: 'include',
            body: JSON.stringify(payload)
        }).then(function(response) {
            return new Promise(function(resolve, reject) {
                if (response.ok) {
                    resolve(response.json());
                } else {
                    console.log('failed to post.');
                    reject(response);
                }
            });
        });
    }).catch(function(e) {
        console.log("upload failed.");
        console.log(e.status, e.statusText, e.toString());
    });
}

function onCloseCropPanelClicked() {
    uploadedImage.value = null;
    showCropPanel.value = false;
}

function imagePlaced(args) {
    var aspRatio = uploadedImage.value.width / uploadedImage.value.height;
    var imgWidth = args.height * aspRatio;
    var leftX = Math.floor((args.width - imgWidth) / 2);
    var rightX = Math.floor(args.width - leftX);
    cornerList.getAt(0).x.value = leftX;
    cornerList.getAt(0).y.value = args.y;
    cornerList.getAt(1).x.value = leftX;
    cornerList.getAt(1).y.value = args.y + args.height;
    cornerList.getAt(2).x.value = rightX;
    cornerList.getAt(2).y.value = args.y + args.height;
    cornerList.getAt(3).x.value = rightX;
    cornerList.getAt(3).y.value = args.y;

    imageElmInfo.height = args.height;
    imageElmInfo.width = args.width;
    imageElmInfo.x = leftX;
    imageElmInfo.y = args.y;
}

module.exports = {
    uploadedImage: uploadedImage,
    isImageUploaded: isImageUploaded,
    onTakePictureClicked: onTakePictureClicked,
    onSelectPictureClicked: onSelectPictureClicked,
    onCancelPictureClicked: onCancelPictureClicked,
    onUploadImageClicked: onUploadImageClicked,
    showCropPanel: showCropPanel,
    cornerList: cornerList,
    onCloseCropPanelClicked: onCloseCropPanelClicked,
    imagePlaced: imagePlaced,
};

補足

Fusetoolsには、Draggableという表示物をドラッグして移動することを意図したクラスがあり、
最初はその利用を試みたのですが、移動後の座標を取得するための機構を発見できなかったため、
自力でDraggable同様の動作で、かつ座標の取得も可能となる実装をしてみました。

おまけ

ユーザ体験として気にしたポイント

すでに定番かもしれませんが、こういった頂点の移動を要求する実装の場合、
PCブラウザのノリで作ると、スマホでは自分の指で対象物が隠れてしまい、見えなくなってしまうのです。
あとは自分で触ってみて、小さな画面に対して細かい操作を強いられている印象(ストレス)を受けました。
スクリーンショット 2017-12-07 13.02.59.png

なので、最終的にはこの形に落ち着いたのですが、ここら辺を気にして実装してあります。
- 当然操作中に、マーカーの中心点を視認できる。
- だいたいの場所に指をおいても、意図したマーカーを操作できる。
- 指に吸い付くような、細かな操作ができる。
- 画面の中で圧迫なく、でも、どっしりと存在するサイズ感にすることで、細かい操作だという印象を無くす。
- マーカーを円形にすることで躊躇なく触ってみたくさせる(自分だけ?)。

スクリーンショット 2017-12-08 1.18.47.png

投稿フォームに関する考察

方策 柔軟性 精度 ユーザ操作負荷 ユーザ操作精度への依存度 向く仕事
アプリ上での切り出し範囲指定 サイズや構造が一定でない対象物の切り出し。
背景と対象物を機械自動で判定
(ex. 二値化 + Canny Edge Detection)
背景を何らかの理由で確実に見分けられる場合。
他の方策との併用。
アプリ上で目安の撮影領域を表示する
(ex.「名刺をこの範囲に納めて撮影して」)
× サイズや構造が一定な対象物の切り出し。
背景の自動判定も併用可。

全般通して、解析を正しい精度で行うための画像要件を定めて、
それを満たさない(何らかの理由で正しく処理できない)画像は理由を伝えた上で再投稿を促すのは必須になりそう。

参考資料