アンドロイド地図アプリ用の等高線付き地図を自動作成するPythonスクリプト(前編)

以前、中部地方の等高線付き地図を作成したときに、ブログの最後の自動化するためのPythonスクリプトを作る、みたいなことを書いてしまったのですが、その後手が付けられずにいてそのままになっていました。
alasixosaka.hatenablog.com
前回のブログで、関東地方の地図を作成したことを書きましたが、そのときもいろいろとめんどくさかったので、やっぱり自動化のスクリプトを作ろうと思い、作ってみました。
alasixosaka.hatenablog.com

記事は長くなりそうなので、前後編に分けて書くことにしました。
今回はその前編です。
まず、スクリプトの機能ですが、日本地図を表示させて、任意のエリアを四角でドラッグし、その範囲の地図を作成するというものになります。できた地図はオープンストリートマップをベースにした地図で、等高線間隔が10mで、mapsforgeで読むことができるmap形式の地図になります。スクリプトを少し変えるだけで、OSM形式やそれを圧縮したPBF形式にもすることができます。
前編では、Pythonを使って、日本地図を表示させ、任意エリアを選択して、そこからエリアの経度、緯度を取得するというところまでをやります。

必要なもの

今回は、オープンストリートマップの地図データにはアクセスしませんので、必要なものはシンプルです。
まず、Pythonの開発環境。私はPyCharmを使っていますが、AnacondaでもVisual Studio Codeでもなんでもかまいません。ちなみに今回は仮想環境は使っていません。理由は後編の方で書きます。Pythonのバージョンは3.8です。
次に、日本地図の画像データが必要です。ネットに転がっているやつでも何でもいいのですが、画像データの端の経度、緯度が分かっている必要があります。今回は、カシミール3Dを使って切り出した画像データを使いました。カシミール3Dには、経度、緯度を指定して地図を切り出す機能がありますので、この機能を使って地理院地図を切り出しました。

カシミール3Dを使って地図を切り取り

緯度は45°36’から30°53’まで、経度は128°15’から145°53’までを切り出しました。ちなみに、保存するときはPNG形式で保存するとPythonで読み込むときに楽です。今回は沖縄は入れていません。沖縄を入れてしまうと南北に非常に長くなってしまい、日本地図全体が見にくくなってしまうためです。
この地図ですが、地理院地図がベースになっているのでユニバーサルメルカトル図法で描かれているはずです。比較的歪みの少ない図法ですが、緯度が高くなると東西方向の経度間隔が短くなるはずなので、本当は注意が必要です。どういうことかというと、地球はほぼ真球に近い球形(本当は自転の影響でやや歪んでいる)なので、それを平面の地図に正確に描くのは不可能です。もう少し詳しく書くと、例えば経度の1°の間隔は赤道直下では、約111㎞ありますが、北極や南極の極点ではゼロになります。つまり、緯度が高いほど経度1°あたりの距離が短くなります。ユニバーサルメルカトル図法では、距離の方を正しく記述する方法なので、北に行けば行くほど東西方向の1㎞あたりの経度差は小さくなります。実際に国土地理院の紙の地形図(1:25000や1:50000)の地図を買ってみると、九州や沖縄では地図が大きく、北海道では地図が小さくなります。紙の大きさは同じなのですが、地図が描かれている領域が違います。九州、沖縄ではほとんど余白がありませんが、北海道の地図は余白がめちゃめちゃあります。これは、地図が東西に経度差で何°、南北に緯度差で何°と決まっているためで、例えば札幌の近くの北緯43°のあたりでは、経度1°あたりの距離は約58㎞ですが、これが那覇市になると北緯26°21’ですから、約79㎞になります。実に21㎞も差があります。
今回使った地図でもそうなっているはずなので、同じ四角をドラッグしても、沖縄では経度差が大きく、北海道では小さくなるはずです。つまり、経度の線を縦にひくと左端の線は90°よりも少し右に傾き、右端の線は90°よりも少し左に傾くことになります。しかし、今回は経度の線と緯度の線は直角に交わっているとして、ドラッグした位置の経度緯度を算出しています(計算の仕方はあとでスクリプトの解説のところで書きます)。
さて、前置きが長くなってしまいましたが、スクリプトの本体に入っていきます。

