YouTube | Facebook | X(Twitter) | RSS

ArcGIS Pro によるパノラマ画像生成ツールの作成

2018/8/31 (金)

はじめに

パノラマ画像とは、見晴らしの良い展望台などで晴れた日に見える風景をパネル展示してるあれです。スマホでもアプリの力で簡単に作れたりします。これと同じような画像を ArcGIS Pro で作ろうというものです。現地に行かなくてもその場所を見渡した際の想像図をシミュレーションすることができます。

富士総合火力演習会場のパノラマ写真

ダウンロード

CreatePanoramaView.zip

バージョン 2.0 以降、1.2 以降に対応していますが、2.1 でのみ動作確認しております。

使用方法

  1. ZIPファイルをダウンロードして解凍し、プロジェクト パッケージ (*.ppkx) を ArcGIS Pro 2.x で開きます。プロジェクト パッケージは「ドキュメント\ArcGIS\Packages\」以下に展開されます。
  2. [カタログ] ウィンドウ → [プロジェクト] タブ → [レイアウト] →「レイアウト」をダブルクリックして表示します。
  3. [レイアウト] タブ → [アクティブ化] をクリックしてレイアウト内のマップ フレーム エレメントをアクティブにします。
  4. 必要に応じてデータをシーンに追加します。
  5. カメラの表示場所(XY座標、高さ)を設定します。方角の初期値はツールで指定するため適当で構いません。
  6. [アクティブ化されたマップ フレーム レイアウト] タブ → [閉じる] をクリックします。
  7. [カタログ] ウィンドウ → [プロジェクト] タブ → [ツールボックス] → [CreatePanoramaView.tbx] → [パノラマ画像の作成] をダブルクリックします。
  8. ツールが起動するのでそれぞれパラメーターを設定し、[実行 ] ボタンをクリックします。

パラメーターの解説

パラメーター 説明 初期値
プロジェクト パス レイアウトを含む ArcGIS Pro プロジェクト ファイルを選択します。 CURRENT(現在のプロジェクト)
レイアウト名 読み込んだプロジェクトに含まれるレイアウトが一覧で表示されます。シーンのマップ フレーム エレメントを含むレイアウトを選択してください。 レイアウト
出力ファイル パス パノラマ画像の出力フルパス。 なし
出力解像度 レイアウトのエクスポートを行う際の出力解像度。 96
開始角度 パノラマ画像の最初に出力される左端画像の中心となる方位角。 0
回転角度 2枚目以降の画像を出力するための回転角。 45
端の重複 最後の画像に重複を行うかどうか。 重複あり
結合前画像の配置フォルダー 個々に出力するテンポラリの画像ファイルを出力するフォルダー。 プロジェクトの場所
結合前画像ファイル接頭辞 結合前の画像ファイル名(拡張子は出力ファイル パスに依存)。 TempImage
結合前画像の削除 結合前画像ファイルを残すかどうか。 消す

注意点

開発環境ではマップ フレームのエレメント サイズを 280mm x 220mm に設定することで視野角がほぼ 45°に設定でき、継ぎ目の少ない画像が生成できました。ツールを一度実行し、継ぎ目の差が大きい場合は用紙サイズとマップ フレーム エレメントのサイズを調整してください。

必要に応じてツールを他のプロジェクトにコピーして使用してください。

実行結果

ツールを実行した結果のサンプル画像です。矢野の人にはイメージ通りなことが分かるでしょう。

絵下山山頂のパノラマ画像

静岡県御殿場付近のパノラマ画像

ツールの作成方法に関する解説

残念ながら ArcGIS Pro の標準機能に該当のツールはありませんが、3D 表示は表示できるので、画像を少しずつ回転させながら自動でつなぎ合わせて行けばやりたいことが実現できるのではないかと考えました。今回の処理はモデルだけでは無理でしたので ArcPy を使用したスクリプト ツールにしました。

3D 表示

ArcGIS Pro はシーンで 3D 表示を行いますが、3D でどの場所からどの方向を見ているかという表示設定は Camera クラスで管理します。しかし ArcPy ではシーンを示す Map クラスから現在の 3D 表示設定を示す Camera オブジェクトは取得できません(defaultCamera プロパティはレイアウトにシーンを追加した際のデフォルト表示を決定するものです)。画面上に表示されている状態を自由に制御できるのはレイアウトに貼り付けられたシーンのマップ フレーム エレメントに限定され、MapFrame.Camera プロパティから取得します。

ArcGIS Pro のシーン(ヘルプより引用)

ArcGIS Pro の Camera クラスは以下のプロパティで画面表示を制御しています。