モジュールのインポート

今回はGUIを使いますので、Tkinterを使います。それから、画面をマウスドラッグした位置を取得するためにpyautoguiを使います。

import tkinter as tk
import tkinter.ttk as ttk
import pyautogui

地図の名称を決める

エリアを決める前に地図の名称を決めてやる必要があります。これもせっかくなのでGUIで取得することにします。
まず、250×100ピクセルのエリアをroot2として指定します。(いかにも後で付け足したのが見え見えの名前ですが)
それから、そこにフレームを貼って、エントリーウィジットとボタンを表示して、適当な名前(kinkiやkantoなど)を入力して実行をクリックすると、getnameという関数に飛んでいきます。
button_execute = ttk.Button(frame, text="実行", command=getname)
のところがそうです。

root2 = tk.Tk()
root2.title("地図の名称")
root2.geometry("250x100")
mapname=""

frame = ttk.Frame(root2)
frame.grid(column=0, row=0, sticky=tk.NSEW, padx=5, pady=10)

entry = ttk.Entry(frame)
button_execute = ttk.Button(frame, text="実行", command=getname)

entry.grid(row=0, column=1)
button_execute.grid(row=1, column=1)

root2.mainloop()

関数getnameでは、エントリーウィジットに入力したテキストをmapnameという変数に格納します。if文では、テキスト入力があったかどうかをチェックし、何かしらの入力があれば、root2を破棄して次の処理に進みます。何も入力がなければroot2にとどまり、テキスト入力を待ちます。ちょっと気を付けないといけないのは、何か入力があれば抜けてしまいますので、スペースでもOKということです、スペースを入力して決定をクリックしてしまうと間抜けな名前の地図ができてしまうので注意が必要です。(後で変えることはできますが)

def getname():
    global mapname
    mapname=entry.get()
    if mapname != "":
        root2.destroy()

実行するとこんな感じになります。

地図の名称を入力するウィジット

日本地図から作成する領域を決定する

いよいよ日本地図を表示してマウスで領域を指定する処理です。

関数がいっぱいありますが、関数はあとで個々に説明することにして、メインの部分はこうなります。rootの中にフレームを一つ置いて、その中にCanvasを作ります。Canvasのサイズは、日本地図の画像のピクセル数に合わせます。カシミール3Dで切り出すと803×837ピクセルになっていましたのでそれに合わせています。
画像ファイル(japan.png)をimgという変数の取り込んで、Canvasに貼り付けます。貼り付けるときに、位置を原点を指定し、アンカーをNW(北西)に指定しています。こうすることでCanvasのサイズぴったりに日本地図の画像が表示されます。

root = tk.Tk()
wd=803
ht=837
frame = tk.Frame()
frame.pack()
canvas = tk.Canvas(frame, width = wd, height = ht)
canvas.pack()
img = tk.PhotoImage(file = 'H:gps\japan.png')
canvas.create_image(0, 0, image=img, anchor=tk.NW)
canvas.pack()
canvas.bind("<ButtonPress-1>", start_point_get)
canvas.bind("<Button1-Motion>", rect_drawing)
canvas.bind("<ButtonRelease-1>", release_action)
root.mainloop()

最後にCanvas.bindが3つ並んでいますが、それぞれが、マウスのボタンをクリックしたとき、マウスをドラッグしたとき、マウスのボタンを離したときの動作です。クリックしたときはその座標を取得します。ドラッグしたときは四角形を描画します。ボタンを離したときはその座標を取得し、OKならrootを破棄して次の処理に写ります。キャンセルの時は、四角形を消去し、再度座標取得に戻ります。これらの一連の処理はこのサイトを参考にしました。
qiita.com
それぞれの関数はこんな感じです。