情報 Camera クラスのプロパティ
中心X X
中心Y Y
高さ Z
回転 heading
ピッチ pitch
ロール角 roll

ArcGIS Pro のカメラ情報

回転(heading)を変更しながらスナップショットを取得し、最後に結合してパノラマ画像を作成します。ここで、回転させる角度の計算に苦労しました。ArcGIS Pro では画角というプロパティはないため、レイアウトのサイズによって画角が異なります。今回は経験的に45°の画角を求めるには W:280mm x H:220mm のマップ フレーム エレメントを設定すればよいことが分かりました。ただし、ディスプレイなどの環境によってこの値は異なるかもしれません。

パラメーター チェック

スクリプト ツールのパラメーター チェック用にいくつかコードを記述しました。パラメーター チェックは .tbx ファイルのプロパティを開き、[確認] ペインにある ToolValidator クラスを定義することで実現できます。ArcGIS Pro のスクリプト ツールでは、バージョン 2.1時点で ArcGIS Pro プロジェクト (.aprx) を指定するためのデータ エレメントがありません。そのため、「プロジェクト パス」のパラメーターは「データ エレメント」型を使用し、拡張子が *.aprx でない場合にエラーを表示しています。また、現在のプロジェクトを意味する "CURRENT" も指定できるようにするため、「文字列 (string)」型も指定できるようにし、文字列が "CURRENT" と一致した場合も有効としています。

また、プロジェクトを読み込んだら [レイアウト名] に自動で一覧がリスト化され、[結合前画像の配置フォルダー] は初期値としてプロジェクト フォルダーが指定されるようにしています。

現状では条件設定が甘いため、想定外のパラメーターを指定するとチェック自体でエラーが発生します。

画像の結合

今回は ArcGIS Pro の標準構成だけで利用できる方法を前提としたため、個々に生成した画像を結合するため、ローカル座標を設定したワールドファイルを作成し、モザイク ツールで結合しました。そのため、最終的に生成されたパノラマ画像のファイルに xml, ovr, pgw と余計なファイルもついてきます。これは個別に削除してください。PIL (Python Image Library) が使用できれば画像結合がもう少し簡単に記述できます。

方位角の表示

1枚のパノラマ画像として生成してもどの部分がどの方位なのか分からないので、ティック マークを表示するようにしました。あらかじめレイアウトにライン エレメントとテキスト エレメントを追加しておき、ライン エレメントはマップ フレーム エレメントの中央となるように表示します。これが Camera.heading プロパティの示す方向です。テキスト エレメントには heading プロパティの数値を整数化して表示しましたが、180 °を超えるとマイナス値がでる場合もあったため、以下の式で 0° <= x < 360° となるようにしました(参考)。

#元の角度に360を加算して 360 で除算した剰余が 0 以上 360 未満の値となる
new_angle = (old_angle + 360) % 360

制限

このツールは画像を複数枚接合しているだけなので完全にシームレスなパノラマ画像の生成はできません。接合部が角ついてしまいます。グローバル シーンで地平線や水平線が湾曲して見えるほど高度をあげると連山のような接合になってしまいます。

また、処理が完了するとシーンに該当ファイルがレイヤーとして追加されてしまいます。個別に削除してください。arcpy.env.addOutputsToMap = False と記述しているのですが別の記述が必要なのか未解決です。

ソース コード

CreatePanoramaView.py

# coding:UTF-8

# レイアウト内のシーンを回転してパノラマ画像を合成
# ArcGIS Pro の画角は不明だが、レイアウト サイズを 280mm x 220 ~ 230mm
# 指定して両端いっぱいに画像を設定すると 45度相当の画角が得られシームレスに結合できる
# レイアウトの中央にマークがあると角度方向が分かりやすい

import arcpy
import os
import sys
import linecache
import math
import time

# メッセージの出力
def add_message(message):
    print(message)
    arcpy.AddMessage(message)

# 秒を時分秒に換算
def s2hms(second):
    minute, second = divmod(second, 60)
    hour, minute = divmod(minute, 60)
    return "{0}:{1:02d}:{2:02d}".format(hour, minute, second)

# 経過時間を出力
def get_pregress_time_hms(start_time):
    return s2hms(int(time.time() - start_time))