ボタンを押したとき
def start_point_get(event):
    global start_x, start_y, end_x, end_y # グローバル変数に書き込みを行なうため宣言

    canvas.delete("rect1")  # すでに"rect1"タグの図形があれば削除

    # canvas1上に四角形を描画(rectangleは矩形の意味)
    canvas.create_rectangle(event.x,
                             event.y,
                             event.x + 1,
                             event.y + 1,
                             outline="red",
                             tag="rect1")
    # グローバル変数に座標を格納
    start_x, start_y = event.x, event.y
マウスをドラッグしたとき
# ドラッグ中のイベント - - - - - - - - - - - - - - - - - - - - - - - - - -
def rect_drawing(event):

    # ドラッグ中のマウスポインタが領域外に出た時の処理
    if event.x < 0:
        end_x = 0
    else:
        end_x = min(wd, event.x)
    if event.y < 0:
        end_y = 0
    else:
        end_y = min(ht, event.y)

    # "rect1"タグの画像を再描画
    canvas.coords("rect1", start_x, start_y, end_x, end_y)
ボタンを離したとき

マウスのボタンを離したときに
start_x, start_y, end_x, end_y = [
round(n * RESIZE_RETIO) for n in canvas.coords("rect1")
]
で、四隅の座標をstart_x, start_y, end_x, end_yとして取得しています。
また、その結果をpyautogui.confirmで表示しています。pyautogui.confirmでは、OKとキャンセルのボタンが表示されるので、その結果はresultに格納されます。
参考サイトではドラッグを終えてボタンを離したときに、pyautogui.alartを使ってOKを押して終了していますが、いざドラッグをしてみて範囲が気に入らないときにやり直せるように、pyautogui.confirmを使い、OK,キャンセルを変数rtに格納するようにしています。そして、rtがOKならrootを破棄して抜け出します。OKでない場合(キャンセル)はcanvas.delet("rect1")で四角形を消去してやり直します。

# ドラッグを離したときのイベント - - - - - - - - - - - - - - - - - - - - - - - - - -
def release_action(event):
    global rt, start_x, start_y, end_x, end_y
    # "rect1"タグの画像の座標を元の縮尺に戻して取得
    start_x, start_y, end_x, end_y = [
        round(n * RESIZE_RETIO) for n in canvas.coords("rect1")
    ]

    # 取得した座標を表示
    result = pyautogui.confirm("start_x : " + str(start_x) + "\n" + "start_y : " +
                    str(start_y) + "\n" + "end_x : " + str(end_x) + "\n" +
                    "end_y : " + str(end_y))

    rt = result
    if rt=='OK':
        root.destroy()
    else:
        canvas.delete("rect1")

下の図は佐渡のエリアを指定したときのものです。

佐渡のエリアを指定

表示されている数字は、経度緯度ではなく、Canvas上の座標です。

取得した座標から経度、緯度を計算する

Canvas上の座標が得られたのでそこから経度と緯度を計算します。Canvas上の座標はstart_x, start_y, end_x, end_yという変数に格納されています。x方向が経度方向、y方向が緯度方向になります。
経度の左端は128.25°ですのでそれをlong_minに右端は145.88°でlong_maxという変数に入れておきます。同様に緯度は下の端が30.88°でlat_min、上の端が45.6°でlat_maxです。
幅方向のピクセル数は803で変数wdです。縦方向のピクセル数は837で変数htです。これらを使って、1ピクセル当たりの緯度、経度の値を計算し、緯度の下の端をlat_s、上の端をlat_e、経度の左端をlong_s、右端をlong_eとして計算しています。

print (start_x,start_y,end_x,end_y)
lat_min=30.88
lat_max=45.6
long_min = 128.25
long_max= 145.88
lat_s = str(round(lat_min + (ht - start_y)*(lat_max-lat_min)/ht,4))
lat_e = str(round(lat_min + (ht - end_y)*(lat_max-lat_min)/ht,4))
long_s = str(round(long_min + start_x*(long_max-long_min)/wd,4))
long_e = str(round(long_min + end_x*(long_max-long_min)/wd,4))
print(lat_s,lat_e,long_s,long_e)