try:
    # 処理開始
    start_time = time.time()
    add_message('処理開始...経過時間:{0}'.format(get_pregress_time_hms(start_time)))

    # 環境設定
    arcpy.env.addOutputsToMap = False

    # パラメーター
    project_path = arcpy.GetParameterAsText(0)      # プロジェクト パス
    layout_name = arcpy.GetParameterAsText(1)       # レイアウト名
    out_path = arcpy.GetParameterAsText(2)          # 出力ファイル パス
    dpi = arcpy.GetParameterAsText(3)               # 出力解像度(オプション)
    start_angle = arcpy.GetParameterAsText(4)       # 開始角度(オプション)0 <= X <= 360
    rotate_angle = arcpy.GetParameterAsText(5)      # 回転角度(オプション)0 <= X <= 360
    is_dupulication = arcpy.GetParameterAsText(6)   # 端の重複(オプション) 
    base_dir = arcpy.GetParameterAsText(7)          # 結合前画像配置の出力フォルダー(オプション)
    base_prefix = arcpy.GetParameterAsText(8)       # 結合前画像ファイル接頭辞(オプション)
    delete_temp = arcpy.GetParameterAsText(9)       # 結合前画像の削除(オプション)
    
    #パラメーター チェック
    if project_path == '' or project_path is None:
        project_path = 'CURRENT'
    if layout_name == '' or layout_name is None:
        layout_name = 'レイアウト'
        raise TypeError('パラメーターが不足しています。')
    if out_path == '' or out_path is None:
        out_path = r'C:\Temp\output01.png'
        raise TypeError('パラメーターが不足しています。')
    if dpi == '' or dpi is None:
        dpi = 96
    if start_angle== '' or start_angle is None:
        start_angle = 0
    if rotate_angle == '' or rotate_angle is None:
        rotate_angle = 45
    if is_dupulication == '' or is_dupulication is None:
        is_dupulication = 'true'
    if base_dir == '' or base_dir is None:
        base_dir = r'C:\Temp'
    if base_prefix  == '' or base_prefix is None:
        base_prefix = 'Image'
    path, base_ext = os.path.splitext(out_path)   #出力ファイル パスから拡張子を特定
    if base_ext  == '' or base_ext is None:
        raise TypeError('出力ファイルの拡張子が不正です。')
    if delete_temp  == '' or delete_temp is None:
        delete_temp = 'true'
    
    # 型変換
    dpi = int(dpi)
    start_angle = float(start_angle)
    rotate_angle = float(rotate_angle)
    base_ext = base_ext.lower()
    if is_dupulication == 'true':
        is_dupulication = True
    else:
        is_dupulication = False
    if delete_temp == 'true':
        delete_temp = True
    else:
        delete_temp = False
    

    #画像作成用ォルダーの作成(存在する場合はそのまま)
    os.makedirs(base_dir, exist_ok = True)

    # プロジェクトの読み込み
    # プロジェクトにシーンを貼り付けたレイアウトが含まれていること
    aprx = arcpy.mp.ArcGISProject(project_path)

    layout = aprx.listLayouts(layout_name)[0]

    mapframe_element = layout.listElements('MAPFRAME_ELEMENT')[0]   # マップ フレーム
    text_element = layout.listElements('TEXT_ELEMENT')[0]           # 角度表示用テキスト エレメント

    # 初期設定
    base_path = r'{0}\{1}{2:03d}{3}'   #フルパス
    created_images = []
    mapframe_element.camera.heading = start_angle

    # 結合前画像ファイル作成
    add_message('結合前画像を作成しています...経過時間: {0}'.format(get_pregress_time_hms(start_time)))
    
    total_count = int(math.ceil(360 / rotate_angle)) + int(is_dupulication)

    for i in range(total_count):
        add_message('    {0}/{1}  処理中...経過時間: {2}'.format(i + 1, total_count, get_pregress_time_hms(start_time)))

        angle = int(mapframe_element.camera.heading)
        text_element.text = '{0}°'.format(str(angle))

        # 出力パス設定
        path = base_path.format(base_dir, base_prefix, i, base_ext.lower())
        
        # 画像のエクスポート
        if base_ext == '.bmp':
            layout.exportToBMP(path, dpi)
        elif base_ext == '.png':
            layout.exportToPNG(path, dpi)
        elif base_ext == '.tif' or base_ext == '.tiff':
            layout.exportToTIFF(path, dpi)
        elif base_ext == '.jpg' or base_ext == '.jpeg':
            layout.exportToJPEG(path, dpi)
        else:
            raise TypeError('指定した拡張子が不正です。')
        
        # 画像をリストに追加
        created_images.append(path)

        # アングルの更新
        mapframe_element.camera.heading += rotate_angle
        
        # 角度の調整(360°以上やマイナス値の場合の修正)
        mapframe_element.camera.heading = (mapframe_element.camera.heading + 360) % 360
        
    # 画像の結合
    # 画像サイズからワールドファイルを作成
    desc = arcpy.Describe(created_images[0] + '\\Band_1')
    image_width = desc.width

    # ワールドファイルの作成
    add_message('ワールドファイルを作成しています...経過時間: {0}'.format(get_pregress_time_hms(start_time)))

    for i, created_image in enumerate(created_images):
        text = '1\n0\n0\n-1\n{0}\n0.5'.format(i * image_width + 0.5)    #ワールドファイル パラメーター
        file = open(created_image + "w", "w")   #ファイルが存在しない場合は新規作成
        file.write(text)
        file.close()

    #出力ラスター データセットの作成
    add_message('出力ラスター データセットを作成しています...経過時間: {0}'.format(get_pregress_time_hms(start_time)))

    output = arcpy.CreateRasterDataset_management(out_path = os.path.split(out_path)[0],
                                                  out_name = os.path.split(out_path)[1],
                                                  pixel_type = '8_BIT_UNSIGNED',
                                                  number_of_bands = 4)

    #モザイク
    add_message('モザイクしています...経過時間: {0}'.format(get_pregress_time_hms(start_time)))

    arcpy.Mosaic_management(inputs = created_images,
                            target = output)

    #結合前ファイル削除
    if delete_temp == True:
        add_message('結合前ファイルを削除しています...経過時間: {0}'.format(get_pregress_time_hms(start_time)))

        for i, created_image in enumerate(created_images):
            add_message('    {0}/{1}  処理中...経過時間: {2}'.format(i + 1, len(created_images), get_pregress_time_hms(start_time)))
            arcpy.Delete_management(created_image)
    
    add_message('処理が終了しました...経過時間: {0}'.format(get_pregress_time_hms(start_time)))