これで、GUIを使って、作成する地図のエリアの経度、緯度を取得することができました。
次回は、これらの値を使ってオープンストリートマップデータから地図を作成することをやります。

今回のスクリプトの全体です。

import tkinter as tk
import tkinter.ttk as ttk
import pyautogui

RESIZE_RETIO = 1 # 縮小倍率の規定
global rt
rt="x"
global start_x, start_y, end_x, end_y
end_x=0
end_y=0
start_x=0
start_y=0

def start_point_get(event):
    global start_x, start_y, end_x, end_y # グローバル変数に書き込みを行なうため宣言

    canvas.delete("rect1")  # すでに"rect1"タグの図形があれば削除

    # canvas1上に四角形を描画(rectangleは矩形の意味)
    canvas.create_rectangle(event.x,
                             event.y,
                             event.x + 1,
                             event.y + 1,
                             outline="red",
                             tag="rect1")
    # グローバル変数に座標を格納
    start_x, start_y = event.x, event.y

# ドラッグ中のイベント - - - - - - - - - - - - - - - - - - - - - - - - - -
def rect_drawing(event):

    # ドラッグ中のマウスポインタが領域外に出た時の処理
    if event.x < 0:
        end_x = 0
    else:
        end_x = min(wd, event.x)
    if event.y < 0:
        end_y = 0
    else:
        end_y = min(ht, event.y)

    # "rect1"タグの画像を再描画
    canvas.coords("rect1", start_x, start_y, end_x, end_y)

# ドラッグを離したときのイベント - - - - - - - - - - - - - - - - - - - - - - - - - -
def release_action(event):
    global rt, start_x, start_y, end_x, end_y
    # "rect1"タグの画像の座標を元の縮尺に戻して取得
    start_x, start_y, end_x, end_y = [
        round(n * RESIZE_RETIO) for n in canvas.coords("rect1")
    ]

    # 取得した座標を表示
    result = pyautogui.confirm("start_x : " + str(start_x) + "\n" + "start_y : " +
                    str(start_y) + "\n" + "end_x : " + str(end_x) + "\n" +
                    "end_y : " + str(end_y))

    rt = result
    if rt=='OK':
        root.destroy()
    else:
        canvas.delete("rect1")

def getname():
    global mapname
    mapname=entry.get()
    if mapname != "":
        root2.destroy()

root2 = tk.Tk()
root2.title("地図の名称")
root2.geometry("250x100")
mapname=""

frame = ttk.Frame(root2)
frame.grid(column=0, row=0, sticky=tk.NSEW, padx=5, pady=10)

entry = ttk.Entry(frame)
button_execute = ttk.Button(frame, text="実行", command=getname)

entry.grid(row=0, column=1)
button_execute.grid(row=1, column=1)

root2.mainloop()



root = tk.Tk()
wd=803
ht=837
frame = tk.Frame()
frame.pack()
canvas = tk.Canvas(frame, width = wd, height = ht)
canvas.pack()
img = tk.PhotoImage(file = 'H:gps\japan.png')
canvas.create_image(0, 0, image=img, anchor=tk.NW)
canvas.pack()
canvas.bind("<ButtonPress-1>", start_point_get)
canvas.bind("<Button1-Motion>", rect_drawing)
canvas.bind("<ButtonRelease-1>", release_action)
root.mainloop()

print (start_x,start_y,end_x,end_y)
lat_min=30.88
lat_max=45.6
long_min = 128.25
long_max= 145.88
lat_s = str(round(lat_min + (ht - start_y)*(lat_max-lat_min)/ht,4))
lat_e = str(round(lat_min + (ht - end_y)*(lat_max-lat_min)/ht,4))
long_s = str(round(long_min + start_x*(long_max-long_min)/wd,4))
long_e = str(round(long_min + end_x*(long_max-long_min)/wd,4))
print(lat_s,lat_e,long_s,long_e)