except Exception as err:

    # エラー 発生行取得
    # http://colibri.sblo.jp/article/104050401.html
    exc_type, exc_obj, tb=sys.exc_info()
    lineno=tb.tb_lineno
    print('{0}行目でエラーが発生しました。\n{1}'.format(str(lineno), str(err)))
    arcpy.AddError('{0}行目でエラーが発生しました。\n{1}'.format(str(lineno), str(err)))


CreatePanoramaView.tbx

import arcpy
import os

class ToolValidator(object):
    """Class for validating a tool's parameter values and controlling
    the behavior of the tool's dialog."""

    def __init__(self):
        """Setup arcpy and the list of tool parameters.""" 
        self.params = arcpy.GetParameterInfo()

    def initializeParameters(self): 
        """Refine the properties of a tool's parameters. This method is 
        called when the tool is opened."""

        # パラメーターの変更
        aprx = arcpy.mp.ArcGISProject("CURRENT")
        self.params[7].value = aprx.homeFolder

    def updateParameters(self):
        """Modify the values and properties of parameters before internal
        validation is performed. This method is called whenever a parameter
        has been changed."""

        # プロジェクト ファイルからレイアウト一覧を取得
        if self.params[0].value and not self.params[0].hasBeenValidated:
            try:
                self.params[1].filter.list = []
                aprx = arcpy.mp.ArcGISProject(self.params[0].valueAsText)
                layouts = aprx.listLayouts()
                if len(layouts) != 0:
                    nameList = []
                    for layout in layouts:
                        nameList.append(layout.name)
                        self.params[1].filter.list = nameList
                        self.params[1].value = self.params[1].filter.list[0]
            except:
                return

    def updateMessages(self):
        """Modify the messages created by internal validation for each tool
        parameter. This method is called after internal validation."""
        
        #プロジェクト ファイル指定のチェック
        value = self.params[0].valueAsText.lower()
        if value:
            if value == "current":
                return
            else:
                if os.path.splitext(value)[1].lower() != '.aprx':
                    self.params[0].setErrorMessage('CURRENT もしくは ArcGIS Pro プロジェクト (*.aprx) ファイルではありません。')
                else:
                    return
  • この記事を書いた人

羽田 康祐

伊達と酔狂のGISエンジニア。GIS上級技術者、Esri認定インストラクター、CompTIA CTT+ Classroom Trainer、潜水士、PADIダイブマスター、四アマ。WordPress は 2.1 からのユーザーで歴だけは長い。 代表著書『"地図リテラシー入門―地図の正しい読み方・描き方がわかる』 GIS を使った自己紹介はこちら。ESRIジャパン(株)所属、元青山学院大学非常勤講師を兼務。日本地図学会第31期常任委員。発言は個人の見解です。

-プログラミング, ArcGIS
-,